Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cd54beb1b | |||
| 0d98d1ecca |
+1
-1
@@ -1 +1 @@
|
||||
trigger: 1784009947
|
||||
# CI trigger Fri Jun 26 09:53:28 PM CST 2026
|
||||
|
||||
+998
-907
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")
|
||||
@@ -19,7 +19,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
|
||||
@@ -100,10 +99,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",
|
||||
|
||||
@@ -85,15 +85,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 +104,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 +117,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 级分页 ──
|
||||
|
||||
@@ -146,7 +146,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 +209,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
|
||||
|
||||
Executable → Regular
-1
@@ -210,7 +210,6 @@ def generate_from_template(
|
||||
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,
|
||||
|
||||
@@ -267,8 +267,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:
|
||||
|
||||
@@ -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_id,retry_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,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,24 +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,
|
||||
GetTemplateUseCase,
|
||||
ListCategoriesUseCase,
|
||||
ListTagsUseCase,
|
||||
ListTemplatesUseCase,
|
||||
NotFoundError,
|
||||
UpdateTemplateUseCase,
|
||||
@@ -75,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,
|
||||
@@ -89,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,
|
||||
)
|
||||
@@ -102,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,
|
||||
)
|
||||
|
||||
@@ -146,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)
|
||||
@@ -247,47 +220,6 @@ def delete_template(
|
||||
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)
|
||||
|
||||
|
||||
@router.post("/{template_id}/toggle-favorite", response_model=ToggleFavoriteResponse)
|
||||
def toggle_favorite(
|
||||
template_id: str,
|
||||
@@ -386,23 +318,4 @@ def delete_category(
|
||||
deleted = use_case.execute(category_id, user_id)
|
||||
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)
|
||||
return
|
||||
|
||||
Executable → Regular
+1
-41
@@ -17,18 +17,13 @@ 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,
|
||||
)
|
||||
@@ -75,41 +70,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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Executable → Regular
-15
@@ -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")
|
||||
|
||||
Executable → Regular
-6
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
Executable → Regular
-19
@@ -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,
|
||||
|
||||
@@ -86,10 +86,7 @@ async function createProject(
|
||||
): Promise<string> {
|
||||
const resp = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `Assets Test Proj ${suffix}`,
|
||||
description: "E2E assets test",
|
||||
},
|
||||
data: { name: `Assets Test Proj ${suffix}`, description: "E2E assets test" },
|
||||
});
|
||||
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy();
|
||||
const data = await resp.json();
|
||||
@@ -181,30 +178,20 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
test("素材库列表页面加载", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-load");
|
||||
const projectId = await createProject(
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
"assets-load",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
await createLibrary(request, headers, projectId, "默认视频库", "video");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
|
||||
// 页面布局容器
|
||||
await expect(page.locator(".xx-assets-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.locator(".xx-assets-page")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 左侧素材库列表
|
||||
await expect(page.locator(".xx-asset-library-list")).toBeVisible();
|
||||
@@ -224,33 +211,23 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
test("创建新素材库 - 通过 UI", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-create");
|
||||
const projectId = await createProject(
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
"assets-create",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
await createLibrary(request, headers, projectId, "初始库", "video");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 点击新建素材库
|
||||
await page.locator(".xx-asset-library-add").click();
|
||||
|
||||
// 弹窗出现
|
||||
const modal = page
|
||||
.locator(".ant-modal-content")
|
||||
.filter({ hasText: "新建素材库" });
|
||||
const modal = page.locator(".ant-modal-content").filter({ hasText: "新建素材库" });
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// 填写表单
|
||||
@@ -282,13 +259,11 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
test("切换不同素材库", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-switch");
|
||||
const projectId = await createProject(
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
"assets-switch",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
|
||||
const videoLibName = "视频素材库 A";
|
||||
const imageLibName = "图片素材库 B";
|
||||
@@ -317,16 +292,10 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
"demo_video.mp4",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 点击视频库,应显示素材
|
||||
const videoLibItem = page
|
||||
@@ -336,9 +305,7 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await expect(videoLibItem).toHaveClass(/active/);
|
||||
|
||||
// 验证视频素材出现
|
||||
await expect(page.getByText("demo_video.mp4")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText("demo_video.mp4")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 点击图片库,应切换且不显示视频
|
||||
const imageLibItem = page
|
||||
@@ -348,22 +315,18 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await expect(imageLibItem).toHaveClass(/active/);
|
||||
|
||||
// 空状态或图片库内容
|
||||
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, { timeout: 5_000 });
|
||||
});
|
||||
|
||||
// ─── 素材搜索 ──────────────────────────────────────
|
||||
|
||||
test("素材搜索功能", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-search");
|
||||
const projectId = await createProject(
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
"assets-search",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
@@ -373,33 +336,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
);
|
||||
|
||||
// 创建两个不同名称的素材
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"apple_clip.mp4",
|
||||
);
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"banana_clip.mp4",
|
||||
);
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "apple_clip.mp4");
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "banana_clip.mp4");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 确保在测试库中
|
||||
const libItem = page
|
||||
@@ -408,9 +351,7 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 两个素材都应可见
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
|
||||
|
||||
// 搜索 apple,只显示 apple
|
||||
@@ -420,9 +361,7 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
// 清空搜索,两个都显示
|
||||
await page.getByPlaceholder("搜索素材名称...").fill("");
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -430,13 +369,11 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
test("素材类型筛选", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-filter");
|
||||
const projectId = await createProject(
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
"assets-filter",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
@@ -446,25 +383,12 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
);
|
||||
|
||||
// 创建视频素材
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"video_clip.mp4",
|
||||
);
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "video_clip.mp4");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
@@ -472,9 +396,7 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 素材应可见
|
||||
await expect(page.getByText("video_clip.mp4")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText("video_clip.mp4")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 筛选类型下拉存在
|
||||
const filterSelect = page.locator(".xx-assets-filters-left select").first();
|
||||
@@ -485,13 +407,11 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
test("素材详情查看 - 播放弹窗", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-detail");
|
||||
const projectId = await createProject(
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
"assets-detail",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
@@ -499,25 +419,12 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
"详情测试库",
|
||||
"video",
|
||||
);
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"play_test.mp4",
|
||||
);
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "play_test.mp4");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
@@ -534,9 +441,7 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await assetCard.locator(".xx-asset-play").click({ force: true });
|
||||
|
||||
// 播放弹窗出现
|
||||
const modal = page
|
||||
.locator(".ant-modal-content")
|
||||
.filter({ hasText: "播放" });
|
||||
const modal = page.locator(".ant-modal-content").filter({ hasText: "播放" });
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// 关闭弹窗
|
||||
@@ -548,13 +453,11 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
test("删除素材 - 带确认对话框", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-delete");
|
||||
const projectId = await createProject(
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
"assets-delete",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
@@ -562,25 +465,12 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
"删除测试库",
|
||||
"video",
|
||||
);
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"to_delete.mp4",
|
||||
);
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "to_delete.mp4");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
@@ -601,15 +491,14 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await deleteBtn.click({ force: true });
|
||||
|
||||
// 确认对话框出现
|
||||
const confirmModal = page
|
||||
.locator(".ant-popover")
|
||||
.filter({ hasText: "确认删除" });
|
||||
const confirmModal = page.locator(".ant-popover").filter({ hasText: "确认删除" });
|
||||
await expect(confirmModal).toBeVisible();
|
||||
|
||||
// 监听删除请求
|
||||
const deletePromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/assets/") && resp.request().method() === "DELETE",
|
||||
resp.url().includes("/assets/") &&
|
||||
resp.request().method() === "DELETE",
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
|
||||
@@ -629,13 +518,11 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
test("批量删除素材", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-batch");
|
||||
const projectId = await createProject(
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
"assets-batch",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
@@ -645,41 +532,14 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
);
|
||||
|
||||
// 创建多个素材
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"batch_1.mp4",
|
||||
);
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"batch_2.mp4",
|
||||
);
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"batch_3.mp4",
|
||||
);
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_1.mp4");
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_2.mp4");
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_3.mp4");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
@@ -687,9 +547,7 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 所有素材应可见
|
||||
await expect(page.getByText("batch_1.mp4")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText("batch_1.mp4")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("batch_2.mp4")).toBeVisible();
|
||||
await expect(page.getByText("batch_3.mp4")).toBeVisible();
|
||||
|
||||
@@ -709,9 +567,7 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await batchDeleteBtn.click();
|
||||
|
||||
// 确认对话框
|
||||
const confirmPop = page
|
||||
.locator(".ant-popover")
|
||||
.filter({ hasText: "确定删除" });
|
||||
const confirmPop = page.locator(".ant-popover").filter({ hasText: "确定删除" });
|
||||
await expect(confirmPop).toBeVisible();
|
||||
|
||||
// 确认删除
|
||||
@@ -739,25 +595,17 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
test("空素材库展示空状态", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-empty");
|
||||
const projectId = await createProject(
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
"assets-empty",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
await createLibrary(request, headers, projectId, "空素材库", "video");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
@@ -765,9 +613,7 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 空状态应显示
|
||||
await expect(page.locator(".xx-assets-empty")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.locator(".xx-assets-empty")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -251,9 +251,6 @@ test.describe("Core generation flow", () => {
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// 清理所有路由,避免页面关闭时飞地API请求导致测试报错
|
||||
await page.unrouteAll({ behavior: "ignoreErrors" });
|
||||
});
|
||||
|
||||
test("generation task API creates and lists tasks", async ({ request }) => {
|
||||
|
||||
@@ -180,11 +180,9 @@ test.describe("Core media upload flow", () => {
|
||||
await expect(page.locator(".xx-assets-content")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible(
|
||||
{
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// Verify asset card shows status
|
||||
const assetCard = page
|
||||
|
||||
@@ -121,11 +121,7 @@ test.describe("去重流程", () => {
|
||||
"dup-load",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication");
|
||||
|
||||
@@ -150,11 +146,7 @@ test.describe("去重流程", () => {
|
||||
"dup-upload-zone",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
@@ -164,12 +156,12 @@ test.describe("去重流程", () => {
|
||||
await expect(uploadZone).toBeVisible();
|
||||
|
||||
// 上传图标和文字
|
||||
await expect(
|
||||
uploadZone.getByText("点击或拖拽视频文件到此区域"),
|
||||
).toBeVisible();
|
||||
await expect(uploadZone.getByText("点击或拖拽视频文件到此区域")).toBeVisible();
|
||||
|
||||
// 格式提示
|
||||
await expect(uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/)).toBeVisible();
|
||||
await expect(
|
||||
uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/),
|
||||
).toBeVisible();
|
||||
|
||||
// 格式标签
|
||||
await expect(page.locator(".dup-upload-formats")).toBeVisible();
|
||||
@@ -192,11 +184,7 @@ test.describe("去重流程", () => {
|
||||
"dup-info",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
@@ -227,11 +215,7 @@ test.describe("去重流程", () => {
|
||||
"dup-list",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
|
||||
@@ -255,11 +239,7 @@ test.describe("去重流程", () => {
|
||||
"dup-list-empty",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
@@ -277,11 +257,7 @@ test.describe("去重流程", () => {
|
||||
"dup-filter",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
@@ -311,11 +287,7 @@ test.describe("去重流程", () => {
|
||||
"dup-nav",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
@@ -334,8 +306,10 @@ test.describe("去重流程", () => {
|
||||
request,
|
||||
}) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "dup-detail");
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-detail",
|
||||
);
|
||||
|
||||
// 先上传一个文件进行查重,获取 record id
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
@@ -361,11 +335,7 @@ test.describe("去重流程", () => {
|
||||
const recordId = uploadData.id;
|
||||
expect(recordId, "应返回查重记录 ID").toBeTruthy();
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
// 访问详情页
|
||||
await page.goto(`/app/duplication/${recordId}`);
|
||||
@@ -383,8 +353,10 @@ test.describe("去重流程", () => {
|
||||
|
||||
test("去重记录删除 - API 验证", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "dup-delete");
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-delete",
|
||||
);
|
||||
|
||||
// 创建查重记录
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
@@ -447,8 +419,10 @@ test.describe("去重流程", () => {
|
||||
|
||||
test("去重记录删除 - UI 验证", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "dup-delete-ui");
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-delete-ui",
|
||||
);
|
||||
|
||||
// 创建查重记录
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
@@ -469,20 +443,14 @@ test.describe("去重流程", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 记录卡片应存在
|
||||
const resultCard = page.locator(".dup-result-card").first();
|
||||
const cardVisible = await resultCard
|
||||
.isVisible({ timeout: 10_000 })
|
||||
.catch(() => false);
|
||||
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false);
|
||||
|
||||
if (cardVisible) {
|
||||
// 删除按钮存在
|
||||
@@ -499,14 +467,12 @@ test.describe("去重流程", () => {
|
||||
});
|
||||
|
||||
// 监听删除请求
|
||||
const deletePromise = page
|
||||
.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/duplication/records/") &&
|
||||
resp.request().method() === "DELETE",
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.catch(() => null);
|
||||
const deletePromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/duplication/records/") &&
|
||||
resp.request().method() === "DELETE",
|
||||
{ timeout: 10_000 },
|
||||
).catch(() => null);
|
||||
|
||||
await deleteBtn.click();
|
||||
|
||||
@@ -521,8 +487,10 @@ test.describe("去重流程", () => {
|
||||
|
||||
test("重试去重按钮 - 失败记录显示重试", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "dup-retry");
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-retry",
|
||||
);
|
||||
|
||||
// 创建查重记录
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
@@ -543,20 +511,14 @@ test.describe("去重流程", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 记录列表中至少有一条记录
|
||||
const resultCard = page.locator(".dup-result-card").first();
|
||||
const cardVisible = await resultCard
|
||||
.isVisible({ timeout: 10_000 })
|
||||
.catch(() => false);
|
||||
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false);
|
||||
|
||||
if (cardVisible) {
|
||||
// 验证记录卡片基本结构
|
||||
|
||||
@@ -6,12 +6,7 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -263,12 +258,7 @@ test.describe("剪辑计划 - API 操作", () => {
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 10,
|
||||
material_type: "video",
|
||||
},
|
||||
{ segment_order: 1, duration_min: 5, duration_max: 10, material_type: "video" },
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -279,12 +269,7 @@ test.describe("剪辑计划 - API 操作", () => {
|
||||
mode: "voice_over",
|
||||
estimated_duration: 60,
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 10,
|
||||
duration_max: 30,
|
||||
material_type: "video",
|
||||
},
|
||||
{ segment_order: 1, duration_min: 10, duration_max: 30, material_type: "video" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
+29
-112
@@ -93,8 +93,7 @@ function mockProducts(count: number, statuses: string[] = ["completed"]) {
|
||||
resolution: "1080x1920",
|
||||
file_size: (5 + i) * 1024 * 1024,
|
||||
duplicate_rate: i * 5,
|
||||
video_url:
|
||||
status === "completed" ? "https://example.com/video.mp4" : undefined,
|
||||
video_url: status === "completed" ? "https://example.com/video.mp4" : undefined,
|
||||
thumbnail_url: undefined,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
@@ -219,11 +218,7 @@ test.describe("作品库页面", () => {
|
||||
const products = mockProducts(3, ["completed", "processing", "failed"]);
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
|
||||
@@ -260,24 +255,12 @@ test.describe("作品库页面", () => {
|
||||
|
||||
const products = [
|
||||
{ ...mockProducts(1, ["completed"])[0], title: "已完成作品" },
|
||||
{
|
||||
...mockProducts(1, ["processing"])[0],
|
||||
title: "处理中作品",
|
||||
id: `mock-prod-${Date.now()}-p`,
|
||||
},
|
||||
{
|
||||
...mockProducts(1, ["failed"])[0],
|
||||
title: "失败作品",
|
||||
id: `mock-prod-${Date.now()}-f`,
|
||||
},
|
||||
{ ...mockProducts(1, ["processing"])[0], title: "处理中作品", id: `mock-prod-${Date.now()}-p` },
|
||||
{ ...mockProducts(1, ["failed"])[0], title: "失败作品", id: `mock-prod-${Date.now()}-f` },
|
||||
];
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
@@ -293,9 +276,9 @@ test.describe("作品库页面", () => {
|
||||
const completedCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "已完成作品" });
|
||||
await expect(
|
||||
completedCard.locator(".xx-product-status.completed"),
|
||||
).toHaveText("已完成");
|
||||
await expect(completedCard.locator(".xx-product-status.completed")).toHaveText(
|
||||
"已完成",
|
||||
);
|
||||
|
||||
const processingCard = page
|
||||
.locator(".xx-product-card")
|
||||
@@ -326,11 +309,7 @@ test.describe("作品库页面", () => {
|
||||
const productId = products[0].id;
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
// 直接访问详情页
|
||||
await page.goto(`/app/products/${productId}`);
|
||||
@@ -358,11 +337,7 @@ test.describe("作品库页面", () => {
|
||||
products[0].video_url = "https://example.com/test-video.mp4";
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
@@ -381,10 +356,7 @@ test.describe("作品库页面", () => {
|
||||
// 播放弹窗出现 - 验证有视频元素或播放器容器
|
||||
// (通过 Mock 的 video_url,video 元素应能渲染)
|
||||
const videoEl = page.locator("video");
|
||||
const videoVisible = await videoEl
|
||||
.first()
|
||||
.isVisible({ timeout: 5000 })
|
||||
.catch(() => false);
|
||||
const videoVisible = await videoEl.first().isVisible({ timeout: 5000 }).catch(() => false);
|
||||
// 或弹窗容器可见
|
||||
const modalVisible = await page
|
||||
.locator(".ant-modal-content")
|
||||
@@ -408,11 +380,7 @@ test.describe("作品库页面", () => {
|
||||
products[0].title = "下载测试作品";
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
@@ -441,11 +409,7 @@ test.describe("作品库页面", () => {
|
||||
products[0].title = "处理中下载测试";
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
@@ -520,11 +484,7 @@ test.describe("作品库页面", () => {
|
||||
route.continue();
|
||||
});
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
@@ -547,12 +507,9 @@ test.describe("作品库页面", () => {
|
||||
const { headers } = await createAuthedUser(request, "products-del-api");
|
||||
|
||||
// 测试删除不存在的产品,验证 API 端点存在
|
||||
const resp = await request.delete(
|
||||
`${apiBase}/products/nonexistent-test-id`,
|
||||
{
|
||||
headers,
|
||||
},
|
||||
);
|
||||
const resp = await request.delete(`${apiBase}/products/nonexistent-test-id`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
// 应返回 404 或 403,不应是 405 (Method Not Allowed) 或 404 (路由不存在)
|
||||
// 404 表示资源不存在但端点存在
|
||||
@@ -572,11 +529,7 @@ test.describe("作品库页面", () => {
|
||||
// Mock 空列表
|
||||
await mockProductsApi(page, []);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
@@ -600,24 +553,12 @@ test.describe("作品库页面", () => {
|
||||
);
|
||||
|
||||
const products = [
|
||||
{
|
||||
...mockProducts(1, ["completed"])[0],
|
||||
title: "苹果宣传视频",
|
||||
id: `mock-prod-${Date.now()}-apple`,
|
||||
},
|
||||
{
|
||||
...mockProducts(1, ["completed"])[0],
|
||||
title: "香蕉推广视频",
|
||||
id: `mock-prod-${Date.now()}-banana`,
|
||||
},
|
||||
{ ...mockProducts(1, ["completed"])[0], title: "苹果宣传视频", id: `mock-prod-${Date.now()}-apple` },
|
||||
{ ...mockProducts(1, ["completed"])[0], title: "香蕉推广视频", id: `mock-prod-${Date.now()}-banana` },
|
||||
];
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
@@ -625,9 +566,7 @@ test.describe("作品库页面", () => {
|
||||
});
|
||||
|
||||
// 两个作品都可见
|
||||
await expect(page.getByText("苹果宣传视频")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(page.getByText("苹果宣传视频")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByText("香蕉推广视频")).toBeVisible();
|
||||
|
||||
// 搜索"苹果"
|
||||
@@ -637,9 +576,7 @@ test.describe("作品库页面", () => {
|
||||
|
||||
// 清空搜索
|
||||
await page.getByPlaceholder("搜索成片名称...").fill("");
|
||||
await expect(page.getByText("香蕉推广视频")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(page.getByText("香蕉推广视频")).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("作品状态筛选", async ({ page, request }) => {
|
||||
@@ -650,24 +587,12 @@ test.describe("作品库页面", () => {
|
||||
);
|
||||
|
||||
const products = [
|
||||
{
|
||||
...mockProducts(1, ["completed"])[0],
|
||||
title: "已完成筛选",
|
||||
id: `mock-prod-${Date.now()}-done`,
|
||||
},
|
||||
{
|
||||
...mockProducts(1, ["processing"])[0],
|
||||
title: "处理中筛选",
|
||||
id: `mock-prod-${Date.now()}-proc`,
|
||||
},
|
||||
{ ...mockProducts(1, ["completed"])[0], title: "已完成筛选", id: `mock-prod-${Date.now()}-done` },
|
||||
{ ...mockProducts(1, ["processing"])[0], title: "处理中筛选", id: `mock-prod-${Date.now()}-proc` },
|
||||
];
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
@@ -704,11 +629,7 @@ test.describe("作品库页面", () => {
|
||||
products[2].title = "批量测试 3";
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
@@ -732,12 +653,8 @@ test.describe("作品库页面", () => {
|
||||
await expect(batchBar.getByText(/已选择 1 项/)).toBeVisible();
|
||||
|
||||
// 批量按钮存在
|
||||
await expect(
|
||||
batchBar.getByRole("button", { name: "批量下载" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
batchBar.getByRole("button", { name: "批量删除" }),
|
||||
).toBeVisible();
|
||||
await expect(batchBar.getByRole("button", { name: "批量下载" })).toBeVisible();
|
||||
await expect(batchBar.getByRole("button", { name: "批量删除" })).toBeVisible();
|
||||
|
||||
// 取消选择
|
||||
await batchBar.getByRole("button", { name: "取消选择" }).click();
|
||||
|
||||
@@ -6,12 +6,7 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -406,10 +401,7 @@ test.describe("个人设置 - 退出登录", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("登出 API - 正向", async ({ request }) => {
|
||||
const { headers, email } = await createAuthedUser(
|
||||
request,
|
||||
"profile-logout",
|
||||
);
|
||||
const { headers, email } = await createAuthedUser(request, "profile-logout");
|
||||
|
||||
const response = await request.post(`${apiBase}/auth/logout`, {
|
||||
headers,
|
||||
|
||||
@@ -49,9 +49,7 @@ test.describe("注册页面", () => {
|
||||
await expect(page.locator(".xx-auth-brand-name")).toHaveText("小虾智剪");
|
||||
|
||||
// 标题/描述
|
||||
await expect(
|
||||
page.getByText("创建账户,开启智能视频创作之旅"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("创建账户,开启智能视频创作之旅")).toBeVisible();
|
||||
|
||||
// 表单字段
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible();
|
||||
@@ -71,10 +69,7 @@ test.describe("注册页面", () => {
|
||||
await page.goto("/register");
|
||||
|
||||
// 直接点击注册按钮
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
|
||||
// 应显示必填错误
|
||||
await expect(page.getByText("请输入邮箱")).toBeVisible();
|
||||
@@ -91,10 +86,7 @@ test.describe("注册页面", () => {
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
|
||||
// 应显示邮箱格式错误
|
||||
await expect(page.getByText("请输入有效的邮箱地址")).toBeVisible();
|
||||
@@ -108,10 +100,7 @@ test.describe("注册页面", () => {
|
||||
await page.getByLabel("密码").fill("123");
|
||||
await page.getByLabel("确认密码").fill("123");
|
||||
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
|
||||
// 应显示密码长度错误
|
||||
await expect(page.getByText("密码至少 8 个字符")).toBeVisible();
|
||||
@@ -125,10 +114,7 @@ test.describe("注册页面", () => {
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill("Different123!");
|
||||
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
|
||||
// 应显示密码不一致错误
|
||||
await expect(page.getByText("两次输入的密码不一致")).toBeVisible();
|
||||
@@ -142,10 +128,7 @@ test.describe("注册页面", () => {
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
|
||||
await expect(page.getByText("请输入用户名")).toBeVisible();
|
||||
});
|
||||
@@ -171,16 +154,10 @@ test.describe("注册页面", () => {
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
|
||||
const resp = await registerResponse;
|
||||
expect(
|
||||
resp.ok(),
|
||||
`注册请求应返回 2xx,实际: ${resp.status()}`,
|
||||
).toBeTruthy();
|
||||
expect(resp.ok(), `注册请求应返回 2xx,实际: ${resp.status()}`).toBeTruthy();
|
||||
|
||||
// 注册成功后应跳转到登录页或显示成功消息
|
||||
// 页面应停留在可识别的状态(成功提示或跳转)
|
||||
@@ -222,19 +199,14 @@ test.describe("注册页面", () => {
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
|
||||
// 应显示错误提示(通过 antd message 或表单错误)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
// 检查是否有错误消息
|
||||
const hasError = await page
|
||||
.getByText(/注册失败|已注册|已存在|exists/)
|
||||
.isVisible();
|
||||
const hasError = await page.getByText(/注册失败|已注册|已存在|exists/).isVisible();
|
||||
return hasError ? "error_shown" : "waiting";
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
@@ -282,12 +254,7 @@ test.describe("注册页面", () => {
|
||||
|
||||
// 注册
|
||||
await request.post(`${apiBase}/auth/register`, {
|
||||
data: {
|
||||
email,
|
||||
password: PASSWORD,
|
||||
username,
|
||||
display_name: "Reg Auth Test",
|
||||
},
|
||||
data: { email, password: PASSWORD, username, display_name: "Reg Auth Test" },
|
||||
});
|
||||
|
||||
// 登录
|
||||
@@ -326,8 +293,6 @@ test.describe("注册页面", () => {
|
||||
// 注册页对已登录用户也可访问(注册页是公开页面)
|
||||
// 验证页面正常渲染
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible();
|
||||
await expect(
|
||||
page.locator("button[type='submit']").filter({ hasText: "注册" }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator("button[type='submit']").filter({ hasText: "注册" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,12 +9,7 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -248,13 +243,8 @@ test.describe("订阅套餐页 - 升级交互", () => {
|
||||
const url = page.url();
|
||||
// 验证页面有响应(跳转到支付或保持在订阅页但有弹窗)
|
||||
expect(
|
||||
url.includes("/subscription/upgrade") ||
|
||||
url.includes("/subscription") ||
|
||||
(await page
|
||||
.locator(".ant-modal, [role='dialog']")
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)),
|
||||
url.includes("/subscription/upgrade") || url.includes("/subscription") ||
|
||||
(await page.locator(".ant-modal, [role='dialog']").first().isVisible().catch(() => false)),
|
||||
).toBeTruthy();
|
||||
}
|
||||
});
|
||||
@@ -548,16 +538,13 @@ test.describe("订阅 - 支付流程", () => {
|
||||
test("创建支付订单 - 正向 API", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-pay-api");
|
||||
|
||||
const response = await request.post(
|
||||
`${apiBase}/subscription/create-order`,
|
||||
{
|
||||
headers,
|
||||
data: {
|
||||
plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
const response = await request.post(`${apiBase}/subscription/create-order`, {
|
||||
headers,
|
||||
data: {
|
||||
plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// 创建支付订单可能成功或接口不存在
|
||||
expect(
|
||||
@@ -573,15 +560,12 @@ test.describe("订阅 - 支付流程", () => {
|
||||
});
|
||||
|
||||
test("未登录创建订单 - 反向", async ({ request }) => {
|
||||
const response = await request.post(
|
||||
`${apiBase}/subscription/create-order`,
|
||||
{
|
||||
data: {
|
||||
plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
const response = await request.post(`${apiBase}/subscription/create-order`, {
|
||||
data: {
|
||||
plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
);
|
||||
});
|
||||
expect([401, 403, 404]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,10 +178,7 @@ test.describe("订阅过期处理", () => {
|
||||
// 免费用户可能不需要取消,返回 400 或类似错误
|
||||
if (!response.ok()) {
|
||||
const data = await response.json();
|
||||
expect(
|
||||
data.error?.message || data.detail || data.message,
|
||||
"应返回错误信息",
|
||||
).toBeTruthy();
|
||||
expect(data.error?.message || data.detail || data.message, "应返回错误信息").toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -6,12 +6,7 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -331,9 +326,7 @@ test.describe("模板库 - 模板展示", () => {
|
||||
if (await modal.isVisible({ timeout: 5_000 })) {
|
||||
await expect(modal).toBeVisible();
|
||||
// 验证预览内容存在
|
||||
await expect(
|
||||
modal.locator(".xx-template-modal-title-row"),
|
||||
).toBeVisible();
|
||||
await expect(modal.locator(".xx-template-modal-title-row")).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -494,10 +487,7 @@ test.describe("模板库 - API 操作", () => {
|
||||
`${apiBase}/templates/${templateId}/favorite`,
|
||||
{ headers },
|
||||
);
|
||||
expect(
|
||||
unfavResp.status() < 500,
|
||||
"取消收藏请求应返回 2xx 或 4xx",
|
||||
).toBeTruthy();
|
||||
expect(unfavResp.status() < 500, "取消收藏请求应返回 2xx 或 4xx").toBeTruthy();
|
||||
});
|
||||
|
||||
test("获取模板详情 - 正向", async ({ request }) => {
|
||||
@@ -525,9 +515,10 @@ test.describe("模板库 - API 操作", () => {
|
||||
expect(createResp.ok()).toBeTruthy();
|
||||
const created = await createResp.json();
|
||||
|
||||
const detailResp = await request.get(`${apiBase}/templates/${created.id}`, {
|
||||
headers,
|
||||
});
|
||||
const detailResp = await request.get(
|
||||
`${apiBase}/templates/${created.id}`,
|
||||
{ headers },
|
||||
);
|
||||
expect(detailResp.ok(), "获取详情应成功").toBeTruthy();
|
||||
const detail = await detailResp.json();
|
||||
expect(detail.id).toBe(created.id);
|
||||
|
||||
@@ -175,9 +175,10 @@ test.describe("认证流程", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect([400, 422], "缺少用户名字段应返回 4xx 校验错误").toContain(
|
||||
response.status(),
|
||||
);
|
||||
expect(
|
||||
[400, 422],
|
||||
"缺少用户名字段应返回 4xx 校验错误",
|
||||
).toContain(response.status());
|
||||
});
|
||||
|
||||
// ─── 登录 ────────────────────────────────────────────
|
||||
@@ -229,9 +230,7 @@ test.describe("认证流程", () => {
|
||||
data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD },
|
||||
});
|
||||
if (response.status() !== 429) break;
|
||||
console.log(
|
||||
`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`,
|
||||
);
|
||||
console.log(`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`);
|
||||
await new Promise((r) => setTimeout(r, 65_000));
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,7 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -229,11 +224,7 @@ test.describe("标题库 - API 完整操作", () => {
|
||||
|
||||
test("编辑标题 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-update");
|
||||
const titleId = await createTitle(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(36),
|
||||
);
|
||||
const titleId = await createTitle(request, headers, Date.now().toString(36));
|
||||
|
||||
const newName = `更新后的标题 ${Date.now()}`;
|
||||
const newText = "这是更新后的标题内容";
|
||||
@@ -265,11 +256,7 @@ test.describe("标题库 - API 完整操作", () => {
|
||||
|
||||
test("删除标题 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-delete");
|
||||
const titleId = await createTitle(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(36),
|
||||
);
|
||||
const titleId = await createTitle(request, headers, Date.now().toString(36));
|
||||
|
||||
// 删除
|
||||
const deleteResp = await request.delete(`${apiBase}/titles/${titleId}`, {
|
||||
@@ -292,21 +279,9 @@ test.describe("标题库 - API 完整操作", () => {
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
const titles = [
|
||||
{
|
||||
name: `批量标题 1 ${suffix}`,
|
||||
text: `内容 1 ${suffix}`,
|
||||
category: "default",
|
||||
},
|
||||
{
|
||||
name: `批量标题 2 ${suffix}`,
|
||||
text: `内容 2 ${suffix}`,
|
||||
category: "种草",
|
||||
},
|
||||
{
|
||||
name: `批量标题 3 ${suffix}`,
|
||||
text: `内容 3 ${suffix}`,
|
||||
category: "知识",
|
||||
},
|
||||
{ name: `批量标题 1 ${suffix}`, text: `内容 1 ${suffix}`, category: "default" },
|
||||
{ name: `批量标题 2 ${suffix}`, text: `内容 2 ${suffix}`, category: "种草" },
|
||||
{ name: `批量标题 3 ${suffix}`, text: `内容 3 ${suffix}`, category: "知识" },
|
||||
];
|
||||
|
||||
const response = await request.post(`${apiBase}/titles/batch-import`, {
|
||||
@@ -322,9 +297,7 @@ test.describe("标题库 - API 完整操作", () => {
|
||||
|
||||
if (response.ok()) {
|
||||
const data = await response.json();
|
||||
expect(
|
||||
Array.isArray(data) || data.success_count !== undefined,
|
||||
).toBeTruthy();
|
||||
expect(Array.isArray(data) || data.success_count !== undefined).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -6,12 +6,7 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -331,9 +326,10 @@ test.describe("声音克隆 - API 操作", () => {
|
||||
).toBeTruthy();
|
||||
|
||||
// 验证已删除
|
||||
const getResp = await request.get(`${apiBase}/voice-clones/${cloneId}`, {
|
||||
headers,
|
||||
});
|
||||
const getResp = await request.get(
|
||||
`${apiBase}/voice-clones/${cloneId}`,
|
||||
{ headers },
|
||||
);
|
||||
expect([404, 410]).toContain(getResp.status());
|
||||
}
|
||||
// 如果创建失败(比如音频格式问题),测试也通过
|
||||
@@ -495,15 +491,11 @@ test.describe("声音克隆 - 上传区域", () => {
|
||||
});
|
||||
|
||||
// 尝试点击克隆新音色按钮
|
||||
const cloneBtn = page.getByRole("button", {
|
||||
name: /克隆新音色|立即克隆|新建/,
|
||||
});
|
||||
const cloneBtn = page.getByRole("button", { name: /克隆新音色|立即克隆|新建/ });
|
||||
if (await cloneBtn.isVisible()) {
|
||||
await cloneBtn.click();
|
||||
// 弹窗应该出现
|
||||
const modal = page.locator(
|
||||
".ant-modal, .vc-edit-dialog, [role='dialog']",
|
||||
);
|
||||
const modal = page.locator(".ant-modal, .vc-edit-dialog, [role='dialog']");
|
||||
if (await modal.first().isVisible({ timeout: 5_000 })) {
|
||||
await expect(modal.first()).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -6,12 +6,7 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -162,9 +157,7 @@ test.describe("音色库页面 - 页面加载", () => {
|
||||
});
|
||||
|
||||
// 验证搜索框存在
|
||||
const searchInput = page.locator(
|
||||
"input[type='search'], .xx-voices-search input, input[placeholder*='搜索']",
|
||||
);
|
||||
const searchInput = page.locator("input[type='search'], .xx-voices-search input, input[placeholder*='搜索']");
|
||||
await expect(searchInput.first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,32 +5,6 @@
|
||||
import apiClient from "./client";
|
||||
import { getOrCreateDefaultProject } from "./projects";
|
||||
|
||||
/** 素材元数据 */
|
||||
export interface AssetMetadata {
|
||||
/** 时长(秒) */
|
||||
duration?: number;
|
||||
/** 宽度(像素) */
|
||||
width?: number;
|
||||
/** 高度(像素) */
|
||||
height?: number;
|
||||
/** 比特率(bps) */
|
||||
bitrate?: number;
|
||||
/** 编码格式 */
|
||||
codec?: string;
|
||||
/** 帧率 */
|
||||
fps?: number;
|
||||
/** 采样率(Hz) */
|
||||
sample_rate?: number;
|
||||
/** 声道数 */
|
||||
channels?: number;
|
||||
/** 其他扩展字段 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** 素材分类状态 */
|
||||
export type AssetClassificationStatus =
|
||||
"pending" | "processing" | "completed" | "failed";
|
||||
|
||||
/** 素材条目 */
|
||||
export interface AssetItem {
|
||||
id: string;
|
||||
@@ -38,14 +12,12 @@ export interface AssetItem {
|
||||
name: string;
|
||||
storage_key: string;
|
||||
mime_type: string;
|
||||
metadata: AssetMetadata;
|
||||
metadata: Record<string, unknown>;
|
||||
file_size?: number;
|
||||
file_url?: string;
|
||||
thumbnail_url?: string;
|
||||
/** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */
|
||||
duration?: number;
|
||||
status?: string;
|
||||
classification_status?: AssetClassificationStatus | null;
|
||||
classification_status?: string | null;
|
||||
quality_score?: number | null;
|
||||
tag_ids?: string[];
|
||||
created_at?: string;
|
||||
@@ -195,7 +167,7 @@ export const createAsset = async (data: {
|
||||
name: string;
|
||||
storage_key: string;
|
||||
mime_type: string;
|
||||
metadata?: AssetMetadata;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): Promise<AssetItem> => {
|
||||
const response = await apiClient.post("/assets", data);
|
||||
return response.data;
|
||||
@@ -378,52 +350,3 @@ export const getClassificationJob = async (
|
||||
const response = await apiClient.get(`/classification-jobs/${jobId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ─── 批量操作 ───────────────────────────────────────────────
|
||||
|
||||
/** 批量操作结果 */
|
||||
export interface BatchOperationResult {
|
||||
succeeded: string[];
|
||||
failed: string[];
|
||||
total: number;
|
||||
success_count: number;
|
||||
failure_count: number;
|
||||
}
|
||||
|
||||
/** 批量删除素材 */
|
||||
export const batchDeleteAssets = async (
|
||||
assetIds: string[],
|
||||
): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-delete", {
|
||||
asset_ids: assetIds,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 批量打标签 */
|
||||
export const batchTagAssets = async (data: {
|
||||
asset_ids: string[];
|
||||
tags: string[];
|
||||
mode: "add" | "replace";
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-tag", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 批量改分类 */
|
||||
export const batchClassifyAssets = async (data: {
|
||||
asset_ids: string[];
|
||||
category: string;
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-classify", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 批量智能标记 */
|
||||
export const batchMarkAssets = async (data: {
|
||||
asset_ids: string[];
|
||||
smart_view: "recommended" | "caution" | "high_risk";
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-mark", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* BGM 预设音乐 API
|
||||
* 对接后端 BGM 混音能力:预设列表查询(按风格分类 + 关键词搜索)
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
|
||||
/* ──────────── 类型 ──────────── */
|
||||
|
||||
/** BGM 风格分类 */
|
||||
export type BgmCategory = "轻快" | "治愈" | "科技" | "电商";
|
||||
|
||||
/** BGM 预设项 */
|
||||
export interface BgmPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
category: BgmCategory;
|
||||
/** 音频文件 URL */
|
||||
url: string;
|
||||
/** 时长(秒) */
|
||||
duration: number;
|
||||
/** 关键词标签 */
|
||||
tags: string[];
|
||||
/** 封面图 URL */
|
||||
cover_url?: string;
|
||||
}
|
||||
|
||||
/** BGM 预设列表查询参数 */
|
||||
export interface BgmPresetsQuery {
|
||||
category?: BgmCategory | string;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
/** BGM 混音配置(嵌入剪辑计划) */
|
||||
export interface BgmMixConfig {
|
||||
/** 是否启用 BGM */
|
||||
enabled: boolean;
|
||||
/** 选中的 BGM ID */
|
||||
music_id: string;
|
||||
/** BGM 音量 0-100 */
|
||||
volume: number;
|
||||
/** 淡入时长(秒) 0-3 */
|
||||
fade_in: number;
|
||||
/** 淡出时长(秒) 0-3 */
|
||||
fade_out: number;
|
||||
/** 人声闪避(sidechain) */
|
||||
voice_dodge: boolean;
|
||||
}
|
||||
|
||||
/** 默认 BGM 混音配置 */
|
||||
export const DEFAULT_BGM_MIX_CONFIG: BgmMixConfig = {
|
||||
enabled: false,
|
||||
music_id: "",
|
||||
volume: 50,
|
||||
fade_in: 0.5,
|
||||
fade_out: 0.5,
|
||||
voice_dodge: true,
|
||||
};
|
||||
|
||||
/* ──────────── API ──────────── */
|
||||
|
||||
/** 获取 BGM 预设列表 */
|
||||
export const getBgmPresets = async (
|
||||
params?: BgmPresetsQuery,
|
||||
): Promise<BgmPreset[]> => {
|
||||
const searchParams: Record<string, string> = {};
|
||||
if (params?.category) searchParams.category = params.category;
|
||||
if (params?.keyword) searchParams.keyword = params.keyword;
|
||||
const res = await apiClient.get("/bgm/presets", { params: searchParams });
|
||||
return res.data?.data ?? res.data ?? [];
|
||||
};
|
||||
+32
-188
@@ -4,15 +4,6 @@
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import type { AssetItem } from "./assets";
|
||||
import type {
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "@/pages/editing-planner/types";
|
||||
|
||||
/* ============================================================
|
||||
* 后端 API 类型(严格匹配后端 Schema)
|
||||
@@ -22,83 +13,6 @@ import type {
|
||||
export type EditPlanStatus =
|
||||
"draft" | "editing" | "rendering" | "completed" | "failed";
|
||||
|
||||
/** 标题配置(对齐后端 title_config) */
|
||||
export interface TitleConfig {
|
||||
ai_auto_select: boolean;
|
||||
content: string;
|
||||
font_preset: string;
|
||||
font_color: string;
|
||||
font_size: number;
|
||||
position: string;
|
||||
}
|
||||
|
||||
/** 字幕配置 */
|
||||
export interface SubtitleConfig {
|
||||
enabled: boolean;
|
||||
position: string;
|
||||
font: string;
|
||||
color: string;
|
||||
size: number;
|
||||
animation: string;
|
||||
}
|
||||
|
||||
/** BGM 配置 */
|
||||
export interface BgmConfig {
|
||||
enabled: boolean;
|
||||
music_id: string;
|
||||
}
|
||||
|
||||
/** 片段 TTS 配置 */
|
||||
export interface SegmentTtsConfig {
|
||||
mode: string;
|
||||
text: string;
|
||||
voice_id: string;
|
||||
speed: number;
|
||||
pitch: number;
|
||||
volume: number;
|
||||
subtitle_sync: boolean;
|
||||
}
|
||||
|
||||
/** 片段裁剪配置 */
|
||||
export interface SegmentTrimConfig {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
}
|
||||
|
||||
/** 片段转场配置 */
|
||||
export interface SegmentTransitionConfig {
|
||||
type: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/** 剪辑计划中的单个片段(config 内部 segments 项) */
|
||||
export interface EditPlanSegment {
|
||||
segment_order: number;
|
||||
duration_min: number;
|
||||
duration_max: number;
|
||||
material_type: string;
|
||||
transition?: SegmentTransitionConfig;
|
||||
playback_speed?: number;
|
||||
tts_config?: SegmentTtsConfig;
|
||||
trim_config?: SegmentTrimConfig;
|
||||
}
|
||||
|
||||
/** 剪辑计划 config 完整类型(对齐后端 config JSON 结构) */
|
||||
export interface EditPlanConfig {
|
||||
title_config?: TitleConfig;
|
||||
subtitle_config?: SubtitleConfig;
|
||||
bgm_config?: BgmConfig;
|
||||
estimated_duration?: number;
|
||||
segments?: EditPlanSegment[];
|
||||
watermark_config?: WatermarkConfig;
|
||||
intro_outro_config?: IntroOutroConfig;
|
||||
pip_config?: PipConfig;
|
||||
filter_config?: FilterConfig;
|
||||
green_screen_config?: ChromaKeyConfig;
|
||||
sticker_config?: StickerConfig;
|
||||
cover_config?: CoverConfig;
|
||||
}
|
||||
|
||||
/** 剪辑计划(后端响应) */
|
||||
export interface EditPlan {
|
||||
id: string;
|
||||
@@ -106,7 +20,7 @@ export interface EditPlan {
|
||||
name: string;
|
||||
status: EditPlanStatus;
|
||||
total_duration: number;
|
||||
config: EditPlanConfig;
|
||||
config: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -115,7 +29,7 @@ export interface EditPlan {
|
||||
export interface CreateEditPlanRequest {
|
||||
template_id: string;
|
||||
name: string;
|
||||
config?: EditPlanConfig;
|
||||
config?: Record<string, unknown>;
|
||||
total_duration?: number;
|
||||
/** 来源剪辑计划 ID(从剪辑计划跳转到一键生成时关联) */
|
||||
source_edit_plan_id?: string;
|
||||
@@ -124,7 +38,7 @@ export interface CreateEditPlanRequest {
|
||||
/** 更新剪辑计划请求 */
|
||||
export interface UpdateEditPlanRequest {
|
||||
name?: string;
|
||||
config?: EditPlanConfig;
|
||||
config?: Record<string, unknown>;
|
||||
total_duration?: number;
|
||||
status?: EditPlanStatus;
|
||||
}
|
||||
@@ -166,24 +80,6 @@ export interface GenerationStatusResponse {
|
||||
clips: ClipStatusItem[];
|
||||
}
|
||||
|
||||
/** 生成视频详情(对应后端 GeneratedVideoResponse) */
|
||||
export interface GeneratedVideo {
|
||||
id: string;
|
||||
project_id?: string;
|
||||
generation_task_id: string;
|
||||
name: string;
|
||||
file_url: string;
|
||||
file_size?: number;
|
||||
duration?: number;
|
||||
thumbnail_url?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fps?: number;
|
||||
status: string;
|
||||
review_status?: string;
|
||||
download_url?: string;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* AI 推荐 & 封面生成(任务 3.09)
|
||||
* ============================================================ */
|
||||
@@ -204,14 +100,14 @@ export interface AIRecommendClipItem {
|
||||
transition_effect: string;
|
||||
asset_id: string;
|
||||
start_time: number;
|
||||
config: EditPlanConfig;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** AI 推荐响应 */
|
||||
export interface AIRecommendResponse {
|
||||
plan_id: string;
|
||||
clips: AIRecommendClipItem[];
|
||||
config: EditPlanConfig;
|
||||
config: Record<string, unknown>;
|
||||
total_duration: number;
|
||||
confidence: number;
|
||||
}
|
||||
@@ -226,13 +122,7 @@ export interface GenerateCoverRequest {
|
||||
/** AI 封面生成响应 */
|
||||
export interface GenerateCoverResponse {
|
||||
plan_id: string;
|
||||
cover: {
|
||||
scheme?: string;
|
||||
asset_id?: string;
|
||||
frame_time?: number;
|
||||
thumbnail_url?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
cover: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -257,27 +147,10 @@ export interface EditPlanClip {
|
||||
order: number;
|
||||
}
|
||||
|
||||
/** 转场效果(14 种预设) */
|
||||
/** 转场效果 */
|
||||
export interface TransitionEffect {
|
||||
type:
|
||||
| "none"
|
||||
| "cut"
|
||||
| "fade"
|
||||
| "dissolve"
|
||||
| "zoom"
|
||||
| "slide_left"
|
||||
| "slide_right"
|
||||
| "slide_up"
|
||||
| "slide_down"
|
||||
| "wipe_left"
|
||||
| "wipe_right"
|
||||
| "wipe_up"
|
||||
| "wipe_down"
|
||||
| "circlecrop"
|
||||
| "rectcrop";
|
||||
type: "none" | "fade" | "dissolve" | "wipe" | "zoom" | "slide";
|
||||
duration: number; // 转场时长(秒)
|
||||
/** 播放速度倍率 */
|
||||
playback_speed?: number;
|
||||
}
|
||||
|
||||
/** 素材库资产(UI 层类型,映射自后端 AssetResponse) */
|
||||
@@ -304,30 +177,15 @@ export interface MediaAsset {
|
||||
* API 函数 — 严格对接后端
|
||||
* ============================================================ */
|
||||
|
||||
/** 剪辑计划列表查询参数 */
|
||||
export interface EditPlanListParams {
|
||||
/** 获取剪辑计划列表 */
|
||||
export async function getEditPlans(params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
template_id?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/** 剪辑计划列表分页响应 */
|
||||
export interface EditPlanListResponse {
|
||||
items: EditPlan[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
/** 获取剪辑计划列表(支持分页和筛选) */
|
||||
export async function getEditPlans(
|
||||
params?: EditPlanListParams,
|
||||
): Promise<EditPlanListResponse> {
|
||||
const response = await apiClient.get<EditPlanListResponse>("/edit-plans", {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
}): Promise<EditPlan[]> {
|
||||
const response = await apiClient.get("/edit-plans", { params });
|
||||
return response.data.items || [];
|
||||
}
|
||||
|
||||
/** 获取单个剪辑计划 */
|
||||
@@ -408,14 +266,6 @@ export async function getEditPlanGenerations(
|
||||
return response.data.items || [];
|
||||
}
|
||||
|
||||
/** 获取生成任务的视频结果列表 */
|
||||
export async function getGenerationTaskResults(
|
||||
taskId: string,
|
||||
): Promise<GeneratedVideo[]> {
|
||||
const response = await apiClient.get(`/generation/tasks/${taskId}/results`);
|
||||
return response.data.items || response.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx
|
||||
* 将后端 AssetResponse 映射为前端 MediaAsset 类型
|
||||
@@ -447,22 +297,26 @@ function inferMediaType(mimeType: string): "video" | "image" | "audio" {
|
||||
}
|
||||
|
||||
function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
|
||||
// 优先取顶层 duration,其次从 metadata 回退
|
||||
const metaDuration =
|
||||
typeof asset.metadata?.duration === "number"
|
||||
? asset.metadata.duration
|
||||
: undefined;
|
||||
const meta = (asset.metadata || {}) as Record<string, unknown>;
|
||||
const ext = asset as AssetItem & Record<string, unknown>;
|
||||
return {
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
type: inferMediaType(asset.mime_type || ""),
|
||||
thumbnail_url: asset.thumbnail_url,
|
||||
duration: asset.duration ?? metaDuration,
|
||||
thumbnail_url:
|
||||
typeof ext.thumbnail_url === "string" ? ext.thumbnail_url : undefined,
|
||||
duration:
|
||||
typeof ext.duration === "number"
|
||||
? ext.duration
|
||||
: typeof meta.duration === "number"
|
||||
? (meta.duration as number)
|
||||
: undefined,
|
||||
size: asset.file_size ?? undefined,
|
||||
tags: [],
|
||||
created_at: asset.created_at ?? "",
|
||||
quality_score: asset.quality_score ?? undefined,
|
||||
classification_status: asset.classification_status ?? undefined,
|
||||
classification_status: (asset.classification_status ??
|
||||
undefined) as MediaAsset["classification_status"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -470,27 +324,17 @@ function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
|
||||
* 常量
|
||||
* ============================================================ */
|
||||
|
||||
/** 转场效果选项(14 种预设) */
|
||||
/** 转场效果选项 */
|
||||
export const TRANSITION_OPTIONS: {
|
||||
value: TransitionEffect["type"];
|
||||
label: string;
|
||||
icon: string;
|
||||
}[] = [
|
||||
{ value: "none", label: "无转场", icon: "⊘" },
|
||||
{ value: "cut", label: "硬切", icon: "✂" },
|
||||
{ value: "fade", label: "淡入淡出", icon: "◐" },
|
||||
{ value: "dissolve", label: "溶解", icon: "◈" },
|
||||
{ value: "zoom", label: "缩放", icon: "⊕" },
|
||||
{ value: "slide_left", label: "左滑", icon: "←" },
|
||||
{ value: "slide_right", label: "右滑", icon: "→" },
|
||||
{ value: "slide_up", label: "上滑", icon: "↑" },
|
||||
{ value: "slide_down", label: "下滑", icon: "↓" },
|
||||
{ value: "wipe_left", label: "左擦除", icon: "▸|" },
|
||||
{ value: "wipe_right", label: "右擦除", icon: "|◂" },
|
||||
{ value: "wipe_up", label: "上擦除", icon: "▴̄" },
|
||||
{ value: "wipe_down", label: "下擦除", icon: "▾̄" },
|
||||
{ value: "circlecrop", label: "圆形裁切", icon: "●" },
|
||||
{ value: "rectcrop", label: "矩形裁切", icon: "■" },
|
||||
{ value: "none", label: "无转场" },
|
||||
{ value: "fade", label: "淡入淡出" },
|
||||
{ value: "dissolve", label: "溶解" },
|
||||
{ value: "wipe", label: "擦除" },
|
||||
{ value: "zoom", label: "缩放" },
|
||||
{ value: "slide", label: "滑动" },
|
||||
];
|
||||
|
||||
/** 素材类型标签 */
|
||||
|
||||
@@ -3,15 +3,6 @@
|
||||
* 对接后端 /api/v1/templates 路由
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import type {
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "@/pages/editing-planner/types";
|
||||
|
||||
/* ──────────── 类型定义 ──────────── */
|
||||
|
||||
@@ -81,20 +72,6 @@ export interface EditingTemplate {
|
||||
bgm_config: BgmConfig;
|
||||
estimated_duration: number;
|
||||
segments: TemplateSegment[];
|
||||
/** 水印配置(后端就绪后启用) */
|
||||
watermark_config?: WatermarkConfig;
|
||||
/** 片头片尾配置(后端就绪后启用) */
|
||||
intro_outro_config?: IntroOutroConfig;
|
||||
/** 画中画配置 */
|
||||
pip_config?: PipConfig;
|
||||
/** 滤镜调色配置 */
|
||||
filter_config?: FilterConfig;
|
||||
/** 绿幕抠像配置 */
|
||||
green_screen_config?: ChromaKeyConfig;
|
||||
/** 贴纸配置 */
|
||||
sticker_config?: StickerConfig;
|
||||
/** 封面配置 */
|
||||
cover_config?: CoverConfig;
|
||||
is_active?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -118,20 +95,6 @@ export interface SaveTemplatePayload {
|
||||
bgm_config: BgmConfig;
|
||||
estimated_duration: number;
|
||||
segments: Omit<TemplateSegment, "id">[];
|
||||
/** 水印配置(后端就绪后启用) */
|
||||
watermark_config?: WatermarkConfig;
|
||||
/** 片头片尾配置(后端就绪后启用) */
|
||||
intro_outro_config?: IntroOutroConfig;
|
||||
/** 画中画配置 */
|
||||
pip_config?: PipConfig;
|
||||
/** 滤镜调色配置 */
|
||||
filter_config?: FilterConfig;
|
||||
/** 绿幕抠像配置 */
|
||||
green_screen_config?: ChromaKeyConfig;
|
||||
/** 贴纸配置 */
|
||||
sticker_config?: StickerConfig;
|
||||
/** 封面配置 */
|
||||
cover_config?: CoverConfig;
|
||||
}
|
||||
|
||||
/** 使用模板生成请求体 */
|
||||
|
||||
+14
-120
@@ -1,13 +1,8 @@
|
||||
/**
|
||||
* 成品 / 视频相关 API
|
||||
* 后端无 /products 路由,实际从 /generation/tasks 端点获取数据
|
||||
* 成品相关 API
|
||||
* Phase 1 重构:去掉 projectId,成品直接归属用户
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import { getGenerationTaskResults } from "./editPlans";
|
||||
import type { GeneratedVideo } from "./editPlans";
|
||||
|
||||
/** 复核状态 */
|
||||
export type ReviewStatus = "pending_review" | "approved" | "rejected";
|
||||
|
||||
/** 成品条目 */
|
||||
export interface ProductItem {
|
||||
@@ -19,134 +14,33 @@ export interface ProductItem {
|
||||
file_size?: number;
|
||||
resolution?: string;
|
||||
status: "processing" | "completed" | "failed";
|
||||
/** 复核状态 */
|
||||
review_status?: ReviewStatus;
|
||||
/** 所属项目 ID */
|
||||
project_id?: string;
|
||||
/** 所属项目名称 */
|
||||
project_name?: string;
|
||||
/** 查重率(百分比) */
|
||||
duplicate_rate?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/** 列表查询参数 */
|
||||
export interface ProductListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
project_id?: string;
|
||||
review_status?: ReviewStatus | "all";
|
||||
}
|
||||
|
||||
/** 分页响应 */
|
||||
export interface ProductListResponse {
|
||||
items: ProductItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
/** 批量下载任务状态 */
|
||||
export interface BatchDownloadStatus {
|
||||
job_id: string;
|
||||
status: "processing" | "completed" | "failed";
|
||||
/** 完成后返回的下载 URL */
|
||||
download_url?: string;
|
||||
/** 进度百分比 */
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 generation task 数据映射为 ProductItem 格式
|
||||
*/
|
||||
function mapTaskToProductItem(
|
||||
task: GeneratedVideo | Record<string, unknown>,
|
||||
): ProductItem {
|
||||
const video = task as GeneratedVideo;
|
||||
return {
|
||||
id: video.id,
|
||||
title: video.name || "未命名视频",
|
||||
video_url: video.file_url,
|
||||
thumbnail_url: video.thumbnail_url,
|
||||
duration_seconds: video.duration,
|
||||
file_size: video.file_size,
|
||||
resolution:
|
||||
video.width && video.height
|
||||
? `${video.width}x${video.height}`
|
||||
: undefined,
|
||||
status:
|
||||
video.status === "completed"
|
||||
? "completed"
|
||||
: video.status === "failed"
|
||||
? "failed"
|
||||
: "processing",
|
||||
review_status: video.review_status as ReviewStatus | undefined,
|
||||
project_id: video.project_id,
|
||||
created_at: (task as Record<string, unknown>).created_at as
|
||||
string | undefined,
|
||||
updated_at: (task as Record<string, unknown>).updated_at as
|
||||
string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取成品列表(支持分页和筛选)— 实际从 generation tasks 获取 */
|
||||
export const getProducts = async (
|
||||
params?: ProductListParams,
|
||||
): Promise<ProductItem[]> => {
|
||||
const response = await apiClient.get("/generation/tasks", { params });
|
||||
const tasks = response.data.items || response.data || [];
|
||||
return tasks.map(mapTaskToProductItem);
|
||||
/** 获取当前用户的所有成品 */
|
||||
export const getProducts = async (): Promise<ProductItem[]> => {
|
||||
const response = await apiClient.get("/products");
|
||||
return response.data.items || response.data || [];
|
||||
};
|
||||
|
||||
/** 获取单个成品详情 — 通过 task ID 获取结果 */
|
||||
/** 获取单个成品详情 */
|
||||
export const getProduct = async (productId: string): Promise<ProductItem> => {
|
||||
const response = await apiClient.get(`/generation/tasks/${productId}`);
|
||||
return mapTaskToProductItem(response.data);
|
||||
const response = await apiClient.get(`/products/${productId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 删除成品 — 删除 generation task */
|
||||
/** 删除成品 */
|
||||
export const deleteProduct = async (productId: string): Promise<void> => {
|
||||
await apiClient.delete(`/generation/tasks/${productId}`);
|
||||
await apiClient.delete(`/products/${productId}`);
|
||||
};
|
||||
|
||||
/** 获取成品下载链接 — 从 generation task results 获取 */
|
||||
/** 获取成品下载链接 */
|
||||
export const getProductDownloadUrl = async (
|
||||
productId: string,
|
||||
): Promise<{ url: string; expires_at: string }> => {
|
||||
const videos = await getGenerationTaskResults(productId);
|
||||
const video = videos[0];
|
||||
if (!video?.download_url) throw new Error("下载链接不可用");
|
||||
return { url: video.download_url, expires_at: "" };
|
||||
};
|
||||
|
||||
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
|
||||
export const updateReviewStatus = async (
|
||||
productId: string,
|
||||
status: ReviewStatus,
|
||||
): Promise<ProductItem> => {
|
||||
// 后端暂无 /generation/tasks/{id}/review 端点
|
||||
// 暂时返回当前状态,后续可扩展
|
||||
const product = await getProduct(productId);
|
||||
return { ...product, review_status: status };
|
||||
};
|
||||
|
||||
/** 发起批量下载 — TODO: 后端暂无对应端点 */
|
||||
export const batchDownload = async (
|
||||
videoIds: string[],
|
||||
): Promise<{ job_id: string }> => {
|
||||
// 后端暂无 /generation/tasks/batch-download 端点
|
||||
// 暂时返回模拟 job_id,后续可扩展
|
||||
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds);
|
||||
return { job_id: `mock-${Date.now()}` };
|
||||
};
|
||||
|
||||
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
|
||||
export const getBatchDownloadStatus = async (
|
||||
jobId: string,
|
||||
): Promise<BatchDownloadStatus> => {
|
||||
// 后端暂无 /generation/tasks/batch-download/{jobId} 端点
|
||||
// 暂时返回模拟状态,后续可扩展
|
||||
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId);
|
||||
return { job_id: jobId, status: "processing", progress: 0 };
|
||||
const response = await apiClient.get(`/products/${productId}/download-url`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
+10
-56
@@ -1,67 +1,31 @@
|
||||
/**
|
||||
* 任务相关 API
|
||||
* 对接后端任务中心 API:
|
||||
* - POST /api/v1/generation/tasks — 创建生成任务
|
||||
* - GET /api/v1/tasks — 用户级任务列表(支持分页/筛选)
|
||||
* - GET /api/v1/tasks/{task_id} — 任务详情(含 error_info)
|
||||
* - POST /api/v1/tasks/{task_id}/retry — 重试失败任务
|
||||
* 对接后端方案 A 扩展后的端点(PR #109)
|
||||
* - POST /api/v1/generation/tasks — 创建生成任务(template_id + asset_ids 细粒度模式)
|
||||
* - GET /api/v1/tasks — 用户级任务列表(跨 project)
|
||||
* - POST /api/v1/tasks/{task_id}/retry — 简化重试
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
|
||||
/* ──────────── 类型定义 ──────────── */
|
||||
|
||||
/** 任务状态 */
|
||||
export type TaskStatus =
|
||||
"pending" | "waiting" | "running" | "completed" | "failed" | "cancelled";
|
||||
|
||||
/** 任务类型 */
|
||||
export type TaskType = "ingest" | "generation" | string;
|
||||
|
||||
/** 错误详情 */
|
||||
export interface TaskErrorInfo {
|
||||
error_type: string;
|
||||
error_message: string;
|
||||
failed_step: string;
|
||||
stack_trace?: string;
|
||||
}
|
||||
|
||||
/** 任务条目(对应用户级 UserTaskResponse) */
|
||||
export interface TaskItem {
|
||||
id: string;
|
||||
task_type: TaskType;
|
||||
task_type: "ingest" | "generation" | string;
|
||||
project_id: string;
|
||||
template_id?: string;
|
||||
status: TaskStatus;
|
||||
template_id: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
current_step: string;
|
||||
error_message: string;
|
||||
user_message: string;
|
||||
retryable: boolean;
|
||||
source_id: string;
|
||||
/** 错误详情(失败任务) */
|
||||
error_info?: TaskErrorInfo;
|
||||
/** 耗时(秒) */
|
||||
duration_seconds?: number;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
/** 任务列表查询参数 */
|
||||
export interface TaskListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
status?: TaskStatus | "all";
|
||||
task_type?: TaskType | "all";
|
||||
}
|
||||
|
||||
/** 任务列表分页响应 */
|
||||
export interface TaskListResponse {
|
||||
items: TaskItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
/** 创建生成任务请求参数 */
|
||||
export interface CreateGenerationTaskRequest {
|
||||
template_id: string;
|
||||
@@ -100,23 +64,13 @@ export const createGenerationTask = async (
|
||||
return data;
|
||||
};
|
||||
|
||||
/** 获取任务列表(支持分页和筛选) */
|
||||
export const getTasks = async (
|
||||
params?: TaskListParams,
|
||||
): Promise<TaskListResponse> => {
|
||||
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
/** 获取当前用户的所有任务(兼容旧接口,跨 project) */
|
||||
/** 获取当前用户的所有任务(跨 project) */
|
||||
export const getUserTasks = async (): Promise<TaskItem[]> => {
|
||||
const { data } = await apiClient.get("/tasks");
|
||||
return data.items || data || [];
|
||||
return data.items || [];
|
||||
};
|
||||
|
||||
/** 获取单个任务详情(含 error_info) */
|
||||
/** 获取单个任务详情(用于轮询进度) */
|
||||
export const getTask = async (taskId: string): Promise<TaskItem> => {
|
||||
const { data } = await apiClient.get(`/tasks/${taskId}`);
|
||||
return data;
|
||||
|
||||
@@ -1,112 +1,26 @@
|
||||
/**
|
||||
* 模板相关 API
|
||||
* 对接后端模板管理接口:
|
||||
* - GET /api/v1/templates — 模板列表(分页/筛选)
|
||||
* - GET /api/v1/templates/{id} — 模板详情
|
||||
* - POST /api/v1/templates/{id}/copy — 复制模板
|
||||
* - POST /api/v1/templates/{id}/generate — 从模板生成剪辑计划
|
||||
* - POST /api/v1/templates/{id}/toggle-favorite — 收藏/取消收藏
|
||||
* Phase 1 新增:全局模板库
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editingPlanner";
|
||||
import type { EditPlanConfig } from "./editPlans";
|
||||
|
||||
/* ──────────── 类型定义 ──────────── */
|
||||
|
||||
/** 模板条目(后端 TemplateResponse) */
|
||||
/** 模板条目 */
|
||||
export interface TemplateItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
tags?: string[];
|
||||
target_duration: number;
|
||||
clip_count: number;
|
||||
/** 使用次数 */
|
||||
usage_count?: number;
|
||||
thumbnail_url?: string;
|
||||
preview_url?: string;
|
||||
is_active: boolean;
|
||||
is_favorite?: boolean;
|
||||
/** 素材规则(片段配置) */
|
||||
segments?: TemplateSegment[];
|
||||
/** 字幕样式 */
|
||||
subtitle_config?: SubtitleConfig;
|
||||
/** BGM 配置 */
|
||||
bgm_config?: BgmConfig;
|
||||
/** 标题配置 */
|
||||
title_config?: TitleConfig;
|
||||
/** 视频比例 */
|
||||
aspect_ratio?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/** 模板片段(素材规则) */
|
||||
export interface TemplateSegment {
|
||||
id?: string;
|
||||
segment_order: number;
|
||||
duration_min: number;
|
||||
duration_max: number;
|
||||
material_type: string | null;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** 模板列表查询参数 */
|
||||
export interface TemplateListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
category?: string;
|
||||
tags?: string;
|
||||
keyword?: string;
|
||||
/** 时长筛选(秒):short < 30, medium 30-120, long > 120 */
|
||||
duration_range?: "short" | "medium" | "long";
|
||||
}
|
||||
|
||||
/** 模板列表分页响应 */
|
||||
export interface TemplateListResponse {
|
||||
items: TemplateItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
/** 从模板生成剪辑计划请求 */
|
||||
export interface GenerateFromTemplateRequest {
|
||||
asset_ids?: string[];
|
||||
name?: string;
|
||||
config?: EditPlanConfig;
|
||||
}
|
||||
|
||||
/** 从模板生成剪辑计划响应 */
|
||||
export interface GenerateFromTemplateResponse {
|
||||
plan_id: string;
|
||||
template_id: string;
|
||||
status: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** 复制模板响应 */
|
||||
export interface CopyTemplateResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
source_template_id: string;
|
||||
}
|
||||
|
||||
/* ──────────── API 函数 ──────────── */
|
||||
|
||||
/** 获取模板列表(支持分页和筛选) */
|
||||
export const getTemplates = async (
|
||||
params?: TemplateListParams,
|
||||
): Promise<TemplateListResponse> => {
|
||||
const { data } = await apiClient.get<TemplateListResponse>("/templates", {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
/** 获取模板列表(兼容旧接口,返回数组) */
|
||||
export const getTemplatesList = async (): Promise<TemplateItem[]> => {
|
||||
/** 获取全局模板列表 */
|
||||
export const getTemplates = async (): Promise<TemplateItem[]> => {
|
||||
const response = await apiClient.get("/templates");
|
||||
return response.data.items || response.data || [];
|
||||
};
|
||||
@@ -128,46 +42,3 @@ export const toggleFavoriteTemplate = async (
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 复制模板(创建副本到我的模板) */
|
||||
export const copyTemplate = async (
|
||||
templateId: string,
|
||||
): Promise<CopyTemplateResponse> => {
|
||||
const response = await apiClient.post<CopyTemplateResponse>(
|
||||
`/templates/${templateId}/copy`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 从模板生成剪辑计划 */
|
||||
export const generateFromTemplate = async (
|
||||
templateId: string,
|
||||
data?: GenerateFromTemplateRequest,
|
||||
): Promise<GenerateFromTemplateResponse> => {
|
||||
const response = await apiClient.post<GenerateFromTemplateResponse>(
|
||||
`/templates/${templateId}/generate`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
/** 模板分类选项 */
|
||||
export const TEMPLATE_CATEGORY_OPTIONS = [
|
||||
{ value: "", label: "全部分类" },
|
||||
{ value: "口播", label: "口播" },
|
||||
{ value: "种草", label: "种草" },
|
||||
{ value: "产品", label: "产品" },
|
||||
{ value: "品牌", label: "品牌" },
|
||||
{ value: "混剪", label: "混剪" },
|
||||
{ value: "Vlog", label: "Vlog" },
|
||||
];
|
||||
|
||||
/** 时长筛选选项 */
|
||||
export const TEMPLATE_DURATION_OPTIONS = [
|
||||
{ value: "", label: "全部时长" },
|
||||
{ value: "short", label: "30秒以内" },
|
||||
{ value: "medium", label: "30秒-2分钟" },
|
||||
{ value: "long", label: "2分钟以上" },
|
||||
];
|
||||
|
||||
@@ -140,52 +140,3 @@ export const saveTtsToLibrary = async (
|
||||
export const deleteTTSJob = async (jobId: string): Promise<void> => {
|
||||
await apiClient.delete(`/tts/jobs/${jobId}`);
|
||||
};
|
||||
|
||||
/* ── 音色列表 ──────────────────────────────────── */
|
||||
|
||||
/** TTS 音色 */
|
||||
export interface TTSVoice {
|
||||
id: string;
|
||||
name: string;
|
||||
/** 音色分类标签:male/female/young/service/news/emotion */
|
||||
category?: string;
|
||||
/** 语言 */
|
||||
language?: string;
|
||||
/** 试听 URL */
|
||||
preview_url?: string;
|
||||
/** 描述 */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** 获取 TTS 音色列表 */
|
||||
export const getTtsVoices = async (): Promise<TTSVoice[]> => {
|
||||
const response = await apiClient.get<TTSVoice[]>("/tts/voices");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/* ── TTS 试听 ──────────────────────────────────── */
|
||||
|
||||
/** TTS 试听请求参数 */
|
||||
export interface TTSPreviewRequest {
|
||||
text: string;
|
||||
voice_id: string;
|
||||
speed?: number;
|
||||
pitch?: number;
|
||||
}
|
||||
|
||||
/** TTS 试听响应 */
|
||||
export interface TTSPreviewResponse {
|
||||
audio_url: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/** TTS 试听 */
|
||||
export const previewTts = async (
|
||||
data: TTSPreviewRequest,
|
||||
): Promise<TTSPreviewResponse> => {
|
||||
const response = await apiClient.post<TTSPreviewResponse>(
|
||||
"/tts/preview",
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -384,6 +384,7 @@
|
||||
border-bottom: 1px solid var(--border-light) !important;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
响应式
|
||||
============================================================ */
|
||||
@@ -408,6 +409,7 @@
|
||||
.xx-modal .ant-modal-header {
|
||||
padding: var(--space-md) !important;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@@ -421,6 +423,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ── xx-card antd 子元素覆盖样式(从 Admin.css 迁移) ── */
|
||||
/* AdminComingSoon 等页面使用 <Card className="xx-card"> 时需要 */
|
||||
/* .xx-card 基础样式和 :hover 已在 global.css 中定义(V21 设计系统) */
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
ScanOutlined,
|
||||
ControlOutlined,
|
||||
CrownOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
/** 导航项类型 */
|
||||
@@ -81,12 +80,6 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
path: "/app/my-templates",
|
||||
icon: React.createElement(FolderOutlined),
|
||||
},
|
||||
{
|
||||
key: "edit-plans",
|
||||
label: "剪辑计划",
|
||||
path: "/app/edit-plans",
|
||||
icon: React.createElement(UnorderedListOutlined),
|
||||
},
|
||||
{
|
||||
key: "generate",
|
||||
label: "一键生成",
|
||||
@@ -111,12 +104,6 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
path: "/app/duplication",
|
||||
icon: React.createElement(ScanOutlined),
|
||||
},
|
||||
{
|
||||
key: "tasks",
|
||||
label: "任务中心",
|
||||
path: "/app/tasks",
|
||||
icon: React.createElement(UnorderedListOutlined),
|
||||
},
|
||||
];
|
||||
|
||||
/** 侧边栏导航分组(Sidebar 分组列表使用) */
|
||||
@@ -142,12 +129,6 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
path: "/app/editing-planner",
|
||||
icon: React.createElement(EditOutlined),
|
||||
},
|
||||
{
|
||||
key: "edit-plans",
|
||||
label: "剪辑计划",
|
||||
path: "/app/edit-plans",
|
||||
icon: React.createElement(UnorderedListOutlined),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -200,12 +181,6 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
path: "/app/history",
|
||||
icon: React.createElement(HistoryOutlined),
|
||||
},
|
||||
{
|
||||
key: "tasks",
|
||||
label: "任务中心",
|
||||
path: "/app/tasks",
|
||||
icon: React.createElement(UnorderedListOutlined),
|
||||
},
|
||||
{
|
||||
key: "duplication",
|
||||
label: "查重",
|
||||
|
||||
@@ -4,17 +4,7 @@
|
||||
* 使用 useQuery 对接后端真实 API(api/assets.ts)
|
||||
*/
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
Upload,
|
||||
Modal as AntModal,
|
||||
message,
|
||||
Popconfirm,
|
||||
Drawer,
|
||||
Tag,
|
||||
Input as AntInput,
|
||||
Radio,
|
||||
Select as AntSelect,
|
||||
} from "antd";
|
||||
import { Upload, Modal as AntModal, message, Popconfirm } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
@@ -27,11 +17,6 @@ import {
|
||||
ExperimentOutlined,
|
||||
LoadingOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
TagsOutlined,
|
||||
FolderOutlined,
|
||||
ThunderboltOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -42,13 +27,8 @@ import {
|
||||
deleteAsset,
|
||||
uploadAssetDirect,
|
||||
getAssetDiagnosis,
|
||||
batchDeleteAssets,
|
||||
batchTagAssets,
|
||||
batchClassifyAssets,
|
||||
batchMarkAssets,
|
||||
type AssetLibraryItem,
|
||||
type AssetItem as ApiAssetItem,
|
||||
type BatchOperationResult,
|
||||
} from "@/api/assets";
|
||||
import { Button, Input, Select } from "@/components/ui";
|
||||
import "./assets.css";
|
||||
@@ -427,33 +407,6 @@ const AssetLibrary: React.FC = () => {
|
||||
/* 诊断中状态 — 记录正在诊断的素材 ID */
|
||||
const [diagnosingId, setDiagnosingId] = useState<string | null>(null);
|
||||
|
||||
/* ── 批量操作弹窗状态 ── */
|
||||
const [tagModalOpen, setTagModalOpen] = useState(false);
|
||||
const [classifyModalOpen, setClassifyModalOpen] = useState(false);
|
||||
const [markModalOpen, setMarkModalOpen] = useState(false);
|
||||
const [resultDrawerOpen, setResultDrawerOpen] = useState(false);
|
||||
|
||||
/* 批量打标签 */
|
||||
const [batchTagInput, setBatchTagInput] = useState("");
|
||||
const [batchTags, setBatchTags] = useState<string[]>([]);
|
||||
const [tagMode, setTagMode] = useState<"add" | "replace">("add");
|
||||
|
||||
/* 批量改分类 */
|
||||
const [batchCategory, setBatchCategory] = useState("");
|
||||
|
||||
/* 批量智能标记 */
|
||||
const [batchSmartView, setBatchSmartView] = useState<
|
||||
"recommended" | "caution" | "high_risk"
|
||||
>("recommended");
|
||||
|
||||
/* 操作结果 */
|
||||
const [operationResult, setOperationResult] =
|
||||
useState<BatchOperationResult | null>(null);
|
||||
const [operationTitle, setOperationTitle] = useState("");
|
||||
|
||||
/* 批量操作 loading */
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
|
||||
/* 派生数据 */
|
||||
const filteredAssets = useMemo(() => {
|
||||
let list = assets;
|
||||
@@ -610,152 +563,19 @@ const AssetLibrary: React.FC = () => {
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const result = await batchDeleteAssets(ids);
|
||||
setOperationResult(result);
|
||||
setOperationTitle("批量删除");
|
||||
setResultDrawerOpen(true);
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||||
setSelectedIds(new Set());
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功删除 ${result.success_count} 个素材`);
|
||||
} else {
|
||||
message.warning(
|
||||
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
);
|
||||
let successCount = 0;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await deleteAsset(id);
|
||||
successCount++;
|
||||
} catch {
|
||||
// 忽略单个失败
|
||||
}
|
||||
} catch {
|
||||
message.error("批量删除失败,请重试");
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* 批量打标签 */
|
||||
const handleBatchTag = async () => {
|
||||
if (batchTags.length === 0) {
|
||||
message.warning("请至少输入一个标签");
|
||||
return;
|
||||
}
|
||||
const ids = Array.from(selectedIds);
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const result = await batchTagAssets({
|
||||
asset_ids: ids,
|
||||
tags: batchTags,
|
||||
mode: tagMode,
|
||||
});
|
||||
setOperationResult(result);
|
||||
setOperationTitle("批量打标签");
|
||||
setResultDrawerOpen(true);
|
||||
setTagModalOpen(false);
|
||||
setBatchTags([]);
|
||||
setBatchTagInput("");
|
||||
setTagMode("add");
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
setSelectedIds(new Set());
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功为 ${result.success_count} 个素材打标签`);
|
||||
} else {
|
||||
message.warning(
|
||||
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
message.error("批量打标签失败,请重试");
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* 批量改分类 */
|
||||
const handleBatchClassify = async () => {
|
||||
if (!batchCategory) {
|
||||
message.warning("请选择分类");
|
||||
return;
|
||||
}
|
||||
const ids = Array.from(selectedIds);
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const result = await batchClassifyAssets({
|
||||
asset_ids: ids,
|
||||
category: batchCategory,
|
||||
});
|
||||
setOperationResult(result);
|
||||
setOperationTitle("批量改分类");
|
||||
setResultDrawerOpen(true);
|
||||
setClassifyModalOpen(false);
|
||||
setBatchCategory("");
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
setSelectedIds(new Set());
|
||||
if (result.failure_count === 0) {
|
||||
message.success(
|
||||
`成功将 ${result.success_count} 个素材改为「${batchCategory}」`,
|
||||
);
|
||||
} else {
|
||||
message.warning(
|
||||
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
message.error("批量改分类失败,请重试");
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* 批量智能标记 */
|
||||
const handleBatchMark = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const result = await batchMarkAssets({
|
||||
asset_ids: ids,
|
||||
smart_view: batchSmartView,
|
||||
});
|
||||
setOperationResult(result);
|
||||
setOperationTitle("批量智能标记");
|
||||
setResultDrawerOpen(true);
|
||||
setMarkModalOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
setSelectedIds(new Set());
|
||||
const labelMap = {
|
||||
recommended: "推荐",
|
||||
caution: "慎用",
|
||||
high_risk: "高风险",
|
||||
};
|
||||
if (result.failure_count === 0) {
|
||||
message.success(
|
||||
`成功将 ${result.success_count} 个素材标记为「${labelMap[batchSmartView]}」`,
|
||||
);
|
||||
} else {
|
||||
message.warning(
|
||||
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
message.error("批量智能标记失败,请重试");
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* 标签输入处理 */
|
||||
const handleTagInputKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && batchTagInput.trim()) {
|
||||
e.preventDefault();
|
||||
const tag = batchTagInput.trim();
|
||||
if (!batchTags.includes(tag)) {
|
||||
setBatchTags([...batchTags, tag]);
|
||||
}
|
||||
setBatchTagInput("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeBatchTag = (tag: string) => {
|
||||
setBatchTags(batchTags.filter((t) => t !== tag));
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||||
setSelectedIds(new Set());
|
||||
message.success(`已删除 ${successCount}/${ids.length} 个素材`);
|
||||
};
|
||||
|
||||
// ── Loading 状态 ──
|
||||
@@ -949,30 +769,6 @@ const AssetLibrary: React.FC = () => {
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={deselectAll}>
|
||||
取消选择
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<TagsOutlined />}
|
||||
onClick={() => setTagModalOpen(true)}
|
||||
>
|
||||
打标签
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<FolderOutlined />}
|
||||
onClick={() => setClassifyModalOpen(true)}
|
||||
>
|
||||
改分类
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => setMarkModalOpen(true)}
|
||||
>
|
||||
智能标记
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={`确定删除 ${selectedIds.size} 个素材?`}
|
||||
onConfirm={handleBatchDelete}
|
||||
@@ -1102,207 +898,6 @@ const AssetLibrary: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</AntModal>
|
||||
|
||||
{/* ─── 批量打标签弹窗 ─── */}
|
||||
<AntModal
|
||||
title={`批量打标签(${selectedIds.size} 个素材)`}
|
||||
open={tagModalOpen}
|
||||
onCancel={() => {
|
||||
setTagModalOpen(false);
|
||||
setBatchTags([]);
|
||||
setBatchTagInput("");
|
||||
}}
|
||||
onOk={handleBatchTag}
|
||||
confirmLoading={batchLoading}
|
||||
okText="确认打标签"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="xx-batch-tag-modal">
|
||||
<div className="xx-batch-tag-mode">
|
||||
<span className="xx-batch-tag-mode-label">模式:</span>
|
||||
<Radio.Group
|
||||
value={tagMode}
|
||||
onChange={(e) => setTagMode(e.target.value)}
|
||||
>
|
||||
<Radio value="add">追加标签</Radio>
|
||||
<Radio value="replace">替换全部标签</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<div className="xx-batch-tag-input-row">
|
||||
<AntInput
|
||||
placeholder="输入标签后按 Enter 添加"
|
||||
value={batchTagInput}
|
||||
onChange={(e) => setBatchTagInput(e.target.value)}
|
||||
onKeyDown={handleTagInputKeyDown}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
{batchTags.length > 0 && (
|
||||
<div className="xx-batch-tag-list">
|
||||
{batchTags.map((tag) => (
|
||||
<Tag
|
||||
key={tag}
|
||||
closable
|
||||
onClose={() => removeBatchTag(tag)}
|
||||
color="blue"
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{tagMode === "replace" && batchTags.length > 0 && (
|
||||
<div className="xx-batch-tag-warning">
|
||||
<ExclamationCircleOutlined /> 替换模式将清除素材原有全部标签
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AntModal>
|
||||
|
||||
{/* ─── 批量改分类弹窗 ─── */}
|
||||
<AntModal
|
||||
title={`批量改分类(${selectedIds.size} 个素材)`}
|
||||
open={classifyModalOpen}
|
||||
onCancel={() => {
|
||||
setClassifyModalOpen(false);
|
||||
setBatchCategory("");
|
||||
}}
|
||||
onOk={handleBatchClassify}
|
||||
confirmLoading={batchLoading}
|
||||
okText="确认修改"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="xx-batch-classify-modal">
|
||||
<p className="xx-batch-classify-hint">
|
||||
将选中的 {selectedIds.size} 个素材统一修改为以下分类:
|
||||
</p>
|
||||
<AntSelect
|
||||
value={batchCategory || undefined}
|
||||
onChange={(v) => setBatchCategory(v)}
|
||||
placeholder="请选择分类"
|
||||
style={{ width: "100%" }}
|
||||
options={[
|
||||
{ value: "person", label: "人物" },
|
||||
{ value: "scenic", label: "风景" },
|
||||
{ value: "product", label: "产品" },
|
||||
{ value: "food", label: "美食" },
|
||||
{ value: "animal", label: "动物" },
|
||||
{ value: "architecture", label: "建筑" },
|
||||
{ value: "other", label: "其他" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</AntModal>
|
||||
|
||||
{/* ─── 批量智能标记弹窗 ─── */}
|
||||
<AntModal
|
||||
title={`批量智能标记(${selectedIds.size} 个素材)`}
|
||||
open={markModalOpen}
|
||||
onCancel={() => setMarkModalOpen(false)}
|
||||
onOk={handleBatchMark}
|
||||
confirmLoading={batchLoading}
|
||||
okText="确认标记"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="xx-batch-mark-modal">
|
||||
<p className="xx-batch-mark-hint">
|
||||
将选中的 {selectedIds.size} 个素材标记为:
|
||||
</p>
|
||||
<Radio.Group
|
||||
value={batchSmartView}
|
||||
onChange={(e) => setBatchSmartView(e.target.value)}
|
||||
className="xx-batch-mark-options"
|
||||
>
|
||||
<div className="xx-batch-mark-option">
|
||||
<Radio value="recommended">
|
||||
<Tag color="success">推荐</Tag>
|
||||
<span className="xx-batch-mark-desc">
|
||||
质量优良,可直接用于生产
|
||||
</span>
|
||||
</Radio>
|
||||
</div>
|
||||
<div className="xx-batch-mark-option">
|
||||
<Radio value="caution">
|
||||
<Tag color="warning">慎用</Tag>
|
||||
<span className="xx-batch-mark-desc">
|
||||
存在一定问题,需人工审核后再使用
|
||||
</span>
|
||||
</Radio>
|
||||
</div>
|
||||
<div className="xx-batch-mark-option">
|
||||
<Radio value="high_risk">
|
||||
<Tag color="error">高风险</Tag>
|
||||
<span className="xx-batch-mark-desc">
|
||||
存在严重问题,不建议使用
|
||||
</span>
|
||||
</Radio>
|
||||
</div>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</AntModal>
|
||||
|
||||
{/* ─── 操作结果 Drawer ─── */}
|
||||
<Drawer
|
||||
title={`${operationTitle} — 操作结果`}
|
||||
open={resultDrawerOpen}
|
||||
onClose={() => {
|
||||
setResultDrawerOpen(false);
|
||||
setOperationResult(null);
|
||||
}}
|
||||
width={420}
|
||||
>
|
||||
{operationResult && (
|
||||
<div className="xx-batch-result">
|
||||
<div className="xx-batch-result-summary">
|
||||
<div className="xx-batch-result-stat">
|
||||
<span className="xx-batch-result-total">
|
||||
总计 {operationResult.total} 个
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-batch-result-stat success">
|
||||
<CheckCircleOutlined />
|
||||
<span>成功 {operationResult.success_count} 个</span>
|
||||
</div>
|
||||
{operationResult.failure_count > 0 && (
|
||||
<div className="xx-batch-result-stat fail">
|
||||
<CloseCircleOutlined />
|
||||
<span>失败 {operationResult.failure_count} 个</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{operationResult.succeeded.length > 0 && (
|
||||
<div className="xx-batch-result-section">
|
||||
<h4 className="xx-batch-result-section-title success">
|
||||
<CheckCircleOutlined /> 成功列表
|
||||
</h4>
|
||||
<div className="xx-batch-result-ids">
|
||||
{operationResult.succeeded.map((id) => (
|
||||
<div key={id} className="xx-batch-result-id">
|
||||
{id}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{operationResult.failed.length > 0 && (
|
||||
<div className="xx-batch-result-section">
|
||||
<h4 className="xx-batch-result-section-title fail">
|
||||
<CloseCircleOutlined /> 失败列表
|
||||
</h4>
|
||||
<div className="xx-batch-result-ids">
|
||||
{operationResult.failed.map((id) => (
|
||||
<div key={id} className="xx-batch-result-id fail">
|
||||
{id}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -653,175 +653,3 @@
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
/* ─── 批量打标签弹窗 ─── */
|
||||
|
||||
.xx-batch-tag-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.xx-batch-tag-mode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-batch-tag-mode-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary, #111827);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.xx-batch-tag-input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-batch-tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-batch-tag-warning {
|
||||
padding: 10px 12px;
|
||||
background: #fff7ed;
|
||||
border: 1px solid #fed7aa;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
color: #c2410c;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ─── 批量改分类弹窗 ─── */
|
||||
|
||||
.xx-batch-classify-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.xx-batch-classify-hint {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ─── 批量智能标记弹窗 ─── */
|
||||
|
||||
.xx-batch-mark-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.xx-batch-mark-hint {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.xx-batch-mark-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.xx-batch-mark-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.xx-batch-mark-desc {
|
||||
margin-left: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
/* ─── 操作结果 Drawer ─── */
|
||||
|
||||
.xx-batch-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.xx-batch-result-summary {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
}
|
||||
|
||||
.xx-batch-result-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.xx-batch-result-stat.success {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.xx-batch-result-stat.fail {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.xx-batch-result-total {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.xx-batch-result-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-batch-result-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.xx-batch-result-section-title.success {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.xx-batch-result-section-title.fail {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.xx-batch-result-ids {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.xx-batch-result-id {
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-surface, #fff);
|
||||
border: 1px solid var(--border-primary, #e5e7eb);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.xx-batch-result-id.fail {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
@@ -1,433 +0,0 @@
|
||||
/**
|
||||
* 剪辑计划管理页面
|
||||
* 展示用户的所有剪辑计划,支持状态筛选、模板筛选、分页、一键重新生成
|
||||
*/
|
||||
import { useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Table,
|
||||
Tabs,
|
||||
Select,
|
||||
Tag,
|
||||
Button,
|
||||
message,
|
||||
Popconfirm,
|
||||
Tooltip,
|
||||
} from "antd";
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
SyncOutlined,
|
||||
CloseCircleOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
FileTextOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import {
|
||||
getEditPlans,
|
||||
deleteEditPlan,
|
||||
generateEditPlan,
|
||||
type EditPlan,
|
||||
type EditPlanStatus,
|
||||
type EditPlanListParams,
|
||||
} from "@/api/editPlans";
|
||||
import { getTemplatesList, type TemplateItem } from "@/api/templates";
|
||||
import "./edit-plans.css";
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
/** 状态 Tab 配置 */
|
||||
const STATUS_TABS: { key: EditPlanStatus | "all"; label: string }[] = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "draft", label: "草稿" },
|
||||
{ key: "editing", label: "编辑中" },
|
||||
{ key: "rendering", label: "渲染中" },
|
||||
{ key: "completed", label: "已完成" },
|
||||
{ key: "failed", label: "失败" },
|
||||
];
|
||||
|
||||
/** 状态标签配置 */
|
||||
const STATUS_CONFIG: Record<
|
||||
EditPlanStatus,
|
||||
{ label: string; color: string; icon: React.ReactNode }
|
||||
> = {
|
||||
draft: {
|
||||
label: "草稿",
|
||||
color: "default",
|
||||
icon: <FileTextOutlined />,
|
||||
},
|
||||
editing: {
|
||||
label: "编辑中",
|
||||
color: "processing",
|
||||
icon: <EditOutlined />,
|
||||
},
|
||||
rendering: {
|
||||
label: "渲染中",
|
||||
color: "warning",
|
||||
icon: <SyncOutlined spin />,
|
||||
},
|
||||
completed: {
|
||||
label: "已完成",
|
||||
color: "success",
|
||||
icon: <CheckCircleOutlined />,
|
||||
},
|
||||
failed: {
|
||||
label: "失败",
|
||||
color: "error",
|
||||
icon: <CloseCircleOutlined />,
|
||||
},
|
||||
};
|
||||
|
||||
/* ──────────── 工具函数 ──────────── */
|
||||
|
||||
/** 格式化时长 */
|
||||
const formatDuration = (seconds: number): string => {
|
||||
if (seconds <= 0) return "-";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
if (m === 0) return `${s}秒`;
|
||||
return `${m}分${s > 0 ? `${s}秒` : ""}`;
|
||||
};
|
||||
|
||||
/** 格式化时间 */
|
||||
const formatTime = (dateStr?: string | null): string => {
|
||||
if (!dateStr) return "-";
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
/* ──────────── 主组件 ──────────── */
|
||||
|
||||
export default function EditPlans() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 筛选状态
|
||||
const [statusFilter, setStatusFilter] = useState<EditPlanStatus | "all">(
|
||||
"all",
|
||||
);
|
||||
const [templateFilter, setTemplateFilter] = useState<string>("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
|
||||
// 查询参数
|
||||
const queryParams: EditPlanListParams = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...(statusFilter !== "all" && { status: statusFilter }),
|
||||
...(templateFilter !== "all" && { template_id: templateFilter }),
|
||||
};
|
||||
|
||||
// 获取剪辑计划列表
|
||||
const {
|
||||
data: planData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["edit-plans", queryParams],
|
||||
queryFn: () => getEditPlans(queryParams),
|
||||
refetchInterval: (query) => {
|
||||
// 有进行中的计划时自动刷新
|
||||
const plans = query.state.data?.items ?? [];
|
||||
const hasRunning = plans.some(
|
||||
(p) => p.status === "rendering" || p.status === "editing",
|
||||
);
|
||||
return hasRunning ? 5000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
// 获取模板列表(用于筛选下拉)
|
||||
const { data: templates } = useQuery({
|
||||
queryKey: ["templates-list-simple"],
|
||||
queryFn: getTemplatesList,
|
||||
});
|
||||
|
||||
const plans = planData?.items ?? [];
|
||||
const total = planData?.total ?? 0;
|
||||
|
||||
// 模板名称映射
|
||||
const templateNameMap = new Map<string, string>();
|
||||
(templates ?? []).forEach((t: TemplateItem) => {
|
||||
templateNameMap.set(t.id, t.name);
|
||||
});
|
||||
|
||||
// 删除计划
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteEditPlan,
|
||||
onSuccess: () => {
|
||||
message.success("剪辑计划已删除");
|
||||
queryClient.invalidateQueries({ queryKey: ["edit-plans"] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("删除失败,请稍后重试");
|
||||
},
|
||||
});
|
||||
|
||||
// 重新生成
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: generateEditPlan,
|
||||
onSuccess: () => {
|
||||
message.success("已重新提交生成");
|
||||
queryClient.invalidateQueries({ queryKey: ["edit-plans"] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("重新生成失败,请稍后重试");
|
||||
},
|
||||
});
|
||||
|
||||
// 跳转到剪辑编辑器
|
||||
const handleEdit = useCallback(
|
||||
(plan: EditPlan) => {
|
||||
navigate(`/app/editing-planner?planId=${plan.id}`);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
// 表格列定义
|
||||
const columns: ColumnsType<EditPlan> = [
|
||||
{
|
||||
title: "计划名称",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
width: 240,
|
||||
ellipsis: true,
|
||||
render: (name: string, record: EditPlan) => (
|
||||
<Tooltip title={name}>
|
||||
<span className="plan-name" onClick={() => handleEdit(record)}>
|
||||
{name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "模板",
|
||||
dataIndex: "template_id",
|
||||
key: "template_id",
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (templateId: string) => {
|
||||
const name = templateNameMap.get(templateId);
|
||||
return (
|
||||
<Tag color="blue" className="plan-template-tag">
|
||||
{name || templateId.slice(0, 8)}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
width: 120,
|
||||
render: (status: EditPlanStatus) => {
|
||||
const config = STATUS_CONFIG[status] || {
|
||||
label: status,
|
||||
color: "default",
|
||||
icon: null,
|
||||
};
|
||||
return (
|
||||
<Tag
|
||||
color={config.color}
|
||||
icon={config.icon}
|
||||
className="plan-status-tag"
|
||||
>
|
||||
{config.label}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "时长",
|
||||
dataIndex: "total_duration",
|
||||
key: "total_duration",
|
||||
width: 100,
|
||||
render: (seconds: number) => (
|
||||
<span className="plan-duration">{formatDuration(seconds)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
width: 130,
|
||||
render: (time: string) => (
|
||||
<span className="plan-time">{formatTime(time)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "更新时间",
|
||||
dataIndex: "updated_at",
|
||||
key: "updated_at",
|
||||
width: 130,
|
||||
render: (time: string) => (
|
||||
<span className="plan-time">{formatTime(time)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 180,
|
||||
fixed: "right",
|
||||
render: (_: unknown, record: EditPlan) => (
|
||||
<div className="plan-actions">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
className="plan-action-btn"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
{(record.status === "failed" || record.status === "completed") && (
|
||||
<Popconfirm
|
||||
title="确认重新生成"
|
||||
description="确定要重新生成这个剪辑计划吗?"
|
||||
onConfirm={() => regenerateMutation.mutate(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={regenerateMutation.isPending}
|
||||
className="plan-action-btn plan-regenerate-btn"
|
||||
>
|
||||
重新生成
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="确定要删除这个剪辑计划吗?此操作不可恢复。"
|
||||
onConfirm={() => deleteMutation.mutate(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deleteMutation.isPending}
|
||||
className="plan-action-btn"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// 错误处理
|
||||
if (error) {
|
||||
return (
|
||||
<div className="edit-plans-page">
|
||||
<div className="edit-plans-error">
|
||||
<CloseCircleOutlined />
|
||||
<p>加载剪辑计划失败</p>
|
||||
<Button onClick={() => window.location.reload()}>刷新页面</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="edit-plans-page">
|
||||
{/* 页面标题 */}
|
||||
<div className="edit-plans-header">
|
||||
<div className="edit-plans-header-text">
|
||||
<h2>剪辑计划</h2>
|
||||
<p>管理所有剪辑计划,支持重新生成和编辑</p>
|
||||
</div>
|
||||
<Button type="primary" onClick={() => navigate("/app/templates")}>
|
||||
从模板创建
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="edit-plans-filters">
|
||||
{/* 状态 Tab */}
|
||||
<Tabs
|
||||
activeKey={statusFilter}
|
||||
onChange={(key) => {
|
||||
setStatusFilter(key as EditPlanStatus | "all");
|
||||
setPage(1);
|
||||
}}
|
||||
items={STATUS_TABS.map((tab) => ({
|
||||
key: tab.key,
|
||||
label: tab.label,
|
||||
}))}
|
||||
className="edit-plans-status-tabs"
|
||||
/>
|
||||
|
||||
{/* 模板筛选 */}
|
||||
<Select
|
||||
value={templateFilter}
|
||||
onChange={(value) => {
|
||||
setTemplateFilter(value);
|
||||
setPage(1);
|
||||
}}
|
||||
options={[
|
||||
{ value: "all", label: "全部模板" },
|
||||
...(templates ?? []).map((t: TemplateItem) => ({
|
||||
value: t.id,
|
||||
label: t.name,
|
||||
})),
|
||||
]}
|
||||
style={{ minWidth: 180 }}
|
||||
placeholder="选择模板"
|
||||
className="edit-plans-template-filter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 计划表格 */}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={plans}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 900 }}
|
||||
className="edit-plans-table"
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="edit-plans-empty">
|
||||
<ClockCircleOutlined />
|
||||
<p>暂无剪辑计划</p>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ marginTop: 12 }}
|
||||
onClick={() => navigate("/app/templates")}
|
||||
>
|
||||
从模板创建
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
/**
|
||||
* 剪辑计划管理页面样式
|
||||
*/
|
||||
|
||||
/* ── 页面容器 ──────────────────────────────────────────── */
|
||||
.edit-plans-page {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── 页面头部 ──────────────────────────────────────────── */
|
||||
.edit-plans-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.edit-plans-header-text h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.edit-plans-header-text p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
/* ── 筛选栏 ────────────────────────────────────────────── */
|
||||
.edit-plans-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.edit-plans-status-tabs {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.edit-plans-status-tabs .ant-tabs-nav {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.edit-plans-status-tabs .ant-tabs-tab {
|
||||
padding: 8px 16px !important;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.edit-plans-status-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||
color: var(--primary-500, #6366f1) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.edit-plans-status-tabs .ant-tabs-ink-bar {
|
||||
background: var(--primary-500, #6366f1) !important;
|
||||
}
|
||||
|
||||
.edit-plans-template-filter {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
/* ── 表格 ──────────────────────────────────────────────── */
|
||||
.edit-plans-table {
|
||||
background: var(--bg-surface, #fff);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
.edit-plans-table .ant-table {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.edit-plans-table .ant-table-thead > tr > th {
|
||||
background: var(--bg-tertiary, #f8fafc) !important;
|
||||
border-bottom: 1px solid var(--border-primary, #e2e8f0);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #64748b);
|
||||
font-size: 13px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.edit-plans-table .ant-table-tbody > tr > td {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-light, #f1f5f9);
|
||||
}
|
||||
|
||||
.edit-plans-table .ant-table-tbody > tr:hover > td {
|
||||
background: var(--bg-hover, #f8fafc) !important;
|
||||
}
|
||||
|
||||
/* ── 计划名称 ──────────────────────────────────────────── */
|
||||
.plan-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #1e293b);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.plan-name:hover {
|
||||
color: var(--primary-500, #6366f1);
|
||||
}
|
||||
|
||||
/* ── 状态标签 ──────────────────────────────────────────── */
|
||||
.plan-status-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.plan-status-tag.ant-tag-default {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.plan-status-tag.ant-tag-processing {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.plan-status-tag.ant-tag-success {
|
||||
background: #f0fdf4;
|
||||
color: #16a34a;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.plan-status-tag.ant-tag-error {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.plan-status-tag.ant-tag-warning {
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* ── 时长 ──────────────────────────────────────────────── */
|
||||
.plan-duration {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
/* ── 时间 ──────────────────────────────────────────────── */
|
||||
.plan-time {
|
||||
color: var(--text-secondary, #64748b);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── 操作按钮 ──────────────────────────────────────────── */
|
||||
.plan-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.plan-action-btn {
|
||||
padding: 4px 8px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.plan-action-btn.ant-btn-link {
|
||||
color: var(--primary-500, #6366f1);
|
||||
}
|
||||
|
||||
.plan-action-btn.ant-btn-link:hover {
|
||||
color: var(--primary-600, #4f46e5);
|
||||
}
|
||||
|
||||
.plan-regenerate-btn {
|
||||
color: var(--primary-500, #6366f1) !important;
|
||||
}
|
||||
|
||||
.plan-regenerate-btn:hover {
|
||||
color: var(--primary-600, #4f46e5) !important;
|
||||
}
|
||||
|
||||
/* ── 空状态 ────────────────────────────────────────────── */
|
||||
.edit-plans-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.edit-plans-empty .anticon {
|
||||
font-size: 48px;
|
||||
color: var(--text-disabled, #cbd5e1);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.edit-plans-empty p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
/* ── 错误状态 ──────────────────────────────────────────── */
|
||||
.edit-plans-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
background: var(--bg-surface, #fff);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
.edit-plans-error .anticon {
|
||||
font-size: 48px;
|
||||
color: #ef4444;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.edit-plans-error p {
|
||||
margin: 0 0 16px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
/* ── 响应式 ────────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.edit-plans-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.edit-plans-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.edit-plans-filters {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.edit-plans-status-tabs {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.edit-plans-template-filter {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
* 剪辑计划编辑器 — V8 原型 1:1 还原
|
||||
* 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px)
|
||||
*/
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { message } from "antd";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
createEditingTemplate,
|
||||
updateEditingTemplate,
|
||||
getTemplateCategories,
|
||||
generateFromTemplate,
|
||||
MODE_LABELS,
|
||||
} from "@/api/editingPlanner";
|
||||
import type { EditPlanGeneration, MediaAsset } from "@/api/editPlans";
|
||||
@@ -27,34 +28,9 @@ import {
|
||||
generateCover,
|
||||
} from "@/api/editPlans";
|
||||
import { useUndoRedo } from "./hooks/useUndoRedo";
|
||||
import type {
|
||||
ClipData,
|
||||
ClipType,
|
||||
TransitionConfig,
|
||||
SpeedConfig,
|
||||
TtsConfig,
|
||||
TrimConfig,
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
TitleSettings,
|
||||
} from "./types";
|
||||
import {
|
||||
DEFAULT_TRANSITION,
|
||||
DEFAULT_SPEED,
|
||||
DEFAULT_TTS_CONFIG,
|
||||
DEFAULT_WATERMARK,
|
||||
DEFAULT_INTRO_OUTRO,
|
||||
DEFAULT_PIP_CONFIG,
|
||||
DEFAULT_FILTER_CONFIG,
|
||||
DEFAULT_CHROMA_KEY_CONFIG,
|
||||
DEFAULT_STICKER_CONFIG,
|
||||
DEFAULT_COVER_CONFIG,
|
||||
} from "./types";
|
||||
import type { TaskItem } from "@/api/tasks";
|
||||
import { createGenerationTask, getTask, retryTask } from "@/api/tasks";
|
||||
import type { ClipData, ClipType } from "./types";
|
||||
import {
|
||||
ensureDefaultLibrary,
|
||||
getAssetsByKind,
|
||||
@@ -66,23 +42,10 @@ import MediaPanel from "./components/MediaPanel";
|
||||
import PreviewPlayer from "./components/PreviewPlayer";
|
||||
import TimelinePanel from "./components/TimelinePanel";
|
||||
import ClipPropertiesPanel from "./components/ClipPropertiesPanel";
|
||||
import BgmSelector from "./components/BgmSelector";
|
||||
import SubtitleStylePanel from "./components/SubtitleStylePanel";
|
||||
import type { SubtitleStyleConfig } from "./components/SubtitleStylePanel";
|
||||
import { DEFAULT_SUBTITLE_STYLE } from "./components/SubtitleStylePanel";
|
||||
import TransitionSelector from "./components/TransitionSelector";
|
||||
import SpeedPanel from "./components/SpeedPanel";
|
||||
import TtsPanel from "./components/TtsPanel";
|
||||
import WatermarkPanel from "./components/WatermarkPanel";
|
||||
import IntroOutroPanel from "./components/IntroOutroPanel";
|
||||
import PipConfigPanel from "./components/PipConfigPanel";
|
||||
import FilterPanel from "./components/FilterPanel";
|
||||
import GreenScreenPanel from "./components/GreenScreenPanel";
|
||||
import StickerPanel from "./components/StickerPanel";
|
||||
import CoverSelector from "./components/CoverSelector";
|
||||
import SaveModal from "./components/SaveModal";
|
||||
import GenerationProgressModal from "./components/GenerationProgressModal";
|
||||
import type { GenPhase } from "./components/GenerationProgressModal";
|
||||
import GenerationHistoryModal from "./components/GenerationHistoryModal";
|
||||
import { DEFAULT_BGM_MIX_CONFIG, type BgmMixConfig } from "@/api/bgm";
|
||||
import "./EditingPlanner.css";
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
@@ -144,7 +107,7 @@ const EditingPlanner: React.FC = () => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
/* ── 标题/字幕/BGM 设置 ── */
|
||||
const [titleSettings, setTitleSettings] = useState<TitleSettings>({
|
||||
const [titleSettings, setTitleSettings] = useState({
|
||||
aiAutoSelect: false,
|
||||
title: "",
|
||||
position: "top",
|
||||
@@ -157,72 +120,17 @@ const EditingPlanner: React.FC = () => {
|
||||
color: "#ffffff",
|
||||
});
|
||||
|
||||
const [subtitleSettings, setSubtitleSettings] = useState<SubtitleStyleConfig>(
|
||||
{
|
||||
...DEFAULT_SUBTITLE_STYLE,
|
||||
},
|
||||
);
|
||||
|
||||
const [bgmSettings, setBgmSettings] = useState<BgmMixConfig>({
|
||||
...DEFAULT_BGM_MIX_CONFIG,
|
||||
const [subtitleSettings, setSubtitleSettings] = useState({
|
||||
enabled: true,
|
||||
position: "bottom",
|
||||
font: "思源黑体",
|
||||
size: 16,
|
||||
animation: "none",
|
||||
});
|
||||
|
||||
/* ── Drawer 开关 ── */
|
||||
const [bgmDrawerOpen, setBgmDrawerOpen] = useState(false);
|
||||
const [subtitleDrawerOpen, setSubtitleDrawerOpen] = useState(false);
|
||||
const [transitionDrawerOpen, setTransitionDrawerOpen] = useState(false);
|
||||
const [speedDrawerOpen, setSpeedDrawerOpen] = useState(false);
|
||||
/** 当前正在编辑转场的片段 ID(null = 全局默认转场) */
|
||||
const [transitionTargetClipId, setTransitionTargetClipId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
/** 当前正在调速的片段 ID */
|
||||
const [speedTargetClipId, setSpeedTargetClipId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
/** TTS 配音面板是否打开 */
|
||||
const [ttsDrawerOpen, setTtsDrawerOpen] = useState(false);
|
||||
/** 当前正在配置 TTS 的片段 ID */
|
||||
const [ttsTargetClipId, setTtsTargetClipId] = useState<string | null>(null);
|
||||
|
||||
/* ── 水印 / 片头片尾 ── */
|
||||
const [watermarkSettings, setWatermarkSettings] = useState<WatermarkConfig>({
|
||||
...DEFAULT_WATERMARK,
|
||||
const [bgmSettings, setBgmSettings] = useState({
|
||||
music: "none",
|
||||
});
|
||||
const [introOutroSettings, setIntroOutroSettings] =
|
||||
useState<IntroOutroConfig>({ ...DEFAULT_INTRO_OUTRO });
|
||||
const [watermarkDrawerOpen, setWatermarkDrawerOpen] = useState(false);
|
||||
const [introOutroDrawerOpen, setIntroOutroDrawerOpen] = useState(false);
|
||||
|
||||
/* ── 画中画 ── */
|
||||
const [pipSettings, setPipSettings] = useState<PipConfig>({
|
||||
...DEFAULT_PIP_CONFIG,
|
||||
});
|
||||
const [pipDrawerOpen, setPipDrawerOpen] = useState(false);
|
||||
|
||||
/* ── 滤镜调色 ── */
|
||||
const [filterSettings, setFilterSettings] = useState<FilterConfig>({
|
||||
...DEFAULT_FILTER_CONFIG,
|
||||
});
|
||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||||
|
||||
/* ── 绿幕抠像 ── */
|
||||
const [chromaKeySettings, setChromaKeySettings] = useState<ChromaKeyConfig>({
|
||||
...DEFAULT_CHROMA_KEY_CONFIG,
|
||||
});
|
||||
const [chromaKeyDrawerOpen, setChromaKeyDrawerOpen] = useState(false);
|
||||
|
||||
/* ── 贴纸 ── */
|
||||
const [stickerSettings, setStickerSettings] = useState<StickerConfig>({
|
||||
...DEFAULT_STICKER_CONFIG,
|
||||
});
|
||||
const [stickerDrawerOpen, setStickerDrawerOpen] = useState(false);
|
||||
|
||||
/* ── 封面 ── */
|
||||
const [coverSettings, setCoverSettings] = useState<CoverConfig>({
|
||||
...DEFAULT_COVER_CONFIG,
|
||||
});
|
||||
const [coverDrawerOpen, setCoverDrawerOpen] = useState(false);
|
||||
|
||||
/* ── 保存弹窗 ── */
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||||
@@ -231,6 +139,15 @@ const EditingPlanner: React.FC = () => {
|
||||
const [draftTags, setDraftTags] = useState("");
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
|
||||
/* ── 生成弹窗 ── */
|
||||
const [genModalOpen, setGenModalOpen] = useState(false);
|
||||
const [genPhase, setGenPhase] = useState<GenPhase>("setup");
|
||||
const [genTask, setGenTask] = useState<TaskItem | null>(null);
|
||||
const [genSubmitting, setGenSubmitting] = useState(false);
|
||||
const [voiceoverDuration, setVoiceoverDuration] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
/* ── 素材库 ── */
|
||||
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>([]);
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
|
||||
@@ -246,19 +163,6 @@ const EditingPlanner: React.FC = () => {
|
||||
|
||||
/* ── 播放 ── */
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [pixelsPerSecond, setPixelsPerSecond] = useState(40);
|
||||
const prevFrameTimeRef = useRef<number | null>(null);
|
||||
|
||||
/** 播放头跳转 */
|
||||
const handleSeek = useCallback((time: number) => {
|
||||
setCurrentTime(Math.max(0, time));
|
||||
}, []);
|
||||
|
||||
/** 轨道缩放 */
|
||||
const handleZoomChange = useCallback((pps: number) => {
|
||||
setPixelsPerSecond(pps);
|
||||
}, []);
|
||||
|
||||
/* ── 配音素材(queryKey 与 VoiceMaterialLibrary 共享缓存) ── */
|
||||
const voiceMaterialsQuery = useQuery({
|
||||
@@ -352,21 +256,16 @@ const EditingPlanner: React.FC = () => {
|
||||
size: tpl.title_config.font_size,
|
||||
color: tpl.title_config.font_color || "#ffffff",
|
||||
}));
|
||||
setSubtitleSettings((prev) => ({
|
||||
...prev,
|
||||
setSubtitleSettings({
|
||||
enabled: tpl.subtitle_config.enabled,
|
||||
position: (tpl.subtitle_config.position ||
|
||||
"bottom") as SubtitleStyleConfig["position"],
|
||||
position: tpl.subtitle_config.position,
|
||||
font: tpl.subtitle_config.font,
|
||||
fontSize: tpl.subtitle_config.size,
|
||||
fontColor: tpl.subtitle_config.color || "#ffffff",
|
||||
size: tpl.subtitle_config.size,
|
||||
animation: tpl.subtitle_config.animation,
|
||||
}));
|
||||
setBgmSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: tpl.bgm_config.enabled,
|
||||
music_id: tpl.bgm_config.music_id || "",
|
||||
}));
|
||||
});
|
||||
setBgmSettings({
|
||||
music: tpl.bgm_config.music_id || "none",
|
||||
});
|
||||
setDraftName(tpl.name);
|
||||
setDraftCategory(tpl.category);
|
||||
setDraftTags(tpl.tags.join(", "));
|
||||
@@ -380,31 +279,6 @@ const EditingPlanner: React.FC = () => {
|
||||
const totalDuration = clips.reduce((sum, c) => sum + c.duration, 0);
|
||||
const selectedClip = clips.find((c) => c.id === selectedClipId) || null;
|
||||
|
||||
/* rAF 帧推进 — 播放时平滑更新播放头位置 */
|
||||
useEffect(() => {
|
||||
if (!isPlaying) {
|
||||
prevFrameTimeRef.current = null;
|
||||
return;
|
||||
}
|
||||
let rafId: number;
|
||||
const tick = (timestamp: number) => {
|
||||
if (prevFrameTimeRef.current !== null) {
|
||||
const delta = (timestamp - prevFrameTimeRef.current) / 1000;
|
||||
setCurrentTime((prev) => {
|
||||
const next = prev + delta;
|
||||
return next >= totalDuration ? totalDuration : next;
|
||||
});
|
||||
}
|
||||
prevFrameTimeRef.current = timestamp;
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
prevFrameTimeRef.current = null;
|
||||
};
|
||||
}, [isPlaying, totalDuration]);
|
||||
|
||||
const filteredTemplates = templates.filter((t) => {
|
||||
if (currentFilter !== "全部" && t.category !== currentFilter) return false;
|
||||
if (
|
||||
@@ -475,175 +349,6 @@ const EditingPlanner: React.FC = () => {
|
||||
[clips.length, setClips],
|
||||
);
|
||||
|
||||
/* ── 裁剪更新:调整片段的 trim_config 和 duration ── */
|
||||
const handleClipTrim = useCallback(
|
||||
(clipId: string, trimConfig: TrimConfig, newDuration: number) => {
|
||||
setClips((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === clipId
|
||||
? { ...c, trim_config: trimConfig, duration: newDuration }
|
||||
: c,
|
||||
),
|
||||
);
|
||||
},
|
||||
[setClips],
|
||||
);
|
||||
|
||||
/* ── 片段分割:在指定比例位置将片段一分为二 ── */
|
||||
const handleClipSplit = useCallback(
|
||||
(clipId: string, splitRatio: number) => {
|
||||
setClips((prev) => {
|
||||
const idx = prev.findIndex((c) => c.id === clipId);
|
||||
if (idx === -1) return prev;
|
||||
const clip = prev[idx];
|
||||
const splitPoint = Math.round(clip.duration * splitRatio * 10) / 10;
|
||||
if (splitPoint < 0.5 || splitPoint >= clip.duration - 0.5) return prev;
|
||||
|
||||
// 前半段
|
||||
const firstHalf: ClipData = {
|
||||
...clip,
|
||||
duration: splitPoint,
|
||||
trim_config: clip.trim_config
|
||||
? {
|
||||
...clip.trim_config,
|
||||
end_time: clip.trim_config.start_time + splitPoint,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
// 后半段
|
||||
const secondHalf: ClipData = {
|
||||
...clip,
|
||||
id: `clip-${Date.now()}`,
|
||||
duration: clip.duration - splitPoint,
|
||||
startOffset: clip.startOffset + splitPoint,
|
||||
trim_config: clip.trim_config
|
||||
? {
|
||||
...clip.trim_config,
|
||||
start_time: clip.trim_config.start_time + splitPoint,
|
||||
}
|
||||
: undefined,
|
||||
order: (clip.order ?? idx) + 1,
|
||||
};
|
||||
|
||||
const updated = [...prev];
|
||||
updated[idx] = firstHalf;
|
||||
updated.splice(idx + 1, 0, secondHalf);
|
||||
return updated.map((c, i) => ({ ...c, order: i }));
|
||||
});
|
||||
},
|
||||
[setClips],
|
||||
);
|
||||
|
||||
/* ── 恢复片段原始长度 ── */
|
||||
const handleClipResetTrim = useCallback(
|
||||
(clipId: string) => {
|
||||
setClips((prev) =>
|
||||
prev.map((c) => {
|
||||
if (c.id !== clipId || !c.trim_config) return c;
|
||||
const originalDuration =
|
||||
c.trim_config.original_duration ?? c.duration;
|
||||
return {
|
||||
...c,
|
||||
duration: originalDuration,
|
||||
trim_config: undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
[setClips],
|
||||
);
|
||||
|
||||
/* ── 转场特效变更 ── */
|
||||
const handleTransitionChange = useCallback(
|
||||
(config: TransitionConfig) => {
|
||||
if (transitionTargetClipId) {
|
||||
// 更新指定片段的转场
|
||||
handleClipUpdate(transitionTargetClipId, { transition: config });
|
||||
}
|
||||
// 同时更新全局默认转场(供新片段使用)
|
||||
},
|
||||
[transitionTargetClipId],
|
||||
);
|
||||
|
||||
/* ── 打开转场选择器 ── */
|
||||
const handleOpenTransitionDrawer = useCallback((clipId?: string) => {
|
||||
setTransitionTargetClipId(clipId ?? null);
|
||||
setTransitionDrawerOpen(true);
|
||||
}, []);
|
||||
|
||||
/* ── 调速变更 ── */
|
||||
const handleSpeedChange = useCallback(
|
||||
(config: SpeedConfig) => {
|
||||
if (speedTargetClipId) {
|
||||
handleClipUpdate(speedTargetClipId, { speed: config });
|
||||
}
|
||||
},
|
||||
[speedTargetClipId],
|
||||
);
|
||||
|
||||
/* ── 打开调速面板 ── */
|
||||
const handleOpenSpeedDrawer = useCallback((clipId: string) => {
|
||||
setSpeedTargetClipId(clipId);
|
||||
setSpeedDrawerOpen(true);
|
||||
}, []);
|
||||
|
||||
/* ── TTS 配音变更 ── */
|
||||
const handleTtsChange = useCallback(
|
||||
(ttsConfig: TtsConfig) => {
|
||||
if (!ttsTargetClipId) return;
|
||||
handleClipUpdate(ttsTargetClipId, { tts_config: ttsConfig });
|
||||
},
|
||||
[ttsTargetClipId],
|
||||
);
|
||||
|
||||
/* ── 打开 TTS 配音面板 ── */
|
||||
const handleOpenTtsDrawer = useCallback((clipId: string) => {
|
||||
setTtsTargetClipId(clipId);
|
||||
setTtsDrawerOpen(true);
|
||||
}, []);
|
||||
|
||||
/* ── 调速应用到所有片段 ── */
|
||||
const handleApplySpeedAll = useCallback((config: SpeedConfig) => {
|
||||
setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } })));
|
||||
message.success("已应用到所有片段");
|
||||
}, []);
|
||||
|
||||
/* ── 水印配置变更 ── */
|
||||
const handleWatermarkChange = useCallback((config: WatermarkConfig) => {
|
||||
setWatermarkSettings(config);
|
||||
}, []);
|
||||
|
||||
/* ── 片头片尾配置变更 ── */
|
||||
const handleIntroOutroChange = useCallback((config: IntroOutroConfig) => {
|
||||
setIntroOutroSettings(config);
|
||||
}, []);
|
||||
|
||||
/* ── 画中画配置变更 ── */
|
||||
const handlePipChange = useCallback((config: PipConfig) => {
|
||||
setPipSettings(config);
|
||||
}, []);
|
||||
|
||||
/* ── 滤镜调色配置变更 ── */
|
||||
const handleFilterChange = useCallback((config: FilterConfig) => {
|
||||
setFilterSettings(config);
|
||||
}, []);
|
||||
|
||||
/* ── 绿幕抠像配置变更 ── */
|
||||
const handleChromaKeyChange = useCallback((config: ChromaKeyConfig) => {
|
||||
setChromaKeySettings(config);
|
||||
}, []);
|
||||
|
||||
/* ── 贴纸配置变更 ── */
|
||||
const handleStickerChange = useCallback((config: StickerConfig) => {
|
||||
setStickerSettings(config);
|
||||
}, []);
|
||||
|
||||
/* ── 封面配置变更 ── */
|
||||
const handleCoverChange = useCallback((config: CoverConfig) => {
|
||||
setCoverSettings(config);
|
||||
}, []);
|
||||
|
||||
/* AI 封面生成 */
|
||||
const handleAiGenerateCover = async (
|
||||
coverType: "ai_frame" | "ai_regenerate",
|
||||
@@ -703,13 +408,13 @@ const EditingPlanner: React.FC = () => {
|
||||
enabled: subtitleSettings.enabled,
|
||||
position: subtitleSettings.position,
|
||||
font: subtitleSettings.font,
|
||||
color: subtitleSettings.fontColor,
|
||||
size: subtitleSettings.fontSize,
|
||||
color: "#ffffff",
|
||||
size: subtitleSettings.size,
|
||||
animation: subtitleSettings.animation,
|
||||
},
|
||||
bgm_config: {
|
||||
enabled: bgmSettings.enabled,
|
||||
music_id: bgmSettings.music_id,
|
||||
enabled: bgmSettings.music !== "none",
|
||||
music_id: bgmSettings.music,
|
||||
},
|
||||
estimated_duration: totalDuration,
|
||||
segments: clips.map((c, i) => ({
|
||||
@@ -717,35 +422,7 @@ const EditingPlanner: React.FC = () => {
|
||||
duration_min: Math.max(1, c.duration - 2),
|
||||
duration_max: c.duration + 2,
|
||||
material_type: c.type === "voice" ? "voiceover" : "video",
|
||||
transition: c.transition
|
||||
? { type: c.transition.type, duration: c.transition.duration }
|
||||
: undefined,
|
||||
playback_speed: c.speed ? c.speed.rate : undefined,
|
||||
tts_config: c.tts_config
|
||||
? {
|
||||
mode: c.tts_config.mode,
|
||||
text: c.tts_config.text,
|
||||
voice_id: c.tts_config.voice_id,
|
||||
speed: c.tts_config.speed,
|
||||
pitch: c.tts_config.pitch,
|
||||
volume: c.tts_config.volume,
|
||||
subtitle_sync: c.tts_config.subtitle_sync,
|
||||
}
|
||||
: undefined,
|
||||
trim_config: c.trim_config
|
||||
? {
|
||||
start_time: c.trim_config.start_time,
|
||||
end_time: c.trim_config.end_time,
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
watermark_config: { ...watermarkSettings },
|
||||
intro_outro_config: { ...introOutroSettings },
|
||||
pip_config: { ...pipSettings },
|
||||
filter_config: { ...filterSettings },
|
||||
green_screen_config: { ...chromaKeySettings },
|
||||
sticker_config: { ...stickerSettings },
|
||||
cover_config: { ...coverSettings },
|
||||
};
|
||||
if (loadedTemplateId) {
|
||||
await updateEditingTemplate(loadedTemplateId, payload);
|
||||
@@ -785,13 +462,12 @@ const EditingPlanner: React.FC = () => {
|
||||
enabled: subtitleSettings.enabled,
|
||||
position: subtitleSettings.position,
|
||||
font: subtitleSettings.font,
|
||||
color: subtitleSettings.fontColor,
|
||||
size: subtitleSettings.fontSize,
|
||||
size: subtitleSettings.size,
|
||||
animation: subtitleSettings.animation,
|
||||
},
|
||||
bgm_config: {
|
||||
enabled: bgmSettings.enabled,
|
||||
music_id: bgmSettings.music_id,
|
||||
enabled: bgmSettings.music !== "none",
|
||||
music_id: bgmSettings.music,
|
||||
},
|
||||
mode: currentMode,
|
||||
total_duration: totalDuration,
|
||||
@@ -803,35 +479,7 @@ const EditingPlanner: React.FC = () => {
|
||||
script_text: c.script_text,
|
||||
voice_asset_id: c.voice_asset_id,
|
||||
voice_file_url: c.voice_file_url,
|
||||
transition: c.transition
|
||||
? { type: c.transition.type, duration: c.transition.duration }
|
||||
: undefined,
|
||||
playback_speed: c.speed ? c.speed.rate : undefined,
|
||||
tts_config: c.tts_config
|
||||
? {
|
||||
mode: c.tts_config.mode,
|
||||
text: c.tts_config.text,
|
||||
voice_id: c.tts_config.voice_id,
|
||||
speed: c.tts_config.speed,
|
||||
pitch: c.tts_config.pitch,
|
||||
volume: c.tts_config.volume,
|
||||
subtitle_sync: c.tts_config.subtitle_sync,
|
||||
}
|
||||
: undefined,
|
||||
trim_config: c.trim_config
|
||||
? {
|
||||
start_time: c.trim_config.start_time,
|
||||
end_time: c.trim_config.end_time,
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
watermark_config: { ...watermarkSettings },
|
||||
intro_outro_config: { ...introOutroSettings },
|
||||
pip_config: { ...pipSettings },
|
||||
filter_config: { ...filterSettings },
|
||||
green_screen_config: { ...chromaKeySettings },
|
||||
sticker_config: { ...stickerSettings },
|
||||
cover_config: { ...coverSettings },
|
||||
};
|
||||
const params = new URLSearchParams();
|
||||
if (loadedTemplateId) {
|
||||
@@ -841,6 +489,87 @@ const EditingPlanner: React.FC = () => {
|
||||
navigate(`/app/generate?${params.toString()}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建生成任务(两步)
|
||||
* 1. generateFromTemplate — 通知后端基于模板生成视频
|
||||
* 2. createGenerationTask — 创建任务记录,返回精简响应
|
||||
* 再用 getTask 查询完整 TaskItem 供轮询使用
|
||||
*/
|
||||
const handleGenerate = async () => {
|
||||
if (!loadedTemplateId) return;
|
||||
setGenSubmitting(true);
|
||||
try {
|
||||
await generateFromTemplate(loadedTemplateId, {
|
||||
voiceover_duration: voiceoverDuration || totalDuration,
|
||||
});
|
||||
// 收集所有 voice 类型片段的配音素材 ID
|
||||
const voiceIds = clips
|
||||
.filter((c) => c.type === "voice" && c.voice_asset_id)
|
||||
.map((c) => c.voice_asset_id as string);
|
||||
const res = await createGenerationTask({
|
||||
template_id: loadedTemplateId,
|
||||
asset_ids: [],
|
||||
title_ids: [],
|
||||
voice_ids: voiceIds,
|
||||
});
|
||||
/* 创建接口返回的是精简响应,需查询完整 TaskItem 用于轮询 */
|
||||
const task = await getTask(res.id);
|
||||
setGenTask(task);
|
||||
setGenPhase("progress");
|
||||
message.info("生成任务已创建");
|
||||
} catch {
|
||||
message.error("创建生成任务失败");
|
||||
} finally {
|
||||
setGenSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 轮询生成任务状态(每 3 秒)
|
||||
* 仅在 genPhase === "progress" 且有任务 ID 时启动
|
||||
* 任务完成/失败时自动停止轮询
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (genPhase !== "progress" || !genTask?.id) return;
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const t = await getTask(genTask.id);
|
||||
setGenTask(t);
|
||||
if (t.status === "completed") {
|
||||
setGenPhase("completed");
|
||||
clearInterval(timer);
|
||||
} else if (t.status === "failed") {
|
||||
setGenPhase("failed");
|
||||
clearInterval(timer);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [genPhase, genTask?.id]);
|
||||
|
||||
const handleRetry = async () => {
|
||||
if (!genTask?.id) return;
|
||||
setGenSubmitting(true);
|
||||
try {
|
||||
const t = await retryTask(genTask.id);
|
||||
setGenTask(t);
|
||||
setGenPhase("progress");
|
||||
} catch {
|
||||
message.error("重试失败");
|
||||
} finally {
|
||||
setGenSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelGen = () => {
|
||||
setGenModalOpen(false);
|
||||
setGenPhase("setup");
|
||||
setGenTask(null);
|
||||
setVoiceoverDuration(null);
|
||||
};
|
||||
|
||||
/* 查看生成历史 */
|
||||
const handleViewGenHistory = async () => {
|
||||
if (!loadedTemplateId) {
|
||||
@@ -949,13 +678,7 @@ const EditingPlanner: React.FC = () => {
|
||||
coverSchemes={COVER_SCHEMES}
|
||||
aiCoverLoading={aiCoverLoading}
|
||||
titleSettings={titleSettings}
|
||||
subtitleSettings={{
|
||||
enabled: subtitleSettings.enabled,
|
||||
position: subtitleSettings.position,
|
||||
font: subtitleSettings.font,
|
||||
size: subtitleSettings.fontSize,
|
||||
animation: subtitleSettings.animation,
|
||||
}}
|
||||
subtitleSettings={subtitleSettings}
|
||||
onClipSelect={handleClipSelect}
|
||||
onCoverSchemeChange={setCurrentCoverScheme}
|
||||
onPlayPause={() => setIsPlaying(!isPlaying)}
|
||||
@@ -971,14 +694,6 @@ const EditingPlanner: React.FC = () => {
|
||||
onClipReorder={handleClipReorder}
|
||||
onClipRemove={handleClipRemove}
|
||||
onAddClip={handleAddClip}
|
||||
onClipTrim={handleClipTrim}
|
||||
onClipSplit={handleClipSplit}
|
||||
onClipResetTrim={handleClipResetTrim}
|
||||
currentTime={currentTime}
|
||||
pixelsPerSecond={pixelsPerSecond}
|
||||
onZoomChange={handleZoomChange}
|
||||
onSeek={handleSeek}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -995,30 +710,16 @@ const EditingPlanner: React.FC = () => {
|
||||
setTitleSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onSubtitleSettingsChange={(partial) =>
|
||||
setSubtitleSettings(
|
||||
(prev) => ({ ...prev, ...partial }) as SubtitleStyleConfig,
|
||||
)
|
||||
setSubtitleSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onBgmSettingsChange={(partial) =>
|
||||
setBgmSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onClipUpdate={handleClipUpdate}
|
||||
onOpenBgmDrawer={() => setBgmDrawerOpen(true)}
|
||||
onOpenSubtitleDrawer={() => setSubtitleDrawerOpen(true)}
|
||||
voiceMaterials={voiceMaterials}
|
||||
voiceMaterialsLoading={voiceMaterialsQuery.isLoading}
|
||||
onRefreshVoiceMaterials={() => voiceMaterialsQuery.refetch()}
|
||||
onClipVoiceSelect={handleClipVoiceSelect}
|
||||
onOpenTransitionDrawer={handleOpenTransitionDrawer}
|
||||
onOpenSpeedDrawer={handleOpenSpeedDrawer}
|
||||
onOpenTtsDrawer={handleOpenTtsDrawer}
|
||||
onOpenWatermarkDrawer={() => setWatermarkDrawerOpen(true)}
|
||||
onOpenIntroOutroDrawer={() => setIntroOutroDrawerOpen(true)}
|
||||
onOpenPipDrawer={() => setPipDrawerOpen(true)}
|
||||
onOpenFilterDrawer={() => setFilterDrawerOpen(true)}
|
||||
onOpenGreenScreenDrawer={() => setChromaKeyDrawerOpen(true)}
|
||||
onOpenStickerDrawer={() => setStickerDrawerOpen(true)}
|
||||
onOpenCoverDrawer={() => setCoverDrawerOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1057,6 +758,20 @@ const EditingPlanner: React.FC = () => {
|
||||
onCancel={() => setSaveModalOpen(false)}
|
||||
/>
|
||||
|
||||
<GenerationProgressModal
|
||||
open={genModalOpen}
|
||||
phase={genPhase}
|
||||
voiceoverDuration={voiceoverDuration}
|
||||
estimatedDuration={totalDuration}
|
||||
onDurationChange={setVoiceoverDuration}
|
||||
onGenerate={handleGenerate}
|
||||
task={genTask}
|
||||
submitting={genSubmitting}
|
||||
onCancel={handleCancelGen}
|
||||
onRetry={handleRetry}
|
||||
onClose={handleCancelGen}
|
||||
/>
|
||||
|
||||
{/* ═══ 生成历史弹窗 ═══ */}
|
||||
<GenerationHistoryModal
|
||||
open={genHistoryOpen}
|
||||
@@ -1064,122 +779,6 @@ const EditingPlanner: React.FC = () => {
|
||||
history={genHistory}
|
||||
onClose={() => setGenHistoryOpen(false)}
|
||||
/>
|
||||
|
||||
{/* ═══ BGM 选择器 Drawer ═══ */}
|
||||
<BgmSelector
|
||||
open={bgmDrawerOpen}
|
||||
onClose={() => setBgmDrawerOpen(false)}
|
||||
config={bgmSettings}
|
||||
onChange={setBgmSettings}
|
||||
/>
|
||||
|
||||
{/* ═══ 字幕样式配置 Drawer ═══ */}
|
||||
<SubtitleStylePanel
|
||||
open={subtitleDrawerOpen}
|
||||
onClose={() => setSubtitleDrawerOpen(false)}
|
||||
config={subtitleSettings}
|
||||
onChange={setSubtitleSettings}
|
||||
/>
|
||||
|
||||
{/* ═══ 转场特效选择器 Drawer ═══ */}
|
||||
<TransitionSelector
|
||||
open={transitionDrawerOpen}
|
||||
onClose={() => setTransitionDrawerOpen(false)}
|
||||
config={
|
||||
transitionTargetClipId
|
||||
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ??
|
||||
DEFAULT_TRANSITION)
|
||||
: DEFAULT_TRANSITION
|
||||
}
|
||||
onChange={handleTransitionChange}
|
||||
title={transitionTargetClipId ? "片段转场设置" : "全局默认转场"}
|
||||
/>
|
||||
|
||||
{/* ═══ 片段调速面板 Drawer ═══ */}
|
||||
{speedTargetClipId && (
|
||||
<SpeedPanel
|
||||
open={speedDrawerOpen}
|
||||
onClose={() => setSpeedDrawerOpen(false)}
|
||||
config={
|
||||
clips.find((c) => c.id === speedTargetClipId)?.speed ??
|
||||
DEFAULT_SPEED
|
||||
}
|
||||
onChange={handleSpeedChange}
|
||||
onApplyAll={handleApplySpeedAll}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ═══ TTS 配音面板 Drawer ═══ */}
|
||||
{ttsTargetClipId && (
|
||||
<TtsPanel
|
||||
open={ttsDrawerOpen}
|
||||
onClose={() => setTtsDrawerOpen(false)}
|
||||
config={
|
||||
clips.find((c) => c.id === ttsTargetClipId)?.tts_config ??
|
||||
DEFAULT_TTS_CONFIG
|
||||
}
|
||||
onChange={handleTtsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ═══ 水印配置面板 ═══ */}
|
||||
<WatermarkPanel
|
||||
open={watermarkDrawerOpen}
|
||||
onClose={() => setWatermarkDrawerOpen(false)}
|
||||
config={watermarkSettings}
|
||||
onChange={handleWatermarkChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 片头片尾配置面板 ═══ */}
|
||||
<IntroOutroPanel
|
||||
open={introOutroDrawerOpen}
|
||||
onClose={() => setIntroOutroDrawerOpen(false)}
|
||||
config={introOutroSettings}
|
||||
onChange={handleIntroOutroChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 画中画配置面板 ═══ */}
|
||||
<PipConfigPanel
|
||||
open={pipDrawerOpen}
|
||||
onClose={() => setPipDrawerOpen(false)}
|
||||
config={pipSettings}
|
||||
onChange={handlePipChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
|
||||
{/* ═══ 滤镜调色面板 ═══ */}
|
||||
<FilterPanel
|
||||
open={filterDrawerOpen}
|
||||
onClose={() => setFilterDrawerOpen(false)}
|
||||
config={filterSettings}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 绿幕抠像面板 ═══ */}
|
||||
<GreenScreenPanel
|
||||
open={chromaKeyDrawerOpen}
|
||||
onClose={() => setChromaKeyDrawerOpen(false)}
|
||||
config={chromaKeySettings}
|
||||
onChange={handleChromaKeyChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 贴纸面板 ═══ */}
|
||||
<StickerPanel
|
||||
open={stickerDrawerOpen}
|
||||
onClose={() => setStickerDrawerOpen(false)}
|
||||
config={stickerSettings}
|
||||
onChange={handleStickerChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
|
||||
{/* ═══ 封面选择器 ═══ */}
|
||||
<CoverSelector
|
||||
open={coverDrawerOpen}
|
||||
onClose={() => setCoverDrawerOpen(false)}
|
||||
config={coverSettings}
|
||||
onChange={handleCoverChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
/**
|
||||
* BGM 选择器 — Drawer 形式
|
||||
* 预设 BGM 列表(按风格分类)、搜索、试听、音量/淡入淡出/人声闪避配置
|
||||
*/
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { Drawer, Slider, Input, Tag, message } from "antd";
|
||||
import {
|
||||
getBgmPresets,
|
||||
type BgmPreset,
|
||||
type BgmCategory,
|
||||
type BgmMixConfig,
|
||||
DEFAULT_BGM_MIX_CONFIG,
|
||||
} from "@/api/bgm";
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
/* ──────────── 分类标签 ──────────── */
|
||||
const CATEGORY_LIST: {
|
||||
key: BgmCategory | "all";
|
||||
label: string;
|
||||
icon: string;
|
||||
}[] = [
|
||||
{ key: "all", label: "全部", icon: "🎶" },
|
||||
{ key: "轻快", label: "轻快", icon: "🎉" },
|
||||
{ key: "治愈", label: "治愈", icon: "🌿" },
|
||||
{ key: "科技", label: "科技", icon: "🔬" },
|
||||
{ key: "电商", label: "电商", icon: "🛒" },
|
||||
];
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
interface BgmSelectorProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: BgmMixConfig;
|
||||
onChange: (config: BgmMixConfig) => void;
|
||||
}
|
||||
|
||||
const BgmSelector: React.FC<BgmSelectorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const [presets, setPresets] = useState<BgmPreset[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeCategory, setActiveCategory] = useState<BgmCategory | "all">(
|
||||
"all",
|
||||
);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
/* ── 加载 BGM 列表 ── */
|
||||
const loadPresets = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: { category?: string; keyword?: string } = {};
|
||||
if (activeCategory !== "all") params.category = activeCategory;
|
||||
if (keyword.trim()) params.keyword = keyword.trim();
|
||||
const data = await getBgmPresets(params);
|
||||
setPresets(data);
|
||||
} catch {
|
||||
message.error("加载 BGM 列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeCategory, keyword]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) loadPresets();
|
||||
}, [open, loadPresets]);
|
||||
|
||||
/* ── 试听 ── */
|
||||
const handlePreview = useCallback(
|
||||
(bgm: BgmPreset) => {
|
||||
if (previewingId === bgm.id) {
|
||||
audioRef.current?.pause();
|
||||
setPreviewingId(null);
|
||||
return;
|
||||
}
|
||||
audioRef.current?.pause();
|
||||
const audio = new Audio(bgm.url);
|
||||
audioRef.current = audio;
|
||||
audio.play().catch(() => {});
|
||||
audio.onended = () => setPreviewingId(null);
|
||||
setPreviewingId(bgm.id);
|
||||
},
|
||||
[previewingId],
|
||||
);
|
||||
|
||||
/* ── 选中 BGM ── */
|
||||
const handleSelect = useCallback(
|
||||
(bgm: BgmPreset) => {
|
||||
onChange({
|
||||
...config,
|
||||
enabled: true,
|
||||
music_id: bgm.id,
|
||||
});
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 关闭时停止播放 ── */
|
||||
const handleClose = useCallback(() => {
|
||||
audioRef.current?.pause();
|
||||
setPreviewingId(null);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
/* ── 移除 BGM ── */
|
||||
const handleClear = useCallback(() => {
|
||||
audioRef.current?.pause();
|
||||
setPreviewingId(null);
|
||||
onChange({ ...DEFAULT_BGM_MIX_CONFIG });
|
||||
}, [onChange]);
|
||||
|
||||
/* ── 当前选中的 BGM ── */
|
||||
const selectedBgm = presets.find((p) => p.id === config.music_id);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="🎵 BGM 音乐选择"
|
||||
placement="right"
|
||||
width={420}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
className="bgm-selector-drawer"
|
||||
>
|
||||
{/* ── 搜索框 ── */}
|
||||
<div className="bgm-search-row">
|
||||
<Search
|
||||
placeholder="搜索 BGM 名称..."
|
||||
allowClear
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onSearch={() => loadPresets()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 分类标签 ── */}
|
||||
<div className="bgm-category-bar">
|
||||
{CATEGORY_LIST.map((cat) => (
|
||||
<Tag
|
||||
key={cat.key}
|
||||
className={`bgm-category-tag${activeCategory === cat.key ? " active" : ""}`}
|
||||
onClick={() => setActiveCategory(cat.key)}
|
||||
>
|
||||
{cat.icon} {cat.label}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── BGM 列表 ── */}
|
||||
<div className="bgm-list">
|
||||
{loading && <div className="bgm-loading">加载中...</div>}
|
||||
{!loading && presets.length === 0 && (
|
||||
<div className="bgm-empty">暂无 BGM 数据</div>
|
||||
)}
|
||||
{presets.map((bgm) => {
|
||||
const isSelected = config.music_id === bgm.id;
|
||||
const isPlaying = previewingId === bgm.id;
|
||||
return (
|
||||
<div
|
||||
key={bgm.id}
|
||||
className={`bgm-item${isSelected ? " selected" : ""}`}
|
||||
onClick={() => handleSelect(bgm)}
|
||||
>
|
||||
<div className="bgm-item-cover">
|
||||
{bgm.cover_url ? (
|
||||
<img src={bgm.cover_url} alt={bgm.name} />
|
||||
) : (
|
||||
<span className="bgm-item-cover-icon">🎵</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bgm-item-info">
|
||||
<div className="bgm-item-name">{bgm.name}</div>
|
||||
<div className="bgm-item-meta">
|
||||
<span className="bgm-item-category">{bgm.category}</span>
|
||||
<span className="bgm-item-duration">
|
||||
{Math.floor(bgm.duration / 60)}:
|
||||
{String(Math.floor(bgm.duration % 60)).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
{bgm.tags.length > 0 && (
|
||||
<div className="bgm-item-tags">
|
||||
{bgm.tags.slice(0, 3).map((t) => (
|
||||
<span key={t} className="bgm-item-tag">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={`bgm-item-preview-btn${isPlaying ? " playing" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreview(bgm);
|
||||
}}
|
||||
title={isPlaying ? "暂停" : "试听"}
|
||||
>
|
||||
{isPlaying ? "⏸" : "▶️"}
|
||||
</button>
|
||||
{isSelected && <span className="bgm-item-check">✓</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── 混音配置 ── */}
|
||||
{config.enabled && config.music_id && (
|
||||
<div className="bgm-mix-config">
|
||||
<div className="bgm-mix-header">
|
||||
<span>混音配置</span>
|
||||
<button className="bgm-mix-clear" onClick={handleClear}>
|
||||
移除 BGM
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bgm-mix-selected">
|
||||
{selectedBgm
|
||||
? `当前:${selectedBgm.name}`
|
||||
: `当前:${config.music_id}`}
|
||||
</div>
|
||||
|
||||
{/* 音量 */}
|
||||
<div className="bgm-mix-field">
|
||||
<label className="bgm-mix-label">
|
||||
音量 <span className="bgm-mix-value">{config.volume}%</span>
|
||||
</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.volume}
|
||||
onChange={(v) => onChange({ ...config, volume: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 淡入 */}
|
||||
<div className="bgm-mix-field">
|
||||
<label className="bgm-mix-label">
|
||||
淡入{" "}
|
||||
<span className="bgm-mix-value">
|
||||
{config.fade_in.toFixed(1)}s
|
||||
</span>
|
||||
</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={config.fade_in}
|
||||
onChange={(v) => onChange({ ...config, fade_in: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 淡出 */}
|
||||
<div className="bgm-mix-field">
|
||||
<label className="bgm-mix-label">
|
||||
淡出{" "}
|
||||
<span className="bgm-mix-value">
|
||||
{config.fade_out.toFixed(1)}s
|
||||
</span>
|
||||
</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={config.fade_out}
|
||||
onChange={(v) => onChange({ ...config, fade_out: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 人声闪避 */}
|
||||
<div className="bgm-mix-field bgm-mix-toggle-row">
|
||||
<label className="bgm-mix-label">人声闪避(sidechain)</label>
|
||||
<div
|
||||
className={`ep-toggle${config.voice_dodge ? " active" : ""}`}
|
||||
onClick={() =>
|
||||
onChange({ ...config, voice_dodge: !config.voice_dodge })
|
||||
}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default BgmSelector;
|
||||
@@ -5,30 +5,32 @@
|
||||
import React, { useRef, useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { TemplateMode } from "@/api/editingPlanner";
|
||||
import type { ClipData, ClipType, TitleSettings } from "../types";
|
||||
import { TRANSITION_OPTIONS } from "@/api/editPlans";
|
||||
import type { ClipData, ClipType } from "../types";
|
||||
import type { AssetItem } from "@/api/assets";
|
||||
|
||||
interface TitleSettings {
|
||||
aiAutoSelect: boolean;
|
||||
title: string;
|
||||
position: string;
|
||||
font: string;
|
||||
size: number;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
stroke: boolean;
|
||||
shadow: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface SubtitleSettings {
|
||||
enabled: boolean;
|
||||
position: string;
|
||||
font: string;
|
||||
fontSize: number;
|
||||
fontColor: string;
|
||||
size: number;
|
||||
animation: string;
|
||||
mode?: string;
|
||||
stroke?: boolean;
|
||||
shadow?: boolean;
|
||||
asrLanguage?: string;
|
||||
}
|
||||
|
||||
interface BgmSettings {
|
||||
enabled: boolean;
|
||||
music_id: string;
|
||||
volume?: number;
|
||||
fade_in?: number;
|
||||
fade_out?: number;
|
||||
voice_dodge?: boolean;
|
||||
music: string;
|
||||
}
|
||||
|
||||
interface ClipPropertiesPanelProps {
|
||||
@@ -43,10 +45,6 @@ interface ClipPropertiesPanelProps {
|
||||
onSubtitleSettingsChange: (partial: Partial<SubtitleSettings>) => void;
|
||||
onBgmSettingsChange: (partial: Partial<BgmSettings>) => void;
|
||||
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void;
|
||||
/** 打开 BGM 选择器 Drawer */
|
||||
onOpenBgmDrawer?: () => void;
|
||||
/** 打开字幕样式配置 Drawer */
|
||||
onOpenSubtitleDrawer?: () => void;
|
||||
/** 配音素材列表(从配音素材库 API 获取) */
|
||||
voiceMaterials?: AssetItem[];
|
||||
/** 配音素材加载中 */
|
||||
@@ -55,26 +53,6 @@ interface ClipPropertiesPanelProps {
|
||||
onRefreshVoiceMaterials?: () => void;
|
||||
/** 为片段选择配音素材 */
|
||||
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void;
|
||||
/** 打开转场特效选择器 Drawer */
|
||||
onOpenTransitionDrawer?: (clipId: string) => void;
|
||||
/** 打开片段调速面板 Drawer */
|
||||
onOpenSpeedDrawer?: (clipId: string) => void;
|
||||
/** 打开 TTS 配音面板 Drawer */
|
||||
onOpenTtsDrawer?: (clipId: string) => void;
|
||||
/** 打开水印设置面板 Drawer */
|
||||
onOpenWatermarkDrawer?: () => void;
|
||||
/** 打开片头片尾设置面板 Drawer */
|
||||
onOpenIntroOutroDrawer?: () => void;
|
||||
/** 打开画中画设置面板 Drawer */
|
||||
onOpenPipDrawer?: () => void;
|
||||
/** 打开滤镜调色面板 Drawer */
|
||||
onOpenFilterDrawer?: () => void;
|
||||
/** 打开绿幕抠像面板 Drawer */
|
||||
onOpenGreenScreenDrawer?: () => void;
|
||||
/** 打开贴纸面板 Drawer */
|
||||
onOpenStickerDrawer?: () => void;
|
||||
/** 打开封面选择器 Drawer */
|
||||
onOpenCoverDrawer?: () => void;
|
||||
}
|
||||
|
||||
const POSITION_OPTIONS = [
|
||||
@@ -100,6 +78,14 @@ const ANIMATION_OPTIONS = [
|
||||
{ value: "typewriter", label: "打字机" },
|
||||
];
|
||||
|
||||
const BGM_OPTIONS = [
|
||||
{ value: "none", label: "无背景音乐" },
|
||||
{ value: "bgm_01", label: "🎵 轻快节奏" },
|
||||
{ value: "bgm_02", label: "🎵 温馨舒缓" },
|
||||
{ value: "bgm_03", label: "🎵 动感活力" },
|
||||
{ value: "bgm_04", label: "🎵 科技感" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 标题样式预设 — 纯样式组合(颜色+描边+阴影+字重+字号)
|
||||
* 不绑定字体,用户可自由搭配任意字体
|
||||
@@ -286,24 +272,12 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
currentMode,
|
||||
onTitleSettingsChange,
|
||||
onSubtitleSettingsChange,
|
||||
onBgmSettingsChange: _onBgmSettingsChange,
|
||||
onBgmSettingsChange,
|
||||
onClipUpdate,
|
||||
onOpenBgmDrawer,
|
||||
onOpenSubtitleDrawer,
|
||||
voiceMaterials = [],
|
||||
voiceMaterialsLoading = false,
|
||||
onRefreshVoiceMaterials,
|
||||
onClipVoiceSelect,
|
||||
onOpenTransitionDrawer,
|
||||
onOpenSpeedDrawer,
|
||||
onOpenTtsDrawer,
|
||||
onOpenWatermarkDrawer,
|
||||
onOpenIntroOutroDrawer,
|
||||
onOpenPipDrawer,
|
||||
onOpenFilterDrawer,
|
||||
onOpenGreenScreenDrawer,
|
||||
onOpenStickerDrawer,
|
||||
onOpenCoverDrawer,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -560,17 +534,15 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
<input
|
||||
className="ep-slider"
|
||||
type="range"
|
||||
min={12}
|
||||
max={48}
|
||||
value={subtitleSettings.fontSize}
|
||||
min={10}
|
||||
max={32}
|
||||
value={subtitleSettings.size}
|
||||
onChange={(e) =>
|
||||
onSubtitleSettingsChange({
|
||||
fontSize: Number(e.target.value),
|
||||
})
|
||||
onSubtitleSettingsChange({ size: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<span className="ep-slider-value">
|
||||
{subtitleSettings.fontSize}px
|
||||
{subtitleSettings.size}px
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -591,16 +563,6 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 高级配置按钮 */}
|
||||
{onOpenSubtitleDrawer && (
|
||||
<button
|
||||
className="ep-advanced-btn"
|
||||
onClick={onOpenSubtitleDrawer}
|
||||
>
|
||||
🎨 高级字幕样式配置
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -612,130 +574,20 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
BGM 设置
|
||||
</div>
|
||||
|
||||
{bgmSettings.enabled && bgmSettings.music_id ? (
|
||||
<div className="ep-bgm-current">
|
||||
<span className="ep-bgm-current-label">🎵 已选择 BGM</span>
|
||||
<span className="ep-bgm-current-id">{bgmSettings.music_id}</span>
|
||||
{bgmSettings.volume !== undefined && (
|
||||
<span className="ep-bgm-current-vol">
|
||||
音量 {bgmSettings.volume}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="ep-bgm-empty">未选择背景音乐</div>
|
||||
)}
|
||||
|
||||
{onOpenBgmDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenBgmDrawer}>
|
||||
🎵 {bgmSettings.enabled ? "更换 BGM / 调整混音" : "选择 BGM 音乐"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 水印设置 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🔖</span>
|
||||
水印设置
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">背景音乐</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={bgmSettings.music}
|
||||
onChange={(e) => onBgmSettingsChange({ music: e.target.value })}
|
||||
>
|
||||
{BGM_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{onOpenWatermarkDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenWatermarkDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🔖</span>
|
||||
<span className="ep-advanced-btn-label">水印配置</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 片头片尾设置 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🎬</span>
|
||||
片头片尾
|
||||
</div>
|
||||
{onOpenIntroOutroDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenIntroOutroDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🎬</span>
|
||||
<span className="ep-advanced-btn-label">片头片尾配置</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 画中画 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🖼️</span>
|
||||
画中画
|
||||
</div>
|
||||
{onOpenPipDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenPipDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🖼️</span>
|
||||
<span className="ep-advanced-btn-label">配置画中画图层</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 滤镜调色 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🎨</span>
|
||||
滤镜调色
|
||||
</div>
|
||||
{onOpenFilterDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenFilterDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🎨</span>
|
||||
<span className="ep-advanced-btn-label">配置滤镜调色</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 绿幕抠像 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🟩</span>
|
||||
绿幕抠像
|
||||
</div>
|
||||
{onOpenGreenScreenDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenGreenScreenDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🟩</span>
|
||||
<span className="ep-advanced-btn-label">配置绿幕抠像</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 贴纸 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🏷️</span>
|
||||
贴纸
|
||||
</div>
|
||||
{onOpenStickerDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenStickerDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🏷️</span>
|
||||
<span className="ep-advanced-btn-label">配置贴纸花字</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 封面 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🖼️</span>
|
||||
封面
|
||||
</div>
|
||||
{onOpenCoverDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenCoverDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🖼️</span>
|
||||
<span className="ep-advanced-btn-label">选择视频封面</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 片段详情(选中时显示) ═══ */}
|
||||
@@ -797,71 +649,6 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 转场效果入口 */}
|
||||
{onOpenTransitionDrawer && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<button
|
||||
className="ep-advanced-btn ep-advanced-btn--transition"
|
||||
onClick={() => onOpenTransitionDrawer(selectedClip.id)}
|
||||
>
|
||||
<span className="ep-advanced-btn-icon">🎬</span>
|
||||
<span className="ep-advanced-btn-label">转场效果</span>
|
||||
<span className="ep-advanced-btn-value">
|
||||
{(() => {
|
||||
const t = selectedClip.transition;
|
||||
if (!t || t.type === "none") return "无转场";
|
||||
const opt = TRANSITION_OPTIONS.find(
|
||||
(o) => o.value === t.type,
|
||||
);
|
||||
return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s`;
|
||||
})()}
|
||||
</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 播放速度入口 */}
|
||||
{onOpenSpeedDrawer && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<button
|
||||
className="ep-advanced-btn ep-advanced-btn--speed"
|
||||
onClick={() => onOpenSpeedDrawer(selectedClip.id)}
|
||||
>
|
||||
<span className="ep-advanced-btn-icon">⚡</span>
|
||||
<span className="ep-advanced-btn-label">播放速度</span>
|
||||
<span className="ep-advanced-btn-value">
|
||||
{selectedClip.speed
|
||||
? `${selectedClip.speed.rate.toFixed(2)}x`
|
||||
: "1.00x"}
|
||||
</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TTS 配音入口 */}
|
||||
{onOpenTtsDrawer && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<button
|
||||
className="ep-advanced-btn ep-advanced-btn--tts"
|
||||
onClick={() => onOpenTtsDrawer(selectedClip.id)}
|
||||
>
|
||||
<span className="ep-advanced-btn-icon">🎙️</span>
|
||||
<span className="ep-advanced-btn-label">TTS 配音</span>
|
||||
<span className="ep-advanced-btn-value">
|
||||
{(() => {
|
||||
const tts = selectedClip.tts_config;
|
||||
if (!tts || tts.mode === "none") return "无配音";
|
||||
if (tts.mode === "upload") return "上传配音";
|
||||
return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}`;
|
||||
})()}
|
||||
</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 素材起始时间 — 仅 voice 类型显示 */}
|
||||
{selectedClip.type === "voice" && (
|
||||
<div className="ep-clip-detail-field">
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
/**
|
||||
* 封面选择器
|
||||
* 抽帧选封面 + 上传自定义封面 + 智能封面推荐
|
||||
*/
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import { Drawer } from "antd";
|
||||
import type { CoverConfig, CoverMode } from "../types";
|
||||
import { DEFAULT_COVER_CONFIG } from "../types";
|
||||
|
||||
interface CoverSelectorProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: CoverConfig;
|
||||
onChange: (config: CoverConfig) => void;
|
||||
totalDuration: number;
|
||||
}
|
||||
|
||||
/** 封面模式标签 */
|
||||
const MODE_LABELS: Record<CoverMode, string> = {
|
||||
auto: "智能封面",
|
||||
frame: "抽帧选封面",
|
||||
upload: "上传封面",
|
||||
};
|
||||
|
||||
/** 封面模式图标 */
|
||||
const MODE_ICONS: Record<CoverMode, string> = {
|
||||
auto: "🤖",
|
||||
frame: "🎞️",
|
||||
upload: "📤",
|
||||
};
|
||||
|
||||
const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
totalDuration,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const update = useCallback(
|
||||
(partial: Partial<CoverConfig>) => {
|
||||
onChange({ ...config, ...partial });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled });
|
||||
}, [config.enabled, onChange]);
|
||||
|
||||
/** 切换模式 */
|
||||
const handleModeChange = useCallback(
|
||||
(mode: CoverMode) => {
|
||||
update({ mode });
|
||||
},
|
||||
[update],
|
||||
);
|
||||
|
||||
/** 处理文件上传 */
|
||||
const handleFileUpload = useCallback(
|
||||
(file: File) => {
|
||||
if (!file.type.startsWith("image/")) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const url = e.target?.result as string;
|
||||
update({ upload_url: url, thumbnail_url: url, mode: "upload" });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
[update],
|
||||
);
|
||||
|
||||
/** 拖拽上传 */
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFileUpload(file);
|
||||
},
|
||||
[handleFileUpload],
|
||||
);
|
||||
|
||||
/** 使用 AI 推荐时间 */
|
||||
const handleUseAiSuggestion = useCallback(() => {
|
||||
if (config.ai_suggested_time !== null) {
|
||||
update({ frame_time: config.ai_suggested_time, mode: "frame" });
|
||||
}
|
||||
}, [config.ai_suggested_time, update]);
|
||||
|
||||
/** 格式化时间 */
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
const ms = Math.floor((seconds % 1) * 10);
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="封面选择"
|
||||
placement="right"
|
||||
width={440}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="cover-selector-drawer"
|
||||
>
|
||||
{/* 顶部开关 */}
|
||||
<div className="cover-header">
|
||||
<span className="cover-header-label">启用自定义封面</span>
|
||||
<label className="cover-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enabled}
|
||||
onChange={(e) => update({ enabled: e.target.checked })}
|
||||
/>
|
||||
<span className="cover-switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 模式选择 */}
|
||||
<div className="cover-mode-section">
|
||||
<div className="cover-section-title">封面来源</div>
|
||||
<div className="cover-mode-tabs">
|
||||
{(["auto", "frame", "upload"] as CoverMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`cover-mode-tab${config.mode === m ? " active" : ""}`}
|
||||
onClick={() => handleModeChange(m)}
|
||||
>
|
||||
<span className="cover-mode-icon">{MODE_ICONS[m]}</span>
|
||||
<span className="cover-mode-label">{MODE_LABELS[m]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模式内容区 */}
|
||||
<div className="cover-mode-content">
|
||||
{/* 智能封面 */}
|
||||
{config.mode === "auto" && (
|
||||
<div className="cover-auto-section">
|
||||
<div className="cover-auto-desc">
|
||||
AI 将分析视频内容,自动选择最具吸引力的画面作为封面。
|
||||
</div>
|
||||
{config.ai_suggested_time !== null ? (
|
||||
<div className="cover-auto-suggestion">
|
||||
<div className="cover-auto-badge">AI 推荐</div>
|
||||
<div className="cover-auto-time">
|
||||
推荐时间点:{formatTime(config.ai_suggested_time)}
|
||||
</div>
|
||||
<button
|
||||
className="cover-auto-use-btn"
|
||||
onClick={handleUseAiSuggestion}
|
||||
>
|
||||
使用此时间点
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cover-auto-pending">
|
||||
<div className="cover-auto-spinner" />
|
||||
<span>AI 分析中...(生成视频后自动推荐)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 抽帧选封面 */}
|
||||
{config.mode === "frame" && (
|
||||
<div className="cover-frame-section">
|
||||
<div className="cover-frame-preview">
|
||||
<div className="cover-frame-placeholder">
|
||||
<span className="cover-frame-icon">🎞️</span>
|
||||
<span className="cover-frame-time">
|
||||
{formatTime(config.frame_time)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cover-frame-timeline">
|
||||
<div className="cover-frame-slider-header">
|
||||
<span className="cover-frame-slider-label">拖动选择封面帧</span>
|
||||
<span className="cover-frame-slider-value">
|
||||
{formatTime(config.frame_time)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="cover-frame-slider"
|
||||
min={0}
|
||||
max={Math.max(totalDuration, 1)}
|
||||
step={0.1}
|
||||
value={config.frame_time}
|
||||
onChange={(e) => update({ frame_time: Number(e.target.value) })}
|
||||
/>
|
||||
<div className="cover-frame-range">
|
||||
<span>00:00</span>
|
||||
<span>{formatTime(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 快捷时间点 */}
|
||||
<div className="cover-frame-quick">
|
||||
<span className="cover-quick-label">快捷选帧:</span>
|
||||
{[0, 0.25, 0.5, 0.75].map((ratio) => {
|
||||
const t = totalDuration * ratio;
|
||||
return (
|
||||
<button
|
||||
key={ratio}
|
||||
className="cover-quick-btn"
|
||||
onClick={() => update({ frame_time: t })}
|
||||
>
|
||||
{formatTime(t)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传封面 */}
|
||||
{config.mode === "upload" && (
|
||||
<div className="cover-upload-section">
|
||||
<div
|
||||
className={`cover-upload-area${isDragging ? " dragging" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{config.upload_url ? (
|
||||
<div className="cover-upload-preview">
|
||||
<img src={config.upload_url} alt="封面预览" />
|
||||
<div className="cover-upload-overlay">点击更换</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cover-upload-placeholder">
|
||||
<span className="cover-upload-icon">📤</span>
|
||||
<span className="cover-upload-text">
|
||||
点击或拖拽上传封面图片
|
||||
</span>
|
||||
<span className="cover-upload-hint">
|
||||
支持 JPG / PNG,建议 16:9 比例
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileUpload(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 封面预览 */}
|
||||
<div className="cover-preview-section">
|
||||
<div className="cover-section-title">封面预览</div>
|
||||
<div className="cover-preview-box">
|
||||
{config.upload_url ? (
|
||||
<img
|
||||
src={config.upload_url}
|
||||
alt="封面预览"
|
||||
className="cover-preview-img"
|
||||
/>
|
||||
) : (
|
||||
<div className="cover-preview-placeholder">
|
||||
<span className="cover-preview-icon">🖼️</span>
|
||||
<span className="cover-preview-text">
|
||||
{config.mode === "auto"
|
||||
? "AI 智能选择"
|
||||
: config.mode === "frame"
|
||||
? `帧 ${formatTime(config.frame_time)}`
|
||||
: "未上传封面"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="cover-preview-ratio">16:9</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className="cover-footer">
|
||||
<button className="cover-reset-btn" onClick={handleReset}>
|
||||
重置封面
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoverSelector;
|
||||
@@ -1,234 +0,0 @@
|
||||
/**
|
||||
* 滤镜调色配置面板
|
||||
* 预设滤镜 + 手动调节(亮度/对比度/饱和度/色温/色调/锐度)
|
||||
*/
|
||||
import React, { useCallback } from "react";
|
||||
import { Drawer, Switch } from "antd";
|
||||
import type { FilterConfig, FilterPreset } from "../types";
|
||||
import { DEFAULT_FILTER_CONFIG, FILTER_PRESET_LABELS } from "../types";
|
||||
|
||||
interface FilterPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: FilterConfig;
|
||||
onChange: (config: FilterConfig) => void;
|
||||
}
|
||||
|
||||
/** 所有预设列表 */
|
||||
const PRESET_LIST: FilterPreset[] = [
|
||||
"none",
|
||||
"original",
|
||||
"fresh",
|
||||
"warm",
|
||||
"cool",
|
||||
"vintage",
|
||||
"cinema",
|
||||
"bw",
|
||||
"sunshine",
|
||||
"film",
|
||||
];
|
||||
|
||||
/** 预设对应的示例渐变色(用于视觉预览) */
|
||||
const PRESET_GRADIENTS: Record<FilterPreset, string> = {
|
||||
none: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
original: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
fresh: "linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)",
|
||||
warm: "linear-gradient(135deg, #f093fb 0%, #f5576c 100%)",
|
||||
cool: "linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)",
|
||||
vintage: "linear-gradient(135deg, #c79081 0%, #dfa579 100%)",
|
||||
cinema: "linear-gradient(135deg, #2c3e50 0%, #4ca1af 100%)",
|
||||
bw: "linear-gradient(135deg, #434343 0%, #000000 100%)",
|
||||
sunshine: "linear-gradient(135deg, #f6d365 0%, #fda085 100%)",
|
||||
film: "linear-gradient(135deg, #8e9eab 0%, #eef2f3 100%)",
|
||||
};
|
||||
|
||||
const FilterPanel: React.FC<FilterPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const update = useCallback(
|
||||
(partial: Partial<FilterConfig>) => {
|
||||
onChange({ ...config, ...partial });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled });
|
||||
}, [config.enabled, onChange]);
|
||||
|
||||
/** 选择预设时重置手动参数 */
|
||||
const handlePresetSelect = useCallback(
|
||||
(preset: FilterPreset) => {
|
||||
if (preset === "none") {
|
||||
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled });
|
||||
} else {
|
||||
onChange({
|
||||
...DEFAULT_FILTER_CONFIG,
|
||||
enabled: config.enabled,
|
||||
preset,
|
||||
});
|
||||
}
|
||||
},
|
||||
[config.enabled, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="滤镜调色"
|
||||
placement="right"
|
||||
width={420}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="filter-panel-drawer"
|
||||
>
|
||||
{/* 顶部开关 */}
|
||||
<div className="filter-header">
|
||||
<span className="filter-header-label">启用滤镜</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={config.enabled}
|
||||
onChange={(checked) => update({ enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 预设滤镜选择 */}
|
||||
<div className="filter-section">
|
||||
<div className="filter-section-title">预设滤镜</div>
|
||||
<div className="filter-presets">
|
||||
{PRESET_LIST.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
className={`filter-preset-item${config.preset === p ? " active" : ""}`}
|
||||
onClick={() => handlePresetSelect(p)}
|
||||
>
|
||||
<div
|
||||
className="filter-preset-preview"
|
||||
style={{ background: PRESET_GRADIENTS[p] }}
|
||||
/>
|
||||
<span className="filter-preset-label">
|
||||
{FILTER_PRESET_LABELS[p]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 手动调节 */}
|
||||
<div className="filter-section">
|
||||
<div className="filter-section-title">手动调节</div>
|
||||
|
||||
{/* 亮度 */}
|
||||
<div className="filter-slider-row">
|
||||
<span className="filter-slider-label">亮度</span>
|
||||
<input
|
||||
type="range"
|
||||
className="filter-slider"
|
||||
min={-100}
|
||||
max={100}
|
||||
value={config.brightness}
|
||||
onChange={(e) => update({ brightness: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="filter-slider-value">{config.brightness}</span>
|
||||
</div>
|
||||
|
||||
{/* 对比度 */}
|
||||
<div className="filter-slider-row">
|
||||
<span className="filter-slider-label">对比度</span>
|
||||
<input
|
||||
type="range"
|
||||
className="filter-slider"
|
||||
min={-100}
|
||||
max={100}
|
||||
value={config.contrast}
|
||||
onChange={(e) => update({ contrast: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="filter-slider-value">{config.contrast}</span>
|
||||
</div>
|
||||
|
||||
{/* 饱和度 */}
|
||||
<div className="filter-slider-row">
|
||||
<span className="filter-slider-label">饱和度</span>
|
||||
<input
|
||||
type="range"
|
||||
className="filter-slider"
|
||||
min={-100}
|
||||
max={100}
|
||||
value={config.saturation}
|
||||
onChange={(e) => update({ saturation: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="filter-slider-value">{config.saturation}</span>
|
||||
</div>
|
||||
|
||||
{/* 色温 */}
|
||||
<div className="filter-slider-row">
|
||||
<span className="filter-slider-label">色温</span>
|
||||
<input
|
||||
type="range"
|
||||
className="filter-slider"
|
||||
min={-100}
|
||||
max={100}
|
||||
value={config.temperature}
|
||||
onChange={(e) => update({ temperature: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="filter-slider-value">{config.temperature}</span>
|
||||
</div>
|
||||
|
||||
{/* 色调 */}
|
||||
<div className="filter-slider-row">
|
||||
<span className="filter-slider-label">色调</span>
|
||||
<input
|
||||
type="range"
|
||||
className="filter-slider"
|
||||
min={-100}
|
||||
max={100}
|
||||
value={config.tint}
|
||||
onChange={(e) => update({ tint: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="filter-slider-value">{config.tint}</span>
|
||||
</div>
|
||||
|
||||
{/* 锐度 */}
|
||||
<div className="filter-slider-row">
|
||||
<span className="filter-slider-label">锐度</span>
|
||||
<input
|
||||
type="range"
|
||||
className="filter-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.sharpness}
|
||||
onChange={(e) => update({ sharpness: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="filter-slider-value">{config.sharpness}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 预览色块 */}
|
||||
<div className="filter-section">
|
||||
<div className="filter-section-title">效果预览</div>
|
||||
<div
|
||||
className="filter-preview-block"
|
||||
style={{
|
||||
background: PRESET_GRADIENTS[config.preset],
|
||||
filter: [
|
||||
`brightness(${100 + config.brightness}%)`,
|
||||
`contrast(${100 + config.contrast}%)`,
|
||||
`saturate(${100 + config.saturation}%)`,
|
||||
].join(" "),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 底部重置 */}
|
||||
<div className="filter-footer">
|
||||
<button className="filter-reset-btn" onClick={handleReset}>
|
||||
重置参数
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterPanel;
|
||||
@@ -1,219 +0,0 @@
|
||||
/**
|
||||
* 绿幕抠像配置面板
|
||||
* 5 种颜色预设 + 自定义颜色 + 相似度/边缘平滑/溢色抑制
|
||||
*/
|
||||
import React, { useCallback } from "react";
|
||||
import { Drawer, Switch } from "antd";
|
||||
import type { ChromaKeyConfig, ChromaKeyColorPreset } from "../types";
|
||||
import {
|
||||
DEFAULT_CHROMA_KEY_CONFIG,
|
||||
CHROMA_KEY_PRESET_LABELS,
|
||||
CHROMA_KEY_PRESET_COLORS,
|
||||
} from "../types";
|
||||
|
||||
interface GreenScreenPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: ChromaKeyConfig;
|
||||
onChange: (config: ChromaKeyConfig) => void;
|
||||
}
|
||||
|
||||
/** 预设列表 */
|
||||
const PRESET_LIST: ChromaKeyColorPreset[] = [
|
||||
"green",
|
||||
"blue",
|
||||
"red",
|
||||
"pure_green",
|
||||
"soft_green",
|
||||
];
|
||||
|
||||
const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const update = useCallback(
|
||||
(partial: Partial<ChromaKeyConfig>) => {
|
||||
onChange({ ...config, ...partial });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_CHROMA_KEY_CONFIG, enabled: config.enabled });
|
||||
}, [config.enabled, onChange]);
|
||||
|
||||
/** 选择颜色预设时同步更新 color 字段 */
|
||||
const handlePresetSelect = useCallback(
|
||||
(preset: ChromaKeyColorPreset) => {
|
||||
update({
|
||||
color_preset: preset,
|
||||
color: CHROMA_KEY_PRESET_COLORS[preset],
|
||||
});
|
||||
},
|
||||
[update],
|
||||
);
|
||||
|
||||
/** 自定义颜色变化时清除预设标记 */
|
||||
const handleColorChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
update({ color: e.target.value });
|
||||
},
|
||||
[update],
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="绿幕抠像"
|
||||
placement="right"
|
||||
width={420}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="green-screen-panel-drawer"
|
||||
>
|
||||
{/* 顶部开关 */}
|
||||
<div className="green-header">
|
||||
<span className="green-header-label">启用绿幕抠像</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={config.enabled}
|
||||
onChange={(checked) => update({ enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 颜色预设 */}
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">颜色预设</div>
|
||||
<div className="green-presets">
|
||||
{PRESET_LIST.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
className={`green-preset-btn${config.color_preset === p ? " active" : ""}`}
|
||||
onClick={() => handlePresetSelect(p)}
|
||||
>
|
||||
<span
|
||||
className="green-preset-dot"
|
||||
style={{ background: CHROMA_KEY_PRESET_COLORS[p] }}
|
||||
/>
|
||||
<span className="green-preset-label">
|
||||
{CHROMA_KEY_PRESET_LABELS[p]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 自定义颜色 */}
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">自定义颜色</div>
|
||||
<div className="green-color-row">
|
||||
<input
|
||||
type="color"
|
||||
className="green-color-picker"
|
||||
value={config.color}
|
||||
onChange={handleColorChange}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="green-color-hex"
|
||||
value={config.color}
|
||||
onChange={handleColorChange}
|
||||
placeholder="#00FF00"
|
||||
/>
|
||||
<div
|
||||
className="green-color-swatch"
|
||||
style={{ background: config.color }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 参数调节 */}
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">参数调节</div>
|
||||
|
||||
{/* 相似度 */}
|
||||
<div className="green-slider-row">
|
||||
<div className="green-slider-header">
|
||||
<span className="green-slider-label">相似度</span>
|
||||
<span className="green-slider-value">{config.similarity}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="green-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.similarity}
|
||||
onChange={(e) => update({ similarity: Number(e.target.value) })}
|
||||
/>
|
||||
<div className="green-slider-desc">越大容忍的色差范围越广</div>
|
||||
</div>
|
||||
|
||||
{/* 边缘平滑 */}
|
||||
<div className="green-slider-row">
|
||||
<div className="green-slider-header">
|
||||
<span className="green-slider-label">边缘平滑</span>
|
||||
<span className="green-slider-value">{config.blend}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="green-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.blend}
|
||||
onChange={(e) => update({ blend: Number(e.target.value) })}
|
||||
/>
|
||||
<div className="green-slider-desc">越大边缘越柔和自然</div>
|
||||
</div>
|
||||
|
||||
{/* 溢色抑制 */}
|
||||
<div className="green-slider-row">
|
||||
<div className="green-slider-header">
|
||||
<span className="green-slider-label">溢色抑制</span>
|
||||
<span className="green-slider-value">{config.spill}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="green-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.spill}
|
||||
onChange={(e) => update({ spill: Number(e.target.value) })}
|
||||
/>
|
||||
<div className="green-slider-desc">去除边缘颜色溢出</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 预览 */}
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">效果预览</div>
|
||||
<div className="green-preview-box">
|
||||
<div
|
||||
className="green-preview-bg"
|
||||
style={{ background: config.color, opacity: 0.3 }}
|
||||
/>
|
||||
<div className="green-preview-subject">
|
||||
<div className="green-preview-circle" />
|
||||
<div className="green-preview-text">主体</div>
|
||||
</div>
|
||||
<div
|
||||
className="green-preview-edge"
|
||||
style={{
|
||||
borderColor: config.color,
|
||||
filter: `blur(${config.blend / 10}px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部重置 */}
|
||||
<div className="green-footer">
|
||||
<button className="green-reset-btn" onClick={handleReset}>
|
||||
重置参数
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default GreenScreenPanel;
|
||||
@@ -1,309 +0,0 @@
|
||||
/**
|
||||
* 片头片尾配置面板 — Drawer 形式
|
||||
* 两个区块:片头(Intro)/ 片尾(Outro)
|
||||
* 每个区块支持:类型选择(无/视频/图片)、素材 URL、时长、过渡动画
|
||||
*/
|
||||
import React, { useCallback } from "react";
|
||||
import { Drawer } from "antd";
|
||||
import type {
|
||||
IntroOutroConfig,
|
||||
IntroOutroItem,
|
||||
IntroOutroKind,
|
||||
TransitionType,
|
||||
} from "../types";
|
||||
import { DEFAULT_INTRO_OUTRO } from "../types";
|
||||
import { TRANSITION_OPTIONS } from "@/api/editPlans";
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
const KIND_OPTIONS: { value: IntroOutroKind; label: string; icon: string }[] = [
|
||||
{ value: "none", label: "无", icon: "🚫" },
|
||||
{ value: "video", label: "视频", icon: "🎬" },
|
||||
{ value: "image", label: "图片", icon: "🖼️" },
|
||||
];
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
interface IntroOutroPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: IntroOutroConfig;
|
||||
onChange: (config: IntroOutroConfig) => void;
|
||||
}
|
||||
|
||||
const IntroOutroPanel: React.FC<IntroOutroPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
/* ── 更新片头 ── */
|
||||
const handleIntroChange = useCallback(
|
||||
(partial: Partial<IntroOutroItem>) => {
|
||||
onChange({ ...config, intro: { ...config.intro, ...partial } });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 更新片尾 ── */
|
||||
const handleOutroChange = useCallback(
|
||||
(partial: Partial<IntroOutroItem>) => {
|
||||
onChange({ ...config, outro: { ...config.outro, ...partial } });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 切换片头类型 ── */
|
||||
const handleIntroKindChange = useCallback(
|
||||
(kind: IntroOutroKind) => {
|
||||
handleIntroChange({ kind, url: kind === "none" ? undefined : "" });
|
||||
},
|
||||
[handleIntroChange],
|
||||
);
|
||||
|
||||
/* ── 切换片尾类型 ── */
|
||||
const handleOutroKindChange = useCallback(
|
||||
(kind: IntroOutroKind) => {
|
||||
handleOutroChange({ kind, url: kind === "none" ? undefined : "" });
|
||||
},
|
||||
[handleOutroChange],
|
||||
);
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_INTRO_OUTRO });
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="🎬 片头片尾设置"
|
||||
placement="right"
|
||||
width={420}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="intro-outro-panel-drawer"
|
||||
>
|
||||
{/* ═══ 片头区块 ═══ */}
|
||||
<div className="iop-block">
|
||||
<div className="iop-block-header">
|
||||
<span className="iop-block-icon">🎞️</span>
|
||||
<span className="iop-block-title">片头</span>
|
||||
</div>
|
||||
|
||||
{/* 类型选择 */}
|
||||
<div className="iop-kind-row">
|
||||
{KIND_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`iop-kind-btn${config.intro.kind === opt.value ? " active" : ""}`}
|
||||
onClick={() => handleIntroKindChange(opt.value)}
|
||||
>
|
||||
<span className="iop-kind-icon">{opt.icon}</span>
|
||||
<span className="iop-kind-label">{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 视频/图片配置 */}
|
||||
{config.intro.kind !== "none" && (
|
||||
<>
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">
|
||||
{config.intro.kind === "video" ? "视频" : "图片"} URL
|
||||
</label>
|
||||
<input
|
||||
className="iop-input"
|
||||
type="text"
|
||||
placeholder={
|
||||
config.intro.kind === "video"
|
||||
? "https://example.com/intro.mp4"
|
||||
: "https://example.com/intro.png"
|
||||
}
|
||||
value={config.intro.url ?? ""}
|
||||
onChange={(e) => handleIntroChange({ url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">显示时长</label>
|
||||
<div className="iop-slider-row">
|
||||
<input
|
||||
className="iop-slider"
|
||||
type="range"
|
||||
min={1}
|
||||
max={15}
|
||||
step={0.5}
|
||||
value={config.intro.duration}
|
||||
onChange={(e) =>
|
||||
handleIntroChange({ duration: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<span className="iop-slider-value">
|
||||
{config.intro.duration}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">进入过渡动画</label>
|
||||
<select
|
||||
className="iop-select"
|
||||
value={config.intro.transition ?? "none"}
|
||||
onChange={(e) =>
|
||||
handleIntroChange({
|
||||
transition: e.target.value as TransitionType,
|
||||
})
|
||||
}
|
||||
>
|
||||
{TRANSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{config.intro.transition && config.intro.transition !== "none" && (
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">过渡时长</label>
|
||||
<div className="iop-slider-row">
|
||||
<input
|
||||
className="iop-slider"
|
||||
type="range"
|
||||
min={0.3}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={config.intro.transition_duration ?? 0.5}
|
||||
onChange={(e) =>
|
||||
handleIntroChange({
|
||||
transition_duration: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="iop-slider-value">
|
||||
{(config.intro.transition_duration ?? 0.5).toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 片尾区块 ═══ */}
|
||||
<div className="iop-block">
|
||||
<div className="iop-block-header">
|
||||
<span className="iop-block-icon">🏁</span>
|
||||
<span className="iop-block-title">片尾</span>
|
||||
</div>
|
||||
|
||||
{/* 类型选择 */}
|
||||
<div className="iop-kind-row">
|
||||
{KIND_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`iop-kind-btn${config.outro.kind === opt.value ? " active" : ""}`}
|
||||
onClick={() => handleOutroKindChange(opt.value)}
|
||||
>
|
||||
<span className="iop-kind-icon">{opt.icon}</span>
|
||||
<span className="iop-kind-label">{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 视频/图片配置 */}
|
||||
{config.outro.kind !== "none" && (
|
||||
<>
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">
|
||||
{config.outro.kind === "video" ? "视频" : "图片"} URL
|
||||
</label>
|
||||
<input
|
||||
className="iop-input"
|
||||
type="text"
|
||||
placeholder={
|
||||
config.outro.kind === "video"
|
||||
? "https://example.com/outro.mp4"
|
||||
: "https://example.com/outro.png"
|
||||
}
|
||||
value={config.outro.url ?? ""}
|
||||
onChange={(e) => handleOutroChange({ url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">显示时长</label>
|
||||
<div className="iop-slider-row">
|
||||
<input
|
||||
className="iop-slider"
|
||||
type="range"
|
||||
min={1}
|
||||
max={15}
|
||||
step={0.5}
|
||||
value={config.outro.duration}
|
||||
onChange={(e) =>
|
||||
handleOutroChange({ duration: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<span className="iop-slider-value">
|
||||
{config.outro.duration}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">退出过渡动画</label>
|
||||
<select
|
||||
className="iop-select"
|
||||
value={config.outro.transition ?? "none"}
|
||||
onChange={(e) =>
|
||||
handleOutroChange({
|
||||
transition: e.target.value as TransitionType,
|
||||
})
|
||||
}
|
||||
>
|
||||
{TRANSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{config.outro.transition && config.outro.transition !== "none" && (
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">过渡时长</label>
|
||||
<div className="iop-slider-row">
|
||||
<input
|
||||
className="iop-slider"
|
||||
type="range"
|
||||
min={0.3}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={config.outro.transition_duration ?? 0.5}
|
||||
onChange={(e) =>
|
||||
handleOutroChange({
|
||||
transition_duration: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="iop-slider-value">
|
||||
{(config.outro.transition_duration ?? 0.5).toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
<div className="iop-footer">
|
||||
<button className="iop-reset-btn" onClick={handleReset}>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntroOutroPanel;
|
||||
@@ -1,582 +0,0 @@
|
||||
/**
|
||||
* 画中画配置面板 — Drawer 形式
|
||||
* 左侧图层列表 + 右侧单图层配置 + 迷你预览区
|
||||
*/
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { Drawer, Switch } from "antd";
|
||||
import type {
|
||||
PipConfig,
|
||||
PipLayer,
|
||||
PipGridPosition,
|
||||
PipAnimType,
|
||||
PipSlideDirection,
|
||||
} from "../types";
|
||||
import { DEFAULT_PIP_LAYER, DEFAULT_PIP_CONFIG } from "../types";
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
/** 九宫格位置 → 百分比坐标映射 */
|
||||
const GRID_POSITION_MAP: Record<PipGridPosition, { x: number; y: number }> = {
|
||||
top_left: { x: 5, y: 5 },
|
||||
top_center: { x: 37.5, y: 5 },
|
||||
top_right: { x: 70, y: 5 },
|
||||
center_left: { x: 5, y: 37.5 },
|
||||
center: { x: 37.5, y: 37.5 },
|
||||
center_right: { x: 70, y: 37.5 },
|
||||
bottom_left: { x: 5, y: 70 },
|
||||
bottom_center: { x: 37.5, y: 70 },
|
||||
bottom_right: { x: 70, y: 70 },
|
||||
};
|
||||
|
||||
/** 九宫格位置选项 */
|
||||
const GRID_POSITIONS: PipGridPosition[] = [
|
||||
"top_left",
|
||||
"top_center",
|
||||
"top_right",
|
||||
"center_left",
|
||||
"center",
|
||||
"center_right",
|
||||
"bottom_left",
|
||||
"bottom_center",
|
||||
"bottom_right",
|
||||
];
|
||||
|
||||
/** 入场动画选项 */
|
||||
const ANIM_OPTIONS: { value: PipAnimType; label: string }[] = [
|
||||
{ value: "none", label: "无" },
|
||||
{ value: "fade_in", label: "淡入" },
|
||||
{ value: "slide_in", label: "滑入" },
|
||||
];
|
||||
|
||||
/** 滑入方向选项 */
|
||||
const SLIDE_DIR_OPTIONS: { value: PipSlideDirection; label: string }[] = [
|
||||
{ value: "left", label: "← 左" },
|
||||
{ value: "right", label: "→ 右" },
|
||||
{ value: "up", label: "↑ 上" },
|
||||
{ value: "down", label: "↓ 下" },
|
||||
];
|
||||
|
||||
/** 预览图层颜色池 */
|
||||
const LAYER_COLORS = [
|
||||
"rgba(22,119,255,0.5)",
|
||||
"rgba(82,196,26,0.5)",
|
||||
"rgba(250,173,20,0.5)",
|
||||
"rgba(255,77,79,0.5)",
|
||||
"rgba(114,46,209,0.5)",
|
||||
"rgba(19,194,194,0.5)",
|
||||
];
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
|
||||
interface PipConfigPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: PipConfig;
|
||||
onChange: (config: PipConfig) => void;
|
||||
totalDuration: number;
|
||||
}
|
||||
|
||||
/* ──────────── 辅助函数 ──────────── */
|
||||
|
||||
let layerIdCounter = 0;
|
||||
const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}`;
|
||||
|
||||
/* ──────────── 组件 ──────────── */
|
||||
|
||||
const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
totalDuration,
|
||||
}) => {
|
||||
/** 当前选中图层 ID */
|
||||
const [selectedId, setSelectedId] = React.useState<string>("");
|
||||
|
||||
/** 当前选中图层 */
|
||||
const selectedLayer = useMemo(
|
||||
() => config.layers.find((l) => l.id === selectedId) ?? null,
|
||||
[config.layers, selectedId],
|
||||
);
|
||||
|
||||
/* ── 添加图层 ── */
|
||||
const handleAddLayer = useCallback(() => {
|
||||
const newLayer: PipLayer = {
|
||||
...DEFAULT_PIP_LAYER,
|
||||
id: genLayerId(),
|
||||
name: `图层 ${config.layers.length + 1}`,
|
||||
z_index: config.layers.length + 1,
|
||||
};
|
||||
onChange({
|
||||
...config,
|
||||
layers: [...config.layers, newLayer],
|
||||
});
|
||||
setSelectedId(newLayer.id);
|
||||
}, [config, onChange]);
|
||||
|
||||
/* ── 删除图层 ── */
|
||||
const handleDeleteLayer = useCallback(
|
||||
(id: string) => {
|
||||
const newLayers = config.layers.filter((l) => l.id !== id);
|
||||
onChange({ ...config, layers: newLayers });
|
||||
if (selectedId === id) {
|
||||
setSelectedId(newLayers.length > 0 ? newLayers[0].id : "");
|
||||
}
|
||||
},
|
||||
[config, onChange, selectedId],
|
||||
);
|
||||
|
||||
/* ── 更新图层 ── */
|
||||
const updateLayer = useCallback(
|
||||
(id: string, partial: Partial<PipLayer>) => {
|
||||
onChange({
|
||||
...config,
|
||||
layers: config.layers.map((l) =>
|
||||
l.id === id ? { ...l, ...partial } : l,
|
||||
),
|
||||
});
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 切换启用 ── */
|
||||
const handleEnableToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
onChange({ ...config, enabled: checked });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_PIP_CONFIG });
|
||||
setSelectedId("");
|
||||
}, [onChange]);
|
||||
|
||||
/* ── 九宫格点击 ── */
|
||||
const handleGridClick = useCallback(
|
||||
(pos: PipGridPosition) => {
|
||||
if (!selectedLayer) return;
|
||||
const coords = GRID_POSITION_MAP[pos];
|
||||
updateLayer(selectedLayer.id, {
|
||||
grid_position: pos,
|
||||
x: coords.x,
|
||||
y: coords.y,
|
||||
});
|
||||
},
|
||||
[selectedLayer, updateLayer],
|
||||
);
|
||||
|
||||
/* ── 宽高比锁定 ── */
|
||||
const handleWidthChange = useCallback(
|
||||
(val: number) => {
|
||||
if (!selectedLayer) return;
|
||||
const partial: Partial<PipLayer> = { width: val };
|
||||
if (selectedLayer.aspect_lock) {
|
||||
// 保持宽高比 1:1(百分比相同)
|
||||
partial.height = val;
|
||||
}
|
||||
updateLayer(selectedLayer.id, partial);
|
||||
},
|
||||
[selectedLayer, updateLayer],
|
||||
);
|
||||
|
||||
const handleHeightChange = useCallback(
|
||||
(val: number) => {
|
||||
if (!selectedLayer) return;
|
||||
const partial: Partial<PipLayer> = { height: val };
|
||||
if (selectedLayer.aspect_lock) {
|
||||
partial.width = val;
|
||||
}
|
||||
updateLayer(selectedLayer.id, partial);
|
||||
},
|
||||
[selectedLayer, updateLayer],
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="🖼️ 画中画设置"
|
||||
placement="right"
|
||||
width={520}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="pip-config-panel-drawer"
|
||||
>
|
||||
{/* ═══ 顶部工具栏 ═══ */}
|
||||
<div className="pip-toolbar">
|
||||
<div className="pip-toolbar-left">
|
||||
<button className="pip-add-btn" onClick={handleAddLayer}>
|
||||
+ 添加图层
|
||||
</button>
|
||||
</div>
|
||||
<div className="pip-enable-switch">
|
||||
<span>启用</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={config.enabled}
|
||||
onChange={handleEnableToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ 主体:图层列表 + 配置区 ═══ */}
|
||||
<div className="pip-body">
|
||||
{/* 左侧图层列表 */}
|
||||
<div className="pip-layer-list">
|
||||
{config.layers.length === 0 ? (
|
||||
<div className="pip-layer-empty">暂无图层,点击上方添加</div>
|
||||
) : (
|
||||
config.layers.map((layer, idx) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={`pip-layer-item${selectedId === layer.id ? " active" : ""}`}
|
||||
onClick={() => setSelectedId(layer.id)}
|
||||
>
|
||||
{layer.thumbnail_url || layer.material_url ? (
|
||||
<img
|
||||
className="pip-layer-thumb"
|
||||
src={layer.thumbnail_url || layer.material_url}
|
||||
alt={layer.name}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="pip-layer-thumb"
|
||||
style={{
|
||||
background: LAYER_COLORS[idx % LAYER_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="pip-layer-name">{layer.name}</span>
|
||||
<button
|
||||
className="pip-layer-delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteLayer(layer.id);
|
||||
}}
|
||||
title="删除图层"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧配置区 */}
|
||||
<div className="pip-config-area">
|
||||
{!selectedLayer ? (
|
||||
<div className="pip-config-empty">选择或添加图层以配置</div>
|
||||
) : (
|
||||
<>
|
||||
{/* ── 迷你预览 ── */}
|
||||
<div className="pip-preview-box">
|
||||
{config.layers.map((layer, idx) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={`pip-preview-layer${selectedId === layer.id ? " selected" : ""}`}
|
||||
style={{
|
||||
left: `${layer.x}%`,
|
||||
top: `${layer.y}%`,
|
||||
width: `${layer.width}%`,
|
||||
height: `${layer.height}%`,
|
||||
background: LAYER_COLORS[idx % LAYER_COLORS.length],
|
||||
opacity: layer.opacity / 100,
|
||||
borderRadius: `${layer.border_radius}%`,
|
||||
}}
|
||||
>
|
||||
<span className="pip-preview-label">{layer.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── 素材类型 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">素材类型</label>
|
||||
<div className="pip-type-btns">
|
||||
<button
|
||||
className={`pip-type-btn${selectedLayer.material_type === "image" ? " active" : ""}`}
|
||||
onClick={() =>
|
||||
updateLayer(selectedLayer.id, { material_type: "image" })
|
||||
}
|
||||
>
|
||||
🖼️ 图片
|
||||
</button>
|
||||
<button
|
||||
className={`pip-type-btn${selectedLayer.material_type === "video" ? " active" : ""}`}
|
||||
onClick={() =>
|
||||
updateLayer(selectedLayer.id, { material_type: "video" })
|
||||
}
|
||||
>
|
||||
🎬 视频
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 素材 URL ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">
|
||||
{selectedLayer.material_type === "image" ? "图片" : "视频"}{" "}
|
||||
URL
|
||||
</label>
|
||||
<input
|
||||
className="pip-input"
|
||||
type="text"
|
||||
placeholder={
|
||||
selectedLayer.material_type === "image"
|
||||
? "https://example.com/image.png"
|
||||
: "https://example.com/video.mp4"
|
||||
}
|
||||
value={selectedLayer.material_url}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
material_url: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 位置:九宫格 + 坐标 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">位置</label>
|
||||
<div
|
||||
style={{ display: "flex", gap: 16, alignItems: "flex-start" }}
|
||||
>
|
||||
<div className="pip-grid">
|
||||
{GRID_POSITIONS.map((pos) => (
|
||||
<button
|
||||
key={pos}
|
||||
className={`pip-grid-btn${selectedLayer.grid_position === pos ? " active" : ""}`}
|
||||
onClick={() => handleGridClick(pos)}
|
||||
>
|
||||
<span className="pip-grid-dot" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="pip-field-row" style={{ flex: 1 }}>
|
||||
<div>
|
||||
<label className="pip-field-label">X (%)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={selectedLayer.x}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
x: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="pip-field-label">Y (%)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={selectedLayer.y}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
y: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 尺寸 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">尺寸</label>
|
||||
<div className="pip-slider-row">
|
||||
<span style={{ fontSize: 12, color: "#999", width: 20 }}>
|
||||
宽
|
||||
</span>
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={80}
|
||||
value={selectedLayer.width}
|
||||
onChange={(e) => handleWidthChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="pip-slider-value">
|
||||
{selectedLayer.width}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="pip-slider-row" style={{ marginTop: 6 }}>
|
||||
<span style={{ fontSize: 12, color: "#999", width: 20 }}>
|
||||
高
|
||||
</span>
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={80}
|
||||
value={selectedLayer.height}
|
||||
onChange={(e) => handleHeightChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="pip-slider-value">
|
||||
{selectedLayer.height}%
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="pip-lock-row"
|
||||
style={{ marginTop: 6 }}
|
||||
onClick={() =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
aspect_lock: !selectedLayer.aspect_lock,
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="pip-lock-icon">
|
||||
{selectedLayer.aspect_lock ? "🔒" : "🔓"}
|
||||
</span>
|
||||
<span>
|
||||
{selectedLayer.aspect_lock ? "已锁定比例" : "锁定宽高比"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 圆角 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">圆角</label>
|
||||
<div className="pip-slider-row">
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={0}
|
||||
max={50}
|
||||
value={selectedLayer.border_radius}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
border_radius: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="pip-slider-value">
|
||||
{selectedLayer.border_radius}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 透明度 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">透明度</label>
|
||||
<div className="pip-slider-row">
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={selectedLayer.opacity}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
opacity: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="pip-slider-value">
|
||||
{selectedLayer.opacity}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 时间 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">时间</label>
|
||||
<div className="pip-field-row">
|
||||
<div>
|
||||
<label className="pip-field-label">开始 (s)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={totalDuration || 999}
|
||||
step={0.1}
|
||||
value={selectedLayer.start_time}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
start_time: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="pip-field-label">持续 (s)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0.1}
|
||||
max={totalDuration || 999}
|
||||
step={0.1}
|
||||
value={selectedLayer.duration}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
duration: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 入场动画 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">入场动画</label>
|
||||
<select
|
||||
className="pip-select"
|
||||
value={selectedLayer.animation}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
animation: e.target.value as PipAnimType,
|
||||
})
|
||||
}
|
||||
>
|
||||
{ANIM_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 滑入方向(仅 slide_in 时显示) */}
|
||||
{selectedLayer.animation === "slide_in" && (
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">滑入方向</label>
|
||||
<select
|
||||
className="pip-select"
|
||||
value={selectedLayer.slide_direction}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
slide_direction: e.target.value as PipSlideDirection,
|
||||
})
|
||||
}
|
||||
>
|
||||
{SLIDE_DIR_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
<div className="pip-footer">
|
||||
<button className="pip-reset-btn" onClick={handleReset}>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default PipConfigPanel;
|
||||
@@ -4,13 +4,25 @@
|
||||
* 封面右侧竖排4个方案按钮
|
||||
*/
|
||||
import React from "react";
|
||||
import type { ClipData, ClipType, TitleSettings } from "../types";
|
||||
import type { ClipData, ClipType } from "../types";
|
||||
|
||||
interface CoverScheme {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface TitleSettings {
|
||||
aiAutoSelect: boolean;
|
||||
title: string;
|
||||
position: string;
|
||||
font: string;
|
||||
size: number;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
stroke: boolean;
|
||||
shadow: boolean;
|
||||
}
|
||||
|
||||
interface SubtitleSettings {
|
||||
enabled: boolean;
|
||||
position: string;
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* 片段调速面板 — Drawer 形式
|
||||
* 速度滑块(0.25x ~ 4x)+ 预设快捷按钮 + 音调修正开关
|
||||
* 支持应用到当前片段 / 所有片段
|
||||
*/
|
||||
import React, { useCallback } from "react";
|
||||
import { Drawer, Slider } from "antd";
|
||||
import type { SpeedConfig } from "../types";
|
||||
import { DEFAULT_SPEED } from "../types";
|
||||
|
||||
/* ──────────── 预设速度 ──────────── */
|
||||
const SPEED_PRESETS: { rate: number; label: string }[] = [
|
||||
{ rate: 0.5, label: "0.5x" },
|
||||
{ rate: 1.0, label: "1x" },
|
||||
{ rate: 1.5, label: "1.5x" },
|
||||
{ rate: 2.0, label: "2x" },
|
||||
];
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
interface SpeedPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** 当前片段调速配置 */
|
||||
config: SpeedConfig;
|
||||
onChange: (config: SpeedConfig) => void;
|
||||
/** 应用到所有片段 */
|
||||
onApplyAll?: (config: SpeedConfig) => void;
|
||||
}
|
||||
|
||||
const SpeedPanel: React.FC<SpeedPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
onApplyAll,
|
||||
}) => {
|
||||
/* ── 修改速度 ── */
|
||||
const handleChangeRate = useCallback(
|
||||
(rate: number) => {
|
||||
onChange({ ...config, rate });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 切换音调修正 ── */
|
||||
const handleTogglePitch = useCallback(() => {
|
||||
onChange({ ...config, pitchCorrection: !config.pitchCorrection });
|
||||
}, [config, onChange]);
|
||||
|
||||
/* ── 选择预设 ── */
|
||||
const handlePreset = useCallback(
|
||||
(rate: number) => {
|
||||
onChange({ ...config, rate });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 应用到所有片段 ── */
|
||||
const handleApplyAll = useCallback(() => {
|
||||
onApplyAll?.(config);
|
||||
}, [config, onApplyAll]);
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_SPEED });
|
||||
}, [onChange]);
|
||||
|
||||
/* ── 速度描述文字 ── */
|
||||
const speedLabel =
|
||||
config.rate < 1
|
||||
? "慢速(慢动作)"
|
||||
: config.rate === 1
|
||||
? "原速"
|
||||
: config.rate < 2
|
||||
? "快速"
|
||||
: "极速";
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="⚡ 片段调速"
|
||||
placement="right"
|
||||
width={380}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="speed-panel-drawer"
|
||||
>
|
||||
{/* ── 速度滑块 ── */}
|
||||
<div className="sp-speed-section">
|
||||
<div className="sp-speed-header">
|
||||
<span className="sp-speed-label">播放速度</span>
|
||||
<span className="sp-speed-value">{config.rate.toFixed(2)}x</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={0.25}
|
||||
max={4.0}
|
||||
step={0.05}
|
||||
value={config.rate}
|
||||
onChange={handleChangeRate}
|
||||
tooltip={{ formatter: (v) => `${(v as number).toFixed(2)}x` }}
|
||||
/>
|
||||
<div className="sp-speed-marks">
|
||||
<span>0.25x</span>
|
||||
<span>1x</span>
|
||||
<span>2x</span>
|
||||
<span>4x</span>
|
||||
</div>
|
||||
<div className="sp-speed-desc">{speedLabel}</div>
|
||||
</div>
|
||||
|
||||
{/* ── 预设快捷按钮 ── */}
|
||||
<div className="sp-presets">
|
||||
<div className="sp-presets-label">快捷预设</div>
|
||||
<div className="sp-presets-row">
|
||||
{SPEED_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.rate}
|
||||
className={`sp-preset-btn${Math.abs(config.rate - p.rate) < 0.01 ? " active" : ""}`}
|
||||
onClick={() => handlePreset(p.rate)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 音调修正开关 ── */}
|
||||
<div className="sp-pitch-section">
|
||||
<div className="sp-pitch-info">
|
||||
<span className="sp-pitch-label">音调修正</span>
|
||||
<span className="sp-pitch-desc">
|
||||
{config.pitchCorrection ? "变速不变调(推荐)" : "变速同时变调"}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`ep-toggle${config.pitchCorrection ? " active" : ""}`}
|
||||
onClick={handleTogglePitch}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
<div className="sp-footer">
|
||||
<button className="sp-reset-btn" onClick={handleReset}>
|
||||
重置原速
|
||||
</button>
|
||||
{onApplyAll && (
|
||||
<button className="sp-apply-all-btn" onClick={handleApplyAll}>
|
||||
应用到所有片段
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpeedPanel;
|
||||
@@ -1,561 +0,0 @@
|
||||
/**
|
||||
* 贴纸配置面板
|
||||
* 贴纸素材库(emoji / 图片)+ 文字花字 + 位置大小调整
|
||||
*/
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Drawer, Switch } from "antd";
|
||||
import type {
|
||||
StickerConfig,
|
||||
StickerItem,
|
||||
StickerType,
|
||||
TextStickerPreset,
|
||||
} from "../types";
|
||||
import {
|
||||
DEFAULT_STICKER_CONFIG,
|
||||
DEFAULT_STICKER_ITEM,
|
||||
TEXT_STICKER_PRESET_LABELS,
|
||||
} from "../types";
|
||||
|
||||
interface StickerPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: StickerConfig;
|
||||
onChange: (config: StickerConfig) => void;
|
||||
totalDuration: number;
|
||||
}
|
||||
|
||||
/** 常用 emoji 素材 */
|
||||
const EMOJI_LIST = [
|
||||
"😀",
|
||||
"😂",
|
||||
"🥰",
|
||||
"😎",
|
||||
"🤩",
|
||||
"😱",
|
||||
"🤔",
|
||||
"😴",
|
||||
"🥳",
|
||||
"😍",
|
||||
"❤️",
|
||||
"🔥",
|
||||
"⭐",
|
||||
"✨",
|
||||
"💯",
|
||||
"👍",
|
||||
"👏",
|
||||
"🎉",
|
||||
"🎵",
|
||||
"💪",
|
||||
"📌",
|
||||
"💡",
|
||||
"🎯",
|
||||
"✅",
|
||||
"❌",
|
||||
"⬆️",
|
||||
"⬇️",
|
||||
"➡️",
|
||||
"⭕",
|
||||
"🔔",
|
||||
];
|
||||
|
||||
/** 文字花字预设对应的 CSS 样式预览 */
|
||||
const TEXT_PRESET_STYLES: Record<TextStickerPreset, React.CSSProperties> = {
|
||||
normal: { color: "#fff", textShadow: "none" },
|
||||
highlight: { color: "#FFD700", textShadow: "0 0 8px rgba(255,215,0,0.6)" },
|
||||
bubble: { color: "#fff", background: "rgba(0,0,0,0.5)", borderRadius: 8 },
|
||||
neon: { color: "#0ff", textShadow: "0 0 6px #0ff, 0 0 12px #0ff" },
|
||||
shadow: { color: "#fff", textShadow: "2px 2px 4px rgba(0,0,0,0.8)" },
|
||||
outline: { color: "#fff", WebkitTextStroke: "1px #000" },
|
||||
gradient: {
|
||||
color: "transparent",
|
||||
background: "linear-gradient(90deg,#f093fb,#f5576c)",
|
||||
WebkitBackgroundClip: "text",
|
||||
},
|
||||
handwrite: { color: "#333", fontStyle: "italic", fontFamily: "cursive" },
|
||||
};
|
||||
|
||||
/** 生成唯一 ID */
|
||||
const genId = () =>
|
||||
`sticker_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const StickerPanel: React.FC<StickerPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
totalDuration,
|
||||
}) => {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<StickerType>("emoji");
|
||||
|
||||
const selectedSticker = config.items.find((s) => s.id === selectedId) ?? null;
|
||||
|
||||
/** 更新单个贴纸 */
|
||||
const updateItem = useCallback(
|
||||
(id: string, partial: Partial<StickerItem>) => {
|
||||
onChange({
|
||||
...config,
|
||||
items: config.items.map((s) =>
|
||||
s.id === id ? { ...s, ...partial } : s,
|
||||
),
|
||||
});
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/** 添加贴纸 */
|
||||
const addSticker = useCallback(
|
||||
(type: StickerType, content: string) => {
|
||||
const newItem: StickerItem = {
|
||||
...DEFAULT_STICKER_ITEM,
|
||||
id: genId(),
|
||||
type,
|
||||
content,
|
||||
duration: totalDuration > 0 ? totalDuration : 5,
|
||||
z_index: config.items.length + 1,
|
||||
};
|
||||
onChange({
|
||||
...config,
|
||||
enabled: true,
|
||||
items: [...config.items, newItem],
|
||||
});
|
||||
setSelectedId(newItem.id);
|
||||
},
|
||||
[config, onChange, totalDuration],
|
||||
);
|
||||
|
||||
/** 删除贴纸 */
|
||||
const removeSticker = useCallback(
|
||||
(id: string) => {
|
||||
onChange({
|
||||
...config,
|
||||
items: config.items.filter((s) => s.id !== id),
|
||||
});
|
||||
if (selectedId === id) setSelectedId(null);
|
||||
},
|
||||
[config, onChange, selectedId],
|
||||
);
|
||||
|
||||
/** 重置所有 */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_STICKER_CONFIG, enabled: config.enabled });
|
||||
setSelectedId(null);
|
||||
}, [config.enabled, onChange]);
|
||||
|
||||
/** 文字花字输入 */
|
||||
const [textInput, setTextInput] = useState("");
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="贴纸"
|
||||
placement="right"
|
||||
width={460}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="sticker-panel-drawer"
|
||||
>
|
||||
{/* 顶部开关 */}
|
||||
<div className="sticker-header">
|
||||
<span className="sticker-header-label">启用贴纸</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={config.enabled}
|
||||
onChange={(checked) => onChange({ ...config, enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 类型 Tab */}
|
||||
<div className="sticker-tabs">
|
||||
{(["emoji", "image", "text"] as StickerType[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`sticker-tab${activeTab === t ? " active" : ""}`}
|
||||
onClick={() => setActiveTab(t)}
|
||||
>
|
||||
{t === "emoji"
|
||||
? "表情贴纸"
|
||||
: t === "image"
|
||||
? "图片贴纸"
|
||||
: "文字花字"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab 内容区 */}
|
||||
<div className="sticker-tab-content">
|
||||
{/* Emoji 素材库 */}
|
||||
{activeTab === "emoji" && (
|
||||
<div className="sticker-emoji-grid">
|
||||
{EMOJI_LIST.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
className="sticker-emoji-btn"
|
||||
onClick={() => addSticker("emoji", emoji)}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片贴纸 */}
|
||||
{activeTab === "image" && (
|
||||
<div className="sticker-image-input">
|
||||
<input
|
||||
type="text"
|
||||
className="sticker-url-input"
|
||||
placeholder="输入图片 URL 添加贴纸..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && e.currentTarget.value.trim()) {
|
||||
addSticker("image", e.currentTarget.value.trim());
|
||||
e.currentTarget.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="sticker-url-add-btn"
|
||||
onClick={() => {
|
||||
const input =
|
||||
document.querySelector<HTMLInputElement>(
|
||||
".sticker-url-input",
|
||||
);
|
||||
if (input?.value.trim()) {
|
||||
addSticker("image", input.value.trim());
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文字花字 */}
|
||||
{activeTab === "text" && (
|
||||
<div className="sticker-text-section">
|
||||
<div className="sticker-text-input-row">
|
||||
<input
|
||||
type="text"
|
||||
className="sticker-text-input"
|
||||
placeholder="输入文字内容..."
|
||||
value={textInput}
|
||||
onChange={(e) => setTextInput(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="sticker-text-add-btn"
|
||||
disabled={!textInput.trim()}
|
||||
onClick={() => {
|
||||
if (textInput.trim()) {
|
||||
addSticker("text", textInput.trim());
|
||||
setTextInput("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
<div className="sticker-text-presets">
|
||||
<div className="sticker-preset-title">花字预设预览</div>
|
||||
<div className="sticker-preset-grid">
|
||||
{(
|
||||
Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]
|
||||
).map((p) => (
|
||||
<div
|
||||
key={p}
|
||||
className="sticker-preset-preview"
|
||||
style={{
|
||||
...TEXT_PRESET_STYLES[p],
|
||||
background:
|
||||
p === "bubble"
|
||||
? "rgba(0,0,0,0.5)"
|
||||
: p === "gradient"
|
||||
? "linear-gradient(90deg,#f093fb,#f5576c)"
|
||||
: "#1a1a2e",
|
||||
}}
|
||||
>
|
||||
<span style={TEXT_PRESET_STYLES[p]}>示例</span>
|
||||
<div className="sticker-preset-name">
|
||||
{TEXT_STICKER_PRESET_LABELS[p]}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 已添加贴纸列表 */}
|
||||
{config.items.length > 0 && (
|
||||
<div className="sticker-list-section">
|
||||
<div className="sticker-section-title">
|
||||
已添加贴纸 ({config.items.length})
|
||||
</div>
|
||||
<div className="sticker-list">
|
||||
{config.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`sticker-list-item${selectedId === item.id ? " active" : ""}`}
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
>
|
||||
<span className="sticker-list-icon">
|
||||
{item.type === "emoji"
|
||||
? item.content
|
||||
: item.type === "text"
|
||||
? "T"
|
||||
: "🖼"}
|
||||
</span>
|
||||
<span className="sticker-list-name">
|
||||
{item.type === "text"
|
||||
? item.content.slice(0, 10)
|
||||
: item.type === "emoji"
|
||||
? "表情贴纸"
|
||||
: "图片贴纸"}
|
||||
</span>
|
||||
<button
|
||||
className="sticker-list-delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeSticker(item.id);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 选中贴纸的属性编辑 */}
|
||||
{selectedSticker && (
|
||||
<div className="sticker-props-section">
|
||||
<div className="sticker-section-title">属性调整</div>
|
||||
|
||||
{/* 位置 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">位置 X</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={selectedSticker.x}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, { x: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<span className="sticker-prop-value">{selectedSticker.x}%</span>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">位置 Y</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={selectedSticker.y}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, { y: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<span className="sticker-prop-value">{selectedSticker.y}%</span>
|
||||
</div>
|
||||
|
||||
{/* 尺寸 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">大小</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={5}
|
||||
max={50}
|
||||
value={selectedSticker.width}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, {
|
||||
width: Number(e.target.value),
|
||||
height: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="sticker-prop-value">{selectedSticker.width}%</span>
|
||||
</div>
|
||||
|
||||
{/* 旋转 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">旋转</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={-180}
|
||||
max={180}
|
||||
value={selectedSticker.rotation}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, {
|
||||
rotation: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="sticker-prop-value">
|
||||
{selectedSticker.rotation}°
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 透明度 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">透明度</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={selectedSticker.opacity}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, {
|
||||
opacity: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="sticker-prop-value">
|
||||
{selectedSticker.opacity}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 时间 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">开始</span>
|
||||
<input
|
||||
type="number"
|
||||
className="sticker-prop-number"
|
||||
min={0}
|
||||
max={totalDuration}
|
||||
step={0.1}
|
||||
value={selectedSticker.start_time}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, {
|
||||
start_time: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="sticker-prop-label">时长</span>
|
||||
<input
|
||||
type="number"
|
||||
className="sticker-prop-number"
|
||||
min={0}
|
||||
max={totalDuration}
|
||||
step={0.1}
|
||||
value={selectedSticker.duration}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, {
|
||||
duration: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 文字贴纸特有属性 */}
|
||||
{selectedSticker.type === "text" && (
|
||||
<>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">花字</span>
|
||||
<select
|
||||
className="sticker-prop-select"
|
||||
value={selectedSticker.text_preset}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, {
|
||||
text_preset: e.target.value as TextStickerPreset,
|
||||
})
|
||||
}
|
||||
>
|
||||
{(
|
||||
Object.keys(
|
||||
TEXT_STICKER_PRESET_LABELS,
|
||||
) as TextStickerPreset[]
|
||||
).map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{TEXT_STICKER_PRESET_LABELS[p]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">字号</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={12}
|
||||
max={72}
|
||||
value={selectedSticker.font_size}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, {
|
||||
font_size: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="sticker-prop-value">
|
||||
{selectedSticker.font_size}px
|
||||
</span>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">颜色</span>
|
||||
<input
|
||||
type="color"
|
||||
className="sticker-prop-color"
|
||||
value={selectedSticker.text_color}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, {
|
||||
text_color: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 预览 */}
|
||||
<div className="sticker-preview-box">
|
||||
<div
|
||||
className="sticker-preview-item"
|
||||
style={{
|
||||
left: `${selectedSticker.x}%`,
|
||||
top: `${selectedSticker.y}%`,
|
||||
width: `${selectedSticker.width}%`,
|
||||
height: `${selectedSticker.width}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${selectedSticker.rotation}deg)`,
|
||||
opacity: selectedSticker.opacity / 100,
|
||||
fontSize:
|
||||
selectedSticker.type === "text"
|
||||
? `${selectedSticker.font_size}px`
|
||||
: undefined,
|
||||
...TEXT_PRESET_STYLES[selectedSticker.text_preset],
|
||||
}}
|
||||
>
|
||||
{selectedSticker.type === "emoji" && selectedSticker.content}
|
||||
{selectedSticker.type === "text" && selectedSticker.content}
|
||||
{selectedSticker.type === "image" && (
|
||||
<img
|
||||
src={selectedSticker.content}
|
||||
alt="sticker"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部重置 */}
|
||||
<div className="sticker-footer">
|
||||
<button className="sticker-reset-btn" onClick={handleReset}>
|
||||
清空所有贴纸
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default StickerPanel;
|
||||
@@ -1,274 +0,0 @@
|
||||
/**
|
||||
* 字幕样式配置面板 — Drawer 形式
|
||||
* 字幕开关(手动 / ASR 自动识别)、字体大小、颜色、描边/阴影、位置、ASR 语言
|
||||
*/
|
||||
import React from "react";
|
||||
import { Drawer, Slider, ColorPicker, Select } from "antd";
|
||||
import type { Color } from "antd/es/color-picker";
|
||||
|
||||
/* ──────────── 类型 ──────────── */
|
||||
|
||||
export type SubtitleMode = "manual" | "asr";
|
||||
|
||||
export interface SubtitleStyleConfig {
|
||||
/** 是否启用字幕 */
|
||||
enabled: boolean;
|
||||
/** 字幕模式:手动输入 / ASR 自动识别 */
|
||||
mode: SubtitleMode;
|
||||
/** 字体大小 px */
|
||||
fontSize: number;
|
||||
/** 字体颜色 */
|
||||
fontColor: string;
|
||||
/** 描边 */
|
||||
stroke: boolean;
|
||||
/** 阴影 */
|
||||
shadow: boolean;
|
||||
/** 字幕位置 */
|
||||
position: "top" | "center" | "bottom";
|
||||
/** 字体 */
|
||||
font: string;
|
||||
/** 动画效果 */
|
||||
animation: string;
|
||||
/** ASR 语言(仅 ASR 模式) */
|
||||
asrLanguage: "zh" | "en";
|
||||
}
|
||||
|
||||
export const DEFAULT_SUBTITLE_STYLE: SubtitleStyleConfig = {
|
||||
enabled: true,
|
||||
mode: "asr",
|
||||
fontSize: 16,
|
||||
fontColor: "#ffffff",
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
position: "bottom",
|
||||
font: "思源黑体",
|
||||
animation: "none",
|
||||
asrLanguage: "zh",
|
||||
};
|
||||
|
||||
/* ──────────── 选项常量 ──────────── */
|
||||
|
||||
const POSITION_OPTIONS = [
|
||||
{ value: "top", label: "顶部" },
|
||||
{ value: "center", label: "居中" },
|
||||
{ value: "bottom", label: "底部" },
|
||||
];
|
||||
|
||||
const FONT_OPTIONS = [
|
||||
"思源黑体",
|
||||
"思源宋体",
|
||||
"苹方",
|
||||
"PingFang",
|
||||
"微软雅黑",
|
||||
"楷体",
|
||||
"华康俪金黑",
|
||||
];
|
||||
|
||||
const ANIMATION_OPTIONS = [
|
||||
{ value: "none", label: "无" },
|
||||
{ value: "fade", label: "淡入淡出" },
|
||||
{ value: "slide", label: "滑动" },
|
||||
{ value: "typewriter", label: "打字机" },
|
||||
];
|
||||
|
||||
const ASR_LANGUAGE_OPTIONS = [
|
||||
{ value: "zh", label: "中文" },
|
||||
{ value: "en", label: "English" },
|
||||
];
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
|
||||
interface SubtitleStylePanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: SubtitleStyleConfig;
|
||||
onChange: (config: SubtitleStyleConfig) => void;
|
||||
}
|
||||
|
||||
const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const update = (partial: Partial<SubtitleStyleConfig>) => {
|
||||
onChange({ ...config, ...partial });
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="💬 字幕样式配置"
|
||||
placement="right"
|
||||
width={380}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="subtitle-style-drawer"
|
||||
>
|
||||
{/* ── 字幕开关 ── */}
|
||||
<div className="sub-field">
|
||||
<div className="sub-toggle-row">
|
||||
<span className="sub-label">启用字幕</span>
|
||||
<div
|
||||
className={`ep-toggle${config.enabled ? " active" : ""}`}
|
||||
onClick={() => update({ enabled: !config.enabled })}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.enabled && (
|
||||
<>
|
||||
{/* ── 模式切换 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">字幕来源</label>
|
||||
<div className="sub-mode-switch">
|
||||
<button
|
||||
className={`sub-mode-btn${config.mode === "manual" ? " active" : ""}`}
|
||||
onClick={() => update({ mode: "manual" })}
|
||||
>
|
||||
✏️ 手动输入
|
||||
</button>
|
||||
<button
|
||||
className={`sub-mode-btn${config.mode === "asr" ? " active" : ""}`}
|
||||
onClick={() => update({ mode: "asr" })}
|
||||
>
|
||||
🤖 ASR 识别
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── ASR 语言(仅 ASR 模式) ── */}
|
||||
{config.mode === "asr" && (
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">识别语言</label>
|
||||
<Select
|
||||
className="sub-select"
|
||||
value={config.asrLanguage}
|
||||
onChange={(v) => update({ asrLanguage: v })}
|
||||
options={ASR_LANGUAGE_OPTIONS}
|
||||
popupMatchSelectWidth={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 字体大小 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">
|
||||
字体大小 <span className="sub-value">{config.fontSize}px</span>
|
||||
</label>
|
||||
<Slider
|
||||
min={12}
|
||||
max={48}
|
||||
value={config.fontSize}
|
||||
onChange={(v) => update({ fontSize: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 字体颜色 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">字体颜色</label>
|
||||
<div className="sub-color-row">
|
||||
<ColorPicker
|
||||
value={config.fontColor}
|
||||
onChange={(_color: Color, hex: string) =>
|
||||
update({ fontColor: hex })
|
||||
}
|
||||
showText
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 字体 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">字体</label>
|
||||
<Select
|
||||
className="sub-select"
|
||||
value={config.font}
|
||||
onChange={(v) => update({ font: v })}
|
||||
options={FONT_OPTIONS.map((f) => ({ value: f, label: f }))}
|
||||
popupMatchSelectWidth={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 字幕位置 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">位置</label>
|
||||
<div className="sub-position-group">
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`sub-position-btn${config.position === opt.value ? " active" : ""}`}
|
||||
onClick={() =>
|
||||
update({
|
||||
position: opt.value as SubtitleStyleConfig["position"],
|
||||
})
|
||||
}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 描边 / 阴影 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">效果</label>
|
||||
<div className="sub-effect-btns">
|
||||
<button
|
||||
className={`sub-effect-btn${config.stroke ? " active" : ""}`}
|
||||
onClick={() => update({ stroke: !config.stroke })}
|
||||
>
|
||||
S 描边
|
||||
</button>
|
||||
<button
|
||||
className={`sub-effect-btn${config.shadow ? " active" : ""}`}
|
||||
onClick={() => update({ shadow: !config.shadow })}
|
||||
>
|
||||
☁ 阴影
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 动画 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">动画效果</label>
|
||||
<Select
|
||||
className="sub-select"
|
||||
value={config.animation}
|
||||
onChange={(v) => update({ animation: v })}
|
||||
options={ANIMATION_OPTIONS.map((o) => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
}))}
|
||||
popupMatchSelectWidth={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 预览 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">预览</label>
|
||||
<div className="sub-preview-box">
|
||||
<span
|
||||
className="sub-preview-text"
|
||||
style={{
|
||||
fontSize: `${Math.min(config.fontSize, 28)}px`,
|
||||
color: config.fontColor,
|
||||
fontFamily: config.font,
|
||||
WebkitTextStroke: config.stroke ? "1px #000" : undefined,
|
||||
textShadow: config.shadow
|
||||
? "2px 2px 4px rgba(0,0,0,0.8)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
这是一段字幕预览
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubtitleStylePanel;
|
||||
@@ -1,12 +1,7 @@
|
||||
/**
|
||||
* 水平轨道时间线 — 支持裁剪手柄、分割、右键菜单
|
||||
* 水平轨道时间线 — 片段 = 时间规划 + 类型标记,不绑定素材
|
||||
* 时间标尺(20px) + 水平片段卡片轨道(100x100) + HTML5拖拽排序
|
||||
* "+" 卡片 → 类型+时长选择器
|
||||
*
|
||||
* 裁剪交互:
|
||||
* - 鼠标悬停片段两端显示拖拽手柄,拖动调整入点/出点
|
||||
* - 拖动时实时显示裁剪预览(入点/出点/时长)
|
||||
* - 右键片段弹出菜单:分割 / 恢复原始长度 / 删除
|
||||
*/
|
||||
import React, {
|
||||
useState,
|
||||
@@ -16,8 +11,7 @@ import React, {
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import type { ClipData, ClipType, TrimConfig } from "../types";
|
||||
import { TRANSITION_OPTIONS } from "@/api/editPlans";
|
||||
import type { ClipData, ClipType } from "../types";
|
||||
|
||||
interface TimelinePanelProps {
|
||||
clips: ClipData[];
|
||||
@@ -27,26 +21,6 @@ interface TimelinePanelProps {
|
||||
onClipReorder: (fromIdx: number, toIdx: number) => void;
|
||||
onClipRemove: (clipId: string) => void;
|
||||
onAddClip: (type: ClipType, duration: number) => void;
|
||||
/** 裁剪更新:调整片段的 trim_config 和 duration */
|
||||
onClipTrim?: (
|
||||
clipId: string,
|
||||
trimConfig: TrimConfig,
|
||||
newDuration: number,
|
||||
) => void;
|
||||
/** 在指定位置分割片段 */
|
||||
onClipSplit?: (clipId: string, splitRatio: number) => void;
|
||||
/** 恢复片段原始长度 */
|
||||
onClipResetTrim?: (clipId: string) => void;
|
||||
/** 当前播放时间(秒) */
|
||||
currentTime?: number;
|
||||
/** 缩放:每秒像素数 */
|
||||
pixelsPerSecond?: number;
|
||||
/** 缩放变更回调 */
|
||||
onZoomChange?: (pps: number) => void;
|
||||
/** 播放头跳转回调 */
|
||||
onSeek?: (time: number) => void;
|
||||
/** 总时长(秒),可选(默认由 clips 计算) */
|
||||
totalDuration?: number;
|
||||
}
|
||||
|
||||
/** 片段类型图标 */
|
||||
@@ -61,25 +35,6 @@ const CLIP_TYPE_LABELS: Record<ClipType, string> = {
|
||||
pip: "画中画",
|
||||
};
|
||||
|
||||
/** 裁剪拖拽方向 */
|
||||
type TrimDirection = "left" | "right";
|
||||
|
||||
/** 裁剪拖拽状态 */
|
||||
interface TrimDragState {
|
||||
clipId: string;
|
||||
direction: TrimDirection;
|
||||
startX: number;
|
||||
originalTrim: TrimConfig;
|
||||
originalDuration: number;
|
||||
}
|
||||
|
||||
/** 右键菜单状态 */
|
||||
interface ContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
clipId: string;
|
||||
}
|
||||
|
||||
const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
clips,
|
||||
selectedClipId,
|
||||
@@ -88,14 +43,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
onClipReorder,
|
||||
onClipRemove,
|
||||
onAddClip,
|
||||
onClipTrim,
|
||||
onClipSplit,
|
||||
onClipResetTrim,
|
||||
currentTime = 0,
|
||||
pixelsPerSecond = 40,
|
||||
onZoomChange,
|
||||
onSeek,
|
||||
totalDuration: totalDurationProp,
|
||||
}) => {
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
@@ -108,28 +55,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
right: 0,
|
||||
});
|
||||
|
||||
/* ── 裁剪拖拽状态 ── */
|
||||
const [trimDrag, setTrimDrag] = useState<TrimDragState | null>(null);
|
||||
const [trimPreview, setTrimPreview] = useState<{
|
||||
clipId: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
duration: number;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
/* ── 右键菜单 ── */
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
|
||||
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null);
|
||||
|
||||
/* ── 播放头拖拽状态 ── */
|
||||
const [playheadDragging, setPlayheadDragging] = useState(false);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
/* ── 根据模式决定可选类型 ── */
|
||||
const availableTypes: ClipType[] = useMemo(
|
||||
() =>
|
||||
@@ -141,7 +66,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
[currentMode],
|
||||
);
|
||||
|
||||
/* ── 默认添加类型:跟随模式 ── */
|
||||
/* ── 默认添加类型:跟随模式(纯单类型模式直接用该类型,混合模式默认 voice) ── */
|
||||
const defaultAddType: ClipType = useMemo(() => {
|
||||
if (currentMode === "voice_over") return "voice";
|
||||
if (currentMode === "pip") return "pip";
|
||||
@@ -158,28 +83,33 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
setAddType(defaultAddType);
|
||||
}
|
||||
}, [currentMode, addType, availableTypes, defaultAddType]);
|
||||
/* ── 面板尺寸(宽度固定,高度由 useLayoutEffect 实测) ── */
|
||||
const PICKER_W = 240; // 面板宽度(与 CSS 一致)
|
||||
const GAP = 6; // 面板与"+"卡片的间距
|
||||
|
||||
/* ── 面板尺寸 ── */
|
||||
const PICKER_W = 240;
|
||||
const GAP = 6;
|
||||
|
||||
/* ── 计算 picker 初始位置 ── */
|
||||
/* ── 计算 picker 初始位置(默认从"+"按钮上方弹出) ── */
|
||||
const updatePickerPosition = useCallback(() => {
|
||||
if (!addCardRef.current) return;
|
||||
const rect = addCardRef.current.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const roughHeight = 180;
|
||||
|
||||
/* 垂直方向:默认向上弹出(上方空间永远比下方大) */
|
||||
const roughHeight = 180; // 粗略估算,useLayoutEffect 会用实际高度校正
|
||||
let top = rect.top - GAP - roughHeight;
|
||||
if (top < 8) top = 8;
|
||||
|
||||
/* 水平方向:右对齐"+"卡片;太靠右超出视口则左移 */
|
||||
let right = vw - rect.right;
|
||||
if (rect.right - PICKER_W < 8) {
|
||||
right = vw - PICKER_W - 8;
|
||||
}
|
||||
|
||||
setPickerPos({ top, right });
|
||||
}, []);
|
||||
|
||||
const handleTogglePicker = () => {
|
||||
if (!showAddPicker) {
|
||||
// 打开面板时,默认选中当前模式下的第一个可用类型
|
||||
const defaultType =
|
||||
currentMode === "pip"
|
||||
? "pip"
|
||||
@@ -192,27 +122,35 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
setShowAddPicker((v) => !v);
|
||||
};
|
||||
|
||||
/* ── 渲染后精确边界校正 ── */
|
||||
/* ── 渲染后用实际 offsetHeight 做精确边界校正(useLayoutEffect 确保 paint 前完成) ── */
|
||||
useLayoutEffect(() => {
|
||||
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return;
|
||||
const pickerEl = pickerRef.current;
|
||||
const addRect = addCardRef.current.getBoundingClientRect();
|
||||
const pickerH = pickerEl.offsetHeight;
|
||||
const pickerH = pickerEl.offsetHeight; // 实际高度,不用硬编码
|
||||
const vh = window.innerHeight;
|
||||
const vw = window.innerWidth;
|
||||
|
||||
/* 默认:面板在"+"按钮上方 */
|
||||
let top = addRect.top - GAP - pickerH;
|
||||
|
||||
/* 上方空间也不够(极端情况)→ 翻转到下方 */
|
||||
if (top < 8) {
|
||||
top = addRect.bottom + GAP;
|
||||
/* 下方也溢出 → clamp */
|
||||
if (top + pickerH > vh - 8) {
|
||||
top = vh - 8 - pickerH;
|
||||
if (top < 8) top = 8;
|
||||
}
|
||||
}
|
||||
|
||||
/* 水平方向:右对齐"+"卡片;左侧溢出保护 */
|
||||
let right = vw - addRect.right;
|
||||
const pickerRect = pickerEl.getBoundingClientRect();
|
||||
if (pickerRect.left < 8) {
|
||||
right = vw - PICKER_W - 8;
|
||||
}
|
||||
|
||||
setPickerPos({ top, right });
|
||||
}, [showAddPicker]);
|
||||
|
||||
@@ -229,70 +167,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [showAddPicker]);
|
||||
|
||||
/* ── 点击外部关闭右键菜单 ── */
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
contextMenuRef.current &&
|
||||
!contextMenuRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setContextMenu(null);
|
||||
}
|
||||
};
|
||||
if (contextMenu) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [contextMenu]);
|
||||
|
||||
/* ── 缩放 & 时长 ── */
|
||||
const pps = pixelsPerSecond ?? 40;
|
||||
const totalDuration =
|
||||
totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0);
|
||||
|
||||
/* ── 播放头拖拽全局 mousemove/mouseup ── */
|
||||
useEffect(() => {
|
||||
if (!playheadDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const trackEl = trackRef.current;
|
||||
if (!trackEl) return;
|
||||
const rect = trackEl.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left + trackEl.scrollLeft;
|
||||
const time = Math.max(0, Math.min(x / pps, totalDuration));
|
||||
onSeek?.(Math.round(time * 10) / 10);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setPlayheadDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [playheadDragging, pps, totalDuration, onSeek]);
|
||||
|
||||
/* ── 标尺点击跳转播放头 ── */
|
||||
const handleRulerClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const time = Math.max(0, Math.min(x / pps, totalDuration));
|
||||
onSeek?.(Math.round(time * 10) / 10);
|
||||
},
|
||||
[pps, totalDuration, onSeek],
|
||||
);
|
||||
|
||||
/* ── 播放头拖拽开始 ── */
|
||||
const handlePlayheadMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setPlayheadDragging(true);
|
||||
}, []);
|
||||
|
||||
/* ── 确认添加片段 ── */
|
||||
const handleConfirmAdd = () => {
|
||||
onAddClip(addType, addDuration);
|
||||
@@ -301,8 +175,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
|
||||
/* ── 片段拖拽排序 ── */
|
||||
const handleDragStart = (e: React.DragEvent, idx: number) => {
|
||||
// 如果正在裁剪拖拽,不允许排序拖拽
|
||||
if (trimDrag) return;
|
||||
dragRef.current = idx;
|
||||
setDragIdx(idx);
|
||||
e.dataTransfer.setData("application/x-clip-drag", String(idx));
|
||||
@@ -338,132 +210,8 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
/* ── 裁剪手柄拖拽 ── */
|
||||
const handleTrimHandleMouseDown = useCallback(
|
||||
(e: React.MouseEvent, clipId: string, direction: TrimDirection) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const clip = clips.find((c) => c.id === clipId);
|
||||
if (!clip) return;
|
||||
|
||||
const trim: TrimConfig = clip.trim_config ?? {
|
||||
start_time: 0,
|
||||
end_time: clip.duration,
|
||||
original_duration: clip.duration,
|
||||
};
|
||||
|
||||
setTrimDrag({
|
||||
clipId,
|
||||
direction,
|
||||
startX: e.clientX,
|
||||
originalTrim: { ...trim },
|
||||
originalDuration: clip.duration,
|
||||
});
|
||||
},
|
||||
[clips],
|
||||
);
|
||||
|
||||
/* ── 裁剪拖拽全局 mousemove/mouseup ── */
|
||||
useEffect(() => {
|
||||
if (!trimDrag) return;
|
||||
|
||||
const PX_PER_SECOND = pixelsPerSecond ?? 40; // 与缩放级别同步
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const dx = e.clientX - trimDrag.startX;
|
||||
const dtSec = dx / PX_PER_SECOND;
|
||||
const clip = clips.find((c) => c.id === trimDrag.clipId);
|
||||
if (!clip) return;
|
||||
|
||||
const origTrim = trimDrag.originalTrim;
|
||||
const origDur = origTrim.original_duration ?? trimDrag.originalDuration;
|
||||
let newStart = origTrim.start_time;
|
||||
let newEnd = origTrim.end_time;
|
||||
|
||||
if (trimDrag.direction === "left") {
|
||||
// 左手柄:调整入点
|
||||
newStart = Math.max(
|
||||
0,
|
||||
Math.min(origTrim.start_time + dtSec, newEnd - 1),
|
||||
);
|
||||
} else {
|
||||
// 右手柄:调整出点
|
||||
newEnd = Math.max(
|
||||
origTrim.start_time + 1,
|
||||
Math.min(origTrim.end_time + dtSec, origDur),
|
||||
);
|
||||
}
|
||||
|
||||
const newDuration = Math.round((newEnd - newStart) * 10) / 10;
|
||||
|
||||
setTrimPreview({
|
||||
clipId: trimDrag.clipId,
|
||||
startTime: Math.round(newStart * 10) / 10,
|
||||
endTime: Math.round(newEnd * 10) / 10,
|
||||
duration: newDuration,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (trimPreview && trimPreview.clipId === trimDrag.clipId && onClipTrim) {
|
||||
const newTrim: TrimConfig = {
|
||||
start_time: trimPreview.startTime,
|
||||
end_time: trimPreview.endTime,
|
||||
original_duration:
|
||||
trimDrag.originalTrim.original_duration ??
|
||||
trimDrag.originalDuration,
|
||||
};
|
||||
onClipTrim(trimDrag.clipId, newTrim, trimPreview.duration);
|
||||
}
|
||||
setTrimDrag(null);
|
||||
setTrimPreview(null);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [trimDrag, trimPreview, clips, onClipTrim, pixelsPerSecond]);
|
||||
|
||||
/* ── 右键菜单 ── */
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent, clipId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, clipId });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/* ── 右键菜单操作 ── */
|
||||
const handleContextSplit = useCallback(() => {
|
||||
if (!contextMenu) return;
|
||||
if (onClipSplit) {
|
||||
onClipSplit(contextMenu.clipId, 0.5); // 在中间分割
|
||||
}
|
||||
setContextMenu(null);
|
||||
}, [contextMenu, onClipSplit]);
|
||||
|
||||
const handleContextResetTrim = useCallback(() => {
|
||||
if (!contextMenu) return;
|
||||
if (onClipResetTrim) {
|
||||
onClipResetTrim(contextMenu.clipId);
|
||||
}
|
||||
setContextMenu(null);
|
||||
}, [contextMenu, onClipResetTrim]);
|
||||
|
||||
const handleContextDelete = useCallback(() => {
|
||||
if (!contextMenu) return;
|
||||
onClipRemove(contextMenu.clipId);
|
||||
setContextMenu(null);
|
||||
}, [contextMenu, onClipRemove]);
|
||||
|
||||
/* ── 时间标尺 ── */
|
||||
const trackWidth = Math.max(totalDuration * pps, 300);
|
||||
const totalDuration = clips.reduce((s, c) => s + c.duration, 0);
|
||||
const rulerMarks: number[] = [];
|
||||
const step = totalDuration <= 30 ? 5 : totalDuration <= 60 ? 10 : 15;
|
||||
for (let t = 0; t <= totalDuration + step; t += step) {
|
||||
@@ -476,11 +224,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
/** 格式化裁剪时间(精确到0.1秒) */
|
||||
const formatTrimTime = (sec: number) => {
|
||||
return `${sec.toFixed(1)}s`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ep-timeline-area">
|
||||
{/* 时间线头部 */}
|
||||
@@ -492,33 +235,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
<div className="ep-timeline-actions">
|
||||
{/* 缩放控件 */}
|
||||
<div className="ep-timeline-zoom">
|
||||
<button
|
||||
className="ep-zoom-btn"
|
||||
onClick={() => onZoomChange?.(Math.max(10, pps - 10))}
|
||||
title="缩小"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
className="ep-zoom-slider"
|
||||
min={10}
|
||||
max={120}
|
||||
step={5}
|
||||
value={pps}
|
||||
onChange={(e) => onZoomChange?.(Number(e.target.value))}
|
||||
/>
|
||||
<button
|
||||
className="ep-zoom-btn"
|
||||
onClick={() => onZoomChange?.(Math.min(120, pps + 10))}
|
||||
title="放大"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<span className="ep-zoom-label">{pps}px/s</span>
|
||||
</div>
|
||||
<button
|
||||
className="ep-timeline-action-btn"
|
||||
onClick={() => {
|
||||
@@ -544,13 +260,21 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
|
||||
{/* 时间标尺 */}
|
||||
{currentMode !== "one_take" && (
|
||||
<div className="ep-time-ruler" onClick={handleRulerClick}>
|
||||
<div className="ep-time-ruler-inner" style={{ width: trackWidth }}>
|
||||
<div className="ep-time-ruler">
|
||||
<div
|
||||
className="ep-time-ruler-inner"
|
||||
style={{ width: Math.max(clips.length * 108, 300) }}
|
||||
>
|
||||
{rulerMarks.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="ep-time-mark"
|
||||
style={{ left: `${t * pps}px` }}
|
||||
style={{
|
||||
left:
|
||||
totalDuration > 0
|
||||
? `${(t / totalDuration) * clips.length * 108}px`
|
||||
: `${t * 20}px`,
|
||||
}}
|
||||
>
|
||||
{t}s
|
||||
</span>
|
||||
@@ -561,151 +285,52 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
|
||||
{/* 水平片段轨道 */}
|
||||
{currentMode !== "one_take" && (
|
||||
<div
|
||||
className="ep-clip-track"
|
||||
ref={trackRef}
|
||||
onDragOver={handleEmptyDragOver}
|
||||
>
|
||||
{/* 播放头 */}
|
||||
{currentMode !== "one_take" && totalDuration > 0 && (
|
||||
<div
|
||||
className="ep-playhead"
|
||||
style={{ left: currentTime * pps }}
|
||||
onMouseDown={handlePlayheadMouseDown}
|
||||
>
|
||||
<div className="ep-playhead-handle" />
|
||||
</div>
|
||||
)}
|
||||
<div className="ep-clip-track" onDragOver={handleEmptyDragOver}>
|
||||
{clips.length === 0 ? (
|
||||
<div className="ep-track-empty">
|
||||
<div className="ep-track-empty-icon">🎬</div>
|
||||
<div className="ep-track-empty-text">点击右侧 + 添加片段</div>
|
||||
</div>
|
||||
) : (
|
||||
clips.map((clip, idx) => {
|
||||
/* 转场指示器 */
|
||||
const trans = clip.transition;
|
||||
const showTransition = idx > 0 && trans && trans.type !== "none";
|
||||
const transOpt = showTransition
|
||||
? TRANSITION_OPTIONS.find((o) => o.value === trans!.type)
|
||||
: undefined;
|
||||
clips.map((clip, idx) => (
|
||||
<div
|
||||
key={clip.id}
|
||||
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDrop={(e) => handleDrop(e, idx)}
|
||||
onClick={() => onClipSelect(clip.id)}
|
||||
>
|
||||
{/* 类型图标 */}
|
||||
<div className="ep-clip-thumbnail">
|
||||
{CLIP_TYPE_ICONS[clip.type] || "🎬"}
|
||||
</div>
|
||||
|
||||
/* 速度徽章 */
|
||||
const speed = clip.speed;
|
||||
const showSpeed = speed && Math.abs(speed.rate - 1.0) > 0.01;
|
||||
{/* 片段信息 */}
|
||||
<div className="ep-clip-info">
|
||||
<span className="ep-clip-name">
|
||||
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
|
||||
</span>
|
||||
<span className="ep-clip-duration">{clip.duration}s</span>
|
||||
</div>
|
||||
|
||||
/* 裁剪状态 */
|
||||
const hasTrim = !!clip.trim_config;
|
||||
const isHovered = hoveredClipId === clip.id;
|
||||
|
||||
return (
|
||||
<React.Fragment key={clip.id}>
|
||||
{/* 转场指示器 */}
|
||||
{showTransition && transOpt && (
|
||||
<div
|
||||
className="ep-transition-indicator"
|
||||
title={`${transOpt.label} · ${trans!.duration.toFixed(1)}s`}
|
||||
>
|
||||
<span className="ep-trans-icon">{transOpt.icon}</span>
|
||||
<span className="ep-trans-duration">
|
||||
{trans!.duration.toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""} ${hasTrim ? "trimmed" : ""}`}
|
||||
style={{ width: Math.max(clip.duration * pps, 60) }}
|
||||
draggable={!trimDrag}
|
||||
onDragStart={(e) => handleDragStart(e, idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDrop={(e) => handleDrop(e, idx)}
|
||||
onClick={() => onClipSelect(clip.id)}
|
||||
onContextMenu={(e) => handleContextMenu(e, clip.id)}
|
||||
onMouseEnter={() => setHoveredClipId(clip.id)}
|
||||
onMouseLeave={() => setHoveredClipId(null)}
|
||||
>
|
||||
{/* 左裁剪手柄 */}
|
||||
{isHovered && onClipTrim && (
|
||||
<div
|
||||
className="ep-trim-handle ep-trim-handle-left"
|
||||
onMouseDown={(e) =>
|
||||
handleTrimHandleMouseDown(e, clip.id, "left")
|
||||
}
|
||||
title="拖动调整入点"
|
||||
>
|
||||
<div className="ep-trim-handle-line" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 类型图标 */}
|
||||
<div className="ep-clip-thumbnail">
|
||||
{CLIP_TYPE_ICONS[clip.type] || "🎬"}
|
||||
</div>
|
||||
|
||||
{/* 片段信息 */}
|
||||
<div className="ep-clip-info">
|
||||
<span className="ep-clip-name">
|
||||
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
|
||||
</span>
|
||||
<span className="ep-clip-duration">
|
||||
{clip.duration}s
|
||||
{hasTrim && (
|
||||
<span className="ep-trim-indicator" title="已裁剪">
|
||||
✂
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 速度徽章 */}
|
||||
{showSpeed && (
|
||||
<span className="ep-speed-badge">
|
||||
{speed!.rate.toFixed(1)}x
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 裁剪徽章 */}
|
||||
{hasTrim && (
|
||||
<span
|
||||
className="ep-trim-badge"
|
||||
title={`入点 ${clip.trim_config!.start_time.toFixed(1)}s / 出点 ${clip.trim_config!.end_time.toFixed(1)}s`}
|
||||
>
|
||||
✂
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 右裁剪手柄 */}
|
||||
{isHovered && onClipTrim && (
|
||||
<div
|
||||
className="ep-trim-handle ep-trim-handle-right"
|
||||
onMouseDown={(e) =>
|
||||
handleTrimHandleMouseDown(e, clip.id, "right")
|
||||
}
|
||||
title="拖动调整出点"
|
||||
>
|
||||
<div className="ep-trim-handle-line" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
className="ep-clip-remove"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClipRemove(clip.id);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
className="ep-clip-remove"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClipRemove(clip.id);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* ── 轨道末尾 "+" 添加卡片 ── */}
|
||||
{/* ── 轨道末尾 "+" 添加卡片 → 类型+时长选择器 ── */}
|
||||
<div className="ep-track-add-card-wrapper">
|
||||
<div
|
||||
ref={addCardRef}
|
||||
@@ -719,73 +344,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 裁剪预览 tooltip */}
|
||||
{trimPreview && (
|
||||
<div
|
||||
className="ep-trim-preview"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: trimPreview.x + 12,
|
||||
top: trimPreview.y - 40,
|
||||
}}
|
||||
>
|
||||
<div className="ep-trim-preview-row">
|
||||
<span className="ep-trim-preview-label">入点</span>
|
||||
<span className="ep-trim-preview-value">
|
||||
{formatTrimTime(trimPreview.startTime)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ep-trim-preview-row">
|
||||
<span className="ep-trim-preview-label">出点</span>
|
||||
<span className="ep-trim-preview-value">
|
||||
{formatTrimTime(trimPreview.endTime)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ep-trim-preview-row ep-trim-preview-duration">
|
||||
<span className="ep-trim-preview-label">时长</span>
|
||||
<span className="ep-trim-preview-value">
|
||||
{formatTrimTime(trimPreview.duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="ep-context-menu"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
}}
|
||||
>
|
||||
<div className="ep-context-menu-item" onClick={handleContextSplit}>
|
||||
<span className="ep-context-menu-icon">✂️</span>
|
||||
<span>分割片段</span>
|
||||
</div>
|
||||
{clips.find((c) => c.id === contextMenu.clipId)?.trim_config && (
|
||||
<div
|
||||
className="ep-context-menu-item"
|
||||
onClick={handleContextResetTrim}
|
||||
>
|
||||
<span className="ep-context-menu-icon">↩️</span>
|
||||
<span>恢复原始长度</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ep-context-menu-divider" />
|
||||
<div
|
||||
className="ep-context-menu-item ep-context-menu-item-danger"
|
||||
onClick={handleContextDelete}
|
||||
>
|
||||
<span className="ep-context-menu-icon">🗑️</span>
|
||||
<span>删除片段</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 类型+时长选择面板 */}
|
||||
{/* 类型+时长选择面板 — fixed 定位,不受任何父容器 overflow 裁剪 */}
|
||||
{showAddPicker && (
|
||||
<div
|
||||
ref={pickerRef}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
/**
|
||||
* 转场特效选择器 — Drawer 形式
|
||||
* 14 种转场预设卡片网格 + 转场时长滑块
|
||||
* 支持全局默认转场 + 单个片段间独立设置
|
||||
*/
|
||||
import React, { useCallback } from "react";
|
||||
import { Drawer, Slider } from "antd";
|
||||
import { TRANSITION_OPTIONS } from "@/api/editPlans";
|
||||
import type { TransitionConfig, TransitionType } from "../types";
|
||||
import { DEFAULT_TRANSITION } from "../types";
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
interface TransitionSelectorProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** 当前转场配置 */
|
||||
config: TransitionConfig;
|
||||
onChange: (config: TransitionConfig) => void;
|
||||
/** 标题提示(区分全局 / 片段间) */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const TransitionSelector: React.FC<TransitionSelectorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
title = "转场特效",
|
||||
}) => {
|
||||
/* ── 选择转场类型 ── */
|
||||
const handleSelectType = useCallback(
|
||||
(type: TransitionType) => {
|
||||
onChange({ ...config, type });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 修改时长 ── */
|
||||
const handleChangeDuration = useCallback(
|
||||
(duration: number) => {
|
||||
onChange({ ...config, duration });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 重置为无转场 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_TRANSITION });
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={`🎬 ${title}`}
|
||||
placement="right"
|
||||
width={480}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="transition-selector-drawer"
|
||||
>
|
||||
{/* ── 时长滑块 ── */}
|
||||
<div className="ts-duration-section">
|
||||
<div className="ts-duration-header">
|
||||
<span className="ts-duration-label">转场时长</span>
|
||||
<span className="ts-duration-value">
|
||||
{config.duration.toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={0.3}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={config.duration}
|
||||
onChange={handleChangeDuration}
|
||||
tooltip={{ formatter: (v) => `${(v as number).toFixed(1)}s` }}
|
||||
/>
|
||||
<div className="ts-duration-marks">
|
||||
<span>0.3s</span>
|
||||
<span>1.0s</span>
|
||||
<span>2.0s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 转场类型卡片网格 ── */}
|
||||
<div className="ts-grid">
|
||||
{TRANSITION_OPTIONS.map((opt) => {
|
||||
const isActive = config.type === opt.value;
|
||||
return (
|
||||
<div
|
||||
key={opt.value}
|
||||
className={`ts-card${isActive ? " active" : ""}`}
|
||||
onClick={() => handleSelectType(opt.value)}
|
||||
>
|
||||
<div className="ts-card-icon">{opt.icon}</div>
|
||||
<div className="ts-card-name">{opt.label}</div>
|
||||
{isActive && <span className="ts-card-check">✓</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
<div className="ts-footer">
|
||||
<button className="ts-reset-btn" onClick={handleReset}>
|
||||
重置为无转场
|
||||
</button>
|
||||
<div className="ts-current">
|
||||
当前:
|
||||
{TRANSITION_OPTIONS.find((o) => o.value === config.type)?.label ??
|
||||
"无转场"}
|
||||
{" · "}
|
||||
{config.duration.toFixed(1)}s
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransitionSelector;
|
||||
@@ -1,349 +0,0 @@
|
||||
/**
|
||||
* TTS 配音面板 — Drawer 形式
|
||||
* 配音模式切换 + 文本输入 + 音色选择 + 语速/语调/音量 + 试听 + 字幕联动
|
||||
*/
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Drawer, Slider, message } from "antd";
|
||||
import type { TtsConfig, TtsMode } from "../types";
|
||||
import { DEFAULT_TTS_CONFIG } from "../types";
|
||||
import { getTtsVoices, previewTts, type TTSVoice } from "@/api/tts";
|
||||
|
||||
/* ──────────── 音色卡片分类图标 ──────────── */
|
||||
const VOICE_CATEGORY_MAP: Record<string, { icon: string; label: string }> = {
|
||||
male: { icon: "👨", label: "男声" },
|
||||
female: { icon: "👩", label: "女声" },
|
||||
young: { icon: "🧑", label: "少年" },
|
||||
service: { icon: "🎧", label: "客服" },
|
||||
news: { icon: "📰", label: "新闻" },
|
||||
emotion: { icon: "🎭", label: "情感" },
|
||||
};
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
interface TtsPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** 当前片段 TTS 配置 */
|
||||
config: TtsConfig;
|
||||
onChange: (config: TtsConfig) => void;
|
||||
}
|
||||
|
||||
const TtsPanel: React.FC<TtsPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
/* ── 音色列表 ── */
|
||||
const [voices, setVoices] = useState<TTSVoice[]>([]);
|
||||
const [voicesLoading, setVoicesLoading] = useState(false);
|
||||
|
||||
/* ── 试听状态 ── */
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
/* ── 加载音色列表 ── */
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setVoicesLoading(true);
|
||||
getTtsVoices()
|
||||
.then((v) => setVoices(v))
|
||||
.catch(() => message.error("加载音色列表失败"))
|
||||
.finally(() => setVoicesLoading(false));
|
||||
}, [open]);
|
||||
|
||||
/* ── 切换配音模式 ── */
|
||||
const handleModeChange = useCallback(
|
||||
(mode: TtsMode) => {
|
||||
onChange({ ...config, mode });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 文本输入 ── */
|
||||
const handleTextChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const text = e.target.value.slice(0, 5000);
|
||||
onChange({ ...config, text });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 选择音色 ── */
|
||||
const handleVoiceSelect = useCallback(
|
||||
(voiceId: string) => {
|
||||
onChange({ ...config, voice_id: voiceId });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 语速 ── */
|
||||
const handleSpeedChange = useCallback(
|
||||
(speed: number) => {
|
||||
onChange({ ...config, speed });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 语调 ── */
|
||||
const handlePitchChange = useCallback(
|
||||
(pitch: number) => {
|
||||
onChange({ ...config, pitch });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 音量 ── */
|
||||
const handleVolumeChange = useCallback(
|
||||
(volume: number) => {
|
||||
onChange({ ...config, volume });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 字幕联动 ── */
|
||||
const handleSubtitleSyncToggle = useCallback(() => {
|
||||
onChange({ ...config, subtitle_sync: !config.subtitle_sync });
|
||||
}, [config, onChange]);
|
||||
|
||||
/* ── 试听 ── */
|
||||
const handlePreview = useCallback(async () => {
|
||||
if (!config.text.trim()) {
|
||||
message.warning("请先输入合成文本");
|
||||
return;
|
||||
}
|
||||
if (!config.voice_id) {
|
||||
message.warning("请先选择音色");
|
||||
return;
|
||||
}
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const res = await previewTts({
|
||||
text: config.text.slice(0, 200), // 试听截取前200字
|
||||
voice_id: config.voice_id,
|
||||
speed: config.speed,
|
||||
pitch: config.pitch,
|
||||
});
|
||||
// 停止上一个
|
||||
audioRef.current?.pause();
|
||||
const audio = new Audio(res.audio_url);
|
||||
audioRef.current = audio;
|
||||
audio.play().catch(() => message.error("播放失败"));
|
||||
audio.onended = () => {
|
||||
audioRef.current = null;
|
||||
};
|
||||
message.success("试听播放中");
|
||||
} catch {
|
||||
message.error("试听生成失败");
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_TTS_CONFIG });
|
||||
}, [onChange]);
|
||||
|
||||
/* ── 关闭时停止音频 ── */
|
||||
const handleClose = useCallback(() => {
|
||||
audioRef.current?.pause();
|
||||
audioRef.current = null;
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
/* ── 音色分类分组 ── */
|
||||
const voiceCategories = Object.entries(VOICE_CATEGORY_MAP);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="🎙️ TTS 配音"
|
||||
placement="right"
|
||||
width={400}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
className="tts-panel-drawer"
|
||||
>
|
||||
{/* ── 配音模式切换 ── */}
|
||||
<div className="tts-mode-section">
|
||||
<div className="tts-mode-label">配音模式</div>
|
||||
<div className="tts-mode-group">
|
||||
{[
|
||||
{ mode: "none" as TtsMode, icon: "🔇", label: "无配音" },
|
||||
{ mode: "upload" as TtsMode, icon: "📁", label: "上传配音" },
|
||||
{ mode: "tts" as TtsMode, icon: "🤖", label: "TTS 合成" },
|
||||
].map((m) => (
|
||||
<button
|
||||
key={m.mode}
|
||||
className={`tts-mode-btn${config.mode === m.mode ? " active" : ""}`}
|
||||
onClick={() => handleModeChange(m.mode)}
|
||||
>
|
||||
<span className="tts-mode-icon">{m.icon}</span>
|
||||
<span className="tts-mode-text">{m.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── TTS 配置(仅 tts 模式显示) ── */}
|
||||
{config.mode === "tts" && (
|
||||
<>
|
||||
{/* 文本输入 */}
|
||||
<div className="tts-text-section">
|
||||
<div className="tts-text-header">
|
||||
<span className="tts-text-label">合成文本</span>
|
||||
<span className="tts-text-count">{config.text.length}/5000</span>
|
||||
</div>
|
||||
<textarea
|
||||
className="tts-text-input"
|
||||
placeholder="请输入需要合成的文本内容..."
|
||||
value={config.text}
|
||||
onChange={handleTextChange}
|
||||
maxLength={5000}
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 音色选择 */}
|
||||
<div className="tts-voice-section">
|
||||
<div className="tts-voice-label">
|
||||
选择音色
|
||||
{voicesLoading && (
|
||||
<span className="tts-voice-loading">加载中...</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="tts-voice-grid">
|
||||
{voiceCategories.map(([cat, info]) => {
|
||||
const voice = voices.find((v) => v.category === cat);
|
||||
const isSelected = voice && config.voice_id === voice.id;
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
className={`tts-voice-card${isSelected ? " active" : ""}`}
|
||||
onClick={() => voice && handleVoiceSelect(voice.id)}
|
||||
disabled={!voice || voicesLoading}
|
||||
>
|
||||
<span className="tts-voice-card-icon">{info.icon}</span>
|
||||
<span className="tts-voice-card-name">
|
||||
{voice?.name || info.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 语速滑块 */}
|
||||
<div className="tts-slider-section">
|
||||
<div className="tts-slider-header">
|
||||
<span className="tts-slider-label">语速</span>
|
||||
<span className="tts-slider-value">
|
||||
{config.speed.toFixed(2)}x
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.05}
|
||||
value={config.speed}
|
||||
onChange={handleSpeedChange}
|
||||
tooltip={{ formatter: (v) => `${(v as number).toFixed(2)}x` }}
|
||||
/>
|
||||
<div className="tts-slider-marks">
|
||||
<span>0.5x</span>
|
||||
<span>1.0x</span>
|
||||
<span>2.0x</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 语调滑块 */}
|
||||
<div className="tts-slider-section">
|
||||
<div className="tts-slider-header">
|
||||
<span className="tts-slider-label">语调</span>
|
||||
<span className="tts-slider-value">
|
||||
{config.pitch > 0 ? "+" : ""}
|
||||
{config.pitch} 半音
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={-12}
|
||||
max={12}
|
||||
step={1}
|
||||
value={config.pitch}
|
||||
onChange={handlePitchChange}
|
||||
tooltip={{ formatter: (v) => `${v}半音` }}
|
||||
/>
|
||||
<div className="tts-slider-marks">
|
||||
<span>-12</span>
|
||||
<span>0</span>
|
||||
<span>+12</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 音量滑块 */}
|
||||
<div className="tts-slider-section">
|
||||
<div className="tts-slider-header">
|
||||
<span className="tts-slider-label">音量</span>
|
||||
<span className="tts-slider-value">{config.volume}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={config.volume}
|
||||
onChange={handleVolumeChange}
|
||||
tooltip={{ formatter: (v) => `${v}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 试听按钮 */}
|
||||
<div className="tts-preview-section">
|
||||
<button
|
||||
className="tts-preview-btn"
|
||||
onClick={handlePreview}
|
||||
disabled={previewLoading}
|
||||
>
|
||||
{previewLoading ? "⏳ 生成中..." : "🔊 试听"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 字幕联动 */}
|
||||
<div className="tts-subtitle-section">
|
||||
<div className="tts-subtitle-info">
|
||||
<span className="tts-subtitle-label">字幕联动</span>
|
||||
<span className="tts-subtitle-desc">
|
||||
{config.subtitle_sync
|
||||
? "TTS 文本自动同步到字幕"
|
||||
: "字幕需手动编辑"}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`ep-toggle${config.subtitle_sync ? " active" : ""}`}
|
||||
onClick={handleSubtitleSyncToggle}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 上传配音模式提示 ── */}
|
||||
{config.mode === "upload" && (
|
||||
<div className="tts-upload-hint">
|
||||
<p>请在右侧面板的「配音素材」中选择已上传的配音文件。</p>
|
||||
<p>如需上传新配音,请前往配音素材库页面。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
{config.mode === "tts" && (
|
||||
<div className="tts-footer">
|
||||
<button className="tts-reset-btn" onClick={handleReset}>
|
||||
重置默认
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default TtsPanel;
|
||||
@@ -1,419 +0,0 @@
|
||||
/**
|
||||
* 水印配置面板 — Drawer 形式
|
||||
* 三个 Tab:图片水印 / 文字水印 / 滚动水印
|
||||
* 通用设置:位置、不透明度
|
||||
*/
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Drawer } from "antd";
|
||||
import type {
|
||||
WatermarkConfig,
|
||||
WatermarkType,
|
||||
WatermarkPosition,
|
||||
ScrollDirection,
|
||||
} from "../types";
|
||||
import { DEFAULT_WATERMARK } from "../types";
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
const WATERMARK_TABS: { key: WatermarkType; label: string; icon: string }[] = [
|
||||
{ key: "none", label: "无水印", icon: "🚫" },
|
||||
{ key: "image", label: "图片水印", icon: "🖼️" },
|
||||
{ key: "text", label: "文字水印", icon: "📝" },
|
||||
{ key: "scroll", label: "滚动水印", icon: "📜" },
|
||||
];
|
||||
|
||||
const POSITION_OPTIONS: { value: WatermarkPosition; label: string }[] = [
|
||||
{ value: "top_left", label: "左上角" },
|
||||
{ value: "top_right", label: "右上角" },
|
||||
{ value: "bottom_left", label: "左下角" },
|
||||
{ value: "bottom_right", label: "右下角" },
|
||||
{ value: "center", label: "居中" },
|
||||
];
|
||||
|
||||
const SCROLL_DIRECTION_OPTIONS: {
|
||||
value: ScrollDirection;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ value: "horizontal", label: "水平滚动" },
|
||||
{ value: "vertical", label: "垂直滚动" },
|
||||
{ value: "diagonal", label: "对角滚动" },
|
||||
];
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
interface WatermarkPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: WatermarkConfig;
|
||||
onChange: (config: WatermarkConfig) => void;
|
||||
}
|
||||
|
||||
const WatermarkPanel: React.FC<WatermarkPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
/* ── 图片上传预览 URL(本地预览用) ── */
|
||||
const [localImageUrl, setLocalImageUrl] = useState<string>("");
|
||||
|
||||
/* ── 切换水印类型 ── */
|
||||
const handleTypeChange = useCallback(
|
||||
(type: WatermarkType) => {
|
||||
onChange({ ...DEFAULT_WATERMARK, type });
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
/* ── 通用设置变更 ── */
|
||||
const handlePositionChange = useCallback(
|
||||
(position: WatermarkPosition) => {
|
||||
onChange({ ...config, position });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleOpacityChange = useCallback(
|
||||
(opacity: number) => {
|
||||
onChange({ ...config, opacity });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 图片水印设置 ── */
|
||||
const handleImageUrlChange = useCallback(
|
||||
(url: string) => {
|
||||
setLocalImageUrl(url);
|
||||
onChange({ ...config, image_url: url });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleImageWidthChange = useCallback(
|
||||
(width: number) => {
|
||||
onChange({ ...config, width });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleImageHeightChange = useCallback(
|
||||
(height: number) => {
|
||||
onChange({ ...config, height });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 文字水印设置 ── */
|
||||
const handleTextChange = useCallback(
|
||||
(text: string) => {
|
||||
onChange({ ...config, text });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleFontSizeChange = useCallback(
|
||||
(font_size: number) => {
|
||||
onChange({ ...config, font_size });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleColorChange = useCallback(
|
||||
(color: string) => {
|
||||
onChange({ ...config, color });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 滚动水印设置 ── */
|
||||
const handleScrollDirectionChange = useCallback(
|
||||
(scroll_direction: ScrollDirection) => {
|
||||
onChange({ ...config, scroll_direction });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleScrollSpeedChange = useCallback(
|
||||
(scroll_speed: number) => {
|
||||
onChange({ ...config, scroll_speed });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
setLocalImageUrl("");
|
||||
onChange({ ...DEFAULT_WATERMARK });
|
||||
}, [onChange]);
|
||||
|
||||
/* ── 当前激活的 Tab ── */
|
||||
const activeTab = config.type;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="🔖 水印设置"
|
||||
placement="right"
|
||||
width={400}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="watermark-panel-drawer"
|
||||
>
|
||||
{/* ── Tab 切换 ── */}
|
||||
<div className="wp-tabs">
|
||||
{WATERMARK_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`wp-tab${activeTab === tab.key ? " active" : ""}`}
|
||||
onClick={() => handleTypeChange(tab.key)}
|
||||
>
|
||||
<span className="wp-tab-icon">{tab.icon}</span>
|
||||
<span className="wp-tab-label">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── 无水印提示 ── */}
|
||||
{config.type === "none" && (
|
||||
<div className="wp-empty-hint">
|
||||
<span className="wp-empty-icon">🚫</span>
|
||||
<p>当前未启用水印</p>
|
||||
<p className="wp-empty-desc">选择上方标签启用水印功能</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 图片水印配置 ── */}
|
||||
{config.type === "image" && (
|
||||
<div className="wp-section">
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">水印图片 URL</label>
|
||||
<input
|
||||
className="wp-input"
|
||||
type="text"
|
||||
placeholder="https://example.com/logo.png"
|
||||
value={localImageUrl || config.image_url || ""}
|
||||
onChange={(e) => handleImageUrlChange(e.target.value)}
|
||||
/>
|
||||
{(localImageUrl || config.image_url) && (
|
||||
<div className="wp-image-preview">
|
||||
<img
|
||||
src={localImageUrl || config.image_url}
|
||||
alt="水印预览"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">宽度(像素,0 表示自适应)</label>
|
||||
<input
|
||||
className="wp-input"
|
||||
type="number"
|
||||
min={0}
|
||||
max={2000}
|
||||
value={config.width ?? 0}
|
||||
onChange={(e) => handleImageWidthChange(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">高度(像素,0 表示自适应)</label>
|
||||
<input
|
||||
className="wp-input"
|
||||
type="number"
|
||||
min={0}
|
||||
max={2000}
|
||||
value={config.height ?? 0}
|
||||
onChange={(e) => handleImageHeightChange(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 文字水印配置 ── */}
|
||||
{config.type === "text" && (
|
||||
<div className="wp-section">
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">水印文字</label>
|
||||
<textarea
|
||||
className="wp-textarea"
|
||||
placeholder="输入水印文字内容"
|
||||
rows={3}
|
||||
value={config.text ?? ""}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">字号</label>
|
||||
<div className="wp-slider-row">
|
||||
<input
|
||||
className="wp-slider"
|
||||
type="range"
|
||||
min={12}
|
||||
max={72}
|
||||
value={config.font_size ?? 24}
|
||||
onChange={(e) => handleFontSizeChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="wp-slider-value">
|
||||
{config.font_size ?? 24}px
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">文字颜色</label>
|
||||
<div className="wp-color-row">
|
||||
<input
|
||||
className="wp-color-input"
|
||||
type="color"
|
||||
value={config.color ?? "#ffffff"}
|
||||
onChange={(e) => handleColorChange(e.target.value)}
|
||||
/>
|
||||
<span className="wp-color-value">
|
||||
{config.color ?? "#ffffff"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 滚动水印配置 ── */}
|
||||
{config.type === "scroll" && (
|
||||
<div className="wp-section">
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">滚动文字</label>
|
||||
<textarea
|
||||
className="wp-textarea"
|
||||
placeholder="输入滚动水印文字"
|
||||
rows={2}
|
||||
value={config.text ?? ""}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">滚动方向</label>
|
||||
<select
|
||||
className="wp-select"
|
||||
value={config.scroll_direction ?? "horizontal"}
|
||||
onChange={(e) =>
|
||||
handleScrollDirectionChange(e.target.value as ScrollDirection)
|
||||
}
|
||||
>
|
||||
{SCROLL_DIRECTION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">滚动速度</label>
|
||||
<div className="wp-slider-row">
|
||||
<input
|
||||
className="wp-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={200}
|
||||
value={config.scroll_speed ?? 50}
|
||||
onChange={(e) =>
|
||||
handleScrollSpeedChange(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
<span className="wp-slider-value">
|
||||
{config.scroll_speed ?? 50}px/s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">字号</label>
|
||||
<div className="wp-slider-row">
|
||||
<input
|
||||
className="wp-slider"
|
||||
type="range"
|
||||
min={12}
|
||||
max={72}
|
||||
value={config.font_size ?? 24}
|
||||
onChange={(e) => handleFontSizeChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="wp-slider-value">
|
||||
{config.font_size ?? 24}px
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">文字颜色</label>
|
||||
<div className="wp-color-row">
|
||||
<input
|
||||
className="wp-color-input"
|
||||
type="color"
|
||||
value={config.color ?? "#ffffff"}
|
||||
onChange={(e) => handleColorChange(e.target.value)}
|
||||
/>
|
||||
<span className="wp-color-value">
|
||||
{config.color ?? "#ffffff"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 通用设置(非 none 时显示) ── */}
|
||||
{config.type !== "none" && (
|
||||
<div className="wp-section wp-common-section">
|
||||
<div className="wp-section-divider" />
|
||||
<div className="wp-common-title">通用设置</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">水印位置</label>
|
||||
<select
|
||||
className="wp-select"
|
||||
value={config.position}
|
||||
onChange={(e) =>
|
||||
handlePositionChange(e.target.value as WatermarkPosition)
|
||||
}
|
||||
>
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">不透明度</label>
|
||||
<div className="wp-slider-row">
|
||||
<input
|
||||
className="wp-slider"
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={config.opacity}
|
||||
onChange={(e) => handleOpacityChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="wp-slider-value">
|
||||
{Math.round(config.opacity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
<div className="wp-footer">
|
||||
<button className="wp-reset-btn" onClick={handleReset}>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default WatermarkPanel;
|
||||
@@ -5,508 +5,6 @@
|
||||
|
||||
export type ClipType = "voice" | "pip";
|
||||
|
||||
/* ──────── 转场特效 ──────── */
|
||||
|
||||
/** 14 种转场类型 */
|
||||
export type TransitionType =
|
||||
| "none"
|
||||
| "cut"
|
||||
| "fade"
|
||||
| "dissolve"
|
||||
| "zoom"
|
||||
| "slide_left"
|
||||
| "slide_right"
|
||||
| "slide_up"
|
||||
| "slide_down"
|
||||
| "wipe_left"
|
||||
| "wipe_right"
|
||||
| "wipe_up"
|
||||
| "wipe_down"
|
||||
| "circlecrop"
|
||||
| "rectcrop";
|
||||
|
||||
/** 片段间转场配置 */
|
||||
export interface TransitionConfig {
|
||||
/** 转场类型 */
|
||||
type: TransitionType;
|
||||
/** 转场时长(秒),0.3 ~ 2.0 */
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/** 默认转场配置 */
|
||||
export const DEFAULT_TRANSITION: TransitionConfig = {
|
||||
type: "none",
|
||||
duration: 0.5,
|
||||
};
|
||||
|
||||
/* ──────── 片段调速 ──────── */
|
||||
|
||||
/** 片段调速配置 */
|
||||
export interface SpeedConfig {
|
||||
/** 播放速度,0.25 ~ 4.0 */
|
||||
rate: number;
|
||||
/** 音调修正(变速不变调) */
|
||||
pitchCorrection: boolean;
|
||||
}
|
||||
|
||||
/** 默认调速配置 */
|
||||
export const DEFAULT_SPEED: SpeedConfig = {
|
||||
rate: 1.0,
|
||||
pitchCorrection: true,
|
||||
};
|
||||
|
||||
/* ──────── TTS 配音 ──────── */
|
||||
|
||||
/** 配音模式 */
|
||||
export type TtsMode = "none" | "upload" | "tts";
|
||||
|
||||
/** TTS 配音配置 */
|
||||
export interface TtsConfig {
|
||||
/** 配音模式 */
|
||||
mode: TtsMode;
|
||||
/** TTS 合成文本 */
|
||||
text: string;
|
||||
/** 音色 ID */
|
||||
voice_id: string;
|
||||
/** 语速 0.5 ~ 2.0 */
|
||||
speed: number;
|
||||
/** 语调(半音)-12 ~ +12 */
|
||||
pitch: number;
|
||||
/** 音量 0 ~ 100 */
|
||||
volume: number;
|
||||
/** 字幕联动 */
|
||||
subtitle_sync: boolean;
|
||||
}
|
||||
|
||||
/** 默认 TTS 配置 */
|
||||
export const DEFAULT_TTS_CONFIG: TtsConfig = {
|
||||
mode: "none",
|
||||
text: "",
|
||||
voice_id: "",
|
||||
speed: 1.0,
|
||||
pitch: 0,
|
||||
volume: 100,
|
||||
subtitle_sync: true,
|
||||
};
|
||||
|
||||
/* ──────── 裁剪配置 ──────── */
|
||||
|
||||
/** 片段裁剪配置 — 定义素材的入点/出点 */
|
||||
export interface TrimConfig {
|
||||
/** 入点(秒),素材原始时间轴上的起始位置 */
|
||||
start_time: number;
|
||||
/** 出点(秒),素材原始时间轴上的结束位置 */
|
||||
end_time: number;
|
||||
/** 素材原始总时长(秒),用于"恢复原始长度" */
|
||||
original_duration?: number;
|
||||
}
|
||||
|
||||
/* ──────── 水印配置 ──────── */
|
||||
|
||||
/** 水印类型 */
|
||||
export type WatermarkType = "none" | "image" | "text" | "scroll";
|
||||
|
||||
/** 水印位置 */
|
||||
export type WatermarkPosition =
|
||||
"top_left" | "top_right" | "bottom_left" | "bottom_right" | "center";
|
||||
|
||||
/** 滚动水印方向 */
|
||||
export type ScrollDirection = "horizontal" | "vertical" | "diagonal";
|
||||
|
||||
/** 水印配置 */
|
||||
export interface WatermarkConfig {
|
||||
/** 水印类型 */
|
||||
type: WatermarkType;
|
||||
/** 图片水印 URL */
|
||||
image_url?: string;
|
||||
/** 水印宽度(像素或百分比 0~1) */
|
||||
width?: number;
|
||||
/** 水印高度(像素或百分比 0~1) */
|
||||
height?: number;
|
||||
/** 水印位置 */
|
||||
position: WatermarkPosition;
|
||||
/** 水印不透明度 0~1 */
|
||||
opacity: number;
|
||||
/** 文字水印内容 */
|
||||
text?: string;
|
||||
/** 文字水印字号 */
|
||||
font_size?: number;
|
||||
/** 文字水印颜色 */
|
||||
color?: string;
|
||||
/** 滚动水印方向 */
|
||||
scroll_direction?: ScrollDirection;
|
||||
/** 滚动水印速度(像素/秒) */
|
||||
scroll_speed?: number;
|
||||
}
|
||||
|
||||
/** 默认水印配置 */
|
||||
export const DEFAULT_WATERMARK: WatermarkConfig = {
|
||||
type: "none",
|
||||
position: "bottom_right",
|
||||
opacity: 0.7,
|
||||
};
|
||||
|
||||
/* ──────── 片头片尾配置 ──────── */
|
||||
|
||||
/** 片头片尾素材类型 */
|
||||
export type IntroOutroKind = "none" | "video" | "image";
|
||||
|
||||
/** 片头/片尾单项配置 */
|
||||
export interface IntroOutroItem {
|
||||
/** 素材类型 */
|
||||
kind: IntroOutroKind;
|
||||
/** 素材 URL */
|
||||
url?: string;
|
||||
/** 显示时长(秒) */
|
||||
duration: number;
|
||||
/** 过渡动画 */
|
||||
transition?: TransitionType;
|
||||
/** 过渡时长(秒) */
|
||||
transition_duration?: number;
|
||||
}
|
||||
|
||||
/** 片头片尾完整配置 */
|
||||
export interface IntroOutroConfig {
|
||||
intro: IntroOutroItem;
|
||||
outro: IntroOutroItem;
|
||||
}
|
||||
|
||||
/** 默认片头片尾配置 */
|
||||
export const DEFAULT_INTRO_OUTRO: IntroOutroConfig = {
|
||||
intro: { kind: "none", duration: 3 },
|
||||
outro: { kind: "none", duration: 3 },
|
||||
};
|
||||
|
||||
/* ──────── 画中画配置 ──────── */
|
||||
|
||||
/** 九宫格位置 */
|
||||
export type PipGridPosition =
|
||||
| "top_left"
|
||||
| "top_center"
|
||||
| "top_right"
|
||||
| "center_left"
|
||||
| "center"
|
||||
| "center_right"
|
||||
| "bottom_left"
|
||||
| "bottom_center"
|
||||
| "bottom_right";
|
||||
|
||||
/** 入场动画类型 */
|
||||
export type PipAnimType = "none" | "fade_in" | "slide_in";
|
||||
|
||||
/** 入场方向 */
|
||||
export type PipSlideDirection = "left" | "right" | "up" | "down";
|
||||
|
||||
/** 画中画图层 */
|
||||
export interface PipLayer {
|
||||
id: string;
|
||||
/** 图层名称(用户可编辑) */
|
||||
name: string;
|
||||
/** 素材类型 */
|
||||
material_type: "image" | "video";
|
||||
/** 素材 URL */
|
||||
material_url: string;
|
||||
/** 素材缩略图 */
|
||||
thumbnail_url?: string;
|
||||
/** 九宫格快捷位置 */
|
||||
grid_position: PipGridPosition;
|
||||
/** 精确 X 坐标(百分比 0~100) */
|
||||
x: number;
|
||||
/** 精确 Y 坐标(百分比 0~100) */
|
||||
y: number;
|
||||
/** 宽度(百分比 0~100,相对主画面) */
|
||||
width: number;
|
||||
/** 高度(百分比 0~100,相对主画面) */
|
||||
height: number;
|
||||
/** 锁定宽高比 */
|
||||
aspect_lock: boolean;
|
||||
/** 圆角(百分比 0~50) */
|
||||
border_radius: number;
|
||||
/** 不透明度(0~100) */
|
||||
opacity: number;
|
||||
/** 开始时间(秒) */
|
||||
start_time: number;
|
||||
/** 持续时长(秒) */
|
||||
duration: number;
|
||||
/** 入场动画 */
|
||||
animation: PipAnimType;
|
||||
/** 入场方向 */
|
||||
slide_direction: PipSlideDirection;
|
||||
/** 图层顺序(z-index) */
|
||||
z_index: number;
|
||||
}
|
||||
|
||||
/** 画中画配置 */
|
||||
export interface PipConfig {
|
||||
/** 是否启用画中画 */
|
||||
enabled: boolean;
|
||||
/** 图层列表 */
|
||||
layers: PipLayer[];
|
||||
}
|
||||
|
||||
/** 默认 PiP 图层 */
|
||||
export const DEFAULT_PIP_LAYER: PipLayer = {
|
||||
id: "",
|
||||
name: "图层",
|
||||
material_type: "image",
|
||||
material_url: "",
|
||||
grid_position: "top_right",
|
||||
x: 70,
|
||||
y: 5,
|
||||
width: 25,
|
||||
height: 25,
|
||||
aspect_lock: true,
|
||||
border_radius: 0,
|
||||
opacity: 100,
|
||||
start_time: 0,
|
||||
duration: 5,
|
||||
animation: "none",
|
||||
slide_direction: "right",
|
||||
z_index: 1,
|
||||
};
|
||||
|
||||
/** 默认 PiP 配置 */
|
||||
export const DEFAULT_PIP_CONFIG: PipConfig = {
|
||||
enabled: false,
|
||||
layers: [],
|
||||
};
|
||||
|
||||
/* ──────── 滤镜调色 ──────── */
|
||||
|
||||
/** 预设滤镜 */
|
||||
export type FilterPreset =
|
||||
| "none"
|
||||
| "original"
|
||||
| "fresh"
|
||||
| "warm"
|
||||
| "cool"
|
||||
| "vintage"
|
||||
| "cinema"
|
||||
| "bw"
|
||||
| "sunshine"
|
||||
| "film";
|
||||
|
||||
/** 预设滤镜标签 */
|
||||
export const FILTER_PRESET_LABELS: Record<FilterPreset, string> = {
|
||||
none: "无",
|
||||
original: "原片",
|
||||
fresh: "清新",
|
||||
warm: "暖调",
|
||||
cool: "冷色",
|
||||
vintage: "复古",
|
||||
cinema: "电影",
|
||||
bw: "黑白",
|
||||
sunshine: "暖阳",
|
||||
film: "胶片",
|
||||
};
|
||||
|
||||
/** 滤镜调色配置 */
|
||||
export interface FilterConfig {
|
||||
/** 是否启用滤镜 */
|
||||
enabled: boolean;
|
||||
/** 预设滤镜 */
|
||||
preset: FilterPreset;
|
||||
/** 亮度(-100 ~ 100) */
|
||||
brightness: number;
|
||||
/** 对比度(-100 ~ 100) */
|
||||
contrast: number;
|
||||
/** 饱和度(-100 ~ 100) */
|
||||
saturation: number;
|
||||
/** 色温(-100 ~ 100,负值偏蓝,正值偏黄) */
|
||||
temperature: number;
|
||||
/** 色调(-100 ~ 100,负值偏绿,正值偏品红) */
|
||||
tint: number;
|
||||
/** 锐度(0 ~ 100) */
|
||||
sharpness: number;
|
||||
}
|
||||
|
||||
/** 默认滤镜调色配置 */
|
||||
export const DEFAULT_FILTER_CONFIG: FilterConfig = {
|
||||
enabled: false,
|
||||
preset: "none",
|
||||
brightness: 0,
|
||||
contrast: 0,
|
||||
saturation: 0,
|
||||
temperature: 0,
|
||||
tint: 0,
|
||||
sharpness: 0,
|
||||
};
|
||||
|
||||
/* ──────── 绿幕抠像 ──────── */
|
||||
|
||||
/** 绿幕抠像颜色预设 */
|
||||
export type ChromaKeyColorPreset =
|
||||
"green" | "blue" | "red" | "pure_green" | "soft_green";
|
||||
|
||||
/** 颜色预设标签 */
|
||||
export const CHROMA_KEY_PRESET_LABELS: Record<ChromaKeyColorPreset, string> = {
|
||||
green: "绿",
|
||||
blue: "蓝",
|
||||
red: "红",
|
||||
pure_green: "精绿",
|
||||
soft_green: "柔绿",
|
||||
};
|
||||
|
||||
/** 颜色预设对应的默认色值 */
|
||||
export const CHROMA_KEY_PRESET_COLORS: Record<ChromaKeyColorPreset, string> = {
|
||||
green: "#00FF00",
|
||||
blue: "#0000FF",
|
||||
red: "#FF0000",
|
||||
pure_green: "#00C800",
|
||||
soft_green: "#40E040",
|
||||
};
|
||||
|
||||
/** 绿幕抠像配置 */
|
||||
export interface ChromaKeyConfig {
|
||||
/** 是否启用绿幕抠像 */
|
||||
enabled: boolean;
|
||||
/** 颜色预设 */
|
||||
color_preset: ChromaKeyColorPreset;
|
||||
/** 抠像目标颜色(HEX) */
|
||||
color: string;
|
||||
/** 相似度(0 ~ 100,越大容忍的色差范围越广) */
|
||||
similarity: number;
|
||||
/** 边缘平滑(0 ~ 100,越大边缘越柔和) */
|
||||
blend: number;
|
||||
/** 溢色抑制(0 ~ 100,去除边缘颜色溢出) */
|
||||
spill: number;
|
||||
}
|
||||
|
||||
/** 默认绿幕抠像配置 */
|
||||
export const DEFAULT_CHROMA_KEY_CONFIG: ChromaKeyConfig = {
|
||||
enabled: false,
|
||||
color_preset: "green",
|
||||
color: "#00FF00",
|
||||
similarity: 30,
|
||||
blend: 10,
|
||||
spill: 20,
|
||||
};
|
||||
|
||||
/* ──────── 贴纸配置 ──────── */
|
||||
|
||||
/** 贴纸类型 */
|
||||
export type StickerType = "emoji" | "image" | "text";
|
||||
|
||||
/** 文字花字预设 */
|
||||
export type TextStickerPreset =
|
||||
| "normal" // 普通
|
||||
| "highlight" // 高亮
|
||||
| "bubble" // 气泡
|
||||
| "neon" // 霓虹
|
||||
| "shadow" // 投影
|
||||
| "outline" // 描边
|
||||
| "gradient" // 渐变
|
||||
| "handwrite"; // 手写
|
||||
|
||||
/** 贴纸项 */
|
||||
export interface StickerItem {
|
||||
id: string;
|
||||
/** 贴纸类型 */
|
||||
type: StickerType;
|
||||
/** 内容(emoji 字符 / 图片 URL / 文字内容) */
|
||||
content: string;
|
||||
/** X 坐标(百分比 0~100) */
|
||||
x: number;
|
||||
/** Y 坐标(百分比 0~100) */
|
||||
y: number;
|
||||
/** 宽度(百分比 0~100) */
|
||||
width: number;
|
||||
/** 高度(百分比 0~100) */
|
||||
height: number;
|
||||
/** 旋转角度(度 -180~180) */
|
||||
rotation: number;
|
||||
/** 不透明度(0~100) */
|
||||
opacity: number;
|
||||
/** 开始时间(秒) */
|
||||
start_time: number;
|
||||
/** 持续时长(秒,0 表示全程显示) */
|
||||
duration: number;
|
||||
/** 图层顺序 */
|
||||
z_index: number;
|
||||
/** 文字花字预设(仅 type=text 时有效) */
|
||||
text_preset: TextStickerPreset;
|
||||
/** 文字颜色(仅 type=text 时有效) */
|
||||
text_color: string;
|
||||
/** 文字大小(px,仅 type=text 时有效) */
|
||||
font_size: number;
|
||||
}
|
||||
|
||||
/** 贴纸配置 */
|
||||
export interface StickerConfig {
|
||||
enabled: boolean;
|
||||
items: StickerItem[];
|
||||
}
|
||||
|
||||
/** 默认贴纸项 */
|
||||
export const DEFAULT_STICKER_ITEM: StickerItem = {
|
||||
id: "",
|
||||
type: "emoji",
|
||||
content: "😀",
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 15,
|
||||
height: 15,
|
||||
rotation: 0,
|
||||
opacity: 100,
|
||||
start_time: 0,
|
||||
duration: 0,
|
||||
z_index: 1,
|
||||
text_preset: "normal",
|
||||
text_color: "#FFFFFF",
|
||||
font_size: 24,
|
||||
};
|
||||
|
||||
/** 默认贴纸配置 */
|
||||
export const DEFAULT_STICKER_CONFIG: StickerConfig = {
|
||||
enabled: false,
|
||||
items: [],
|
||||
};
|
||||
|
||||
/** 文字花字预设标签 */
|
||||
export const TEXT_STICKER_PRESET_LABELS: Record<TextStickerPreset, string> = {
|
||||
normal: "普通",
|
||||
highlight: "高亮",
|
||||
bubble: "气泡",
|
||||
neon: "霓虹",
|
||||
shadow: "投影",
|
||||
outline: "描边",
|
||||
gradient: "渐变",
|
||||
handwrite: "手写",
|
||||
};
|
||||
|
||||
/* ──────── 封面配置 ──────── */
|
||||
|
||||
/** 封面来源模式 */
|
||||
export type CoverMode = "auto" | "frame" | "upload";
|
||||
|
||||
/** 封面配置 */
|
||||
export interface CoverConfig {
|
||||
/** 是否启用自定义封面 */
|
||||
enabled: boolean;
|
||||
/** 封面来源模式 */
|
||||
mode: CoverMode;
|
||||
/** 抽帧时间点(秒,mode=frame 时使用) */
|
||||
frame_time: number;
|
||||
/** 上传的封面 URL(mode=upload 时使用) */
|
||||
upload_url: string;
|
||||
/** AI 智能推荐的抽帧时间(由后端分析得出) */
|
||||
ai_suggested_time: number | null;
|
||||
/** 封面缩略图 URL */
|
||||
thumbnail_url: string;
|
||||
}
|
||||
|
||||
/** 默认封面配置 */
|
||||
export const DEFAULT_COVER_CONFIG: CoverConfig = {
|
||||
enabled: false,
|
||||
mode: "auto",
|
||||
frame_time: 0,
|
||||
upload_url: "",
|
||||
ai_suggested_time: null,
|
||||
thumbnail_url: "",
|
||||
};
|
||||
|
||||
/* ──────── 片段数据 ──────── */
|
||||
|
||||
export interface ClipData {
|
||||
id: string;
|
||||
type: ClipType; // 片段类型:voice(口播)或 pip(画中画)
|
||||
@@ -520,31 +18,4 @@ export interface ClipData {
|
||||
voice_asset_id?: string;
|
||||
/** 配音素材文件 URL(voice 类型片段使用) */
|
||||
voice_file_url?: string;
|
||||
/** 与前一片段之间的转场效果 */
|
||||
transition?: TransitionConfig;
|
||||
/** 播放速度配置 */
|
||||
speed?: SpeedConfig;
|
||||
/** TTS 配音配置 */
|
||||
tts_config?: TtsConfig;
|
||||
/** 裁剪配置 — 定义素材入点/出点 */
|
||||
trim_config?: TrimConfig;
|
||||
}
|
||||
|
||||
/* ──────── 标题设置 ──────── */
|
||||
|
||||
/**
|
||||
* 标题设置 — 对齐后端 title_config 字段
|
||||
* 前端 UI 使用 camelCase,发送到后端时映射为 snake_case
|
||||
*/
|
||||
export interface TitleSettings {
|
||||
aiAutoSelect: boolean;
|
||||
title: string;
|
||||
position: string;
|
||||
font: string;
|
||||
size: number;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
stroke: boolean;
|
||||
shadow: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
@@ -29,9 +29,7 @@ import {
|
||||
createEditPlan,
|
||||
generateEditPlan,
|
||||
updateEditPlan,
|
||||
getGenerationTaskResults,
|
||||
} from "@/api/editPlans";
|
||||
import type { GeneratedVideo, EditPlanConfig } from "@/api/editPlans";
|
||||
import { getEditingTemplates } from "@/api/editingPlanner";
|
||||
import { getTitles } from "@/api/titles";
|
||||
import apiClient from "@/api/client";
|
||||
@@ -154,9 +152,6 @@ const GeneratePage: React.FC = () => {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [generated, setGenerated] = useState(false);
|
||||
const [generateError, setGenerateError] = useState<string | null>(null);
|
||||
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
|
||||
const [videoUrl, setVideoUrl] = useState<string>("");
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string>("");
|
||||
|
||||
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
@@ -502,7 +497,7 @@ const GeneratePage: React.FC = () => {
|
||||
bgm,
|
||||
generate_count: generateCount,
|
||||
material_mode: materialMode,
|
||||
} as EditPlanConfig,
|
||||
},
|
||||
total_duration: duration,
|
||||
source_edit_plan_id: editPlanId || undefined,
|
||||
});
|
||||
@@ -523,25 +518,6 @@ const GeneratePage: React.FC = () => {
|
||||
setProgress(100);
|
||||
setGenerating(false);
|
||||
setGenerated(true);
|
||||
|
||||
// 获取生成的视频结果
|
||||
if (data.generation_task_id) {
|
||||
try {
|
||||
const videos = await getGenerationTaskResults(
|
||||
data.generation_task_id,
|
||||
);
|
||||
setGeneratedVideos(videos);
|
||||
if (videos.length > 0) {
|
||||
setVideoUrl(
|
||||
videos[0].file_url || videos[0].download_url || "",
|
||||
);
|
||||
setThumbnailUrl(videos[0].thumbnail_url || "");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[获取生成结果失败]", err);
|
||||
}
|
||||
}
|
||||
|
||||
message.success("视频生成完成!");
|
||||
return;
|
||||
}
|
||||
@@ -732,42 +708,6 @@ const GeneratePage: React.FC = () => {
|
||||
materialMode,
|
||||
]);
|
||||
|
||||
/* ── 下载视频 ── */
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (!generatedVideos.length) return;
|
||||
const video = generatedVideos[0];
|
||||
try {
|
||||
// 优先使用 download_url(签名 URL),回退到 file_url
|
||||
const url = video.download_url || video.file_url;
|
||||
if (url) {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = video.name || "generated-video.mp4";
|
||||
a.target = "_blank";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[下载失败]", err);
|
||||
message.error("下载失败,请重试");
|
||||
}
|
||||
}, [generatedVideos]);
|
||||
|
||||
/* ── 分享视频 ── */
|
||||
const handleShare = useCallback(async () => {
|
||||
if (!generatedVideos.length) return;
|
||||
const video = generatedVideos[0];
|
||||
const shareUrl = video.file_url || window.location.href;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
message.success("视频链接已复制到剪贴板");
|
||||
} catch {
|
||||
// fallback: 显示 URL 让用户手动复制
|
||||
message.info(`视频链接: ${shareUrl}`);
|
||||
}
|
||||
}, [generatedVideos]);
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
const goNext = useCallback(() => {
|
||||
if (currentStep === 1 && !selectedTemplate) {
|
||||
@@ -1784,25 +1724,8 @@ const GeneratePage: React.FC = () => {
|
||||
<div className="xx-generate-preview">
|
||||
{/* 视频预览 */}
|
||||
<div className="xx-preview-video">
|
||||
{generated && videoUrl ? (
|
||||
<video
|
||||
src={videoUrl}
|
||||
controls
|
||||
preload="metadata"
|
||||
poster={thumbnailUrl || undefined}
|
||||
style={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||
/>
|
||||
) : generated ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: 24,
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<LoadingOutlined style={{ fontSize: 24, marginBottom: 8 }} />
|
||||
<div>视频处理中,请稍候…</div>
|
||||
</div>
|
||||
{generated ? (
|
||||
<video src="" controls preload="none" />
|
||||
) : (
|
||||
<button className="xx-play-btn" type="button">
|
||||
<PlayCircleOutlined />
|
||||
@@ -1855,18 +1778,12 @@ const GeneratePage: React.FC = () => {
|
||||
{/* 生成完成后显示下载/分享 */}
|
||||
{generated && (
|
||||
<div className="xx-generate-actions" style={{ marginTop: 8 }}>
|
||||
<button className="xx-btn xx-btn-ghost" onClick={handleDownload}>
|
||||
<button className="xx-btn xx-btn-ghost">
|
||||
<DownloadOutlined /> 下载视频
|
||||
</button>
|
||||
<button className="xx-btn xx-btn-ghost" onClick={handleShare}>
|
||||
<button className="xx-btn xx-btn-ghost">
|
||||
<ShareAltOutlined /> 分享
|
||||
</button>
|
||||
<button
|
||||
className="xx-btn xx-btn-ghost"
|
||||
onClick={() => navigate("/app/products")}
|
||||
>
|
||||
前往成片库 →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -31,11 +31,7 @@ import {
|
||||
getProducts,
|
||||
deleteProduct,
|
||||
getProductDownloadUrl,
|
||||
updateReviewStatus,
|
||||
batchDownload,
|
||||
getBatchDownloadStatus,
|
||||
type ProductItem as ApiProductItem,
|
||||
type ReviewStatus,
|
||||
} from "@/api/products";
|
||||
import "./products.css";
|
||||
|
||||
@@ -57,12 +53,6 @@ interface ProductItem {
|
||||
fileSize: number; // MB
|
||||
videoUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
/** 复核状态 */
|
||||
reviewStatus?: ReviewStatus;
|
||||
/** 所属项目 ID */
|
||||
projectId?: string;
|
||||
/** 所属项目名称 */
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
/** 将后端 status 映射为前端 ProductStatus */
|
||||
@@ -118,9 +108,6 @@ const mapApiProduct = (item: ApiProductItem): ProductItem => ({
|
||||
fileSize: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
|
||||
videoUrl: item.video_url,
|
||||
thumbnailUrl: item.thumbnail_url,
|
||||
reviewStatus: item.review_status,
|
||||
projectId: item.project_id,
|
||||
projectName: item.project_name,
|
||||
});
|
||||
|
||||
/* ============================================================
|
||||
@@ -148,30 +135,6 @@ const formatSize = (mb: number): string => {
|
||||
return `${mb.toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
/** 复核状态配置 */
|
||||
const reviewStatusConfig: Record<
|
||||
ReviewStatus,
|
||||
{ text: string; className: string }
|
||||
> = {
|
||||
pending_review: { text: "待复核", className: "review-pending" },
|
||||
approved: { text: "已通过", className: "review-approved" },
|
||||
rejected: { text: "需修改", className: "review-rejected" },
|
||||
};
|
||||
|
||||
/** 复核状态循环顺序 */
|
||||
const REVIEW_STATUS_CYCLE: ReviewStatus[] = [
|
||||
"pending_review",
|
||||
"approved",
|
||||
"rejected",
|
||||
];
|
||||
|
||||
/** 获取下一个复核状态 */
|
||||
const getNextReviewStatus = (current?: ReviewStatus): ReviewStatus => {
|
||||
if (!current) return "approved";
|
||||
const idx = REVIEW_STATUS_CYCLE.indexOf(current);
|
||||
return REVIEW_STATUS_CYCLE[(idx + 1) % REVIEW_STATUS_CYCLE.length];
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* ProductCard 组件
|
||||
* ============================================================ */
|
||||
@@ -185,7 +148,6 @@ const ProductCard: React.FC<{
|
||||
onShare: (product: ProductItem) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onPublish: (product: ProductItem) => void;
|
||||
onReviewStatusChange: (id: string) => void;
|
||||
}> = ({
|
||||
product,
|
||||
isSelected,
|
||||
@@ -196,7 +158,6 @@ const ProductCard: React.FC<{
|
||||
onShare,
|
||||
onDelete,
|
||||
onPublish,
|
||||
onReviewStatusChange,
|
||||
}) => {
|
||||
const st = statusConfig[product.status];
|
||||
|
||||
@@ -240,33 +201,6 @@ const ProductCard: React.FC<{
|
||||
{/* 已发布徽章(右上角) */}
|
||||
{product.isPublished && <div className="xx-product-badge">✅ 已发布</div>}
|
||||
|
||||
{/* 复核状态标签(右上角) */}
|
||||
{product.reviewStatus && (
|
||||
<div
|
||||
className={`xx-product-review-tag ${reviewStatusConfig[product.reviewStatus].className}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReviewStatusChange(product.id);
|
||||
}}
|
||||
title={`点击切换复核状态(当前:${reviewStatusConfig[product.reviewStatus].text})`}
|
||||
>
|
||||
{reviewStatusConfig[product.reviewStatus].text}
|
||||
</div>
|
||||
)}
|
||||
{/* 无复核状态时显示"待复核"入口 */}
|
||||
{!product.reviewStatus && (
|
||||
<div
|
||||
className="xx-product-review-tag review-pending"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReviewStatusChange(product.id);
|
||||
}}
|
||||
title="点击设置复核状态"
|
||||
>
|
||||
待复核
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-product-thumb">
|
||||
{product.thumbnailUrl ? (
|
||||
@@ -583,7 +517,7 @@ const ProductLibrary: React.FC = () => {
|
||||
refetch,
|
||||
} = useQuery<ApiProductItem[], Error>({
|
||||
queryKey: ["products"],
|
||||
queryFn: () => getProducts(),
|
||||
queryFn: getProducts,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -602,26 +536,11 @@ const ProductLibrary: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
/* ── 复核状态 mutation ── */
|
||||
const reviewMutation = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) =>
|
||||
updateReviewStatus(id, status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] });
|
||||
message.success("复核状态已更新");
|
||||
},
|
||||
onError: () => {
|
||||
message.error("更新复核状态失败");
|
||||
},
|
||||
});
|
||||
|
||||
/* 筛选 */
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [filterStatus, setFilterStatus] = useState<string>("all");
|
||||
const [filterTime, setFilterTime] = useState<string>("all");
|
||||
const [filterDuration, setFilterDuration] = useState<string>("all");
|
||||
const [filterProject, setFilterProject] = useState<string>("all");
|
||||
const [filterReviewStatus, setFilterReviewStatus] = useState<string>("all");
|
||||
|
||||
/* 批量操作 */
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
@@ -677,20 +596,6 @@ const ProductLibrary: React.FC = () => {
|
||||
});
|
||||
}
|
||||
|
||||
/* 项目筛选 */
|
||||
if (filterProject !== "all") {
|
||||
list = list.filter((p) => p.projectId === filterProject);
|
||||
}
|
||||
|
||||
/* 复核状态筛选 */
|
||||
if (filterReviewStatus !== "all") {
|
||||
if (filterReviewStatus === "none") {
|
||||
list = list.filter((p) => !p.reviewStatus);
|
||||
} else {
|
||||
list = list.filter((p) => p.reviewStatus === filterReviewStatus);
|
||||
}
|
||||
}
|
||||
|
||||
/* 搜索 */
|
||||
if (searchText.trim()) {
|
||||
const q = searchText.trim().toLowerCase();
|
||||
@@ -698,15 +603,7 @@ const ProductLibrary: React.FC = () => {
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [
|
||||
products,
|
||||
filterStatus,
|
||||
filterTime,
|
||||
filterDuration,
|
||||
filterProject,
|
||||
filterReviewStatus,
|
||||
searchText,
|
||||
]);
|
||||
}, [products, filterStatus, filterTime, filterDuration, searchText]);
|
||||
|
||||
/* 全选 */
|
||||
const allSelected =
|
||||
@@ -782,56 +679,24 @@ const ProductLibrary: React.FC = () => {
|
||||
message.info("发布功能待后端 API 补齐");
|
||||
};
|
||||
|
||||
/* 切换复核状态 */
|
||||
const handleReviewStatusChange = (id: string) => {
|
||||
const current = products.find((p) => p.id === id)?.reviewStatus;
|
||||
const nextStatus = getNextReviewStatus(current);
|
||||
reviewMutation.mutate({ id, status: nextStatus });
|
||||
};
|
||||
|
||||
/* 批量下载 — 使用 batch-download API + 轮询 */
|
||||
const [batchDownloading, setBatchDownloading] = useState(false);
|
||||
|
||||
/* 批量下载 */
|
||||
const handleBatchDownload = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
if (ids.length === 0) return;
|
||||
setBatchDownloading(true);
|
||||
try {
|
||||
// 发起批量下载任务
|
||||
const { job_id } = await batchDownload(ids);
|
||||
message.info(`批量下载任务已创建,正在打包 ${ids.length} 个视频...`);
|
||||
|
||||
// 轮询下载状态(最多 60 次,每次 2 秒)
|
||||
let attempts = 0;
|
||||
const maxAttempts = 60;
|
||||
const poll = async (): Promise<void> => {
|
||||
if (attempts >= maxAttempts) {
|
||||
message.warning("打包超时,请稍后在消息中心查看");
|
||||
return;
|
||||
}
|
||||
attempts++;
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const status = await getBatchDownloadStatus(job_id);
|
||||
if (status.status === "completed" && status.download_url) {
|
||||
const a = document.createElement("a");
|
||||
a.href = status.download_url;
|
||||
a.download = "";
|
||||
a.click();
|
||||
message.success(`已打包下载 ${ids.length} 个视频`);
|
||||
setSelectedIds(new Set());
|
||||
} else if (status.status === "failed") {
|
||||
message.error("批量下载失败,请重试");
|
||||
} else {
|
||||
// 继续轮询
|
||||
await poll();
|
||||
}
|
||||
};
|
||||
await poll();
|
||||
} catch {
|
||||
message.error("发起批量下载失败");
|
||||
} finally {
|
||||
setBatchDownloading(false);
|
||||
let successCount = 0;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const { url } = await getProductDownloadUrl(id);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "";
|
||||
a.click();
|
||||
successCount++;
|
||||
} catch {
|
||||
// 忽略单个失败
|
||||
}
|
||||
}
|
||||
message.success(`已下载 ${successCount}/${ids.length} 个视频`);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
/* 批量删除 */
|
||||
@@ -958,9 +823,8 @@ const ProductLibrary: React.FC = () => {
|
||||
buttonSize="sm"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleBatchDownload}
|
||||
disabled={batchDownloading}
|
||||
>
|
||||
{batchDownloading ? "打包中..." : "批量下载"}
|
||||
批量下载
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
@@ -1040,36 +904,6 @@ const ProductLibrary: React.FC = () => {
|
||||
{ value: "long", label: ">3分钟" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={filterProject}
|
||||
onChange={setFilterProject}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部项目" },
|
||||
...Array.from(
|
||||
new Map(
|
||||
products
|
||||
.filter((p) => p.projectId && p.projectName)
|
||||
.map((p) => [p.projectId!, p.projectName!] as const),
|
||||
),
|
||||
).map(([id, name]) => ({
|
||||
value: id as string,
|
||||
label: name as string,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={filterReviewStatus}
|
||||
onChange={setFilterReviewStatus}
|
||||
style={{ width: 130 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部复核" },
|
||||
{ value: "none", label: "未设置" },
|
||||
{ value: "pending_review", label: "待复核" },
|
||||
{ value: "approved", label: "已通过" },
|
||||
{ value: "rejected", label: "需修改" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-products-filters-right">
|
||||
<span
|
||||
@@ -1098,7 +932,6 @@ const ProductLibrary: React.FC = () => {
|
||||
onShare={handleShare}
|
||||
onDelete={handleDelete}
|
||||
onPublish={handlePublish}
|
||||
onReviewStatusChange={handleReviewStatusChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -222,42 +222,6 @@
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* 复核状态标签(右上角,位于已发布徽章下方) */
|
||||
.xx-product-review-tag {
|
||||
position: absolute;
|
||||
top: 34px;
|
||||
right: 10px;
|
||||
z-index: 2;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
backdrop-filter: blur(4px);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-all);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.xx-product-review-tag:hover {
|
||||
transform: scale(1.05);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.xx-product-review-tag.review-pending {
|
||||
background: rgba(156, 163, 175, 0.9);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.xx-product-review-tag.review-approved {
|
||||
background: rgba(16, 185, 129, 0.9);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.xx-product-review-tag.review-rejected {
|
||||
background: rgba(239, 68, 68, 0.9);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* 缩略图区域 */
|
||||
.xx-product-thumb {
|
||||
aspect-ratio: 9 / 16;
|
||||
|
||||
@@ -1,450 +0,0 @@
|
||||
/**
|
||||
* 任务中心页面
|
||||
* 展示用户的所有任务(生成任务、素材导入等),支持状态筛选、类型筛选、分页、重试
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Table,
|
||||
Tabs,
|
||||
Select,
|
||||
Tag,
|
||||
Button,
|
||||
message,
|
||||
Popconfirm,
|
||||
Tooltip,
|
||||
} from "antd";
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
SyncOutlined,
|
||||
CloseCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
MinusCircleOutlined,
|
||||
RedoOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import {
|
||||
getTasks,
|
||||
retryTask,
|
||||
type TaskItem,
|
||||
type TaskStatus,
|
||||
type TaskListParams,
|
||||
} from "@/api/tasks";
|
||||
import "./tasks.css";
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
/** 状态 Tab 配置 */
|
||||
const STATUS_TABS: { key: TaskStatus | "all"; label: string }[] = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "waiting", label: "等待中" },
|
||||
{ key: "running", label: "进行中" },
|
||||
{ key: "completed", label: "已完成" },
|
||||
{ key: "failed", label: "失败" },
|
||||
{ key: "cancelled", label: "已取消" },
|
||||
];
|
||||
|
||||
/** 类型筛选选项 */
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: "all", label: "全部类型" },
|
||||
{ value: "generation", label: "生成任务" },
|
||||
{ value: "ingest", label: "素材导入" },
|
||||
];
|
||||
|
||||
/** 状态标签配置 */
|
||||
const STATUS_CONFIG: Record<
|
||||
TaskStatus,
|
||||
{ label: string; color: string; icon: React.ReactNode }
|
||||
> = {
|
||||
pending: {
|
||||
label: "等待中",
|
||||
color: "default",
|
||||
icon: <ClockCircleOutlined />,
|
||||
},
|
||||
waiting: {
|
||||
label: "排队中",
|
||||
color: "processing",
|
||||
icon: <SyncOutlined spin />,
|
||||
},
|
||||
running: {
|
||||
label: "进行中",
|
||||
color: "processing",
|
||||
icon: <SyncOutlined spin />,
|
||||
},
|
||||
completed: {
|
||||
label: "已完成",
|
||||
color: "success",
|
||||
icon: <CheckCircleOutlined />,
|
||||
},
|
||||
failed: {
|
||||
label: "失败",
|
||||
color: "error",
|
||||
icon: <CloseCircleOutlined />,
|
||||
},
|
||||
cancelled: {
|
||||
label: "已取消",
|
||||
color: "default",
|
||||
icon: <MinusCircleOutlined />,
|
||||
},
|
||||
};
|
||||
|
||||
/** 任务类型标签 */
|
||||
const TYPE_LABELS: Record<string, { label: string; color: string }> = {
|
||||
generation: { label: "生成任务", color: "blue" },
|
||||
ingest: { label: "素材导入", color: "green" },
|
||||
};
|
||||
|
||||
/* ──────────── 工具函数 ──────────── */
|
||||
|
||||
/** 格式化耗时 */
|
||||
const formatDuration = (seconds?: number): string => {
|
||||
if (!seconds) return "-";
|
||||
if (seconds < 60) return `${Math.round(seconds)}秒`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = Math.round(seconds % 60);
|
||||
if (minutes < 60) return `${minutes}分${secs}秒`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return `${hours}小时${mins}分`;
|
||||
};
|
||||
|
||||
/** 格式化时间 */
|
||||
const formatTime = (dateStr?: string | null): string => {
|
||||
if (!dateStr) return "-";
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
/* ──────────── 主组件 ──────────── */
|
||||
|
||||
export default function TaskCenter() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 筛选状态
|
||||
const [statusFilter, setStatusFilter] = useState<TaskStatus | "all">("all");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [expandedTaskId, setExpandedTaskId] = useState<string | null>(null);
|
||||
const [expandedTaskDetail, setExpandedTaskDetail] = useState<TaskItem | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// 查询参数
|
||||
const queryParams: TaskListParams = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...(statusFilter !== "all" && { status: statusFilter }),
|
||||
...(typeFilter !== "all" && { task_type: typeFilter }),
|
||||
};
|
||||
|
||||
// 获取任务列表
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["tasks", queryParams],
|
||||
queryFn: () => getTasks(queryParams),
|
||||
refetchInterval: (query) => {
|
||||
// 有进行中的任务时自动刷新
|
||||
const tasks = query.state.data?.items ?? [];
|
||||
const hasRunning = tasks.some(
|
||||
(t) => t.status === "running" || t.status === "waiting",
|
||||
);
|
||||
return hasRunning ? 5000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
// 重试任务
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: retryTask,
|
||||
onSuccess: () => {
|
||||
message.success("任务已重新提交");
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("重试失败,请检查任务状态");
|
||||
},
|
||||
});
|
||||
|
||||
// 展开查看详情
|
||||
const handleExpand = async (expanded: boolean, record: TaskItem) => {
|
||||
if (!expanded) {
|
||||
setExpandedTaskId(null);
|
||||
setExpandedTaskDetail(null);
|
||||
return;
|
||||
}
|
||||
setExpandedTaskId(record.id);
|
||||
// 如果是失败任务,获取详情(含 error_info)
|
||||
if (record.status === "failed" && record.error_info) {
|
||||
setExpandedTaskDetail(record);
|
||||
}
|
||||
};
|
||||
|
||||
// 表格列定义
|
||||
const columns: ColumnsType<TaskItem> = [
|
||||
{
|
||||
title: "任务ID",
|
||||
dataIndex: "id",
|
||||
key: "id",
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (id: string) => (
|
||||
<Tooltip title={id}>
|
||||
<span className="task-id">{id.slice(0, 8)}...</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "task_type",
|
||||
key: "task_type",
|
||||
width: 100,
|
||||
render: (type: string) => {
|
||||
const config = TYPE_LABELS[type] || { label: type, color: "default" };
|
||||
return <Tag color={config.color}>{config.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
width: 120,
|
||||
render: (status: TaskStatus, record: TaskItem) => {
|
||||
const config = STATUS_CONFIG[status] || {
|
||||
label: status,
|
||||
color: "default",
|
||||
icon: null,
|
||||
};
|
||||
return (
|
||||
<Tag
|
||||
color={config.color}
|
||||
icon={config.icon}
|
||||
className="task-status-tag"
|
||||
>
|
||||
{config.label}
|
||||
{status === "running" && record.progress > 0 && (
|
||||
<span className="task-progress"> {record.progress}%</span>
|
||||
)}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "当前步骤",
|
||||
dataIndex: "current_step",
|
||||
key: "current_step",
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (step: string) => (
|
||||
<span className="task-step">{step || "-"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "耗时",
|
||||
dataIndex: "duration_seconds",
|
||||
key: "duration_seconds",
|
||||
width: 100,
|
||||
render: (seconds: number) => (
|
||||
<span className="task-duration">{formatDuration(seconds)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
width: 120,
|
||||
render: (time: string) => (
|
||||
<span className="task-time">{formatTime(time)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 100,
|
||||
fixed: "right",
|
||||
render: (_: unknown, record: TaskItem) => {
|
||||
if (record.status === "failed" && record.retryable) {
|
||||
return (
|
||||
<Popconfirm
|
||||
title="确认重试"
|
||||
description="确定要重试这个失败的任务吗?"
|
||||
onConfirm={() => retryMutation.mutate(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<RedoOutlined />}
|
||||
loading={retryMutation.isPending}
|
||||
className="task-retry-btn"
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
);
|
||||
}
|
||||
if (record.status === "failed") {
|
||||
return (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<InfoCircleOutlined />}
|
||||
onClick={() => {
|
||||
setExpandedTaskId(record.id);
|
||||
setExpandedTaskDetail(record);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return <span className="task-action-placeholder">-</span>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 展开行渲染(错误详情)
|
||||
const expandedRowRender = (record: TaskItem) => {
|
||||
const detail = expandedTaskDetail || record;
|
||||
const errorInfo = detail.error_info;
|
||||
|
||||
if (!errorInfo && !detail.error_message) {
|
||||
return <div className="task-expand-empty">暂无错误详情</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="task-error-detail">
|
||||
<div className="task-error-header">
|
||||
<ExclamationCircleOutlined className="task-error-icon" />
|
||||
<span>错误详情</span>
|
||||
</div>
|
||||
<div className="task-error-body">
|
||||
{errorInfo?.error_type && (
|
||||
<div className="task-error-row">
|
||||
<span className="task-error-label">错误类型:</span>
|
||||
<Tag color="error">{errorInfo.error_type}</Tag>
|
||||
</div>
|
||||
)}
|
||||
{(errorInfo?.error_message || detail.error_message) && (
|
||||
<div className="task-error-row">
|
||||
<span className="task-error-label">错误信息:</span>
|
||||
<span className="task-error-message">
|
||||
{errorInfo?.error_message || detail.error_message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{errorInfo?.failed_step && (
|
||||
<div className="task-error-row">
|
||||
<span className="task-error-label">失败阶段:</span>
|
||||
<span>{errorInfo.failed_step}</span>
|
||||
</div>
|
||||
)}
|
||||
{errorInfo?.stack_trace && (
|
||||
<div className="task-error-row task-error-stack">
|
||||
<span className="task-error-label">堆栈信息:</span>
|
||||
<pre>{errorInfo.stack_trace}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 错误处理
|
||||
if (error) {
|
||||
return (
|
||||
<div className="task-center">
|
||||
<div className="task-error">
|
||||
<CloseCircleOutlined />
|
||||
<p>加载任务列表失败</p>
|
||||
<Button onClick={() => window.location.reload()}>刷新页面</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="task-center">
|
||||
{/* 页面标题 */}
|
||||
<div className="task-header">
|
||||
<h1 className="task-title">任务中心</h1>
|
||||
<p className="task-subtitle">查看和管理所有生成任务与素材导入任务</p>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="task-filters">
|
||||
{/* 状态 Tab */}
|
||||
<Tabs
|
||||
activeKey={statusFilter}
|
||||
onChange={(key) => {
|
||||
setStatusFilter(key as TaskStatus | "all");
|
||||
setPage(1);
|
||||
}}
|
||||
items={STATUS_TABS.map((tab) => ({
|
||||
key: tab.key,
|
||||
label: tab.label,
|
||||
}))}
|
||||
className="task-status-tabs"
|
||||
/>
|
||||
|
||||
{/* 类型筛选 */}
|
||||
<div className="task-type-filter">
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onChange={(value) => {
|
||||
setTypeFilter(value);
|
||||
setPage(1);
|
||||
}}
|
||||
options={TYPE_OPTIONS}
|
||||
style={{ width: 140 }}
|
||||
placeholder="选择类型"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 任务表格 */}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data?.items || []}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total || 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
expandable={{
|
||||
expandedRowRender,
|
||||
expandedRowKeys: expandedTaskId ? [expandedTaskId] : [],
|
||||
onExpand: handleExpand,
|
||||
rowExpandable: (record) =>
|
||||
record.status === "failed" &&
|
||||
(!!record.error_info || !!record.error_message),
|
||||
}}
|
||||
scroll={{ x: 800 }}
|
||||
className="task-table"
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="task-empty">
|
||||
<ClockCircleOutlined />
|
||||
<p>暂无任务记录</p>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
/* ──────────── 任务中心 ──────────── */
|
||||
|
||||
.task-center {
|
||||
padding: var(--space-lg);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 页面标题 */
|
||||
.task-header {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.task-subtitle {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 筛选栏 */
|
||||
.task-filters {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-lg);
|
||||
padding: var(--space-md);
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.task-status-tabs {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.task-status-tabs .ant-tabs-nav {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.task-status-tabs .ant-tabs-tab {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.task-type-filter {
|
||||
flex-shrink: 0;
|
||||
margin-left: var(--space-md);
|
||||
}
|
||||
|
||||
/* 表格 */
|
||||
.task-table {
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.task-table .ant-table {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.task-table .ant-table-thead > tr > th {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-table .ant-table-tbody > tr > td {
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.task-table .ant-table-tbody > tr:hover > td {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* 任务 ID */
|
||||
.task-id {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 状态标签 */
|
||||
.task-status-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.task-progress {
|
||||
font-size: var(--font-size-xs);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 当前步骤 */
|
||||
.task-step {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 耗时 */
|
||||
.task-duration {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 时间 */
|
||||
.task-time {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.task-retry-btn {
|
||||
color: var(--primary-500);
|
||||
}
|
||||
|
||||
.task-retry-btn:hover {
|
||||
color: var(--primary-600);
|
||||
}
|
||||
|
||||
.task-action-placeholder {
|
||||
color: var(--text-disabled, var(--text-secondary));
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* 展开行 - 错误详情 */
|
||||
.task-error-detail {
|
||||
padding: var(--space-md);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.task-error-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--error-500, #ef4444);
|
||||
}
|
||||
|
||||
.task-error-icon {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.task-error-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.task-error-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.task-error-label {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.task-error-message {
|
||||
color: var(--error-500, #ef4444);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.task-error-stack {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.task-error-stack pre {
|
||||
margin: var(--space-xs) 0 0 0;
|
||||
padding: var(--space-sm);
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
overflow-x: auto;
|
||||
max-height: 200px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.task-expand-empty {
|
||||
padding: var(--space-md);
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.task-empty {
|
||||
padding: var(--space-2xl) 0;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-empty .anticon {
|
||||
font-size: 48px;
|
||||
margin-bottom: var(--space-md);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.task-empty p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
/* 错误状态 */
|
||||
.task-error {
|
||||
padding: var(--space-2xl);
|
||||
text-align: center;
|
||||
color: var(--error-500, #ef4444);
|
||||
}
|
||||
|
||||
.task-error .anticon {
|
||||
font-size: 48px;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.task-error p {
|
||||
margin: 0 0 var(--space-md) 0;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.task-center {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.task-filters {
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.task-type-filter {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-type-filter .ant-select {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.task-status-tabs .ant-tabs-nav {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.task-status-tabs .ant-tabs-tab {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,106 @@
|
||||
/**
|
||||
* 模板库页面(升级版)— V21 设计系统
|
||||
* 对接后端模板管理 API:
|
||||
* - 分页查询(page/page_size/category/keyword/duration_range)
|
||||
* - 模板详情(素材规则、字幕样式、BGM、比例等参数配置)
|
||||
* - 复制模板 / 从模板生成剪辑计划
|
||||
* - 卡片网格布局 + 类型筛选 + 搜索 + 收藏
|
||||
* 任务 2.13:模板类型分类展示、模板预览功能、创建 EditPlan 入口
|
||||
*
|
||||
* - 按 EditTemplate 类型分组展示
|
||||
* - 缩略图 + 预览弹窗
|
||||
* - 创建 EditPlan 入口 UI
|
||||
* - 使用 useQuery 对接后端真实 API(api/templates.ts, api/editPlans.ts)
|
||||
*/
|
||||
import React, { useState, useMemo, useCallback } from "react";
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button, message, Pagination, Tooltip, Tag, Descriptions } from "antd";
|
||||
import { Button } from "@/components/ui";
|
||||
import {
|
||||
LoadingOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
InboxOutlined,
|
||||
SearchOutlined,
|
||||
CopyOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
getTemplates,
|
||||
getTemplate,
|
||||
toggleFavoriteTemplate,
|
||||
copyTemplate,
|
||||
generateFromTemplate,
|
||||
type TemplateItem,
|
||||
type TemplateListParams,
|
||||
type TemplateSegment,
|
||||
} from "@/api/templates";
|
||||
import { createEditPlan } from "@/api/editPlans";
|
||||
import "./templates.css";
|
||||
|
||||
/* ============================================================
|
||||
* 类型定义
|
||||
* 类型定义(对齐后端 EditTemplate / EditPlan / TemplateClipConfig)
|
||||
* ============================================================ */
|
||||
|
||||
/** 模板片段配置 */
|
||||
interface TemplateClipConfig {
|
||||
id: string;
|
||||
order: number;
|
||||
clipType: string;
|
||||
description: string;
|
||||
duration: number; // 秒
|
||||
}
|
||||
|
||||
/** 模板类型 */
|
||||
type EditTemplateType = "口播" | "种草" | "产品" | "品牌" | "混剪" | "Vlog";
|
||||
|
||||
/** 模板数据(UI 层,映射自后端 TemplateItem) */
|
||||
interface EditTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
type: EditTemplateType;
|
||||
description: string;
|
||||
usageCount: number;
|
||||
isFavorite: boolean;
|
||||
thumbnailGradient: string;
|
||||
scriptContent: string;
|
||||
clipConfigs: TemplateClipConfig[];
|
||||
recommendedDuration: number; // 秒
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 映射:后端 TemplateItem → 前端 EditTemplate
|
||||
* ============================================================ */
|
||||
|
||||
/** 根据 category 推断模板类型 */
|
||||
const inferTemplateType = (category: string): EditTemplateType => {
|
||||
const map: Record<string, EditTemplateType> = {
|
||||
口播: "口播",
|
||||
种草: "种草",
|
||||
产品: "产品",
|
||||
品牌: "品牌",
|
||||
混剪: "混剪",
|
||||
Vlog: "Vlog",
|
||||
};
|
||||
return map[category] ?? "口播";
|
||||
};
|
||||
|
||||
/** 根据 category 生成占位渐变色 */
|
||||
const gradientForCategory = (category: string): string => {
|
||||
const gradients: Record<string, string> = {
|
||||
口播: "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
||||
种草: "linear-gradient(135deg, #10b981, #059669)",
|
||||
产品: "linear-gradient(135deg, #0ea5e9, #0284c7)",
|
||||
品牌: "linear-gradient(135deg, #f59e0b, #d97706)",
|
||||
混剪: "linear-gradient(135deg, #8b5cf6, #6d28d9)",
|
||||
Vlog: "linear-gradient(135deg, #ec4899, #db2777)",
|
||||
};
|
||||
return gradients[category] ?? "linear-gradient(135deg, #6366f1, #8b5cf6)";
|
||||
};
|
||||
|
||||
/** 将后端 TemplateItem 映射为前端 EditTemplate */
|
||||
const mapTemplateItemToEditTemplate = (item: TemplateItem): EditTemplate => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: inferTemplateType(item.category),
|
||||
description: item.description ?? "",
|
||||
usageCount: 0,
|
||||
isFavorite: item.is_favorite ?? false,
|
||||
thumbnailGradient: gradientForCategory(item.category),
|
||||
scriptContent: "",
|
||||
clipConfigs: [],
|
||||
recommendedDuration: item.target_duration ?? 0,
|
||||
tags: [item.category],
|
||||
});
|
||||
|
||||
/* ============================================================
|
||||
* 模板类型配置
|
||||
* ============================================================ */
|
||||
@@ -56,106 +120,98 @@ const TEMPLATE_TYPES: Array<{
|
||||
{ type: "Vlog", label: "Vlog", icon: "📹", color: "#ec4899" },
|
||||
];
|
||||
|
||||
/** 时长筛选选项 */
|
||||
const DURATION_OPTIONS: Array<{
|
||||
value: "" | "short" | "medium" | "long";
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "", label: "全部时长" },
|
||||
{ value: "short", label: "30秒以内" },
|
||||
{ value: "medium", label: "30秒-2分钟" },
|
||||
{ value: "long", label: "2分钟以上" },
|
||||
];
|
||||
|
||||
/* ============================================================
|
||||
* 辅助函数
|
||||
* ============================================================ */
|
||||
|
||||
/** 获取类型对应颜色 */
|
||||
const getTypeColor = (type: string): string => {
|
||||
const getTypeColor = (type: EditTemplateType): string => {
|
||||
const found = TEMPLATE_TYPES.find((t) => t.type === type);
|
||||
return found?.color ?? "#6366f1";
|
||||
};
|
||||
|
||||
/** 根据 category 生成占位渐变色 */
|
||||
const gradientForCategory = (category: string): string => {
|
||||
const gradients: Record<string, string> = {
|
||||
口播: "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
||||
种草: "linear-gradient(135deg, #10b981, #059669)",
|
||||
产品: "linear-gradient(135deg, #0ea5e9, #0284c7)",
|
||||
品牌: "linear-gradient(135deg, #f59e0b, #d97706)",
|
||||
混剪: "linear-gradient(135deg, #8b5cf6, #6d28d9)",
|
||||
Vlog: "linear-gradient(135deg, #ec4899, #db2777)",
|
||||
/** 获取片段类型标签 */
|
||||
const getClipTypeLabel = (clipType: string): string => clipType;
|
||||
|
||||
/** 获取片段类型颜色 */
|
||||
const getClipTypeColor = (clipType: string): string => {
|
||||
const colorMap: Record<string, string> = {
|
||||
开场: "#6366f1",
|
||||
产品展示: "#0ea5e9",
|
||||
卖点讲解: "#10b981",
|
||||
结尾: "#f59e0b",
|
||||
场景引入: "#8b5cf6",
|
||||
产品体验: "#ec4899",
|
||||
效果对比: "#14b8a6",
|
||||
总结推荐: "#f59e0b",
|
||||
悬念开场: "#6366f1",
|
||||
产品全景: "#0ea5e9",
|
||||
功能演示: "#10b981",
|
||||
技术规格: "#64748b",
|
||||
品牌起源: "#f59e0b",
|
||||
发展历程: "#0ea5e9",
|
||||
核心理念: "#8b5cf6",
|
||||
未来展望: "#10b981",
|
||||
知识点1: "#6366f1",
|
||||
知识点2: "#8b5cf6",
|
||||
痛点: "#ef4444",
|
||||
产品引入: "#10b981",
|
||||
使用展示: "#0ea5e9",
|
||||
效果: "#f59e0b",
|
||||
产品亮相: "#6366f1",
|
||||
外观对比: "#0ea5e9",
|
||||
性能测试: "#10b981",
|
||||
总结: "#f59e0b",
|
||||
悬念: "#6366f1",
|
||||
亮点: "#10b981",
|
||||
福利: "#f59e0b",
|
||||
引导: "#0ea5e9",
|
||||
高能开场: "#ef4444",
|
||||
过渡: "#64748b",
|
||||
高潮: "#ec4899",
|
||||
早安: "#f59e0b",
|
||||
出门: "#10b981",
|
||||
日常: "#0ea5e9",
|
||||
晚安: "#8b5cf6",
|
||||
};
|
||||
return gradients[category] ?? "linear-gradient(135deg, #6366f1, #8b5cf6)";
|
||||
return colorMap[clipType] ?? "#6366f1";
|
||||
};
|
||||
|
||||
/** 格式化时长 */
|
||||
const formatDuration = (seconds: number): string => {
|
||||
if (seconds <= 0) return "0秒";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
if (m === 0) return `${s}秒`;
|
||||
return `${m}分${s > 0 ? `${s}秒` : ""}`;
|
||||
};
|
||||
|
||||
/** 格式化配置对象为可读文本 */
|
||||
const formatConfig = (config?: object): string => {
|
||||
if (!config || Object.keys(config).length === 0) return "默认";
|
||||
const c = config as Record<string, unknown>;
|
||||
const parts: string[] = [];
|
||||
if (c.font_size) parts.push(`字号: ${c.font_size}`);
|
||||
if (c.font_family) parts.push(`字体: ${c.font_family}`);
|
||||
if (c.color) parts.push(`颜色: ${c.color}`);
|
||||
if (c.position) parts.push(`位置: ${c.position}`);
|
||||
if (c.volume !== undefined) parts.push(`音量: ${c.volume}%`);
|
||||
if (c.name) parts.push(String(c.name));
|
||||
return parts.length > 0 ? parts.join(" / ") : JSON.stringify(config);
|
||||
};
|
||||
|
||||
/** 素材类型标签 */
|
||||
const MATERIAL_TYPE_LABELS: Record<string, string> = {
|
||||
video: "视频",
|
||||
image: "图片",
|
||||
audio: "音频",
|
||||
voiceover: "配音",
|
||||
subtitle: "字幕",
|
||||
null: "不限",
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* 模板详情弹窗组件
|
||||
* 预览弹窗组件
|
||||
* ============================================================ */
|
||||
|
||||
interface TemplateDetailModalProps {
|
||||
template: TemplateItem;
|
||||
interface TemplatePreviewModalProps {
|
||||
template: EditTemplate;
|
||||
isFavorite: boolean;
|
||||
onClose: () => void;
|
||||
onToggleFavorite: (id: string) => void;
|
||||
onUse: (template: TemplateItem) => void;
|
||||
onCopy: (template: TemplateItem) => void;
|
||||
onUse: (template: EditTemplate) => void;
|
||||
}
|
||||
|
||||
const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
|
||||
template,
|
||||
isFavorite,
|
||||
onClose,
|
||||
onToggleFavorite,
|
||||
onUse,
|
||||
onCopy,
|
||||
}) => {
|
||||
const segments = template.segments ?? [];
|
||||
const totalSegmentDuration = segments.reduce(
|
||||
(sum, s) => sum + (s.duration_min + s.duration_max) / 2,
|
||||
const totalDuration = template.clipConfigs.reduce(
|
||||
(sum, c) => sum + c.duration,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="xx-template-modal-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="xx-template-modal xx-template-modal-wide"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="xx-template-modal" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
className="xx-template-modal-close"
|
||||
@@ -168,23 +224,15 @@ const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
{/* 预览区域 */}
|
||||
<div
|
||||
className="xx-template-modal-preview"
|
||||
style={{ background: gradientForCategory(template.category) }}
|
||||
style={{ background: template.thumbnailGradient }}
|
||||
>
|
||||
{template.thumbnail_url ? (
|
||||
<img
|
||||
src={template.thumbnail_url}
|
||||
alt={template.name}
|
||||
className="xx-template-modal-thumb-img"
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-template-modal-preview-content">
|
||||
<span className="xx-template-preview-icon">
|
||||
{TEMPLATE_TYPES.find((t) => t.type === template.category)
|
||||
?.icon ?? "📋"}
|
||||
</span>
|
||||
<span className="xx-template-preview-title">{template.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-template-modal-preview-content">
|
||||
<span className="xx-template-preview-icon">
|
||||
{TEMPLATE_TYPES.find((t) => t.type === template.type)?.icon ??
|
||||
"📋"}
|
||||
</span>
|
||||
<span className="xx-template-preview-title">{template.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
@@ -195,12 +243,12 @@ const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
<span
|
||||
className="xx-template-modal-type-badge"
|
||||
style={{
|
||||
color: getTypeColor(template.category),
|
||||
background: `${getTypeColor(template.category)}18`,
|
||||
color: getTypeColor(template.type),
|
||||
background: `${getTypeColor(template.type)}18`,
|
||||
}}
|
||||
>
|
||||
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon}{" "}
|
||||
{template.category}
|
||||
{TEMPLATE_TYPES.find((t) => t.type === template.type)?.icon}{" "}
|
||||
{template.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -208,128 +256,66 @@ const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
<p className="xx-template-modal-desc">{template.description}</p>
|
||||
|
||||
{/* 标签 */}
|
||||
{(template.tags?.length ?? 0) > 0 && (
|
||||
<div className="xx-template-modal-tags">
|
||||
{template.tags!.map((tag) => (
|
||||
<span key={tag} className="xx-template-modal-tag">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
<div className="xx-template-modal-tags">
|
||||
{template.tags.map((tag) => (
|
||||
<span key={tag} className="xx-template-modal-tag">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 脚本内容 */}
|
||||
{template.scriptContent && (
|
||||
<div className="xx-template-modal-section">
|
||||
<h4>📝 脚本内容</h4>
|
||||
<pre className="xx-template-modal-script">
|
||||
{template.scriptContent}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 基本信息 */}
|
||||
<Descriptions
|
||||
column={2}
|
||||
size="small"
|
||||
className="xx-template-modal-desc-table"
|
||||
items={[
|
||||
{
|
||||
key: "duration",
|
||||
label: "目标时长",
|
||||
children: formatDuration(template.target_duration),
|
||||
},
|
||||
{
|
||||
key: "clips",
|
||||
label: "片段数量",
|
||||
children: `${template.clip_count} 个`,
|
||||
},
|
||||
{
|
||||
key: "ratio",
|
||||
label: "视频比例",
|
||||
children: template.aspect_ratio ?? "16:9",
|
||||
},
|
||||
{
|
||||
key: "usage",
|
||||
label: "使用次数",
|
||||
children: `${template.usage_count ?? 0} 次`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 素材规则(片段配置) */}
|
||||
{segments.length > 0 && (
|
||||
{/* 视频结构 */}
|
||||
{template.clipConfigs.length > 0 && (
|
||||
<div className="xx-template-modal-section">
|
||||
<h4>🎬 素材规则</h4>
|
||||
<h4>🎬 视频结构</h4>
|
||||
<div className="xx-template-modal-clip-list">
|
||||
{segments
|
||||
.sort((a, b) => a.segment_order - b.segment_order)
|
||||
.map((seg: TemplateSegment, idx: number) => (
|
||||
<div
|
||||
key={seg.id ?? idx}
|
||||
className="xx-template-modal-clip-item"
|
||||
>
|
||||
<span className="xx-template-modal-clip-order">
|
||||
#{seg.segment_order}
|
||||
</span>
|
||||
{template.clipConfigs
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((clip) => (
|
||||
<div key={clip.id} className="xx-template-modal-clip-item">
|
||||
<span
|
||||
className="xx-template-modal-clip-badge"
|
||||
style={{
|
||||
color: seg.material_type
|
||||
? getTypeColor(seg.material_type)
|
||||
: "#64748b",
|
||||
background: seg.material_type
|
||||
? `${getTypeColor(seg.material_type)}18`
|
||||
: "#f1f5f9",
|
||||
color: getClipTypeColor(clip.clipType),
|
||||
background: `${getClipTypeColor(clip.clipType)}18`,
|
||||
}}
|
||||
>
|
||||
{MATERIAL_TYPE_LABELS[seg.material_type ?? "null"] ??
|
||||
seg.material_type ??
|
||||
"不限"}
|
||||
{getClipTypeLabel(clip.clipType)}
|
||||
</span>
|
||||
<span className="xx-template-modal-clip-desc">
|
||||
{seg.description || `片段 ${seg.segment_order}`}
|
||||
{clip.description}
|
||||
</span>
|
||||
<span className="xx-template-modal-clip-duration">
|
||||
{clip.duration}秒
|
||||
</span>
|
||||
<Tooltip
|
||||
title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}秒`}
|
||||
>
|
||||
<span className="xx-template-modal-clip-duration">
|
||||
{seg.duration_min}-{seg.duration_max}秒
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="xx-template-modal-total-duration">
|
||||
预估总时长:{formatDuration(Math.round(totalSegmentDuration))}
|
||||
总时长:{formatDuration(totalDuration)}
|
||||
{template.recommendedDuration !== totalDuration && (
|
||||
<span>
|
||||
{" "}
|
||||
· 推荐时长:{formatDuration(template.recommendedDuration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 样式配置 */}
|
||||
<div className="xx-template-modal-section">
|
||||
<h4>🎨 样式配置</h4>
|
||||
<div className="xx-template-modal-style-grid">
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">字幕样式</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{formatConfig(template.subtitle_config)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">标题样式</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{formatConfig(template.title_config)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">BGM 配置</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{formatConfig(template.bgm_config)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">视频比例</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{template.aspect_ratio ?? "16:9"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<div className="xx-template-modal-stats">
|
||||
<span>已使用 {template.usage_count ?? 0} 次</span>
|
||||
<span>已使用 {template.usageCount} 次</span>
|
||||
<button
|
||||
className={`xx-template-modal-fav-btn${isFavorite ? " is-favorite" : ""}`}
|
||||
onClick={() => onToggleFavorite(template.id)}
|
||||
@@ -340,15 +326,15 @@ const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="xx-template-modal-actions">
|
||||
<Button icon={<CopyOutlined />} onClick={() => onCopy(template)}>
|
||||
复制模板
|
||||
<Button buttonType="ghost" buttonSize="md" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ThunderboltOutlined />}
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={() => onUse(template)}
|
||||
>
|
||||
使用此模板生成
|
||||
使用此模板
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -362,11 +348,11 @@ const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
* ============================================================ */
|
||||
|
||||
interface TemplateCardProps {
|
||||
template: TemplateItem;
|
||||
template: EditTemplate;
|
||||
isFavorite: boolean;
|
||||
onPreview: (template: TemplateItem) => void;
|
||||
onPreview: (template: EditTemplate) => void;
|
||||
onToggleFavorite: (id: string, e: React.MouseEvent) => void;
|
||||
onUse: (template: TemplateItem) => void;
|
||||
onUse: (template: EditTemplate) => void;
|
||||
}
|
||||
|
||||
const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
@@ -380,29 +366,15 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
<div className="xx-template-card" onClick={() => onPreview(template)}>
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-template-thumb">
|
||||
{template.thumbnail_url ? (
|
||||
<img
|
||||
src={template.thumbnail_url}
|
||||
alt={template.name}
|
||||
className="xx-template-thumb-img"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="xx-template-thumb-bg"
|
||||
style={{ background: gradientForCategory(template.category) }}
|
||||
>
|
||||
{(template.description ?? "").slice(0, 80)}
|
||||
{(template.description ?? "").length > 80 ? "..." : ""}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="xx-template-thumb-bg"
|
||||
style={{ background: template.thumbnailGradient }}
|
||||
>
|
||||
{(template.description ?? "").slice(0, 80)}...
|
||||
</div>
|
||||
<div className="xx-template-thumb-overlay" />
|
||||
<div className="xx-template-thumb-name">{template.name}</div>
|
||||
<div className="xx-template-thumb-meta">
|
||||
<span className="xx-template-thumb-duration">
|
||||
{formatDuration(template.target_duration)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-preview-hint">点击查看详情</div>
|
||||
<div className="xx-template-preview-hint">点击预览</div>
|
||||
<button
|
||||
className={`xx-template-fav-btn${isFavorite ? " is-favorite" : ""}`}
|
||||
onClick={(e) => onToggleFavorite(template.id, e)}
|
||||
@@ -418,22 +390,17 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
<span
|
||||
className="xx-template-category-pill"
|
||||
style={{
|
||||
color: getTypeColor(template.category),
|
||||
background: `${getTypeColor(template.category)}18`,
|
||||
color: getTypeColor(template.type),
|
||||
background: `${getTypeColor(template.type)}18`,
|
||||
}}
|
||||
>
|
||||
{template.category}
|
||||
{template.type}
|
||||
</span>
|
||||
{(template.tags ?? []).slice(0, 2).map((tag) => (
|
||||
<Tag key={tag} className="xx-template-tag-pill" bordered={false}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
<p className="xx-template-desc">{template.description ?? ""}</p>
|
||||
<div className="xx-template-meta">
|
||||
<span className="xx-template-usage">
|
||||
已使用 {template.usage_count ?? 0} 次
|
||||
已使用 {template.usageCount} 次
|
||||
</span>
|
||||
<button
|
||||
className="xx-template-use-btn"
|
||||
@@ -457,158 +424,98 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
const TemplateLibrary: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 筛选状态
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [activeType, setActiveType] = useState<EditTemplateType | "全部">(
|
||||
"全部",
|
||||
);
|
||||
const [durationRange, setDurationRange] = useState<
|
||||
"" | "short" | "medium" | "long"
|
||||
>("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(12);
|
||||
|
||||
// 弹窗状态
|
||||
const [previewTemplate, setPreviewTemplate] = useState<TemplateItem | null>(
|
||||
const [previewTemplate, setPreviewTemplate] = useState<EditTemplate | null>(
|
||||
null,
|
||||
);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
// ── 构建查询参数 ──
|
||||
const queryParams: TemplateListParams = useMemo(() => {
|
||||
const params: TemplateListParams = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
};
|
||||
if (activeType !== "全部") params.category = activeType;
|
||||
if (searchText.trim()) params.keyword = searchText.trim();
|
||||
if (durationRange) params.duration_range = durationRange;
|
||||
return params;
|
||||
}, [page, pageSize, activeType, searchText, durationRange]);
|
||||
|
||||
// ── 获取模板列表(后端分页 + 筛选) ──
|
||||
// ── 获取模板列表 ──
|
||||
const {
|
||||
data: templateData,
|
||||
data: apiTemplates = [],
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["templates", queryParams],
|
||||
queryFn: () => getTemplates(queryParams),
|
||||
staleTime: 30_000,
|
||||
} = useQuery<TemplateItem[], Error>({
|
||||
queryKey: ["templates"],
|
||||
queryFn: getTemplates,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const templates = templateData?.items ?? [];
|
||||
const totalTemplates = templateData?.total ?? 0;
|
||||
// 将后端数据映射为前端 EditTemplate
|
||||
const templates = useMemo(
|
||||
() => apiTemplates.map(mapTemplateItemToEditTemplate),
|
||||
[apiTemplates],
|
||||
);
|
||||
|
||||
// ── 收藏 mutation ──
|
||||
const favMutation = useMutation({
|
||||
mutationFn: toggleFavoriteTemplate,
|
||||
onSuccess: (_data, templateId) => {
|
||||
// 乐观更新:刷新模板列表
|
||||
queryClient.invalidateQueries({ queryKey: ["templates"] });
|
||||
// 同时更新当前预览(如果有)
|
||||
if (previewTemplate && previewTemplate.id === templateId) {
|
||||
setPreviewTemplate((prev) =>
|
||||
prev ? { ...prev, is_favorite: !prev.is_favorite } : prev,
|
||||
prev ? { ...prev, isFavorite: !prev.isFavorite } : prev,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ── 复制模板 mutation ──
|
||||
const copyMutation = useMutation({
|
||||
mutationFn: copyTemplate,
|
||||
onSuccess: (data) => {
|
||||
message.success(`模板「${data.name}」已复制到「我的模板」`);
|
||||
queryClient.invalidateQueries({ queryKey: ["templates"] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("复制模板失败,请稍后重试");
|
||||
},
|
||||
});
|
||||
|
||||
// ── 从模板生成剪辑计划 mutation ──
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: ({ templateId, name }: { templateId: string; name: string }) =>
|
||||
generateFromTemplate(templateId, { name }),
|
||||
onSuccess: (data) => {
|
||||
message.success(`剪辑计划「${data.name}」已创建`);
|
||||
navigate("/app/edit-plans");
|
||||
},
|
||||
onError: () => {
|
||||
message.error("生成剪辑计划失败,请稍后重试");
|
||||
},
|
||||
// ── 创建 EditPlan mutation ──
|
||||
const createPlanMutation = useMutation({
|
||||
mutationFn: (params: { template_id: string; name: string }) =>
|
||||
createEditPlan(params),
|
||||
});
|
||||
|
||||
/** 切换收藏 */
|
||||
const toggleFavorite = useCallback(
|
||||
(id: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
favMutation.mutate(id);
|
||||
},
|
||||
[favMutation],
|
||||
);
|
||||
const toggleFavorite = (id: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
favMutation.mutate(id);
|
||||
};
|
||||
|
||||
/** 点击卡片 → 获取详情并展示弹窗 */
|
||||
const handlePreview = useCallback(async (template: TemplateItem) => {
|
||||
setDetailLoading(true);
|
||||
setPreviewTemplate(template);
|
||||
try {
|
||||
const detail = await getTemplate(template.id);
|
||||
setPreviewTemplate(detail);
|
||||
} catch {
|
||||
// 详情加载失败时使用列表数据
|
||||
message.warning("模板详情加载失败,显示摘要信息");
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
/** 过滤模板 */
|
||||
const filtered = useMemo(() => {
|
||||
return templates.filter((t) => {
|
||||
const matchSearch =
|
||||
!searchText ||
|
||||
t.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
(t.description ?? "")
|
||||
.toLowerCase()
|
||||
.includes(searchText.toLowerCase()) ||
|
||||
t.tags.some((tag) =>
|
||||
tag.toLowerCase().includes(searchText.toLowerCase()),
|
||||
);
|
||||
const matchType = activeType === "全部" || t.type === activeType;
|
||||
return matchSearch && matchType;
|
||||
});
|
||||
}, [searchText, activeType, templates]);
|
||||
|
||||
/** 按类型分组 */
|
||||
const groupedTemplates = useMemo(() => {
|
||||
const groups: Record<string, EditTemplate[]> = {};
|
||||
for (const tpl of filtered) {
|
||||
if (!groups[tpl.type]) groups[tpl.type] = [];
|
||||
groups[tpl.type].push(tpl);
|
||||
}
|
||||
}, []);
|
||||
return groups;
|
||||
}, [filtered]);
|
||||
|
||||
/** 复制模板 */
|
||||
const handleCopy = useCallback(
|
||||
(template: TemplateItem) => {
|
||||
copyMutation.mutate(template.id);
|
||||
},
|
||||
[copyMutation],
|
||||
);
|
||||
|
||||
/** 使用模板 → 生成剪辑计划 */
|
||||
const handleUse = useCallback(
|
||||
(template: TemplateItem) => {
|
||||
generateMutation.mutate({
|
||||
templateId: template.id,
|
||||
/** 使用模板 → 创建 EditPlan */
|
||||
const handleUseTemplate = async (template: EditTemplate) => {
|
||||
try {
|
||||
await createPlanMutation.mutateAsync({
|
||||
template_id: template.id,
|
||||
name: `基于「${template.name}」的剪辑计划`,
|
||||
});
|
||||
},
|
||||
[generateMutation, navigate],
|
||||
);
|
||||
|
||||
/** 搜索防抖处理 */
|
||||
const handleSearchChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchText(e.target.value);
|
||||
setPage(1);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** 切换分类 */
|
||||
const handleCategoryChange = useCallback(
|
||||
(type: EditTemplateType | "全部") => {
|
||||
setActiveType(type);
|
||||
setPage(1);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** 切换时长筛选 */
|
||||
const handleDurationChange = useCallback(
|
||||
(value: "" | "short" | "medium" | "long") => {
|
||||
setDurationRange(value);
|
||||
setPage(1);
|
||||
},
|
||||
[],
|
||||
);
|
||||
navigate("/app/editing-planner");
|
||||
} catch (err) {
|
||||
console.error("[TemplateLibrary] createEditPlan failed:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Loading 状态 ──
|
||||
if (isLoading) {
|
||||
@@ -647,12 +554,16 @@ const TemplateLibrary: React.FC = () => {
|
||||
<h2>模板库</h2>
|
||||
<p>选择模板快速创建剪辑计划,支持自定义修改</p>
|
||||
</div>
|
||||
<Button type="primary" onClick={() => navigate("/app/editing-planner")}>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={() => navigate("/app/editing-planner")}
|
||||
>
|
||||
+ 创建模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── 工具栏:搜索 + 类型按钮组 + 时长筛选 ─────────────── */}
|
||||
{/* ── 工具栏:搜索 + 类型按钮组 ─────────────────────────── */}
|
||||
<div className="xx-templates-toolbar">
|
||||
<div className="xx-templates-search">
|
||||
<span className="xx-templates-search-icon">
|
||||
@@ -663,7 +574,7 @@ const TemplateLibrary: React.FC = () => {
|
||||
type="text"
|
||||
placeholder="搜索模板名称、描述或标签..."
|
||||
value={searchText}
|
||||
onChange={handleSearchChange}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-templates-categories">
|
||||
@@ -671,94 +582,90 @@ const TemplateLibrary: React.FC = () => {
|
||||
<button
|
||||
key={cat.type}
|
||||
className={`xx-templates-cat-btn${activeType === cat.type ? " active" : ""}`}
|
||||
onClick={() => handleCategoryChange(cat.type)}
|
||||
onClick={() => setActiveType(cat.type)}
|
||||
>
|
||||
<span className="xx-templates-cat-icon">{cat.icon}</span>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* 时长筛选 */}
|
||||
<div className="xx-templates-duration-filter">
|
||||
{DURATION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`xx-templates-duration-btn${durationRange === opt.value ? " active" : ""}`}
|
||||
onClick={() => handleDurationChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 模板展示区 ────────────────────────────────────────── */}
|
||||
{templates.length === 0 ? (
|
||||
{filtered.length === 0 ? (
|
||||
<div className="xx-templates-empty">
|
||||
<div className="xx-templates-empty-icon">
|
||||
<InboxOutlined />
|
||||
</div>
|
||||
<h3>
|
||||
{searchText || activeType !== "全部" || durationRange
|
||||
{searchText || activeType !== "全部"
|
||||
? "未找到匹配的模板"
|
||||
: "暂无模板"}
|
||||
</h3>
|
||||
<p>
|
||||
{searchText || activeType !== "全部" || durationRange
|
||||
{searchText || activeType !== "全部"
|
||||
? "试试调整搜索条件或切换类型"
|
||||
: "点击上方「创建模板」开始创作"}
|
||||
</p>
|
||||
</div>
|
||||
) : activeType === "全部" ? (
|
||||
/* 全部类型 → 按类型分组展示 */
|
||||
<div className="xx-templates-grouped">
|
||||
{Object.entries(groupedTemplates).map(([type, tpls]) => {
|
||||
const typeConfig = TEMPLATE_TYPES.find((t) => t.type === type);
|
||||
return (
|
||||
<div key={type} className="xx-templates-group">
|
||||
<div className="xx-templates-group-header">
|
||||
<span className="xx-templates-group-icon">
|
||||
{typeConfig?.icon ?? "📋"}
|
||||
</span>
|
||||
<h3>{type}</h3>
|
||||
<span className="xx-templates-group-count">
|
||||
{tpls.length} 个模板
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-templates-grid">
|
||||
{tpls.map((tpl) => (
|
||||
<TemplateCard
|
||||
key={tpl.id}
|
||||
template={tpl}
|
||||
isFavorite={tpl.isFavorite}
|
||||
onPreview={setPreviewTemplate}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
onUse={handleUseTemplate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="xx-templates-grid">
|
||||
{templates.map((tpl) => (
|
||||
<TemplateCard
|
||||
key={tpl.id}
|
||||
template={tpl}
|
||||
isFavorite={tpl.is_favorite ?? false}
|
||||
onPreview={handlePreview}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
onUse={handleUse}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalTemplates > pageSize && (
|
||||
<div className="xx-templates-pagination">
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={totalTemplates}
|
||||
showSizeChanger={false}
|
||||
showQuickJumper
|
||||
showTotal={(total) => `共 ${total} 个模板`}
|
||||
onChange={(p) => setPage(p)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
/* 单类型 → 平铺网格 */
|
||||
<div className="xx-templates-grid">
|
||||
{filtered.map((tpl) => (
|
||||
<TemplateCard
|
||||
key={tpl.id}
|
||||
template={tpl}
|
||||
isFavorite={tpl.isFavorite}
|
||||
onPreview={setPreviewTemplate}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
onUse={handleUseTemplate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 详情弹窗 ──────────────────────────────────────────── */}
|
||||
{/* ── 预览弹窗 ──────────────────────────────────────────── */}
|
||||
{previewTemplate && (
|
||||
<TemplateDetailModal
|
||||
<TemplatePreviewModal
|
||||
template={previewTemplate}
|
||||
isFavorite={previewTemplate.is_favorite ?? false}
|
||||
isFavorite={previewTemplate.isFavorite}
|
||||
onClose={() => setPreviewTemplate(null)}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
onUse={handleUse}
|
||||
onCopy={handleCopy}
|
||||
onUse={handleUseTemplate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 详情加载中的提示(可选覆盖层) */}
|
||||
{detailLoading && previewTemplate && (
|
||||
<div className="xx-template-detail-loading">
|
||||
<LoadingOutlined /> 加载中...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -135,13 +135,6 @@ export const router = createBrowserRouter([
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "tasks",
|
||||
lazy: () =>
|
||||
import("@/pages/tasks/TaskCenter").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "editing-planner",
|
||||
lazy: () =>
|
||||
@@ -156,13 +149,6 @@ export const router = createBrowserRouter([
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "edit-plans",
|
||||
lazy: () =>
|
||||
import("@/pages/edit-plans/EditPlans").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "voice-clone",
|
||||
lazy: () =>
|
||||
|
||||
@@ -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: %s,ASR 自动字幕功能未启用", 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())
|
||||
@@ -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.0,BGM 已经在预处理阶段调好了音量。
|
||||
amix 会自动归一化,需要用 volume 补偿。
|
||||
"""
|
||||
# 使用 amix:inputs=2,duration=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 gain,1.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,697 +0,0 @@
|
||||
"""视频拼接/合并引擎 — 多段视频按顺序拼接成一个成片.
|
||||
|
||||
基于 FFmpeg 实现两种拼接模式:
|
||||
1. **concat demuxer(stream copy)**:最快,所有视频编码参数必须一致
|
||||
2. **concat filter(重新编码)**:更灵活,支持不同分辨率/编码/帧率的视频
|
||||
|
||||
使用场景:
|
||||
- 多段素材按顺序合并成一个视频
|
||||
- 视频分割后重新拼接
|
||||
- 片头 + 正片 + 片尾拼接
|
||||
|
||||
降级策略:
|
||||
- 优先尝试 stream copy(速度快、无质量损失)
|
||||
- 参数不一致时自动降级到 concat filter
|
||||
- 某段视频失败时跳过,不阻断整体拼接
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, probe_video_info, run_ffmpeg
|
||||
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
MAX_CONCAT_SEGMENTS = 50 # 最大拼接段数(安全上限,防止OOM)
|
||||
|
||||
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"}
|
||||
|
||||
# concat demuxer 要求一致的参数列表
|
||||
CONCAT_DEMUXER_REQUIRED_PARAMS = [
|
||||
"codec_name", # 视频编码
|
||||
"width", # 宽度
|
||||
"height", # 高度
|
||||
"r_frame_rate", # 帧率
|
||||
"pix_fmt", # 像素格式
|
||||
"sample_rate", # 音频采样率
|
||||
"channels", # 音频声道数
|
||||
"audio_codec", # 音频编码
|
||||
]
|
||||
|
||||
|
||||
# ── 拼接片段配置 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConcatSegment:
|
||||
"""单个拼接片段."""
|
||||
|
||||
video_path: str # 视频文件路径
|
||||
start_time: float = 0.0 # 开始时间(秒),从视频的哪个位置开始取
|
||||
duration: float = 0.0 # 持续时长(秒),0表示取到末尾
|
||||
has_audio: bool = True # 是否包含音频
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, seg: dict) -> "ConcatSegment":
|
||||
"""从字典创建拼接片段,带安全类型转换."""
|
||||
try:
|
||||
start_time = max(0.0, float(seg.get("start_time", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
start_time = 0.0
|
||||
|
||||
try:
|
||||
duration = max(0.0, float(seg.get("duration", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
duration = 0.0
|
||||
|
||||
return cls(
|
||||
video_path=str(seg.get("video_path", "")),
|
||||
start_time=start_time,
|
||||
duration=duration,
|
||||
has_audio=bool(seg.get("has_audio", True)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConcatConfig:
|
||||
"""视频拼接配置."""
|
||||
|
||||
segments: list[ConcatSegment] = field(default_factory=list)
|
||||
output_width: int = 0 # 输出宽度(0=自动取第一段)
|
||||
output_height: int = 0 # 输出高度(0=自动取第一段)
|
||||
output_fps: float = 0.0 # 输出帧率(0=自动取第一段)
|
||||
force_reencode: bool = False # 强制重新编码(不用 stream copy)
|
||||
transition: str = "none" # 转场效果(none/crossfade)- 预留
|
||||
transition_duration: float = 0.3 # 转场时长
|
||||
|
||||
@classmethod
|
||||
def from_config_dict(cls, config: dict | None) -> "ConcatConfig":
|
||||
"""从配置字典创建 ConcatConfig."""
|
||||
if not config or not isinstance(config, dict):
|
||||
return cls()
|
||||
|
||||
segments_raw = config.get("segments", [])
|
||||
segments: list[ConcatSegment] = []
|
||||
|
||||
if isinstance(segments_raw, list):
|
||||
for s in segments_raw:
|
||||
if isinstance(s, dict) and s.get("video_path"):
|
||||
try:
|
||||
seg = ConcatSegment.from_dict(s)
|
||||
if seg.video_path:
|
||||
segments.append(seg)
|
||||
except Exception:
|
||||
logger.warning("[concat] skip invalid segment: %s", s)
|
||||
continue
|
||||
|
||||
try:
|
||||
output_width = max(0, int(config.get("output_width", 0)))
|
||||
except (TypeError, ValueError):
|
||||
output_width = 0
|
||||
|
||||
try:
|
||||
output_height = max(0, int(config.get("output_height", 0)))
|
||||
except (TypeError, ValueError):
|
||||
output_height = 0
|
||||
|
||||
try:
|
||||
output_fps = max(0.0, float(config.get("output_fps", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
output_fps = 0.0
|
||||
|
||||
return cls(
|
||||
segments=segments,
|
||||
output_width=output_width,
|
||||
output_height=output_height,
|
||||
output_fps=output_fps,
|
||||
force_reencode=bool(config.get("force_reencode", False)),
|
||||
transition=str(config.get("transition", "none")),
|
||||
transition_duration=max(0.1, float(config.get("transition_duration", 0.3))),
|
||||
)
|
||||
|
||||
@property
|
||||
def has_effect(self) -> bool:
|
||||
"""是否有有效片段需要拼接."""
|
||||
return len([s for s in self.segments if s.video_path]) >= 2
|
||||
|
||||
@property
|
||||
def total_segments(self) -> int:
|
||||
"""有效片段数量."""
|
||||
return len([s for s in self.segments if s.video_path])
|
||||
|
||||
|
||||
# ── 路径安全校验 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _validate_video_path(video_path: str, work_dir: Path) -> None:
|
||||
"""校验视频文件路径安全性.
|
||||
|
||||
规则:
|
||||
- local:// schema → 必须在 work_dir 内
|
||||
- 相对路径 → 必须在 work_dir 内
|
||||
- 绝对路径 → 必须在允许目录白名单内
|
||||
- 扩展名必须是视频格式
|
||||
|
||||
Raises:
|
||||
PathSecurityError: 路径不安全
|
||||
"""
|
||||
if not video_path or not isinstance(video_path, str):
|
||||
raise PathSecurityError("视频路径不能为空")
|
||||
|
||||
# 本地路径(local:// 或相对路径 / 绝对路径)
|
||||
if video_path.startswith("local://") or not video_path.startswith(("http://", "https://", "oss://")):
|
||||
is_abs = video_path.startswith("/") and not video_path.startswith("local://")
|
||||
resolved_path = safe_resolve_path(
|
||||
video_path,
|
||||
work_dir,
|
||||
allow_outside=is_abs,
|
||||
allowed_extensions=ALLOWED_VIDEO_EXTENSIONS,
|
||||
)
|
||||
# 绝对路径额外检查白名单目录(用realpath规范化后的真实路径比较,防止 ../ 遍历绕过)
|
||||
if is_abs:
|
||||
resolved_work_dir = work_dir.resolve()
|
||||
try:
|
||||
resolved_path.relative_to(resolved_work_dir)
|
||||
except ValueError:
|
||||
if not is_in_allowed_dirs(resolved_path):
|
||||
raise PathSecurityError(f"视频路径不在允许目录内: {video_path[:80]}")
|
||||
# URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责)
|
||||
# 但检查扩展名
|
||||
else:
|
||||
path_part = video_path.split("?")[0].split("#")[0]
|
||||
ext = Path(path_part).suffix.lower()
|
||||
if ext and ext not in ALLOWED_VIDEO_EXTENSIONS:
|
||||
raise PathSecurityError(f"不允许的视频文件类型: {ext}")
|
||||
|
||||
|
||||
# ── 视频拼接引擎 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ConcatEngine:
|
||||
"""视频拼接引擎 — 支持 stream copy 和重新编码两种模式."""
|
||||
|
||||
def __init__(self, work_dir: Path):
|
||||
self.work_dir = work_dir
|
||||
self.work_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ── 主入口 ────────────────────────────────────────────────────────
|
||||
|
||||
def concat_videos(
|
||||
self,
|
||||
config: ConcatConfig,
|
||||
output_path: Path,
|
||||
) -> Path:
|
||||
"""拼接多段视频.
|
||||
|
||||
自动选择最优拼接策略:
|
||||
1. 所有片段参数一致 → concat demuxer(stream copy,最快)
|
||||
2. 参数不一致或有裁剪 → concat filter(重新编码)
|
||||
|
||||
Args:
|
||||
config: 拼接配置
|
||||
output_path: 输出文件路径
|
||||
|
||||
Returns:
|
||||
输出文件路径
|
||||
"""
|
||||
valid_segments = [s for s in config.segments if s.video_path]
|
||||
|
||||
if not valid_segments:
|
||||
raise ValueError("No valid video segments to concat")
|
||||
|
||||
# ── 安全校验:段数上限 ──
|
||||
if len(valid_segments) > MAX_CONCAT_SEGMENTS:
|
||||
raise ValueError(f"Too many concat segments: {len(valid_segments)} > {MAX_CONCAT_SEGMENTS}")
|
||||
|
||||
# ── 安全校验:所有视频路径白名单校验 ──
|
||||
safe_segments = []
|
||||
for seg in valid_segments:
|
||||
try:
|
||||
_validate_video_path(seg.video_path, self.work_dir)
|
||||
safe_segments.append(seg)
|
||||
except PathSecurityError as e:
|
||||
logger.warning("[concat] skip segment: path security check failed: %s", e)
|
||||
|
||||
if len(safe_segments) != len(valid_segments):
|
||||
valid_segments = safe_segments
|
||||
config.segments = safe_segments
|
||||
logger.info("[concat] %d segments passed security check", len(safe_segments))
|
||||
|
||||
if not valid_segments:
|
||||
raise ValueError("No valid video segments after security check")
|
||||
|
||||
if len(valid_segments) == 1:
|
||||
# 只有一段,直接复制
|
||||
import shutil
|
||||
|
||||
logger.info("[concat] single segment, copy directly")
|
||||
shutil.copy2(valid_segments[0].video_path, output_path)
|
||||
return output_path
|
||||
|
||||
# 判断能否用 stream copy
|
||||
can_stream_copy = self._can_use_stream_copy(config)
|
||||
|
||||
if can_stream_copy and not config.force_reencode:
|
||||
logger.info("[concat] using concat demuxer (stream copy)")
|
||||
try:
|
||||
return self._concat_demuxer(config, output_path)
|
||||
except Exception as e:
|
||||
logger.warning("[concat] demuxer failed, fallback to filter: %s", e)
|
||||
|
||||
# 降级到 concat filter
|
||||
logger.info("[concat] using concat filter (re-encode)")
|
||||
return self._concat_filter(config, output_path)
|
||||
|
||||
# ── 模式判断 ──────────────────────────────────────────────────────
|
||||
|
||||
def _can_use_stream_copy(self, config: ConcatConfig) -> bool:
|
||||
"""判断是否可以使用 concat demuxer(stream copy).
|
||||
|
||||
条件:
|
||||
1. 所有视频编码参数一致(分辨率、帧率、编码、像素格式)
|
||||
2. 所有音频参数一致(采样率、声道、编码)
|
||||
3. 没有设置 start_time 裁剪(或可以通过 concat demuxer 的 inpoint/outpoint 实现)
|
||||
4. 没有强制重新编码
|
||||
"""
|
||||
if config.force_reencode:
|
||||
return False
|
||||
|
||||
# 如果有转场效果,必须重新编码
|
||||
if config.transition != "none":
|
||||
return False
|
||||
|
||||
# 探测所有视频的参数
|
||||
video_infos = []
|
||||
for seg in config.segments:
|
||||
if not seg.video_path:
|
||||
continue
|
||||
try:
|
||||
info = probe_video_info(seg.video_path)
|
||||
video_infos.append(info)
|
||||
except Exception:
|
||||
logger.warning("[concat] probe failed for %s", seg.video_path[-40:])
|
||||
return False
|
||||
|
||||
if len(video_infos) < 2:
|
||||
return False
|
||||
|
||||
# 检查参数一致性
|
||||
base_info = video_infos[0]
|
||||
for info in video_infos[1:]:
|
||||
for param in CONCAT_DEMUXER_REQUIRED_PARAMS:
|
||||
base_val = base_info.get(param)
|
||||
curr_val = info.get(param)
|
||||
if base_val != curr_val:
|
||||
logger.debug(
|
||||
"[concat] param mismatch: %s (%s vs %s)",
|
||||
param,
|
||||
base_val,
|
||||
curr_val,
|
||||
)
|
||||
return False
|
||||
|
||||
# 检查是否有裁剪需求
|
||||
# concat demuxer 支持 inpoint/outpoint,所以有裁剪也可以用
|
||||
# 但为了简单和稳定性,有裁剪时也用 filter 模式
|
||||
# (inpoint/outpoint 不是所有格式都支持得好)
|
||||
has_trimming = any(seg.start_time > 0 or seg.duration > 0 for seg in config.segments if seg.video_path)
|
||||
if has_trimming:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
# ── 模式1:concat demuxer(stream copy) ──────────────────────────
|
||||
|
||||
def _concat_demuxer(self, config: ConcatConfig, output_path: Path) -> Path:
|
||||
"""使用 concat demuxer 拼接(stream copy).
|
||||
|
||||
优点:速度极快,无质量损失
|
||||
缺点:要求所有视频参数完全一致
|
||||
"""
|
||||
# 生成 concat 文件列表
|
||||
list_file = self.work_dir / "concat_list.txt"
|
||||
lines = []
|
||||
for seg in config.segments:
|
||||
if not seg.video_path:
|
||||
continue
|
||||
# 路径转义:单引号替换为 '\''
|
||||
safe_path = str(seg.video_path).replace("'", "'\\''")
|
||||
lines.append(f"file '{safe_path}'")
|
||||
|
||||
list_file.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
str(list_file),
|
||||
"-c",
|
||||
"copy",
|
||||
"-copyts",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.info("[concat] demuxer: %d segments", config.total_segments)
|
||||
run_ffmpeg(command)
|
||||
return output_path
|
||||
|
||||
# ── 模式2:concat filter(重新编码) ──────────────────────────────
|
||||
|
||||
def _concat_filter(self, config: ConcatConfig, output_path: Path) -> Path:
|
||||
"""使用 concat filter 拼接(重新编码).
|
||||
|
||||
优点:支持不同参数的视频,支持裁剪
|
||||
缺点:需要重新编码,较慢
|
||||
"""
|
||||
valid_segments = [s for s in config.segments if s.video_path]
|
||||
num_segments = len(valid_segments)
|
||||
|
||||
# 构建输入参数
|
||||
input_args: list[str] = []
|
||||
for seg in valid_segments:
|
||||
input_args.extend(["-i", seg.video_path])
|
||||
|
||||
# 确定输出参数
|
||||
output_width, output_height, output_fps = self._get_output_params(config)
|
||||
|
||||
# 构建 filter_complex
|
||||
filter_parts: list[str] = []
|
||||
concat_inputs = ""
|
||||
|
||||
for i, seg in enumerate(valid_segments):
|
||||
vid_label = f"v{i}"
|
||||
aud_label = f"a{i}"
|
||||
|
||||
seg_filters: list[str] = []
|
||||
|
||||
# 1. 裁剪(start_time + duration)
|
||||
if seg.start_time > 0 or seg.duration > 0:
|
||||
start = seg.start_time
|
||||
if seg.duration > 0:
|
||||
end = start + seg.duration
|
||||
seg_filters.append(f"trim=start={start:.3f}:end={end:.3f}")
|
||||
else:
|
||||
seg_filters.append(f"trim=start={start:.3f}")
|
||||
seg_filters.append("setpts=PTS-STARTPTS")
|
||||
|
||||
# 音频同步裁剪
|
||||
if seg.has_audio:
|
||||
if seg.duration > 0:
|
||||
filter_parts.append(
|
||||
f"[{i}:a]atrim=start={start:.3f}:end={end:.3f}," f"asetpts=PTS-STARTPTS[{aud_label}]"
|
||||
)
|
||||
else:
|
||||
filter_parts.append(f"[{i}:a]atrim=start={start:.3f}," f"asetpts=PTS-STARTPTS[{aud_label}]")
|
||||
else:
|
||||
# 无音频时生成静音轨
|
||||
filter_parts.append(
|
||||
f"[{i}:v]trim=start={start:.3f}," f"setpts=PTS-STARTPTS, " f"aevalsrc=0:d={0.1}[{aud_label}]"
|
||||
)
|
||||
else:
|
||||
# 无裁剪,直接用原始标签
|
||||
if not seg.has_audio:
|
||||
# 无音频时需要生成静音
|
||||
try:
|
||||
dur = probe_duration(seg.video_path)
|
||||
except Exception:
|
||||
dur = 10.0
|
||||
filter_parts.append(f"aevalsrc=0:d={dur:.3f}:s=44100[{aud_label}]")
|
||||
|
||||
# 2. 缩放/帧率统一
|
||||
vf_parts = []
|
||||
if not seg_filters:
|
||||
vf_parts.append(f"[{i}:v]")
|
||||
else:
|
||||
vf_parts.append("")
|
||||
|
||||
# 分辨率统一
|
||||
if output_width and output_height:
|
||||
vf_parts.append(
|
||||
f"scale={output_width}:{output_height}:force_original_aspect_ratio=decrease,"
|
||||
f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black"
|
||||
)
|
||||
|
||||
# 帧率统一
|
||||
if output_fps > 0:
|
||||
vf_parts.append(f"fps={output_fps}")
|
||||
|
||||
# 像素格式统一
|
||||
vf_parts.append("format=yuv420p")
|
||||
|
||||
if len(vf_parts) > 1 or (seg_filters and vf_parts):
|
||||
if seg_filters:
|
||||
# 先裁剪后缩放
|
||||
crop_str = "".join(seg_filters)
|
||||
scale_str = "".join(vf_parts[1:]) # 跳过空字符串
|
||||
if scale_str:
|
||||
filter_parts.append(f"[{i}:v]{crop_str},{scale_str}[{vid_label}]")
|
||||
else:
|
||||
filter_parts.append(f"[{i}:v]{crop_str}[{vid_label}]")
|
||||
else:
|
||||
filter_parts.append(f"{vf_parts[0]}{''.join(vf_parts[1:])}[{vid_label}]")
|
||||
else:
|
||||
if seg_filters:
|
||||
filter_parts.append(f"[{i}:v]{''.join(seg_filters)}[{vid_label}]")
|
||||
else:
|
||||
# 什么都不需要,直接用输入
|
||||
pass
|
||||
|
||||
# 拼接 concat 的输入标签
|
||||
if seg_filters or (output_width and output_height) or output_fps > 0:
|
||||
concat_inputs += f"[{vid_label}]"
|
||||
else:
|
||||
concat_inputs += f"[{i}:v]"
|
||||
|
||||
# 音频标签
|
||||
if seg.start_time > 0 or seg.duration > 0:
|
||||
# 已经生成了 aud_label
|
||||
pass
|
||||
elif not seg.has_audio:
|
||||
# 已经生成了静音 aud_label
|
||||
pass
|
||||
else:
|
||||
# 使用原始音频
|
||||
pass
|
||||
|
||||
# 简化处理:用更直接的方式构建 filter
|
||||
# 重新整理一下,确保所有输入都有对应的 v_i 和 a_i 标签
|
||||
filter_parts.clear()
|
||||
concat_inputs = "" # 按段交织: [v0][a0][v1][a1]...
|
||||
|
||||
for i, seg in enumerate(valid_segments):
|
||||
v_label = f"v{i}_in"
|
||||
a_label = f"a{i}_in"
|
||||
|
||||
# 视频处理链
|
||||
v_steps: list[str] = [f"[{i}:v]"]
|
||||
|
||||
# 裁剪
|
||||
if seg.start_time > 0 or seg.duration > 0:
|
||||
start = seg.start_time
|
||||
if seg.duration > 0:
|
||||
end = start + seg.duration
|
||||
v_steps.append(f"trim=start={start:.3f}:end={end:.3f},")
|
||||
else:
|
||||
v_steps.append(f"trim=start={start:.3f},")
|
||||
v_steps.append("setpts=PTS-STARTPTS,")
|
||||
|
||||
# 缩放
|
||||
if output_width and output_height:
|
||||
v_steps.append(
|
||||
f"scale={output_width}:{output_height}:force_original_aspect_ratio=decrease,"
|
||||
f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black,"
|
||||
)
|
||||
|
||||
# 帧率
|
||||
if output_fps > 0:
|
||||
v_steps.append(f"fps={output_fps},")
|
||||
|
||||
# 像素格式
|
||||
v_steps.append("format=yuv420p")
|
||||
|
||||
v_filter = "".join(v_steps) + f"[{v_label}]"
|
||||
filter_parts.append(v_filter)
|
||||
|
||||
# 音频处理链
|
||||
a_steps: list[str] = []
|
||||
if seg.has_audio:
|
||||
a_steps.append(f"[{i}:a]")
|
||||
|
||||
if seg.start_time > 0 or seg.duration > 0:
|
||||
start = seg.start_time
|
||||
if seg.duration > 0:
|
||||
end = start + seg.duration
|
||||
a_steps.append(f"atrim=start={start:.3f}:end={end:.3f},")
|
||||
else:
|
||||
a_steps.append(f"atrim=start={start:.3f},")
|
||||
a_steps.append("asetpts=PTS-STARTPTS,")
|
||||
|
||||
a_steps.append("aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo")
|
||||
else:
|
||||
# 生成静音音频
|
||||
try:
|
||||
dur = probe_duration(seg.video_path)
|
||||
except Exception:
|
||||
dur = 10.0
|
||||
# 减去裁剪
|
||||
if seg.start_time > 0:
|
||||
dur = max(0.1, dur - seg.start_time)
|
||||
if seg.duration > 0 and seg.duration < dur:
|
||||
dur = seg.duration
|
||||
a_steps.append(f"aevalsrc=0:d={dur:.3f}:s=44100:c=stereo")
|
||||
|
||||
a_filter = "".join(a_steps) + f"[{a_label}]"
|
||||
filter_parts.append(a_filter)
|
||||
|
||||
# 按段交织排列(v_i, a_i),这是 FFmpeg concat filter 要求的顺序
|
||||
concat_inputs += f"[{v_label}][{a_label}]"
|
||||
|
||||
# concat filter: 输入按 [v0][a0][v1][a1]... 顺序
|
||||
filter_parts.append(f"{concat_inputs}" f"concat=n={num_segments}:v=1:a=1[vout][aout]")
|
||||
|
||||
filter_complex = ";".join(filter_parts)
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
*input_args,
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
"[vout]",
|
||||
"-map",
|
||||
"[aout]",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"fast",
|
||||
"-crf",
|
||||
"23",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"[concat] filter: %d segments, %dx%d, %.2f fps",
|
||||
num_segments,
|
||||
output_width,
|
||||
output_height,
|
||||
output_fps,
|
||||
)
|
||||
run_ffmpeg(command)
|
||||
return output_path
|
||||
|
||||
# ── 辅助方法 ──────────────────────────────────────────────────────
|
||||
|
||||
def _get_output_params(self, config: ConcatConfig) -> tuple[int, int, float]:
|
||||
"""获取输出参数(宽、高、帧率).
|
||||
|
||||
优先级:
|
||||
1. config 中显式指定的
|
||||
2. 第一段视频的参数
|
||||
"""
|
||||
valid_segments = [s for s in config.segments if s.video_path]
|
||||
|
||||
width = config.output_width
|
||||
height = config.output_height
|
||||
fps = config.output_fps
|
||||
|
||||
# 如果没有显式指定,用第一段的参数
|
||||
if (width == 0 or height == 0 or fps == 0) and valid_segments:
|
||||
try:
|
||||
info = probe_video_info(valid_segments[0].video_path)
|
||||
if width == 0:
|
||||
width = int(info.get("width", 1080))
|
||||
if height == 0:
|
||||
height = int(info.get("height", 1920))
|
||||
if fps == 0:
|
||||
fps_str = info.get("r_frame_rate", "30/1")
|
||||
if "/" in str(fps_str):
|
||||
num, den = str(fps_str).split("/")
|
||||
try:
|
||||
fps = float(num) / float(den)
|
||||
except (ValueError, ZeroDivisionError):
|
||||
fps = 30.0
|
||||
else:
|
||||
fps = float(fps_str) if fps_str else 30.0
|
||||
except Exception:
|
||||
# 探测失败,用默认值
|
||||
if width == 0:
|
||||
width = 1080
|
||||
if height == 0:
|
||||
height = 1920
|
||||
if fps == 0:
|
||||
fps = 30.0
|
||||
|
||||
return width, height, fps
|
||||
|
||||
|
||||
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def concat_video_files(
|
||||
video_paths: list[str],
|
||||
output_path: Path,
|
||||
*,
|
||||
work_dir: Path | None = None,
|
||||
force_reencode: bool = False,
|
||||
) -> Path:
|
||||
"""简单拼接多个视频文件.
|
||||
|
||||
Args:
|
||||
video_paths: 视频文件路径列表
|
||||
output_path: 输出路径
|
||||
work_dir: 工作目录(默认输出文件所在目录)
|
||||
force_reencode: 是否强制重新编码
|
||||
|
||||
Returns:
|
||||
输出文件路径
|
||||
"""
|
||||
if work_dir is None:
|
||||
work_dir = output_path.parent
|
||||
|
||||
segments = [ConcatSegment(video_path=p) for p in video_paths if p]
|
||||
config = ConcatConfig(segments=segments, force_reencode=force_reencode)
|
||||
|
||||
engine = ConcatEngine(work_dir)
|
||||
return engine.concat_videos(config, output_path)
|
||||
|
||||
|
||||
def concat_videos_from_config(
|
||||
config_dict: dict | None,
|
||||
output_path: Path,
|
||||
*,
|
||||
work_dir: Path,
|
||||
) -> Path | None:
|
||||
"""从配置字典执行视频拼接.
|
||||
|
||||
降级策略:配置无效或拼接失败时返回 None.
|
||||
"""
|
||||
config = ConcatConfig.from_config_dict(config_dict)
|
||||
if not config.has_effect:
|
||||
return None
|
||||
|
||||
try:
|
||||
engine = ConcatEngine(work_dir)
|
||||
return engine.concat_videos(config, output_path)
|
||||
except Exception as e:
|
||||
logger.error("[concat] concat failed: %s", e)
|
||||
return None
|
||||
@@ -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
|
||||
Executable → Regular
-13
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -119,54 +100,6 @@ def run_ffmpeg(
|
||||
raise
|
||||
|
||||
|
||||
def run_ffprobe(
|
||||
command: list[str],
|
||||
*,
|
||||
capture_output: bool = True,
|
||||
timeout: int = 30,
|
||||
) -> tuple[str, str]:
|
||||
"""执行 FFprobe 命令。
|
||||
|
||||
Args:
|
||||
command: 完整的 ffprobe 命令列表(含 "ffprobe" 本身)
|
||||
capture_output: 是否捕获 stdout/stderr
|
||||
timeout: 超时时间(秒),默认 30s;None 表示不设超时
|
||||
|
||||
Returns:
|
||||
(stdout, stderr) 元组
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: 命令执行失败时抛出
|
||||
subprocess.TimeoutExpired: 超时未完成时抛出
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run( # nosec B603
|
||||
command,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE if capture_output else None,
|
||||
stderr=subprocess.PIPE if capture_output else None,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
return (result.stdout or "", result.stderr or "")
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(
|
||||
"FFprobe 命令超时 (%ds): command=%s",
|
||||
timeout or -1,
|
||||
" ".join(str(c) for c in command[:20]),
|
||||
)
|
||||
raise
|
||||
except subprocess.CalledProcessError as e:
|
||||
stderr_text = (e.stderr or "").strip()
|
||||
logger.error(
|
||||
"FFprobe 命令失败: exit_code=%d command=%s\nstderr:\n%s",
|
||||
e.returncode,
|
||||
" ".join(str(c) for c in command[:20]),
|
||||
stderr_text[:5000],
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def probe_has_audio(local_path: str | Path) -> bool:
|
||||
"""探测文件是否包含音频流。
|
||||
|
||||
|
||||
@@ -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,478 +0,0 @@
|
||||
"""多轨道混音引擎 — 支持多路音频独立音量调节与混合.
|
||||
|
||||
基于 FFmpeg amix / amerge 实现:
|
||||
- 支持任意数量音频轨道(原音、BGM、配音、音效等)
|
||||
- 每轨独立音量调节
|
||||
- 每轨独立淡入淡出
|
||||
- 每轨独立时间偏移(delay)
|
||||
- 总输出音量归一化补偿
|
||||
|
||||
作为 render_audio.py 的增强模块,在 mix_audio 后处理阶段被调用。
|
||||
与 bgm_mixer.py 的关系:
|
||||
- bgm_mixer 专注 BGM 单轨道的复杂处理(循环、人声闪避)
|
||||
- 本模块专注多路轨道的统一音量调节与混合
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
|
||||
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from video_processing.render_audio import RenderContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
TRACK_TYPE_MAIN = "main" # 原音(视频原声)
|
||||
TRACK_TYPE_BGM = "bgm" # 背景音乐
|
||||
TRACK_TYPE_VOICEOVER = "voiceover" # 配音(TTS/人声)
|
||||
TRACK_TYPE_SFX = "sfx" # 音效
|
||||
TRACK_TYPE_AMBIENT = "ambient" # 环境音
|
||||
|
||||
MAX_AUDIO_TRACKS = 8 # 最大混音轨道数(安全上限,防止资源耗尽)
|
||||
|
||||
# 各轨道默认音量(相对主音频)
|
||||
DEFAULT_VOLUMES = {
|
||||
TRACK_TYPE_MAIN: 1.0,
|
||||
TRACK_TYPE_BGM: 0.3,
|
||||
TRACK_TYPE_VOICEOVER: 1.0,
|
||||
TRACK_TYPE_SFX: 0.7,
|
||||
TRACK_TYPE_AMBIENT: 0.2,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioTrack:
|
||||
"""单条音频轨道配置."""
|
||||
|
||||
track_id: str # 轨道唯一标识
|
||||
track_type: str # 轨道类型(main/bgm/voiceover/sfx/ambient)
|
||||
audio_path: str # 音频文件路径
|
||||
volume: float = 1.0 # 音量 0.0 ~ 2.0
|
||||
fade_in: float = 0.0 # 淡入时长(秒)
|
||||
fade_out: float = 0.0 # 淡出时长(秒)
|
||||
start_time: float = 0.0 # 开始时间(相对于视频起点,秒)
|
||||
duration: float = 0.0 # 持续时长(0表示到文件末尾)
|
||||
enabled: bool = True # 是否启用
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, track: dict) -> "AudioTrack":
|
||||
"""从字典创建 AudioTrack,带安全类型转换."""
|
||||
track_type = str(track.get("track_type", TRACK_TYPE_SFX))
|
||||
default_vol = DEFAULT_VOLUMES.get(track_type, 1.0)
|
||||
|
||||
try:
|
||||
volume = float(track.get("volume", default_vol))
|
||||
except (TypeError, ValueError):
|
||||
volume = default_vol
|
||||
volume = max(0.0, min(2.0, volume))
|
||||
|
||||
try:
|
||||
fade_in = max(0.0, float(track.get("fade_in", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
fade_in = 0.0
|
||||
|
||||
try:
|
||||
fade_out = max(0.0, float(track.get("fade_out", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
fade_out = 0.0
|
||||
|
||||
try:
|
||||
start_time = max(0.0, float(track.get("start_time", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
start_time = 0.0
|
||||
|
||||
try:
|
||||
duration = max(0.0, float(track.get("duration", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
duration = 0.0
|
||||
|
||||
return cls(
|
||||
track_id=str(track.get("track_id", "")),
|
||||
track_type=track_type,
|
||||
audio_path=str(track.get("audio_path", "")),
|
||||
volume=volume,
|
||||
fade_in=fade_in,
|
||||
fade_out=fade_out,
|
||||
start_time=start_time,
|
||||
duration=duration,
|
||||
enabled=bool(track.get("enabled", True)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MultiTrackMixConfig:
|
||||
"""多轨道混音配置."""
|
||||
|
||||
tracks: list[AudioTrack] = field(default_factory=list)
|
||||
master_volume: float = 1.0 # 主输出音量
|
||||
normalize: bool = True # 是否自动归一化补偿
|
||||
max_output_volume: float = 1.5 # 最大输出音量(防止爆音)
|
||||
|
||||
@classmethod
|
||||
def from_config_dict(cls, config: dict | None) -> "MultiTrackMixConfig":
|
||||
"""从 plan.config.audio_tracks 字典创建配置."""
|
||||
if not config or not isinstance(config, dict):
|
||||
return cls()
|
||||
|
||||
tracks_raw = config.get("tracks", [])
|
||||
tracks: list[AudioTrack] = []
|
||||
|
||||
if isinstance(tracks_raw, list):
|
||||
for t in tracks_raw:
|
||||
if isinstance(t, dict) and t.get("audio_path"):
|
||||
try:
|
||||
track = AudioTrack.from_dict(t)
|
||||
if track.enabled and track.audio_path:
|
||||
tracks.append(track)
|
||||
except Exception:
|
||||
logger.warning("[multi-track] skip invalid track config: %s", t)
|
||||
continue
|
||||
|
||||
try:
|
||||
master_volume = float(config.get("master_volume", 1.0))
|
||||
master_volume = max(0.0, min(2.0, master_volume))
|
||||
except (TypeError, ValueError):
|
||||
master_volume = 1.0
|
||||
|
||||
return cls(
|
||||
tracks=tracks,
|
||||
master_volume=master_volume,
|
||||
normalize=bool(config.get("normalize", True)),
|
||||
max_output_volume=float(config.get("max_output_volume", 1.5)),
|
||||
)
|
||||
|
||||
@property
|
||||
def has_effect(self) -> bool:
|
||||
"""是否有有效轨道需要混音."""
|
||||
return len([t for t in self.tracks if t.enabled and t.audio_path]) > 0
|
||||
|
||||
|
||||
# ── 路径安全校验 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
ALLOWED_AUDIO_EXTENSIONS = {".mp3", ".wav", ".aac", ".ogg", ".flac", ".m4a", ".wma"}
|
||||
|
||||
|
||||
def _validate_audio_path(audio_path: str, work_dir: Path) -> None:
|
||||
"""校验音频文件路径安全性.
|
||||
|
||||
规则:
|
||||
- local:// schema → 必须在 work_dir 内
|
||||
- 相对路径 → 必须在 work_dir 内
|
||||
- 绝对路径 → 必须在允许目录白名单内
|
||||
- 扩展名必须是音频格式
|
||||
|
||||
Raises:
|
||||
PathSecurityError: 路径不安全
|
||||
"""
|
||||
if not audio_path or not isinstance(audio_path, str):
|
||||
raise PathSecurityError("音频路径不能为空")
|
||||
|
||||
# 本地路径(local:// 或相对路径)
|
||||
if audio_path.startswith("local://") or not audio_path.startswith(("http://", "https://", "oss://")):
|
||||
is_abs = audio_path.startswith("/") and not audio_path.startswith("local://")
|
||||
resolved_path = safe_resolve_path(
|
||||
audio_path,
|
||||
work_dir,
|
||||
allow_outside=is_abs,
|
||||
allowed_extensions=ALLOWED_AUDIO_EXTENSIONS,
|
||||
)
|
||||
# 绝对路径额外检查白名单目录(用realpath规范化后的真实路径比较,防止 ../ 遍历绕过)
|
||||
if is_abs:
|
||||
resolved_work_dir = work_dir.resolve()
|
||||
try:
|
||||
resolved_path.relative_to(resolved_work_dir)
|
||||
except ValueError:
|
||||
if not is_in_allowed_dirs(resolved_path):
|
||||
raise PathSecurityError(f"音频路径不在允许目录内: {audio_path[:80]}")
|
||||
# URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责)
|
||||
# 但检查扩展名
|
||||
else:
|
||||
# URL路径,检查扩展名白名单(取 ? 之前的部分)
|
||||
path_part = audio_path.split("?")[0].split("#")[0]
|
||||
from pathlib import Path as _P
|
||||
|
||||
ext = _P(path_part).suffix.lower()
|
||||
if ext and ext not in ALLOWED_AUDIO_EXTENSIONS:
|
||||
raise PathSecurityError(f"不允许的音频文件类型: {ext}")
|
||||
|
||||
|
||||
# ── 单轨道预处理 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _prepare_single_track(
|
||||
ctx: "RenderContext",
|
||||
track: AudioTrack,
|
||||
target_duration: float,
|
||||
output_path: Path,
|
||||
) -> bool:
|
||||
"""预处理单条轨道:音量 + 淡入淡出 + 时间偏移 + 截断.
|
||||
|
||||
生成一个精确对齐时间轴的音频文件,后续统一 amix 混音。
|
||||
|
||||
Returns:
|
||||
True 表示处理成功,False 表示失败(跳过)
|
||||
"""
|
||||
try:
|
||||
audio_dur = probe_duration(track.audio_path)
|
||||
except Exception:
|
||||
logger.warning("[multi-track] probe failed, skip track: %s", track.track_id)
|
||||
return False
|
||||
|
||||
if audio_dur <= 0:
|
||||
return False
|
||||
|
||||
# 计算实际有效时长
|
||||
effective_start = track.start_time
|
||||
if track.duration > 0:
|
||||
effective_dur = min(track.duration, audio_dur)
|
||||
else:
|
||||
effective_dur = audio_dur
|
||||
|
||||
# 如果轨道完全在视频时长之外,跳过
|
||||
if effective_start >= target_duration:
|
||||
return False
|
||||
if effective_start + effective_dur <= 0:
|
||||
return False
|
||||
|
||||
# 构建滤镜链
|
||||
filter_parts: list[str] = []
|
||||
|
||||
# 1. 先截断到有效范围
|
||||
trim_start = 0.0 # 从源文件的哪个位置开始取
|
||||
if effective_start < 0:
|
||||
trim_start = -effective_start
|
||||
effective_start = 0.0
|
||||
|
||||
# 实际需要的源时长
|
||||
need_dur = min(effective_dur, target_duration - effective_start)
|
||||
if need_dur <= 0:
|
||||
return False
|
||||
|
||||
filter_parts.append(f"atrim={trim_start:.3f}:{trim_start + need_dur:.3f}")
|
||||
filter_parts.append("asetpts=N/SR/TB")
|
||||
|
||||
# 2. 音量调节
|
||||
if abs(track.volume - 1.0) > 0.001:
|
||||
filter_parts.append(f"volume={track.volume:.3f}")
|
||||
|
||||
# 3. 淡入
|
||||
if track.fade_in > 0 and track.fade_in < need_dur:
|
||||
filter_parts.append(f"afade=t=in:st=0:d={track.fade_in:.3f}")
|
||||
|
||||
# 4. 淡出
|
||||
if track.fade_out > 0 and track.fade_out < need_dur:
|
||||
fade_start = need_dur - track.fade_out
|
||||
if fade_start > 0:
|
||||
filter_parts.append(f"afade=t=out:st={fade_start:.3f}:d={track.fade_out:.3f}")
|
||||
|
||||
# 5. 时间偏移(用 adelay 实现开头静音填充)
|
||||
if effective_start > 0.01:
|
||||
delay_ms = int(effective_start * 1000)
|
||||
filter_parts.append(f"adelay={delay_ms}|{delay_ms}")
|
||||
|
||||
# 6. 最终截断到目标总时长
|
||||
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",
|
||||
track.audio_path,
|
||||
"-filter:a",
|
||||
filter_str,
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"[multi-track] prepare track: id=%s type=%s vol=%.2f start=%.2f dur=%.2f",
|
||||
track.track_id,
|
||||
track.track_type,
|
||||
track.volume,
|
||||
effective_start,
|
||||
need_dur,
|
||||
)
|
||||
|
||||
try:
|
||||
run_ffmpeg(command)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("[multi-track] track prepare failed: %s, error=%s", track.track_id, e)
|
||||
return False
|
||||
|
||||
|
||||
# ── 多轨道混音主入口 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def mix_multi_track(
|
||||
ctx: "RenderContext",
|
||||
main_audio_path: Path,
|
||||
config: MultiTrackMixConfig,
|
||||
target_duration: float,
|
||||
) -> Path:
|
||||
"""多轨道混音:主音频 + 多条附加轨道.
|
||||
|
||||
Args:
|
||||
ctx: 渲染上下文
|
||||
main_audio_path: 主音频文件路径(原音)
|
||||
config: 多轨道混音配置
|
||||
target_duration: 目标总时长
|
||||
|
||||
Returns:
|
||||
混音后的音频文件路径
|
||||
"""
|
||||
output_path = ctx.work_dir / f"multi_track_mix_{ctx.plan_id}.aac"
|
||||
|
||||
if target_duration <= 0:
|
||||
target_duration = 5.0
|
||||
|
||||
# ── 安全校验:轨道数量上限 ──
|
||||
enabled_tracks = [t for t in config.tracks if t.enabled and t.audio_path]
|
||||
if len(enabled_tracks) > MAX_AUDIO_TRACKS:
|
||||
logger.warning(
|
||||
"[multi-track] too many tracks: %d > %d, truncating to max",
|
||||
len(enabled_tracks),
|
||||
MAX_AUDIO_TRACKS,
|
||||
)
|
||||
enabled_tracks = enabled_tracks[:MAX_AUDIO_TRACKS]
|
||||
# 更新 config.tracks 为截断后的列表
|
||||
config.tracks = enabled_tracks
|
||||
|
||||
# ── 安全校验:所有音频路径白名单校验 ──
|
||||
# 主音频路径
|
||||
try:
|
||||
_validate_audio_path(str(main_audio_path), ctx.work_dir)
|
||||
except PathSecurityError as e:
|
||||
logger.error("[multi-track] main audio path security check failed: %s", e)
|
||||
raise
|
||||
|
||||
# 各轨道音频路径
|
||||
valid_tracks = []
|
||||
for track in enabled_tracks:
|
||||
try:
|
||||
_validate_audio_path(track.audio_path, ctx.work_dir)
|
||||
valid_tracks.append(track)
|
||||
except PathSecurityError as e:
|
||||
logger.warning("[multi-track] skip track %s: path security check failed: %s", track.track_id, e)
|
||||
|
||||
if len(valid_tracks) != len(enabled_tracks):
|
||||
config.tracks = valid_tracks
|
||||
logger.info("[multi-track] %d tracks passed security check", len(valid_tracks))
|
||||
|
||||
# 收集所有有效轨道(已预处理好的)
|
||||
prepared_tracks: list[Path] = []
|
||||
|
||||
# 主音频作为第0轨
|
||||
prepared_tracks.append(main_audio_path)
|
||||
|
||||
# 预处理每条附加轨道
|
||||
for i, track in enumerate(config.tracks):
|
||||
if not track.enabled or not track.audio_path:
|
||||
continue
|
||||
|
||||
track_out = ctx.work_dir / f"track_{i}_{ctx.plan_id}.aac"
|
||||
if _prepare_single_track(ctx, track, target_duration, track_out):
|
||||
prepared_tracks.append(track_out)
|
||||
|
||||
# 如果只有主音频,直接返回(无需混音)
|
||||
if len(prepared_tracks) <= 1:
|
||||
import shutil
|
||||
|
||||
shutil.copy2(main_audio_path, output_path)
|
||||
return output_path
|
||||
|
||||
# 使用 amix 混音
|
||||
num_inputs = len(prepared_tracks)
|
||||
|
||||
# 构建输入参数
|
||||
input_args: list[str] = []
|
||||
for tp in prepared_tracks:
|
||||
input_args.extend(["-i", str(tp)])
|
||||
|
||||
# amix 的 duration=first 以第一个输入(主音频)时长为准
|
||||
# normalize 补偿:amix 会把每路音量除以 N,需要乘回来
|
||||
# 但如果所有轨道都同时有声,可能会爆音,所以用 master_volume 控制
|
||||
if config.normalize:
|
||||
# 经验值:不是所有轨道都同时有声,补偿系数取 N * 0.7
|
||||
compensate = num_inputs * 0.7
|
||||
else:
|
||||
compensate = 1.0
|
||||
|
||||
final_volume = compensate * config.master_volume
|
||||
final_volume = min(final_volume, config.max_output_volume)
|
||||
|
||||
# 构建 filter_complex
|
||||
inputs_label = "".join(f"[{i}:a]" for i in range(num_inputs))
|
||||
filter_complex = (
|
||||
f"{inputs_label}amix=inputs={num_inputs}:duration=first:dropout_transition=0[outa];"
|
||||
f"[outa]volume={final_volume:.3f}[final]"
|
||||
)
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
*input_args,
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
"[final]",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"[multi-track] mix %d tracks, master_vol=%.2f compensate=%.2f final_vol=%.2f",
|
||||
num_inputs,
|
||||
config.master_volume,
|
||||
compensate,
|
||||
final_volume,
|
||||
)
|
||||
|
||||
try:
|
||||
run_ffmpeg(command)
|
||||
except Exception as e:
|
||||
logger.error("[multi-track] mix failed, fallback to main audio only: %s", e)
|
||||
import shutil
|
||||
|
||||
shutil.copy2(main_audio_path, output_path)
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
# ── 便捷函数:从 plan.config 快速混音 ───────────────────────────────────────
|
||||
|
||||
|
||||
def mix_audio_tracks_from_config(
|
||||
ctx: "RenderContext",
|
||||
main_audio_path: Path,
|
||||
audio_tracks_config: dict | None,
|
||||
target_duration: float,
|
||||
) -> Path:
|
||||
"""从 plan.config.audio_tracks 配置执行多轨道混音.
|
||||
|
||||
降级策略:配置无效或混音失败时返回主音频。
|
||||
"""
|
||||
config = MultiTrackMixConfig.from_config_dict(audio_tracks_config)
|
||||
if not config.has_effect:
|
||||
return main_audio_path
|
||||
|
||||
return mix_multi_track(ctx, main_audio_path, config, target_duration)
|
||||
@@ -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 afftdn(Audio 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
|
||||
@@ -220,64 +220,25 @@ def resolve_asset_path(asset_id: str, work_dir: Path) -> Path | None:
|
||||
"""从 asset_id 解析到本地文件路径。
|
||||
|
||||
策略(按优先级):
|
||||
1. 如果 asset_id 是本地绝对路径(/var/storage/...)→ 安全校验后返回
|
||||
1. 如果 asset_id 是本地绝对路径(/var/storage/...)→ 直接返回
|
||||
2. 如果 work_dir 下已有缓存文件 → 返回缓存路径
|
||||
3. 从 OSS 下载到 work_dir/{hash}.mp4 → 返回下载路径
|
||||
4. 下载失败 → 返回 None
|
||||
|
||||
缓存策略:以 asset_id 的 SHA256 前 16 位为文件名,避免重复下载。
|
||||
|
||||
安全:
|
||||
- 本地绝对路径必须在 ASSET_ALLOWED_DIRS 环境变量指定的目录内
|
||||
- 文件名经过 sanitize,防止路径遍历
|
||||
- 禁止空字节、控制字符
|
||||
"""
|
||||
from video_processing.path_security import (
|
||||
PathSecurityError,
|
||||
get_allowed_local_dirs,
|
||||
is_in_allowed_dirs,
|
||||
sanitize_filename,
|
||||
)
|
||||
|
||||
if not asset_id or not isinstance(asset_id, str):
|
||||
return None
|
||||
|
||||
# 空字节检测
|
||||
if "\x00" in asset_id:
|
||||
logger.warning("asset_id 包含空字节,拒绝: %s", asset_id[:50])
|
||||
return None
|
||||
|
||||
# 1. 本地绝对路径 — 必须在允许的目录内
|
||||
# 1. 本地绝对路径
|
||||
if asset_id.startswith("/") and os.path.exists(asset_id):
|
||||
try:
|
||||
resolved = Path(asset_id).resolve()
|
||||
if is_in_allowed_dirs(resolved, get_allowed_local_dirs()):
|
||||
return resolved
|
||||
else:
|
||||
logger.warning(
|
||||
"本地素材路径不在允许目录内,拒绝: %s (allowed=%s)",
|
||||
asset_id[:80],
|
||||
get_allowed_local_dirs(),
|
||||
)
|
||||
return None
|
||||
except (OSError, PathSecurityError):
|
||||
return None
|
||||
return Path(asset_id)
|
||||
|
||||
# 2. 缓存命中(使用 hash 而非原始 ID,防止路径遍历)
|
||||
# 2. 缓存命中
|
||||
cache_hash = hashlib.sha256(asset_id.encode()).hexdigest()[:16]
|
||||
safe_name = sanitize_filename(cache_hash)
|
||||
cached_path = work_dir / f"{safe_name}.mp4"
|
||||
cached_path = work_dir / f"{cache_hash}.mp4"
|
||||
if cached_path.exists() and cached_path.stat().st_size > 0:
|
||||
return cached_path
|
||||
|
||||
# 3. 从 OSS 下载(先标准化 key,防止路径遍历注入)
|
||||
safe_key = normalize_storage_key(asset_id)
|
||||
# 额外校验:存储键不能包含 ../ 或绝对路径
|
||||
if ".." in safe_key or safe_key.startswith("/"):
|
||||
logger.warning("asset_id 包含路径遍历模式,拒绝下载: %s", asset_id[:80])
|
||||
return None
|
||||
|
||||
if download_asset(safe_key, cached_path):
|
||||
# 3. 从 OSS 下载
|
||||
if download_asset(asset_id, cached_path):
|
||||
return cached_path
|
||||
|
||||
return None
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
"""路径安全校验工具 — 路径遍历防护.
|
||||
|
||||
统一的文件路径安全校验方案,覆盖所有渲染管线中的路径处理场景:
|
||||
- 本地素材路径校验
|
||||
- local:// 路径 schema 校验
|
||||
- 工作目录内路径安全约束
|
||||
- 防止路径遍历攻击 (../)
|
||||
|
||||
防护要点:
|
||||
1. 所有用户可控路径必须在允许的目录内
|
||||
2. 解析符号链接后的真实路径仍需在允许目录内
|
||||
3. 禁止空路径、相对路径遍历、绝对路径逃逸
|
||||
4. 路径字符限制与规范化
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 最大路径长度
|
||||
MAX_PATH_LENGTH = 4096
|
||||
|
||||
# 允许的文件扩展名(渲染相关)
|
||||
ALLOWED_MEDIA_EXTENSIONS = {
|
||||
".mp4",
|
||||
".mov",
|
||||
".avi",
|
||||
".mkv",
|
||||
".webm",
|
||||
".flv",
|
||||
".wmv", # 视频
|
||||
".mp3",
|
||||
".wav",
|
||||
".aac",
|
||||
".ogg",
|
||||
".flac",
|
||||
".m4a",
|
||||
".wma", # 音频
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".bmp",
|
||||
".webp",
|
||||
".tiff", # 图片
|
||||
".srt",
|
||||
".ass",
|
||||
".vtt",
|
||||
".sub", # 字幕
|
||||
".txt",
|
||||
".json", # 文本/配置
|
||||
}
|
||||
|
||||
# local:// schema 前缀
|
||||
LOCAL_SCHEMA_PREFIX = "local://"
|
||||
|
||||
|
||||
class PathSecurityError(ValueError):
|
||||
"""路径安全校验失败."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def safe_resolve_path(
|
||||
input_path: str | Path,
|
||||
base_dir: str | Path,
|
||||
*,
|
||||
allow_outside: bool = False,
|
||||
allowed_extensions: set[str] | None = None,
|
||||
) -> Path:
|
||||
"""安全解析路径,确保最终路径在 base_dir 内.
|
||||
|
||||
Args:
|
||||
input_path: 输入路径(相对或绝对)
|
||||
base_dir: 基路径目录,解析后的路径必须在此目录内
|
||||
allow_outside: 是否允许路径在 base_dir 外(默认禁止)
|
||||
allowed_extensions: 允许的文件扩展名集合(None 表示不限制)
|
||||
|
||||
Returns:
|
||||
解析后的绝对路径 Path 对象
|
||||
|
||||
Raises:
|
||||
PathSecurityError: 路径不安全
|
||||
"""
|
||||
if input_path is None:
|
||||
raise PathSecurityError("路径不能为空")
|
||||
|
||||
path_str = str(input_path).strip()
|
||||
if not path_str:
|
||||
raise PathSecurityError("路径不能为空")
|
||||
|
||||
if len(path_str) > MAX_PATH_LENGTH:
|
||||
raise PathSecurityError(f"路径过长 ({len(path_str)} > {MAX_PATH_LENGTH})")
|
||||
|
||||
# 空字节检测(必须在 Path() 之前)
|
||||
if "\x00" in path_str:
|
||||
raise PathSecurityError("路径包含空字节")
|
||||
|
||||
# 处理 local:// schema
|
||||
if path_str.startswith(LOCAL_SCHEMA_PREFIX):
|
||||
path_str = path_str[len(LOCAL_SCHEMA_PREFIX) :]
|
||||
# local:// 后必须是相对路径(相对于 base_dir),不能是绝对路径
|
||||
if os.path.isabs(path_str):
|
||||
raise PathSecurityError("local:// 路径不能是绝对路径")
|
||||
|
||||
# 规范化 base_dir
|
||||
base_dir = Path(base_dir).resolve()
|
||||
if not base_dir.is_dir():
|
||||
raise PathSecurityError(f"基路径不是有效目录: {base_dir}")
|
||||
|
||||
# 解析输入路径
|
||||
input_path_obj = Path(path_str)
|
||||
|
||||
# 如果是绝对路径且不允许外部路径
|
||||
if input_path_obj.is_absolute() and not allow_outside:
|
||||
raise PathSecurityError("禁止使用绝对路径(需在工作目录内)")
|
||||
|
||||
# 组合并解析为绝对路径
|
||||
if input_path_obj.is_absolute():
|
||||
full_path = input_path_obj.resolve()
|
||||
else:
|
||||
full_path = (base_dir / input_path_obj).resolve()
|
||||
|
||||
# 检查路径遍历 — 确保最终路径在 base_dir 内
|
||||
if not allow_outside:
|
||||
try:
|
||||
full_path.relative_to(base_dir)
|
||||
except ValueError:
|
||||
raise PathSecurityError(f"路径遍历检测:路径 '{path_str}' 超出基路径 '{base_dir}' 范围")
|
||||
|
||||
# 扩展名校验
|
||||
if allowed_extensions is not None:
|
||||
ext = full_path.suffix.lower()
|
||||
if ext and ext not in allowed_extensions:
|
||||
raise PathSecurityError(f"不允许的文件类型: {ext}")
|
||||
|
||||
# 检查危险路径模式
|
||||
_check_dangerous_patterns(full_path)
|
||||
|
||||
return full_path
|
||||
|
||||
|
||||
def _check_dangerous_patterns(path: Path) -> None:
|
||||
"""检查危险路径模式."""
|
||||
path_str = str(path)
|
||||
|
||||
# 检查空字节
|
||||
if "\x00" in path_str:
|
||||
raise PathSecurityError("路径包含空字节")
|
||||
|
||||
# 检查特殊设备文件(Linux)
|
||||
dangerous_prefixes = [
|
||||
"/proc/",
|
||||
"/sys/",
|
||||
"/dev/",
|
||||
"/etc/passwd",
|
||||
"/etc/shadow",
|
||||
"/root/",
|
||||
"/boot/",
|
||||
"/var/run/",
|
||||
]
|
||||
for prefix in dangerous_prefixes:
|
||||
if path_str.startswith(prefix):
|
||||
raise PathSecurityError(f"禁止访问系统路径: {prefix}")
|
||||
|
||||
|
||||
def is_path_safe(
|
||||
input_path: str | Path,
|
||||
base_dir: str | Path,
|
||||
*,
|
||||
allow_outside: bool = False,
|
||||
) -> bool:
|
||||
"""便捷函数:检查路径是否安全,不抛异常."""
|
||||
try:
|
||||
safe_resolve_path(input_path, base_dir, allow_outside=allow_outside)
|
||||
return True
|
||||
except PathSecurityError:
|
||||
return False
|
||||
|
||||
|
||||
def validate_local_schema_path(
|
||||
schema_path: str,
|
||||
work_dir: str | Path,
|
||||
) -> Path:
|
||||
"""校验 local:// schema 路径,返回安全的本地路径.
|
||||
|
||||
local:// 路径规则:
|
||||
- 必须以 local:// 开头
|
||||
- 后面必须是相对路径
|
||||
- 最终解析后必须在 work_dir 内
|
||||
- 不允许 ../ 遍历
|
||||
|
||||
Args:
|
||||
schema_path: local:// 开头的路径
|
||||
work_dir: 工作目录
|
||||
|
||||
Returns:
|
||||
解析后的安全路径
|
||||
|
||||
Raises:
|
||||
PathSecurityError: 路径不安全
|
||||
"""
|
||||
if not schema_path.startswith(LOCAL_SCHEMA_PREFIX):
|
||||
raise PathSecurityError(f"路径必须以 {LOCAL_SCHEMA_PREFIX} 开头")
|
||||
|
||||
return safe_resolve_path(schema_path, work_dir, allow_outside=False)
|
||||
|
||||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
"""清理文件名,移除危险字符.
|
||||
|
||||
保留:字母、数字、下划线、连字符、点、中文字符
|
||||
移除:路径分隔符、控制字符、特殊符号等
|
||||
"""
|
||||
import re
|
||||
|
||||
if not filename:
|
||||
return "unnamed"
|
||||
|
||||
# 移除路径分隔符和危险字符
|
||||
# 保留: 字母数字、中文字符、下划线、连字符、点、空格
|
||||
sanitized = re.sub(r'[\\/\x00-\x1f\x7f<>:"|?*]', "_", filename)
|
||||
|
||||
# 移除开头的点和连续的点(防止隐藏文件和路径遍历)
|
||||
while sanitized.startswith("."):
|
||||
sanitized = sanitized[1:]
|
||||
|
||||
# 限制长度
|
||||
if len(sanitized) > 255:
|
||||
name, ext = os.path.splitext(sanitized)
|
||||
sanitized = name[: 255 - len(ext)] + ext
|
||||
|
||||
# 空文件名兜底
|
||||
if not sanitized or sanitized == ".":
|
||||
sanitized = "unnamed"
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
# ── 允许目录配置 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_allowed_local_dirs() -> list[Path]:
|
||||
"""获取允许的本地素材目录列表(从环境变量读取).
|
||||
|
||||
环境变量 ASSET_ALLOWED_DIRS,多个目录用冒号分隔(Linux)或分号分隔(Windows)。
|
||||
默认包含 /tmp。
|
||||
|
||||
用于:
|
||||
- resolve_asset_path 本地绝对路径白名单
|
||||
- PiP local_path 类型白名单
|
||||
- 贴纸本地路径白名单
|
||||
"""
|
||||
env_dirs = os.environ.get("ASSET_ALLOWED_DIRS", "")
|
||||
dirs: list[Path] = []
|
||||
if env_dirs:
|
||||
import re
|
||||
|
||||
sep = ";" if os.name == "nt" else ":"
|
||||
for d in re.split(f"[{sep}]", env_dirs):
|
||||
d = d.strip()
|
||||
if d:
|
||||
try:
|
||||
dirs.append(Path(d).resolve())
|
||||
except OSError:
|
||||
pass
|
||||
# 默认允许 /tmp
|
||||
if not dirs:
|
||||
try:
|
||||
dirs.append(Path("/tmp").resolve()) # nosec B108
|
||||
except OSError:
|
||||
pass
|
||||
return dirs
|
||||
|
||||
|
||||
def is_in_allowed_dirs(path: str | Path, allowed_dirs: list[Path] | None = None) -> bool:
|
||||
"""检查路径是否在允许的目录列表内.
|
||||
|
||||
Args:
|
||||
path: 待检查的路径
|
||||
allowed_dirs: 允许的目录列表,None 则使用默认配置
|
||||
|
||||
Returns:
|
||||
True 表示在允许目录内
|
||||
"""
|
||||
if allowed_dirs is None:
|
||||
allowed_dirs = get_allowed_local_dirs()
|
||||
|
||||
try:
|
||||
resolved = Path(path).resolve()
|
||||
for allowed in allowed_dirs:
|
||||
try:
|
||||
resolved.relative_to(allowed)
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
@@ -1,509 +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(降级跳过).
|
||||
|
||||
安全:
|
||||
- local_path 类型:必须在允许的目录内,防止路径遍历
|
||||
- url 类型:必须通过 SSRF 安全校验
|
||||
"""
|
||||
from video_processing.path_security import is_in_allowed_dirs
|
||||
from video_processing.url_security import UrlSecurityError, validate_url_safety
|
||||
|
||||
try:
|
||||
if layer.source_type == "local_path":
|
||||
if not layer.source:
|
||||
return None
|
||||
# 路径安全校验:必须在允许目录内
|
||||
src_path = Path(layer.source)
|
||||
if not src_path.exists():
|
||||
return None
|
||||
if not is_in_allowed_dirs(src_path):
|
||||
logger.warning(
|
||||
"PiP local_path 不在允许目录内,拒绝: %s",
|
||||
layer.source[:80],
|
||||
)
|
||||
return None
|
||||
return src_path.resolve()
|
||||
elif layer.source_type == "asset_id":
|
||||
if layer.source in asset_path_map:
|
||||
return asset_path_map[layer.source]
|
||||
return None
|
||||
elif layer.source_type == "url":
|
||||
# URL类型:先做SSRF安全校验,由调用者负责实际下载
|
||||
try:
|
||||
validate_url_safety(layer.source, purpose="pip_source")
|
||||
logger.info("PiP URL 安全校验通过: %s", layer.source[:80])
|
||||
except UrlSecurityError as e:
|
||||
logger.warning("PiP URL 安全校验失败: %s (error=%s)", layer.source[:80], e)
|
||||
return None
|
||||
# 暂时不支持直接URL下载,返回None表示降级跳过
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("PiP素材验证失败: %s", e)
|
||||
|
||||
return None
|
||||
Executable → Regular
+26
-214
@@ -22,8 +22,6 @@ from pathlib import Path
|
||||
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
|
||||
@@ -37,8 +35,6 @@ 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)
|
||||
|
||||
@@ -74,10 +70,6 @@ def mix_audio(
|
||||
ctx: RenderContext,
|
||||
layers: list[RenderLayer],
|
||||
video_duration: float,
|
||||
*,
|
||||
bgm_path: str | None = None,
|
||||
bgm_config: dict | None = None,
|
||||
audio_tracks_config: dict | None = None,
|
||||
) -> Path | None:
|
||||
"""音频后处理混音.
|
||||
|
||||
@@ -87,17 +79,11 @@ def mix_audio(
|
||||
3. 独立音频轨(audio role)用 amix 混入
|
||||
4. 输出时长截断到 video_duration
|
||||
5. 无音频流的 clip 会被自动跳过,避免 FFmpeg 引用 [i:a] 失败
|
||||
6. 如果提供了 bgm_path,则额外混入 BGM(支持淡入淡出、循环、人声闪避)
|
||||
7. 如果配置了 audio_tracks,则混入多轨道音频(配音、音效等)
|
||||
8. 如果配置了降噪,最后应用降噪
|
||||
|
||||
Args:
|
||||
ctx: 渲染上下文
|
||||
layers: 图层列表
|
||||
video_duration: 视频总时长(用于截断音频)
|
||||
bgm_path: BGM 音频本地路径,为 None 时不混入 BGM
|
||||
bgm_config: BGM 配置字典(volume/fade_in/fade_out/sidechain 等)
|
||||
audio_tracks_config: 多轨道音频配置(tracks/master_volume 等)
|
||||
|
||||
Returns:
|
||||
混音后的音频文件路径,无音频时返回 None
|
||||
@@ -130,15 +116,6 @@ def mix_audio(
|
||||
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
|
||||
|
||||
# 构建音频处理命令
|
||||
@@ -147,83 +124,11 @@ def mix_audio(
|
||||
# 简单场景:只有主图层 + 无独立音频 → 直接从视频提取音频并拼接
|
||||
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)
|
||||
return output_path
|
||||
|
||||
# ── 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)
|
||||
output_path = final_path
|
||||
except Exception:
|
||||
logger.exception("[bgm] BGM 混音失败,回退到无 BGM 音频: plan_id=%s", ctx.plan_id)
|
||||
|
||||
# ── 多轨道混音(配音/音效等) ──
|
||||
if audio_tracks_config and audio_tracks_config.get("enabled", False):
|
||||
from video_processing.multi_track_mixer import mix_audio_tracks_from_config
|
||||
|
||||
try:
|
||||
tracks_config = audio_tracks_config.get("tracks_config") or audio_tracks_config
|
||||
multi_output = mix_audio_tracks_from_config(ctx, output_path, tracks_config, video_duration)
|
||||
if multi_output and multi_output != output_path:
|
||||
output_path = multi_output
|
||||
except Exception:
|
||||
logger.exception("[multi-track] 多轨道混音失败,回退: plan_id=%s", ctx.plan_id)
|
||||
|
||||
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
|
||||
# 有独立音频轨 → amix 混音
|
||||
mix_with_independent_audio(ctx, main_clips, audio_clips, output_path, video_duration)
|
||||
return output_path
|
||||
|
||||
|
||||
def concat_main_audio(
|
||||
@@ -240,130 +145,40 @@ def concat_main_audio(
|
||||
# 单 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
|
||||
# 最终时长:取 clip 有效时长和视频总时长的较小值
|
||||
# (视频总时长由主图层决定,但单 clip 场景下两者应该一致,仍做保护)
|
||||
final_duration = effective_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)
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
str(clip.local_path),
|
||||
"-vn",
|
||||
"-acodec",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
]
|
||||
if final_duration > 0:
|
||||
command.extend(["-t", f"{final_duration:.3f}"])
|
||||
command.append(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)
|
||||
filter_parts.append(f"[{i}:a]atrim=0:{effective_duration:.3f},asetpts=PTS-STARTPTS[a{i}]")
|
||||
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}]")
|
||||
filter_parts.append(f"[{i}:a]asetpts=PTS-STARTPTS[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]")
|
||||
@@ -421,11 +236,9 @@ def mix_with_independent_audio(
|
||||
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}]"
|
||||
f"[{input_idx}:a]atrim=0:{effective_duration:.3f},asetpts=PTS-STARTPTS[ma{input_idx}]"
|
||||
)
|
||||
else:
|
||||
filter_parts.append(f"[{input_idx}:a]asetpts=PTS-STARTPTS[ma{input_idx}]")
|
||||
@@ -442,12 +255,11 @@ def mix_with_independent_audio(
|
||||
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(f"atrim=0:{effective_duration:.3f}")
|
||||
filters.append("asetpts=PTS-STARTPTS")
|
||||
if volume != 1.0:
|
||||
filters.append(f"volume={volume}")
|
||||
|
||||
@@ -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.0,1.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_speed,0 或缺失则使用全局速度。"""
|
||||
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,612 +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:
|
||||
# 图片贴纸 — 安全校验:区分本地路径和URL
|
||||
image_path = s.get("image_path", "")
|
||||
image_url = s.get("image_url", "")
|
||||
|
||||
safe_image_path: Path | None = None
|
||||
|
||||
if image_path:
|
||||
# 本地路径:路径遍历防护
|
||||
from video_processing.path_security import is_in_allowed_dirs
|
||||
|
||||
try:
|
||||
p = Path(image_path)
|
||||
if not p.exists():
|
||||
logger.warning("贴纸素材不存在,跳过: %s", image_path[:80])
|
||||
continue
|
||||
if not is_in_allowed_dirs(p):
|
||||
logger.warning("贴纸路径不在允许目录内,拒绝: %s", image_path[:80])
|
||||
continue
|
||||
safe_image_path = p.resolve()
|
||||
except Exception as e:
|
||||
logger.warning("贴纸路径校验失败,跳过: %s error=%s", image_path[:80], e)
|
||||
continue
|
||||
elif image_url:
|
||||
# URL:SSRF 安全校验(暂不自动下载,仅校验安全性)
|
||||
from video_processing.url_security import (
|
||||
UrlSecurityError,
|
||||
validate_url_safety,
|
||||
)
|
||||
|
||||
try:
|
||||
validate_url_safety(image_url, purpose="sticker_image")
|
||||
except UrlSecurityError as e:
|
||||
logger.warning("贴纸URL安全校验失败,跳过: %s error=%s", image_url[:80], e)
|
||||
continue
|
||||
# URL 类型暂不支持自动下载,跳过
|
||||
logger.info("贴纸URL类型暂不支持自动下载,跳过: %s", image_url[:80])
|
||||
continue
|
||||
else:
|
||||
logger.warning("贴纸缺少 image_path 和 image_url,跳过")
|
||||
continue
|
||||
|
||||
if safe_image_path is None:
|
||||
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=image_url,
|
||||
)
|
||||
parsed_stickers.append((z, config))
|
||||
image_stickers.append(config)
|
||||
image_paths.append(str(safe_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,687 +0,0 @@
|
||||
"""字幕渲染引擎 — 统一管理字幕样式配置与视频烧录.
|
||||
|
||||
与现有模块的关系:
|
||||
- render_subtitles.py:生成静态整段标题/字幕的 ASS 文件
|
||||
- subtitle_generator.py:从 ASR 时间轴生成 ASS 文件
|
||||
- 本模块:统一的字幕样式配置 + 烧录滤镜生成 + 多源字幕合并
|
||||
|
||||
支持的字幕来源:
|
||||
1. 静态标题/字幕(title_config / subtitle_config)
|
||||
2. ASR 自动字幕(asr_subtitle_timeline)
|
||||
3. 手动字幕(manual_subtitles 时间轴)
|
||||
|
||||
支持的样式配置:
|
||||
- 字体、字号、颜色
|
||||
- 描边(颜色、宽度)
|
||||
- 阴影(偏移、模糊、颜色)
|
||||
- 背景框(颜色、透明度、圆角、边距)
|
||||
- 位置(9宫格 + 自定义坐标)
|
||||
- 对齐方式
|
||||
- 动画(淡入淡出、滑入滑出、打字机)
|
||||
- 多行/换行规则
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
|
||||
from video_processing.render_subtitles import generate_ass_subtitles
|
||||
from video_processing.subtitle_generator import generate_ass_from_timeline
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
ALLOWED_SUBTITLE_EXTENSIONS = {".srt", ".ass", ".vtt", ".sub"}
|
||||
|
||||
# 9宫格位置映射(ASS alignment 编号)
|
||||
POSITION_ALIGNMENT = {
|
||||
"top_left": 7,
|
||||
"top_center": 8,
|
||||
"top_right": 9,
|
||||
"middle_left": 4,
|
||||
"center": 5,
|
||||
"middle_right": 6,
|
||||
"bottom_left": 1,
|
||||
"bottom_center": 2,
|
||||
"bottom_right": 3,
|
||||
}
|
||||
|
||||
# 位置简称兼容
|
||||
POSITION_ALIASES = {
|
||||
"top": "top_center",
|
||||
"bottom": "bottom_center",
|
||||
"middle": "center",
|
||||
"left": "middle_left",
|
||||
"right": "middle_right",
|
||||
}
|
||||
|
||||
DEFAULT_FONT = "思源黑体"
|
||||
DEFAULT_FONT_SIZE = 24
|
||||
DEFAULT_COLOR = "#FFFFFF"
|
||||
DEFAULT_STROKE_COLOR = "#000000"
|
||||
DEFAULT_STROKE_WIDTH = 1.5
|
||||
DEFAULT_POSITION = "bottom_center"
|
||||
DEFAULT_MAX_CHARS_PER_LINE = 20
|
||||
|
||||
|
||||
# ── 字幕样式配置 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubtitleStyle:
|
||||
"""字幕样式配置."""
|
||||
|
||||
font_name: str = DEFAULT_FONT
|
||||
font_size: int = DEFAULT_FONT_SIZE
|
||||
font_color: str = DEFAULT_COLOR
|
||||
bold: bool = False
|
||||
italic: bool = False
|
||||
|
||||
# 描边
|
||||
stroke_enabled: bool = True
|
||||
stroke_color: str = DEFAULT_STROKE_COLOR
|
||||
stroke_width: float = DEFAULT_STROKE_WIDTH
|
||||
|
||||
# 阴影
|
||||
shadow_enabled: bool = False
|
||||
shadow_color: str = "#000000"
|
||||
shadow_offset_x: int = 2
|
||||
shadow_offset_y: int = 2
|
||||
shadow_blur: float = 0.0
|
||||
|
||||
# 背景框
|
||||
background_enabled: bool = False
|
||||
background_color: str = "#000000"
|
||||
background_opacity: float = 0.5 # 0.0 ~ 1.0
|
||||
background_padding: int = 8
|
||||
background_radius: int = 4
|
||||
|
||||
# 位置
|
||||
position: str = DEFAULT_POSITION # 9宫格位置名
|
||||
margin_v: int = 60 # 垂直边距
|
||||
margin_l: int = 40 # 左边距
|
||||
margin_r: int = 40 # 右边距
|
||||
|
||||
# 多行
|
||||
max_chars_per_line: int = DEFAULT_MAX_CHARS_PER_LINE
|
||||
line_spacing: int = 0 # 行间距
|
||||
|
||||
# 动画
|
||||
fade_in: float = 0.0 # 淡入时长(秒)
|
||||
fade_out: float = 0.0 # 淡出时长(秒)
|
||||
animation_type: str = "none" # none/fade/slide/typewriter
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, config: dict[str, Any] | None) -> "SubtitleStyle":
|
||||
"""从字典创建样式配置,带安全类型转换."""
|
||||
if not config or not isinstance(config, dict):
|
||||
return cls()
|
||||
|
||||
def safe_str(key: str, default: str) -> str:
|
||||
val = config.get(key, default)
|
||||
return str(val) if val is not None else default
|
||||
|
||||
def safe_int(key: str, default: int) -> int:
|
||||
try:
|
||||
return int(config.get(key, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def safe_float(key: str, default: float) -> float:
|
||||
try:
|
||||
return float(config.get(key, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def safe_bool(key: str, default: bool) -> bool:
|
||||
return bool(config.get(key, default))
|
||||
|
||||
position = safe_str("position", DEFAULT_POSITION)
|
||||
position = POSITION_ALIASES.get(position, position)
|
||||
if position not in POSITION_ALIGNMENT:
|
||||
position = DEFAULT_POSITION
|
||||
|
||||
return cls(
|
||||
font_name=safe_str("font", DEFAULT_FONT),
|
||||
font_size=safe_int("size", DEFAULT_FONT_SIZE),
|
||||
font_color=safe_str("color", DEFAULT_COLOR),
|
||||
bold=safe_bool("bold", False),
|
||||
italic=safe_bool("italic", False),
|
||||
stroke_enabled=safe_bool("stroke_enabled", True),
|
||||
stroke_color=safe_str("stroke_color", DEFAULT_STROKE_COLOR),
|
||||
stroke_width=safe_float("stroke_width", DEFAULT_STROKE_WIDTH),
|
||||
shadow_enabled=safe_bool("shadow_enabled", False),
|
||||
shadow_color=safe_str("shadow_color", "#000000"),
|
||||
shadow_offset_x=safe_int("shadow_offset_x", 2),
|
||||
shadow_offset_y=safe_int("shadow_offset_y", 2),
|
||||
shadow_blur=safe_float("shadow_blur", 0.0),
|
||||
background_enabled=safe_bool("background_enabled", False),
|
||||
background_color=safe_str("background_color", "#000000"),
|
||||
background_opacity=max(0.0, min(1.0, safe_float("background_opacity", 0.5))),
|
||||
background_padding=safe_int("background_padding", 8),
|
||||
background_radius=safe_int("background_radius", 4),
|
||||
position=position,
|
||||
margin_v=safe_int("margin_v", 60),
|
||||
margin_l=safe_int("margin_l", 40),
|
||||
margin_r=safe_int("margin_r", 40),
|
||||
max_chars_per_line=safe_int("max_chars_per_line", DEFAULT_MAX_CHARS_PER_LINE),
|
||||
line_spacing=safe_int("line_spacing", 0),
|
||||
fade_in=max(0.0, safe_float("fade_in", 0.0)),
|
||||
fade_out=max(0.0, safe_float("fade_out", 0.0)),
|
||||
animation_type=safe_str("animation_type", "none"),
|
||||
)
|
||||
|
||||
@property
|
||||
def alignment(self) -> int:
|
||||
"""获取 ASS alignment 编号."""
|
||||
return POSITION_ALIGNMENT.get(self.position, 2)
|
||||
|
||||
@property
|
||||
def ass_font_color(self) -> str:
|
||||
"""ASS 格式颜色 &HAABBGGRR."""
|
||||
return _hex_to_ass_color(self.font_color)
|
||||
|
||||
@property
|
||||
def ass_stroke_color(self) -> str:
|
||||
return _hex_to_ass_color(self.stroke_color)
|
||||
|
||||
@property
|
||||
def ass_shadow_color(self) -> str:
|
||||
return _hex_to_ass_color(self.shadow_color)
|
||||
|
||||
@property
|
||||
def ass_background_color(self) -> str:
|
||||
"""背景框颜色(ASS BackColour),带透明度."""
|
||||
alpha_hex = _opacity_to_ass_alpha(self.background_opacity)
|
||||
color_bgr = _hex_to_ass_bgr(self.background_color)
|
||||
return f"&H{alpha_hex}{color_bgr}"
|
||||
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _hex_to_ass_color(hex_color: str) -> str:
|
||||
"""HEX → ASS 颜色 &HAABBGGRR(默认不透明)."""
|
||||
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"&H00{b.upper()}{g.upper()}{r.upper()}"
|
||||
|
||||
|
||||
def _hex_to_ass_bgr(hex_color: str) -> str:
|
||||
"""HEX → ASS BGR 部分(不含 alpha)."""
|
||||
hex_color = hex_color.lstrip("#")
|
||||
if len(hex_color) != 6:
|
||||
return "FFFFFF"
|
||||
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
|
||||
return f"{b.upper()}{g.upper()}{r.upper()}"
|
||||
|
||||
|
||||
def _opacity_to_ass_alpha(opacity: float) -> str:
|
||||
"""不透明度 → ASS alpha(00=不透明,FF=完全透明)."""
|
||||
alpha = 255 - int(opacity * 255)
|
||||
return f"{alpha:02X}"
|
||||
|
||||
|
||||
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 _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 _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:
|
||||
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
|
||||
|
||||
|
||||
# ── 字幕片段 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubtitleSegment:
|
||||
"""单个字幕片段."""
|
||||
|
||||
start: float # 开始时间(秒)
|
||||
end: float # 结束时间(秒)
|
||||
text: str # 字幕文本
|
||||
style_name: str = "Default" # 使用的样式名
|
||||
|
||||
|
||||
# ── 字幕渲染引擎 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class SubtitleRenderEngine:
|
||||
"""字幕渲染引擎 — 统一管理多源字幕的 ASS 文件生成.
|
||||
|
||||
支持合并多个字幕来源到同一个 ASS 文件:
|
||||
- 标题(顶部,单独样式)
|
||||
- 字幕(底部,单独样式)
|
||||
- ASR 时间轴字幕
|
||||
- 手动字幕
|
||||
|
||||
输出一个统一的 ASS 文件,供 FFmpeg subtitles filter 烧录。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
video_width: int = 1080,
|
||||
video_height: int = 1920,
|
||||
video_duration: float = 0.0,
|
||||
):
|
||||
self.video_width = video_width
|
||||
self.video_height = video_height
|
||||
self.video_duration = video_duration
|
||||
self._styles: dict[str, SubtitleStyle] = {}
|
||||
self._segments: list[SubtitleSegment] = []
|
||||
self._style_counter = 0
|
||||
|
||||
# ── 样式管理 ──────────────────────────────────────────────────────
|
||||
|
||||
def add_style(self, name: str, style: SubtitleStyle) -> str:
|
||||
"""注册一个样式,返回样式名."""
|
||||
self._styles[name] = style
|
||||
return name
|
||||
|
||||
def get_or_create_style(self, base_name: str, style: SubtitleStyle) -> str:
|
||||
"""获取或创建样式(避免重复)."""
|
||||
if base_name in self._styles:
|
||||
return base_name
|
||||
self._styles[base_name] = style
|
||||
return base_name
|
||||
|
||||
# ── 字幕源添加 ────────────────────────────────────────────────────
|
||||
|
||||
def add_title(self, text: str, style: SubtitleStyle | None = None) -> None:
|
||||
"""添加整段标题(显示整个视频时长)."""
|
||||
if not text or not text.strip():
|
||||
return
|
||||
|
||||
style = style or SubtitleStyle(
|
||||
position="top_center",
|
||||
font_size=48,
|
||||
bold=True,
|
||||
stroke_enabled=True,
|
||||
stroke_width=2.0,
|
||||
)
|
||||
style_name = self.get_or_create_style("TitleStyle", style)
|
||||
|
||||
self._segments.append(
|
||||
SubtitleSegment(
|
||||
start=0.0,
|
||||
end=self.video_duration if self.video_duration > 0 else 9999.0,
|
||||
text=text.strip(),
|
||||
style_name=style_name,
|
||||
)
|
||||
)
|
||||
|
||||
def add_subtitle_text(self, text: str, style: SubtitleStyle | None = None) -> None:
|
||||
"""添加整段字幕(显示整个视频时长)."""
|
||||
if not text or not text.strip():
|
||||
return
|
||||
|
||||
style = style or SubtitleStyle()
|
||||
style_name = self.get_or_create_style("SubtitleStyle", style)
|
||||
|
||||
self._segments.append(
|
||||
SubtitleSegment(
|
||||
start=0.0,
|
||||
end=self.video_duration if self.video_duration > 0 else 9999.0,
|
||||
text=text.strip(),
|
||||
style_name=style_name,
|
||||
)
|
||||
)
|
||||
|
||||
def add_timeline_segments(
|
||||
self,
|
||||
segments: list[dict] | list[SubtitleSegment],
|
||||
style: SubtitleStyle | None = None,
|
||||
) -> None:
|
||||
"""添加时间轴字幕片段(ASR 或手动字幕).
|
||||
|
||||
segments 可以是:
|
||||
- SubtitleSegment 列表
|
||||
- dict 列表,每个 dict 含 start/end/text 字段
|
||||
"""
|
||||
if not segments:
|
||||
return
|
||||
|
||||
style = style or SubtitleStyle()
|
||||
style_name = self.get_or_create_style("Default", style)
|
||||
|
||||
for seg in segments:
|
||||
if isinstance(seg, SubtitleSegment):
|
||||
seg.style_name = style_name
|
||||
self._segments.append(seg)
|
||||
elif isinstance(seg, dict):
|
||||
try:
|
||||
start = float(seg.get("start", 0))
|
||||
end = float(seg.get("end", 0))
|
||||
text = str(seg.get("text", ""))
|
||||
if end > start and text.strip():
|
||||
self._segments.append(
|
||||
SubtitleSegment(
|
||||
start=start,
|
||||
end=end,
|
||||
text=text.strip(),
|
||||
style_name=style_name,
|
||||
)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
def add_asr_timeline(self, timeline: Any, style: SubtitleStyle | None = None) -> None:
|
||||
"""从 SubtitleTimeline 对象添加 ASR 字幕."""
|
||||
if not timeline or not hasattr(timeline, "segments") or not timeline.segments:
|
||||
return
|
||||
|
||||
style = style or SubtitleStyle()
|
||||
style_name = self.get_or_create_style("ASRStyle", style)
|
||||
|
||||
for seg in timeline.segments:
|
||||
if hasattr(seg, "start") and hasattr(seg, "end") and hasattr(seg, "text"):
|
||||
if seg.end > seg.start and seg.text.strip():
|
||||
self._segments.append(
|
||||
SubtitleSegment(
|
||||
start=seg.start,
|
||||
end=seg.end,
|
||||
text=seg.text.strip(),
|
||||
style_name=style_name,
|
||||
)
|
||||
)
|
||||
|
||||
# ── ASS 文件生成 ──────────────────────────────────────────────────
|
||||
|
||||
def generate_ass(self, output_path: Path) -> Path:
|
||||
"""生成 ASS 字幕文件.
|
||||
|
||||
Returns:
|
||||
生成的文件路径;如果没有字幕内容,返回空文件。
|
||||
"""
|
||||
if not self._segments:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text("", encoding="utf-8")
|
||||
return output_path
|
||||
|
||||
# 确保至少有 Default 样式
|
||||
if "Default" not in self._styles:
|
||||
self._styles["Default"] = SubtitleStyle()
|
||||
|
||||
# 生成样式行
|
||||
style_lines = []
|
||||
for name, style in self._styles.items():
|
||||
style_lines.append(self._build_ass_style_line(name, style))
|
||||
|
||||
# 生成事件行(按时间排序)
|
||||
self._segments.sort(key=lambda s: s.start)
|
||||
event_lines = []
|
||||
for seg in self._segments:
|
||||
event_lines.append(self._build_ass_event_line(seg))
|
||||
|
||||
# 组装文件
|
||||
ass_content = f"""[Script Info]
|
||||
ScriptType: v4.00+
|
||||
PlayResX: {self.video_width}
|
||||
PlayResY: {self.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
|
||||
{chr(10).join(style_lines)}
|
||||
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
{chr(10).join(event_lines)}
|
||||
"""
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(ass_content, encoding="utf-8")
|
||||
return output_path
|
||||
|
||||
def _build_ass_style_line(self, name: str, style: SubtitleStyle) -> str:
|
||||
"""构建一条 ASS Style 行."""
|
||||
bold_val = -1 if style.bold else 0
|
||||
italic_val = -1 if style.italic else 0
|
||||
|
||||
# BorderStyle: 1=outline+shadow, 3=opaque box(背景框)
|
||||
if style.background_enabled:
|
||||
border_style = 3
|
||||
back_color = style.ass_background_color
|
||||
else:
|
||||
border_style = 1
|
||||
back_color = style.ass_shadow_color if style.shadow_enabled else style.ass_font_color
|
||||
|
||||
outline_val = style.stroke_width if style.stroke_enabled else 0.0
|
||||
shadow_val = style.shadow_offset_y if style.shadow_enabled else 0
|
||||
|
||||
return (
|
||||
f"Style: {name},{style.font_name},{style.font_size},{style.ass_font_color},"
|
||||
f"&H000000FF,{style.ass_stroke_color},{back_color},"
|
||||
f"{bold_val},{italic_val},0,0,100,100,0,0,"
|
||||
f"{border_style},{outline_val},{shadow_val},{style.alignment},"
|
||||
f"{style.margin_l},{style.margin_r},{style.margin_v},1"
|
||||
)
|
||||
|
||||
def _build_ass_event_line(self, seg: SubtitleSegment) -> str:
|
||||
"""构建一条 ASS Dialogue 事件行."""
|
||||
style = self._styles.get(seg.style_name, SubtitleStyle())
|
||||
max_chars = style.max_chars_per_line
|
||||
|
||||
# 自动换行
|
||||
lines = _wrap_text(seg.text, max_chars)
|
||||
display_text = "\\N".join(lines)
|
||||
|
||||
# 动画效果(淡入淡出)
|
||||
effect_tags = ""
|
||||
if style.fade_in > 0 or style.fade_out > 0:
|
||||
fade_in_ms = int(style.fade_in * 1000)
|
||||
fade_out_ms = int(style.fade_out * 1000)
|
||||
effect_tags = f"{{\\fad({fade_in_ms},{fade_out_ms})}}"
|
||||
|
||||
safe_text = _escape_ass_text(display_text)
|
||||
start_time = _format_ass_time(max(0, seg.start))
|
||||
end_time = _format_ass_time(max(seg.start + 0.1, seg.end))
|
||||
|
||||
return f"Dialogue: 0,{start_time},{end_time},{seg.style_name},,0,0,0,," f"{effect_tags}{safe_text}"
|
||||
|
||||
@property
|
||||
def has_subtitles(self) -> bool:
|
||||
"""是否有字幕内容."""
|
||||
return len(self._segments) > 0
|
||||
|
||||
|
||||
# ── 便捷函数:从 plan.config 快速生成 ASS ────────────────────────────────────
|
||||
|
||||
|
||||
def build_subtitles_from_plan(
|
||||
output_path: Path,
|
||||
plan_config: dict,
|
||||
*,
|
||||
video_width: int,
|
||||
video_height: int,
|
||||
video_duration: float,
|
||||
asr_timeline: Any = None,
|
||||
) -> Path | None:
|
||||
"""从 plan.config 构建字幕 ASS 文件.
|
||||
|
||||
支持的配置项:
|
||||
- title_config: 标题配置(含 text/style)
|
||||
- subtitle_config: 字幕配置(含 text/style)
|
||||
- asr_subtitles: ASR 字幕开关 + 样式
|
||||
- manual_subtitles: 手动字幕片段列表
|
||||
|
||||
Returns:
|
||||
生成的 ASS 文件路径;如果没有任何字幕,返回 None
|
||||
"""
|
||||
engine = SubtitleRenderEngine(
|
||||
video_width=video_width,
|
||||
video_height=video_height,
|
||||
video_duration=video_duration,
|
||||
)
|
||||
|
||||
has_any = False
|
||||
|
||||
# 1. 标题
|
||||
title_cfg = plan_config.get("title_config") or {}
|
||||
if isinstance(title_cfg, dict):
|
||||
title_text = str(title_cfg.get("text", ""))
|
||||
title_enabled = title_cfg.get("enabled", True)
|
||||
if title_enabled and title_text.strip():
|
||||
style_dict = title_cfg.get("style") or {}
|
||||
style = SubtitleStyle.from_dict(style_dict)
|
||||
# 标题默认样式:顶部、大字号、粗体
|
||||
if style.position == DEFAULT_POSITION and style.font_size == DEFAULT_FONT_SIZE:
|
||||
style.position = "top_center"
|
||||
style.font_size = 48
|
||||
style.bold = True
|
||||
engine.add_title(title_text, style)
|
||||
has_any = True
|
||||
|
||||
# 2. 静态字幕
|
||||
sub_cfg = plan_config.get("subtitle_config") or {}
|
||||
if isinstance(sub_cfg, dict):
|
||||
sub_text = str(sub_cfg.get("text", ""))
|
||||
sub_enabled = sub_cfg.get("enabled", True)
|
||||
if sub_enabled and sub_text.strip():
|
||||
style_dict = sub_cfg.get("style") or {}
|
||||
style = SubtitleStyle.from_dict(style_dict)
|
||||
engine.add_subtitle_text(sub_text, style)
|
||||
has_any = True
|
||||
|
||||
# 3. ASR 自动字幕
|
||||
asr_cfg = plan_config.get("asr_subtitles") or {}
|
||||
if isinstance(asr_cfg, dict) and asr_cfg.get("enabled", False):
|
||||
if asr_timeline is not None:
|
||||
style_dict = asr_cfg.get("style") or {}
|
||||
style = SubtitleStyle.from_dict(style_dict)
|
||||
engine.add_asr_timeline(asr_timeline, style)
|
||||
has_any = has_any or engine.has_subtitles
|
||||
|
||||
# 4. 手动字幕
|
||||
manual_segs = plan_config.get("manual_subtitles") or []
|
||||
if isinstance(manual_segs, list) and manual_segs:
|
||||
style_dict = (plan_config.get("manual_subtitle_style") or {}) or {}
|
||||
style = SubtitleStyle.from_dict(style_dict)
|
||||
engine.add_timeline_segments(manual_segs, style)
|
||||
has_any = has_any or engine.has_subtitles
|
||||
|
||||
if not has_any:
|
||||
return None
|
||||
|
||||
return engine.generate_ass(output_path)
|
||||
|
||||
|
||||
# ── FFmpeg 烧录滤镜生成 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_subtitle_filter(
|
||||
ass_path: Path | str,
|
||||
*,
|
||||
video_input_label: str = "0:v",
|
||||
output_label: str = "subtitled",
|
||||
work_dir: Path | str | None = None,
|
||||
) -> str:
|
||||
"""生成 FFmpeg subtitles 滤镜字符串.
|
||||
|
||||
Args:
|
||||
ass_path: ASS 字幕文件路径
|
||||
video_input_label: 视频输入标签(如 "0:v" 或 "[v_out]")
|
||||
output_label: 输出标签
|
||||
work_dir: 工作目录(必填,用于路径安全校验,防止路径遍历绕过)
|
||||
|
||||
Returns:
|
||||
filter_complex 片段,如 "[0:v]subtitles=xxx.ass[subtitled]"
|
||||
|
||||
Raises:
|
||||
PathSecurityError: 字幕路径不安全或 work_dir 未提供
|
||||
"""
|
||||
# ── 安全校验:字幕文件路径白名单 ──
|
||||
ass_path_str = str(ass_path)
|
||||
if work_dir is None or not str(work_dir).strip():
|
||||
raise PathSecurityError("work_dir 必须提供,不能为 None 或空")
|
||||
|
||||
_validate_subtitle_path(ass_path_str, Path(work_dir))
|
||||
|
||||
# FFmpeg subtitles filter 的路径需要转义:
|
||||
# - Windows 路径的 \ → /
|
||||
# - 冒号 : → \:
|
||||
# - 单引号 ' → '\''
|
||||
safe_path = ass_path_str.replace("\\", "/").replace(":", "\\:").replace("'", "'\\''")
|
||||
return f"{video_input_label}subtitles='{safe_path}'[{output_label}]"
|
||||
|
||||
|
||||
def _validate_subtitle_path(subtitle_path: str, work_dir: Path) -> None:
|
||||
"""校验字幕文件路径安全性.
|
||||
|
||||
规则:
|
||||
- 必须是本地路径(不支持远程URL字幕)
|
||||
- local:// schema → 必须在 work_dir 内
|
||||
- 相对路径 → 必须在 work_dir 内
|
||||
- 绝对路径 → 必须在允许目录白名单内
|
||||
- 扩展名必须是字幕格式
|
||||
|
||||
Raises:
|
||||
PathSecurityError: 路径不安全
|
||||
"""
|
||||
if not subtitle_path or not isinstance(subtitle_path, str):
|
||||
raise PathSecurityError("字幕路径不能为空")
|
||||
|
||||
# 不允许远程URL字幕(subtitles滤镜不支持远程加载,且有SSRF风险)
|
||||
if subtitle_path.startswith(("http://", "https://", "oss://")):
|
||||
raise PathSecurityError("不允许使用远程URL字幕文件")
|
||||
|
||||
is_abs = subtitle_path.startswith("/") and not subtitle_path.startswith("local://")
|
||||
|
||||
resolved_path = safe_resolve_path(
|
||||
subtitle_path,
|
||||
work_dir,
|
||||
allow_outside=is_abs,
|
||||
allowed_extensions=ALLOWED_SUBTITLE_EXTENSIONS,
|
||||
)
|
||||
|
||||
# 绝对路径额外检查白名单目录(用realpath规范化后的真实路径比较,防止 ../ 遍历绕过)
|
||||
if is_abs:
|
||||
resolved_work_dir = work_dir.resolve()
|
||||
try:
|
||||
resolved_path.relative_to(resolved_work_dir)
|
||||
except ValueError:
|
||||
if not is_in_allowed_dirs(resolved_path):
|
||||
raise PathSecurityError(f"字幕路径不在允许目录内: {subtitle_path[:80]}")
|
||||
@@ -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"},
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
# 三选二推导
|
||||
# 判断顺序很重要:先判断需要两个显式值的组合,最后判断含默认值的
|
||||
# 情况1:start + 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
|
||||
# 情况2:end + duration 都有显式值
|
||||
elif end > 0 and dur > 0:
|
||||
start = end - dur
|
||||
if start < 0:
|
||||
start = 0.0
|
||||
dur = end # 重新计算
|
||||
# 情况3:start + 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)
|
||||
@@ -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
|
||||
Regular → Executable
+21
-753
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user