Compare commits

..

1 Commits

Author SHA1 Message Date
saas-agent a4c6f492a7 feat(generate): #1970 智能剪辑流程重构 - 选择模式→素材→标题→确认→封面
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m35s
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m31s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m48s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 28s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m4s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m13s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 5m40s
AI Code Review / AI Code Review (pull_request) Successful in 7m0s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m42s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 17m36s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 18s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 50s
## 主流程改造
- STEPS 改为 5 步:选择模式→选择素材→选择标题→确认生成→选择封面
- 原"选择配音"步骤从主流程移除,改为 Step1 下一步分支弹窗
- Step1 新组件 Step1EditMode:模式卡片(🎲随机混剪/📖叙事剪辑,紫底紫框) + 生成设置(数量-/+1-10、比例9:16/16:9、智能降重开关默认开)

## 新增 3 个弹窗
- VoiceSelectModal(随机混剪):复用配音库音频卡片+▶试听,无 TTS/克隆入口
- ScriptSelectModal(叙事剪辑):搜索框+300ms防抖+命中黄高亮+标签筛选(全部/带货/工厂/测评/教程/口播/种草)+max-height 420px 卡片列表
- TtsVoiceModal(叙事剪辑合成配音):Tabs(系统音色/🎙️我的克隆音色) + 2列音色卡片 + 合成按钮 + loading(spinner+"正在合成配音..."+"通常10-30秒")

## Step2 素材页
- 叙事剪辑模式显示"🤖 AI智能匹配"紫色提示卡

## Step4 确认页
- 摘要卡片按 editMode 展示剪辑模式+配音来源/文案标题+TTS音色+比例+降重

## payload 新增字段
- assembly_mode('random'|'narrative')、script_id、tts_voice_id、tts_voice_source('preset'|'clone')、dedup_enabled(默认true)、video_ratio('9:16')

## 不改动
- Step3/Step5 内容、文案库/配音库页面、AI数字人
2026-09-18 00:02:18 +08:00
168 changed files with 938 additions and 16967 deletions
+4 -24
View File
@@ -198,13 +198,10 @@ DOUBAO_TIMEOUT=30
DOUBAO_MAX_RETRIES=2
# ==================== 积分/会员系统 (#1895) ====================
# 积分系统总开关:默认 false暂停积分系统)。
# - false:生成视频/口型同步/数字人/AI标题/TTS/克隆音色等所有功能对登录
# 用户免费放行,不扣积分、不做余额拦截;积分余额/流水/会员状态查询接口
# 保留可用,但数据不再变动。积分相关的表、代码、接口均保留不删除
# - 恢复积分:设置 ENABLE_CREDIT_SYSTEM=true 即可,无需改代码。
ENABLE_CREDIT_SYSTEM=false
# 旧开关名(兼容别名):与 ENABLE_CREDIT_SYSTEM 任一为 true 即启用。
# 积分扣点总开关:默认 false对现有用户零影响)。
# P2 阶段各业务路由逐个接入 @points_gate 时,用
# `if settings.points_enabled: ...`
# 包裹扣点逻辑;所有路由接入完成并验证通过后再在 staging/prod 打开
POINTS_ENABLED=false
# ==================== 抖音解析多源轮询 (#1963) ====================
@@ -216,20 +213,3 @@ TIKHUB_API_KEY=
# apizero.cn API Key (https://v1.apizero.cn) — 国内抖音解析服务
APIZERO_API_KEY=
# ==================== GPU MuseTalk Worker(反向轮询口型同步)====================
# GPU Worker 长期鉴权 TokenWorker 端 .env 的 GPU_WORKER_TOKEN 必须与此一致
# 留空时 development 环境允许匿名访问(仅本地调试),staging/production 必须配置
GPU_WORKER_TOKEN=
# 单任务超时(秒),processing 超过此时长无任务心跳才回退 pending 或标记 failed
# #1970RTX2060 6G 推理 720p 长视频需 5 分钟以上,默认 900
GPU_TASK_TIMEOUT_SECONDS=900
# 是否启用 GPU 口型同步(开关)。开启后需同时有 Worker 在心跳窗口内(5分钟)才会走 GPU 路径;
# 开关关闭 / 无可用 Worker / GPU 任务失败或超时 → 自动回退现有 MediaKit 云端 lipsync
USE_GPU_LIPSYNC=false
# 业务侧轮询 GPU 任务结果的间隔(秒)
GPU_LIPSYNC_POLL_INTERVAL=5
# 业务侧等待 GPU 任务总超时(秒);超时回退 MediaKit
GPU_LIPSYNC_WAIT_TIMEOUT=1200
# Worker 心跳新鲜度窗口(秒),last_heartbeat_at 在此窗口内视为在线
GPU_WORKER_STALE_SECONDS=300
-4
View File
@@ -1186,12 +1186,10 @@ jobs:
DOUBAO_API_KEY: "${{ secrets.DOUBAO_API_KEY }}"
DOUBAO_MODEL: "${{ secrets.DOUBAO_MODEL }}"
DOUBAO_BASE_URL: "${{ secrets.DOUBAO_BASE_URL }}"
DOUBAO_VISION_MODEL: "${{ secrets.DOUBAO_VISION_MODEL }}"
WECHAT_APP_ID: "${{ secrets.WECHAT_APP_ID }}"
WECHAT_APP_SECRET: "${{ secrets.WECHAT_APP_SECRET }}"
TIKHUB_API_KEY: "${{ secrets.TIKHUB_API_KEY }}"
APIZERO_API_KEY: "${{ secrets.APIZERO_API_KEY }}"
GPU_WORKER_TOKEN: "${{ secrets.GPU_WORKER_TOKEN }}"
run: |
set -eu
echo "Rendering .env from template + secrets..."
@@ -1642,12 +1640,10 @@ jobs:
DOUBAO_API_KEY: "${{ secrets.DOUBAO_API_KEY }}"
DOUBAO_MODEL: "${{ secrets.DOUBAO_MODEL }}"
DOUBAO_BASE_URL: "${{ secrets.DOUBAO_BASE_URL }}"
DOUBAO_VISION_MODEL: "${{ secrets.DOUBAO_VISION_MODEL }}"
WECHAT_APP_ID: "${{ secrets.WECHAT_APP_ID }}"
WECHAT_APP_SECRET: "${{ secrets.WECHAT_APP_SECRET }}"
TIKHUB_API_KEY: "${{ secrets.TIKHUB_API_KEY }}"
APIZERO_API_KEY: "${{ secrets.APIZERO_API_KEY }}"
GPU_WORKER_TOKEN: "${{ secrets.GPU_WORKER_TOKEN }}"
run: |
set -eu
echo "Rendering .env from template + secrets..."
-58
View File
@@ -1,58 +0,0 @@
"""add asset_atom_clips table
Revision ID: 079_asset_atom_clips
Revises: 078_drop_script_title_fields
Create Date: 2026-09-17
"""
import sqlalchemy as sa
from alembic import op
revision = "079_asset_atom_clips"
down_revision = "078_drop_script_title_fields"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"asset_atom_clips",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column(
"asset_id",
sa.String(36),
sa.ForeignKey("assets.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("start_time", sa.Float(), nullable=False),
sa.Column("end_time", sa.Float(), nullable=False),
sa.Column("duration", sa.Float(), nullable=False),
sa.Column("clip_index", sa.Integer(), nullable=False),
sa.Column("tags", sa.JSON(), nullable=False, server_default=sa.text("'[]'")),
sa.Column("scene_change_at", sa.Float(), nullable=True),
sa.Column(
"is_fallback",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("NOW()"),
),
)
# 按素材查片段并按索引排序(复合索引前缀可独立用于 asset_id 过滤)
op.create_index(
"ix_asset_atom_clips_asset_index",
"asset_atom_clips",
["asset_id", "clip_index"],
unique=True,
)
def downgrade() -> None:
op.drop_index("ix_asset_atom_clips_asset_index", table_name="asset_atom_clips")
op.drop_table("asset_atom_clips")
@@ -1,37 +0,0 @@
"""add edit_plan_clips.atom_clip_id for #1970
Revision ID: 080_edit_plan_clips_atom_clip_id
Revises: 079_asset_atom_clips
Create Date: 2026-09-17
"""
import sqlalchemy as sa
from alembic import op
revision = "080_edit_plan_clips_atom_clip_id"
down_revision = "079_asset_atom_clips"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"edit_plan_clips",
sa.Column(
"atom_clip_id",
sa.String(36),
nullable=False,
server_default=sa.text("''"),
),
)
op.create_index(
"ix_edit_plan_clips_atom_clip_id",
"edit_plan_clips",
["atom_clip_id"],
)
def downgrade() -> None:
op.drop_index("ix_edit_plan_clips_atom_clip_id", table_name="edit_plan_clips")
op.drop_column("edit_plan_clips", "atom_clip_id")
@@ -1,58 +0,0 @@
"""add gpu_lipsync_tasks and gpu_workers tables for MuseTalk reverse-poll worker
Revision ID: 081_add_gpu_lipsync
Revises: 080_edit_plan_clips_atom_clip_id
Create Date: 2026-09-18
"""
import sqlalchemy as sa
from alembic import op
revision = "081_add_gpu_lipsync"
down_revision = "080_edit_plan_clips_atom_clip_id"
branch_labels = None
depends_on = None
def upgrade() -> None:
# GPU Worker 注册表
op.create_table(
"gpu_workers",
sa.Column("worker_id", sa.String(100), primary_key=True),
sa.Column("hostname", sa.String(200), nullable=False, server_default=""),
sa.Column("gpu_name", sa.String(200), nullable=False, server_default=""),
sa.Column("free_vram_mb", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("capabilities", sa.String(500), nullable=False, server_default=""),
sa.Column("last_heartbeat_at", sa.DateTime(), nullable=True, index=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
# GPU 口型同步任务表
op.create_table(
"gpu_lipsync_tasks",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("lipsync_job_id", sa.String(36), nullable=False, server_default="", index=True),
sa.Column("user_id", sa.String(36), nullable=False, server_default="", index=True),
sa.Column("project_id", sa.String(36), nullable=False, server_default="", index=True),
sa.Column("video_url", sa.Text(), nullable=False),
sa.Column("audio_url", sa.Text(), nullable=False),
sa.Column("result_url", sa.Text(), nullable=False, server_default=""),
sa.Column("result_duration", sa.Float(), nullable=False, server_default=sa.text("0.0")),
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
sa.Column("worker_id", sa.String(100), nullable=False, server_default="", index=True),
sa.Column("attempt", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("error_msg", sa.Text(), nullable=False, server_default=""),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("started_at", sa.DateTime(), nullable=True),
sa.Column("finished_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("last_heartbeat_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_gpu_lipsync_status_created", "gpu_lipsync_tasks", ["status", "created_at"])
def downgrade() -> None:
op.drop_index("ix_gpu_lipsync_status_created", table_name="gpu_lipsync_tasks")
op.drop_table("gpu_lipsync_tasks")
op.drop_table("gpu_workers")
-26
View File
@@ -1,26 +0,0 @@
"""add ai_tags to asset_atom_clips for #1970 fragment-level AI tagging
Revision ID: 082_atom_clip_ai_tags
Revises: 081_add_gpu_lipsync
Create Date: 2026-09-18
"""
import sqlalchemy as sa
from alembic import op
revision = "082_atom_clip_ai_tags"
down_revision = "081_add_gpu_lipsync"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"asset_atom_clips",
sa.Column("ai_tags", sa.JSON(), nullable=True),
)
def downgrade() -> None:
op.drop_column("asset_atom_clips", "ai_tags")
@@ -1,26 +0,0 @@
"""#2001 爆款标题样式面板升级: ai_avatar_render_jobs 新增 cover_title_config
Revision ID: 083_cover_title_config
Revises: 082_atom_clip_ai_tags
Create Date: 2026-09-20
"""
import sqlalchemy as sa
from alembic import op
revision = "083_cover_title_config"
down_revision = "082_atom_clip_ai_tags"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"ai_avatar_render_jobs",
sa.Column("cover_title_config", sa.JSON(), nullable=False, server_default=sa.text("'{}'")),
)
def downgrade() -> None:
op.drop_column("ai_avatar_render_jobs", "cover_title_config")
@@ -1,26 +0,0 @@
"""lipsync_jobs 新增 style 字段(TTS 语气风格)
Revision ID: 084_lipsync_jobs_style
Revises: 083_cover_title_config
Create Date: 2026-09-21
"""
import sqlalchemy as sa
from alembic import op
revision = "084_lipsync_jobs_style"
down_revision = "083_cover_title_config"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"lipsync_jobs",
sa.Column("style", sa.String(length=32), nullable=False, server_default=""),
)
def downgrade() -> None:
op.drop_column("lipsync_jobs", "style")
-6
View File
@@ -14,7 +14,6 @@ from app.api.routes.generation_cover import router as generation_cover_router
from app.api.routes.generation_preview import router as generation_preview_router
from app.api.routes.generation_tasks import router as generation_tasks_router
from app.api.routes.generation_variant_plans import router as generation_variant_plans_router
from app.api.routes.gpu_lipsync import router as gpu_lipsync_router
from app.api.routes.health import router as health_check_router
from app.api.routes.ingest_jobs import router as ingest_jobs_router
from app.api.routes.internal_render import router as internal_render_router
@@ -212,8 +211,3 @@ api_router.include_router(
prefix="/usage",
tags=["Usage"],
)
api_router.include_router(
gpu_lipsync_router,
prefix="/gpu",
tags=["GPU Worker"],
)
@@ -63,7 +63,6 @@ def create_render_job(
b_roll_segments=[s.model_dump() for s in body.b_roll_segments],
title_config=body.title_config,
cover_config=body.cover_config,
cover_title_config=body.cover_title_config,
project_id=body.project_id,
)
except AiAvatarRenderError as exc:
+4 -159
View File
@@ -16,12 +16,10 @@ from app.core.task_enqueue import (
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_cosyvoice_service,
get_db_session,
get_generated_video_repository,
get_generation_task_repository,
get_project_repository,
get_voice_clone_profile_repository,
)
from app.schemas.generated_video import (
GeneratedVideoResponse,
@@ -134,8 +132,6 @@ def _select_assets_from_library(
mode: str,
count: int,
rng=None,
script_tags: list | None = None,
tag_names_by_id: dict | None = None,
) -> list[str]:
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
@@ -145,8 +141,6 @@ def _select_assets_from_library(
count: 选取数量,0 表示全部(仅 smart 模式有效)
rng: 可选随机源(smart 模式排序噪声用),生产环境不传则内部随机;
测试可注入固定种子或零噪声随机源获得确定性结果。
script_tags: #1970 叙事模式文案标签;非空时标签命中素材优先,不足再用其余素材兜底。
tag_names_by_id: asset_id → 素材标签名列表(素材只存 tag_ids 时由调用方查名称注入)。
Returns:
选中的素材 ID 列表
@@ -156,20 +150,6 @@ def _select_assets_from_library(
if not ready_video_assets:
return []
# 叙事模式(#1970 PR3):文案标签命中池优先;无任何命中时完全降级为现有随机逻辑。
if script_tags:
from packages.domain.narrative_match import pick_narrative_assets
limit = count if count > 0 else None
picked = pick_narrative_assets(
ready_video_assets,
script_tags=script_tags,
tag_names_by_id=tag_names_by_id,
limit=limit,
rng=rng,
)
return [a.id for a in picked]
if mode == "smart":
# 智能匹配:统一使用 packages/domain/smart_match.py 的多维评分+多样性选取
# 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%)
@@ -182,78 +162,16 @@ def _select_assets_from_library(
return [a.id for a in ready_video_assets]
# #1970 PR3video_ratio → 默认输出分辨率(显式 output_width/output_height 优先)
_VIDEO_RATIO_DIMENSIONS = {
"9:16": (1080, 1920),
"16:9": (1920, 1080),
"1:1": (1080, 1080),
"3:4": (1080, 1440),
"4:3": (1440, 1080),
}
def _resolve_output_dimensions(request: CreateGenerationTaskRequest) -> tuple[int, int]:
"""解析输出分辨率:显式 output_width/output_height 非旧默认值时优先,否则按 video_ratio。
前端 #1973 总是同时传 video_ratio 与具体分辨率,两者一致;此函数主要服务
只传比例的调用方,并保证旧调用(不传比例)维持 1280x720 行为。
"""
width, height = request.output_width, request.output_height
ratio = (request.video_ratio or "").strip()
if ratio in _VIDEO_RATIO_DIMENSIONS and (width, height) == (1280, 720):
return _VIDEO_RATIO_DIMENSIONS[ratio]
return width, height
def _load_asset_tag_names(db: Session, assets: list, user_id: str) -> dict[str, list[str]]:
"""叙事模式:查 TagModel 名称,构造 asset_id → 标签名列表(失败返回空 dict 降级随机)。"""
try:
from packages.adapters.sqlalchemy_impl.models import AssetTagModel, TagModel
tag_ids = {tid for a in assets for tid in (getattr(a, "tag_ids", None) or [])}
if not tag_ids:
return {}
name_rows = (
db.query(TagModel.id, TagModel.name).filter(TagModel.id.in_(tag_ids), TagModel.user_id == user_id).all()
)
name_by_id = {row.id: row.name for row in name_rows}
links = db.query(AssetTagModel.asset_id, AssetTagModel.tag_id).filter(AssetTagModel.tag_id.in_(tag_ids)).all()
index: dict[str, list[str]] = {}
for asset_id, tag_id in links:
name = name_by_id.get(tag_id)
if name:
index.setdefault(asset_id, []).append(name)
return index
except Exception: # noqa: BLE001 - 标签匹配是加分项,查询失败不阻断生成
logger.warning("[叙事模式] 素材标签查询失败,降级随机选片", exc_info=True)
return {}
def _writeback_edit_plan_config(
plan_id: str,
task_id: str,
title_config: dict | None,
db: Session,
dedup_enabled: bool | None = None,
video_index: int | None = None,
assembly_mode: str | None = None,
script_id: str | None = None,
video_ratio: str | None = None,
) -> None:
"""[已下沉] 路由层兼容别名 → app.services.generation_common.writeback_edit_plan_config。"""
from app.services.generation_common import writeback_edit_plan_config
return writeback_edit_plan_config(
plan_id,
task_id,
title_config,
db,
dedup_enabled=dedup_enabled,
video_index=video_index,
assembly_mode=assembly_mode,
script_id=script_id,
video_ratio=video_ratio,
)
return writeback_edit_plan_config(plan_id, task_id, title_config, db)
def _resolve_project_and_library(
@@ -303,63 +221,16 @@ def create_generation_task(
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
db: Session = Depends(get_db_session),
cosyvoice_service: Any = Depends(get_cosyvoice_service),
voice_clone_repository: Any = Depends(get_voice_clone_profile_repository),
) -> BatchGenerationTaskResponse:
logger.info(
"[生成任务] 接收请求: user_id=%s, template_id=%s, asset_count=%d, mode=%s, assembly=%s, count=%d",
"[生成任务] 接收请求: user_id=%s, template_id=%s, asset_count=%d, mode=%s, count=%d",
authenticated_user.user.id,
request.template_id,
len(request.asset_ids),
request.asset_select_mode,
request.assembly_mode,
request.count,
)
# video_ratio → 默认分辨率(显式分辨率优先)
request.output_width, request.output_height = _resolve_output_dimensions(request)
# ── #1970 PR3 叙事模式:入队前同步合成配音并落为 audio asset ──
# 合成结果覆盖 voice_library_id(下游按 audio asset id 消费),失败直接 4xx 不入队。
narrative_script_tags: list = []
if request.assembly_mode == "narrative":
from app.config import settings as _settings
from app.services.narrative_service import NarrativeError, prepare_narrative_voice
from packages.adapters.sqlalchemy_impl.tts_job_repository import SQLAlchemyTTSJobRepository
try:
narrative_ctx = prepare_narrative_voice(
db=db,
user_id=authenticated_user.user.id,
script_id=request.script_id,
tts_voice_id=request.tts_voice_id,
tts_voice_source=request.tts_voice_source,
tts_repository=SQLAlchemyTTSJobRepository(db),
cosyvoice_service=cosyvoice_service,
voice_clone_repository=voice_clone_repository,
asset_repository=asset_repository,
asset_library_repository=asset_library_repository,
project_repository=project_repository,
storage_service=get_storage_service(),
points_enabled=bool(getattr(_settings, "points_enabled", False)),
is_member=bool(getattr(authenticated_user.user, "is_member", False)),
member_type=getattr(authenticated_user.user, "member_type", None),
)
except NarrativeError as e:
logger.warning("[叙事模式] 配音前置处理失败: %s", e.message)
raise HTTPException(status_code=e.status_code, detail=e.message) from e
request.voice_library_id = narrative_ctx.voice_asset_id
narrative_script_tags = list(getattr(narrative_ctx.script, "tags", None) or [])
logger.info(
"[叙事模式] 配音已就绪: script_id=%s, tts_job=%s, voice_asset=%s, duration=%.2f",
request.script_id,
narrative_ctx.tts_job_id,
narrative_ctx.voice_asset_id,
narrative_ctx.audio_duration,
)
try:
project_id, asset_library_id = _resolve_project_and_library(
request, project_repository, asset_library_repository, asset_repository, authenticated_user
@@ -385,29 +256,19 @@ def create_generation_task(
# 素材库自动匹配:当未显式指定 asset_ids 时,按模式自动选取
if not resolved_asset_ids:
_tag_index = (
_load_asset_tag_names(db, assets, authenticated_user.user.id) if narrative_script_tags else None
)
resolved_asset_ids = _select_assets_from_library(
assets,
mode=request.asset_select_mode,
count=request.asset_select_count,
script_tags=narrative_script_tags or None,
tag_names_by_id=_tag_index,
)
elif project_id and not resolved_asset_ids and (request.asset_select_mode in ("smart",) or narrative_script_tags):
# 项目级模式:未指定 asset_ids 且选择了 smart 模式(或叙事模式按标签匹配)时自动选取
elif project_id and not resolved_asset_ids and request.asset_select_mode in ("smart",):
# 项目级模式:未指定 asset_ids 且选择了 smart 模式时,也自动选取
assets = asset_repository.find_by_project(project_id)
if assets:
_tag_index = (
_load_asset_tag_names(db, assets, authenticated_user.user.id) if narrative_script_tags else None
)
resolved_asset_ids = _select_assets_from_library(
assets,
mode=request.asset_select_mode,
count=request.asset_select_count,
script_tags=narrative_script_tags or None,
tag_names_by_id=_tag_index,
)
if not resolved_asset_ids:
raise HTTPException(
@@ -471,10 +332,6 @@ def create_generation_task(
task_id=preview_task.id,
title_config=fallback_title_config,
db=db,
dedup_enabled=request.dedup_enabled,
assembly_mode=request.assembly_mode,
script_id=request.script_id or None,
video_ratio=request.video_ratio or None,
)
logger.info(
@@ -619,12 +476,9 @@ def create_generation_task(
variant_plan_ids.append(_plan0.id)
# #1855 P0:批次区间避让表,从变体0实际clips构建初始值(公共函数)
from app.services.generation_common import collect_plan_atom_clip_ids as _collect_atom_ids
from app.services.generation_common import collect_plan_segments as _collect_segments
_batch_segments = _collect_segments(_plan0.id, _plan_svc._clip_repo)
# #1970:批次内原子片段硬避让集合
_batch_atom_ids: list[str] = _collect_atom_ids(_plan0.id, _plan_svc._clip_repo)
# 变体 1..N-1 独立选片(传入累积batch_segments做素材区间避让)
for task_index in range(1, count):
@@ -639,7 +493,6 @@ def create_generation_task(
name_suffix=f"批量{task_index + 1}",
voice_duration=voice_durations[task_index] if task_index < len(voice_durations) else 0.0,
batch_segments=_batch_segments,
batch_used_atom_ids=_batch_atom_ids,
)
break
except ValueError as ve:
@@ -676,8 +529,6 @@ def create_generation_task(
_new_segs = _collect_segments(variant.id, _plan_svc._clip_repo)
for _aid, _ivs in _new_segs.items():
_batch_segments.setdefault(_aid, []).extend(_ivs)
# #1970:同步累积原子片段ID
_batch_atom_ids.extend(_collect_atom_ids(variant.id, _plan_svc._clip_repo))
except Exception:
logger.exception("[生成任务] 变体%d 区间收集失败(不阻断)", task_index)
@@ -821,11 +672,6 @@ def create_generation_task(
task_id=task.id,
title_config=variant_title_config,
db=db,
dedup_enabled=request.dedup_enabled,
video_index=task_index,
assembly_mode=request.assembly_mode,
script_id=request.script_id or None,
video_ratio=request.video_ratio or None,
)
if safe_enqueue_generation_task(
@@ -916,7 +762,6 @@ def confirm_generation(
generation_task_repository.update(source_task)
# 同步标题到 EditPlan.config
# #1970:确认生成复用预览计划,dedup_enabled 沿用计划已有值,不在此覆盖
if confirmed_title_config and source_task.source_edit_plan_id:
_writeback_edit_plan_config(
plan_id=source_task.source_edit_plan_id,
-231
View File
@@ -1,231 +0,0 @@
"""GPU MuseTalk Worker 反向轮询路由 — /api/v1/gpu/lipsync/*.
仅面向部署在用户 RTX2060 本地的 GPU Worker 脚本,不面向前端用户。
鉴权方式:长期 API Token`Authorization: Bearer <GPU_WORKER_TOKEN>`),不走用户 JWT。
接口:
POST /api/v1/gpu/register Worker 注册/心跳
GET /api/v1/gpu/lipsync/poll Worker 轮询拉任务(无任务返回 204)
POST /api/v1/gpu/lipsync/result Worker multipart 上传结果视频/上报失败
GET /api/v1/gpu/lipsync/status/{id} 业务侧查询任务状态(内部接口,暂开放给登录用户)
"""
from __future__ import annotations
import logging
import tempfile
from datetime import UTC, datetime
from pathlib import Path
from typing import Optional
import requests
from app.core.storage import get_storage_service
from app.dependencies import get_db_session
from app.schemas.gpu_lipsync import (
GpuLipsyncPollResponse,
GpuLipsyncResultResponse,
GpuLipsyncStatusResponse,
GpuLipsyncTaskPayload,
GpuWorkerRegisterRequest,
GpuWorkerRegisterResponse,
)
from app.services.gpu_lipsync_service import GpuLipsyncService
from fastapi import (
APIRouter,
Depends,
File,
Form,
HTTPException,
Query,
Request,
UploadFile,
status,
)
from fastapi.responses import Response
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from packages.config import get_api_settings
logger = logging.getLogger(__name__)
router = APIRouter()
# 复用 bearer scheme 抽 Token,但不校验用户 JWT
_gpu_bearer = HTTPBearer(auto_error=False)
def _verify_gpu_token(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_gpu_bearer),
) -> str:
"""校验 GPU Worker Token,返回 worker 提供的 token 串(仅用于日志,不做身份识别).
- development 且未配置 token → 直接放行(方便本地调试)。
- production/staging 未配置 token → 拒绝(避免裸奔)。
- token 不匹配 → 401。
"""
settings = get_api_settings()
expected = (settings.gpu_worker_token or "").strip()
is_dev = settings.environment == "development"
if not expected:
if is_dev:
return credentials.credentials if credentials else ""
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="GPU_WORKER_TOKEN not configured on server",
)
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
if credentials.credentials != expected:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid GPU worker token")
return credentials.credentials
def _get_svc(db=Depends(get_db_session)) -> GpuLipsyncService:
return GpuLipsyncService(db)
# ── POST /register — Worker 注册/心跳 ──────────────────────────────
@router.post("/register", response_model=GpuWorkerRegisterResponse)
def register_worker(
body: GpuWorkerRegisterRequest,
svc: GpuLipsyncService = Depends(_get_svc),
_token: str = Depends(_verify_gpu_token),
):
svc.register_worker(
worker_id=body.worker_id,
hostname=body.hostname,
gpu_name=body.gpu_name,
free_vram_mb=body.free_vram_mb,
capabilities=body.capabilities,
task_id=body.task_id,
)
return GpuWorkerRegisterResponse(ok=True, server_time=datetime.now(UTC), message="ok")
# ── GET /lipsync/poll — Worker 轮询拉任务 ─────────────────────────
@router.get("/lipsync/poll")
def poll_task(
worker_id: str = Query(..., min_length=1, max_length=100, description="Worker 唯一 ID"),
svc: GpuLipsyncService = Depends(_get_svc),
_token: str = Depends(_verify_gpu_token),
):
task = svc.poll_task(worker_id=worker_id)
if task is None:
return Response(status_code=status.HTTP_204_NO_CONTENT)
payload = GpuLipsyncTaskPayload(
task_id=task.id,
video_url=getattr(task, "_signed_video_url", task.video_url),
audio_url=getattr(task, "_signed_audio_url", task.audio_url),
lipsync_job_id=task.lipsync_job_id or "",
user_id=task.user_id or "",
project_id=task.project_id or "",
created_at=task.created_at,
upload_url=getattr(task, "_signed_upload_url", ""),
upload_method="PUT",
expires_at=getattr(task, "_upload_expires_at", datetime.now(UTC)),
)
return GpuLipsyncPollResponse(task=payload)
# ── POST /lipsync/result — Worker 上报结果(multipart) ─────────────
@router.post("/lipsync/result", response_model=GpuLipsyncResultResponse)
async def report_result(
request: Request,
task_id: str = Form(...),
worker_id: str = Form(...),
success: bool = Form(True),
duration_seconds: float = Form(0.0),
error_msg: str = Form(""),
result: Optional[UploadFile] = File(None),
svc: GpuLipsyncService = Depends(_get_svc),
_token: str = Depends(_verify_gpu_token),
):
# 参数校验:
# - success=true + result 文件 → API 代为上传到 OSS(方便 Worker 端实现)
# - success=true + 无文件 → Worker 已经自己 PUT 到预签名 upload_url,直接确认
# - success=false → 不上传文件,错误信息通过 error_msg 传递
if success and result is not None:
# 把文件落盘到临时目录,然后 PUT 到预签名 URL
storage = get_storage_service()
result_key = svc._result_key(task_id)
upload_url = storage.get_upload_url(result_key, expires_seconds=3600, content_type="video/mp4")
try:
with tempfile.TemporaryDirectory(prefix="gpu_result_") as tmpdir:
tmp_path = Path(tmpdir) / "result.mp4"
content = await result.read()
if not content:
raise HTTPException(status_code=400, detail="上传的 result 文件为空")
tmp_path.write_bytes(content)
headers = {"Content-Type": "video/mp4"}
with open(tmp_path, "rb") as f:
resp = requests.put(upload_url, data=f, headers=headers, timeout=300)
if resp.status_code >= 400:
logger.error(
"上传 GPU 结果到 OSS 失败: status=%d body=%s",
resp.status_code,
resp.text[:500],
)
raise HTTPException(
status_code=502,
detail=f"上传结果视频到 OSS 失败 (HTTP {resp.status_code})",
)
except HTTPException:
raise
except Exception as exc:
logger.exception("上传 GPU 结果视频异常: %s", exc)
raise HTTPException(status_code=500, detail=f"上传结果视频异常: {exc}") from exc
elif not success:
# 失败时忽略 result 文件(即便传了也没用)
pass
# 其他情况:success=true 且无文件 → Worker 已自行 PUT 到预签名 URL,直接标记完成
try:
task = svc.report_result(
task_id=task_id,
worker_id=worker_id,
success=success,
duration_seconds=duration_seconds,
error_msg=error_msg,
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return GpuLipsyncResultResponse(
ok=True,
task_id=task.id,
status=task.status,
message="ok",
)
# ── GET /lipsync/status/{task_id} — 业务侧查询状态 ─────────────────
# 说明:此接口会被 lipsync_service 内部在业务流程里直接读 DB,不通过 HTTP。
# 但仍暴露一个简单查询接口,方便调试和前端轮询(如后续需要)。暂不做用户权限校验,
# task_id 本身是 UUID,不可枚举。
@router.get("/lipsync/status/{task_id}", response_model=GpuLipsyncStatusResponse)
def get_task_status(
task_id: str,
svc: GpuLipsyncService = Depends(_get_svc),
):
task = svc.get_task(task_id)
if task is None:
raise HTTPException(status_code=404, detail="task not found")
return GpuLipsyncStatusResponse(
task_id=task.id,
status=task.status,
result_url=task.result_url,
result_duration=task.result_duration,
error_msg=task.error_msg,
worker_id=task.worker_id,
attempt=task.attempt,
created_at=task.created_at,
started_at=task.started_at,
finished_at=task.finished_at,
)
-4
View File
@@ -111,8 +111,6 @@ def create_lipsync_job(
voice_id=body.voice_id,
script_text=body.script_text,
speed=body.speed,
style=body.style or "",
volume=body.volume if body.volume is not None else 50,
emotion=body.emotion,
enable_video_loop=body.enable_video_loop,
project_id=body.project_id,
@@ -213,8 +211,6 @@ def preview_tts(
voice_id=body.voice_id,
script_text=body.script_text,
speed=body.speed,
style=body.style or "",
volume=body.volume if body.volume is not None else 50,
emotion=body.emotion,
)
except MediaKitError as exc:
+6 -33
View File
@@ -12,7 +12,6 @@ from datetime import datetime, timedelta, timezone
from typing import Optional
from app.auth import AuthenticatedUser, get_current_user
from app.config import settings
from app.dependencies import get_db_session
from app.schemas.points import (
DailyUsageResponse,
@@ -45,12 +44,6 @@ from packages.domain.points_service import PointsService
logger = logging.getLogger(__name__)
def _credits_enabled() -> bool:
"""积分系统总开关(ENABLE_CREDIT_SYSTEM),关闭时全部功能免费放行。"""
return bool(getattr(settings, "points_enabled", False))
# ── 两个 router ──
points_router = APIRouter()
usage_router = APIRouter()
@@ -179,19 +172,6 @@ def check_points(
"valid_scenes": sorted(POINTS_SCENES.keys()),
},
)
# 积分系统暂停(ENABLE_CREDIT_SYSTEM=false):所有场景直接放行,需 0 积分
if not _credits_enabled():
svc = _get_service()
account = svc.get_or_create_account(current_user.user.id, db)
return PointsCheckResponse(
allowed=True,
required_points=0,
current_balance=account["balance"],
remaining_after=account["balance"],
is_free_quota=False,
)
is_mem = _is_member(current_user)
mt = _member_type(current_user)
@@ -229,19 +209,8 @@ def deduct_points(
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""积分扣减(内部服务调用)。
积分系统暂停(ENABLE_CREDIT_SYSTEM=false)时为 no-op:不扣分、余额不变,
直接返回成功,保证内部调用方拿到 success=True 继续业务流程。
"""
"""积分扣减(内部服务调用)。"""
svc = _get_service()
if not _credits_enabled():
account = svc.get_or_create_account(current_user.user.id, db)
return SimpleMessageResponse(
success=True,
message="积分系统已暂停,未扣减积分",
data={"transaction_id": "", "balance": account["balance"]},
)
result = svc.deduct_points(
user_id=current_user.user.id,
amount=body.amount,
@@ -274,7 +243,11 @@ def refund_points(
"""积分退还(内部服务调用)。"""
from packages.adapters.sqlalchemy_impl.models import PointsTransactionModel
txn = db.query(PointsTransactionModel).filter(PointsTransactionModel.id == body.transaction_id).first()
txn = (
db.query(PointsTransactionModel)
.filter(PointsTransactionModel.id == body.transaction_id)
.first()
)
if txn is None:
raise HTTPException(status_code=404, detail="交易记录不存在")
if txn.user_id != current_user.user.id:
+6 -26
View File
@@ -55,9 +55,7 @@ _DOUYIN_DEBUG_ERRORS = os.environ.get("DOUYIN_DEBUG_ERRORS", "").lower() in (
"1",
"true",
"yes",
) or os.environ.get(
"APP_ENV", ""
).lower() in ("staging", "dev", "development", "test")
) or os.environ.get("APP_ENV", "").lower() in ("staging", "dev", "development", "test")
_TAIL_PUNCT = ".,;:!?,。;:!?)]》" + chr(34) + chr(39) + "<>"
_URL_EXTRACT_RE = re.compile(r"https?://\S+", re.IGNORECASE)
@@ -142,7 +140,6 @@ def _extract_and_validate_douyin_url(raw_input):
def _mk_post_json(self, path, payload):
import httpx
if not self.is_available:
raise MediaKitError("MediaKit API Key 未配置", code="NotConfigured")
url = self._base_url + path
@@ -171,7 +168,6 @@ def _mk_post_json(self, path, payload):
def _mk_get_json(self, path):
import httpx
if not self.is_available:
raise MediaKitError("MediaKit API Key 未配置", code="NotConfigured")
url = self._base_url + path
@@ -287,7 +283,7 @@ def _direct_url_download_and_local_asr(direct_url, page_url, temp_dir):
raise
except httpx.TimeoutException:
logger.warning("直链下载超时: %s", page_url)
raise HTTPException(status_code=status.HTTP_504_GATEWAY_TIMEOUT, detail="视频下载超时,请稍后重试") from None
raise HTTPException(status_code=status.HTTP_504_GATEWAY_TIMEOUT, detail="视频下载超时,请稍后重试")
except Exception as exc: # noqa: BLE001
logger.exception("直链下载失败: url=%s err=%s", page_url, exc)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="视频下载失败: " + str(exc)[:200]) from exc
@@ -424,10 +420,7 @@ def extract_from_douyin(
if text:
logger.info(
"抖音 MediaKit ASR 成功: source=%s text_len=%d duration=%.1f total_time=%.1fs",
result.source,
len(text),
duration,
time.time() - t0,
result.source, len(text), duration, time.time() - t0,
)
else:
logger.info("抖音 MediaKit ASR 返回空文本(无旁白/BGM视频)")
@@ -447,25 +440,13 @@ def extract_from_douyin(
if text:
logger.info(
"抖音本地 ASR 成功: source=%s text_len=%d total_time=%.1fs",
result.source,
len(text),
time.time() - t0,
result.source, len(text), time.time() - t0,
)
last_err_stage = "asr"
except HTTPException as exc:
# 下载超时(504)是明确的网络错误,直接抛出
if exc.status_code == status.HTTP_504_GATEWAY_TIMEOUT:
raise
# 本地 ASR 不可用/失败(502/503)时记录后继续走 desc 兜底,
# 不直接抛 502,避免 API 镜像缺 worker 模块时整条链路挂掉
logger.warning("本地 ASR 链路失败(status=%d): %s", exc.status_code, exc.detail)
text = ""
# 如果是下载失败(非ASR错误),保持stage为download
if "语音识别" in str(exc.detail) or "ASR" in str(exc.detail):
last_err_stage = "asr"
except HTTPException:
raise
except Exception as exc: # noqa: BLE001
logger.warning("本地 ASR 链路异常: %s", exc)
text = ""
# ── Phase C:结果判定 & 兜底 ──
@@ -548,7 +529,6 @@ def ai_generate_titles(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文案内容不能为空")
count = max(1, min(5, request.count))
from app.services.ai_service import generate_smart_titles
result = generate_smart_titles(description=content, style="viral", count=count)
titles = result.get("titles", [])[:count]
return AiGenerateTitlesResponse(titles=titles)
-6
View File
@@ -207,9 +207,6 @@ def synthesize(
synthesis_meta = {
"speed": request.speed,
"emotion": request.emotion or "",
"style": request.style or "",
"volume": request.volume if request.volume is not None else 50,
"pitch": request.pitch if request.pitch is not None else 1.0,
"language": request.language or "zh-CN",
}
if request.metadata_:
@@ -657,9 +654,6 @@ def preview_tts(
text=request.text,
voice_id=actual_voice_id,
speed=request.speed,
style=request.style or "",
volume=request.volume if request.volume is not None else 50,
pitch=request.pitch,
emotion=request.emotion,
language=getattr(request, "language", "zh-CN"),
)
+1 -7
View File
@@ -53,14 +53,9 @@ class CreateAiAvatarRenderRequest(BaseModel):
script_id: str = Field("", description="文案 ID(选自文案库时传;手动输入文案直生场景可留空)")
b_roll_segments: list[BRollSegment] = Field(default_factory=list, description="B-roll 片段列表")
title_config: dict[str, Any] = Field(
default_factory=dict,
description="标题配置(可含 title_image_dataurl:前端 Canvas 渲染的标题 PNG dataURL;含 line_overrides 逐行样式)",
default_factory=dict, description="标题配置(可含 title_image_dataurl:前端 Canvas 渲染的标题 PNG dataURL"
)
cover_config: dict[str, Any] = Field(default_factory=dict, description="封面配置")
cover_title_config: dict[str, Any] = Field(
default_factory=dict,
description="封面独立标题配置(#2001),结构同 title_config;为空时封面不叠标题",
)
project_id: str = Field("", description="项目 ID")
@field_validator("lipsync_job_id")
@@ -88,7 +83,6 @@ class AiAvatarRenderJobResponse(BaseModel):
b_roll_segments: list[dict[str, Any]]
title_config: dict[str, Any]
cover_config: dict[str, Any]
cover_title_config: dict[str, Any] = Field(default_factory=dict, description="封面独立标题配置")
status: str
progress: int
output_video_url: str
-38
View File
@@ -98,24 +98,6 @@ class CreateGenerationTaskRequest(BaseModel):
description="各变体独立标题文字数组:长度1=共用,长度=count=独立。为空时使用 title_config.text",
)
# ── 智能降重开关(#1970)──
# True(默认):edge_crop + 片段级微变换(hflip/变速/亮度/对比度/饱和度/BGM偏移)全部生效;
# False:跳过 edge_crop、不注入微变换,渲染确定性(固定种子)。
dedup_enabled: bool = Field(default=True, description="智能降重开关,默认开启;关闭后跳过边缘裁切与微变换")
# ── 剪辑组装模式(#1970 PR3)──
# random(默认,完全兼容现有随机混剪)/ narrative(叙事剪辑:文案→TTS 配音→标签匹配画面)
assembly_mode: str = Field(default="random", description="组装模式:random=随机混剪(默认),narrative=叙事剪辑")
# 叙事模式必填:文案库 scripts.id(后端据此读取 content 合成 TTS
script_id: str = Field(default="", description="叙事模式必填:文案库 ID")
# 叙事模式必填:TTS 音色 IDpreset 为 CosyVoice 音色 idclone 为克隆档案 id)
tts_voice_id: str = Field(default="", description="叙事模式必填:TTS 音色 ID(系统音色或克隆档案 ID)")
tts_voice_source: str = Field(default="preset", description="TTS 音色来源:preset=系统预设(默认),clone=克隆音色")
# 视频比例:当前前端 9:16/16:9;与 output_width/output_height 并存,传了具体分辨率时以分辨率为准
video_ratio: str = Field(
default="", description="视频比例,如 9:16(默认竖屏)/16:9;与显式分辨率冲突时以分辨率为准"
)
@model_validator(mode="after")
def _check_variant_arrays(self) -> "CreateGenerationTaskRequest":
"""变体数组字段长度校验 + #1749 配音严格守卫。
@@ -145,26 +127,6 @@ class CreateGenerationTaskRequest(BaseModel):
raise ValueError(f"variant_plan_ids 长度({len(self.variant_plan_ids)})必须与 count({self.count})一致")
return self
@model_validator(mode="after")
def _check_assembly_mode(self) -> "CreateGenerationTaskRequest":
"""#1970 组装模式与叙事模式入参校验。"""
if self.assembly_mode not in ("random", "narrative"):
raise ValueError("assembly_mode 仅支持 'random'(默认)或 'narrative'")
if self.tts_voice_source not in ("preset", "clone"):
raise ValueError("tts_voice_source 仅支持 'preset''clone'")
if self.video_ratio:
parts = self.video_ratio.split(":")
if len(parts) != 2 or not all(p.isdigit() and int(p) > 0 for p in parts):
raise ValueError("video_ratio 格式必须为 '宽:高',如 9:16 或 16:9")
if self.video_ratio not in ("9:16", "16:9", "1:1", "3:4", "4:3"):
raise ValueError("video_ratio 仅支持 9:16 / 16:9 / 1:1 / 3:4 / 4:3")
if self.assembly_mode == "narrative":
if not self.script_id.strip():
raise ValueError("叙事模式(narrative)必须提供 script_id(文案库 ID")
if not self.tts_voice_id.strip():
raise ValueError("叙事模式(narrative)必须提供 tts_voice_idTTS 音色 ID")
return self
@model_validator(mode="after")
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
has_project = bool(self.project_id.strip())
-111
View File
@@ -1,111 +0,0 @@
"""GPU MuseTalk 反向轮询 API Schema 定义.
面向部署在用户 RTX2060 本地的 GPU Worker 脚本,不面向前端用户。
Worker 用长期 GPU_WORKER_TOKEN 鉴权(不是用户 JWT)。
"""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
# ── Worker 注册/心跳 ──────────────────────────────────────────────
class GpuWorkerRegisterRequest(BaseModel):
"""Worker 启动/心跳时上报自身信息."""
worker_id: str = Field(..., min_length=1, max_length=100, description="Worker 唯一 ID(机器名+UUID 等)")
hostname: str = Field("", max_length=200, description="主机名,用于运维排查")
gpu_name: str = Field("", max_length=200, description="GPU 型号,如 'NVIDIA GeForce RTX 2060'")
free_vram_mb: int = Field(0, ge=0, description="当前空闲显存(MB")
capabilities: str = Field("musetalk", max_length=500, description="能力列表,逗号分隔,如 'musetalk'")
task_id: Optional[str] = Field(
None,
max_length=64,
description=(
"当前正在处理的任务 ID。Worker 推理期间定期心跳时携带,"
"服务端同步刷新该任务 last_heartbeat_at,防止长推理被误判超时;空闲时不传"
),
)
class GpuWorkerRegisterResponse(BaseModel):
ok: bool = True
server_time: datetime
message: str = "ok"
# ── 轮询任务 ────────────────────────────────────────────────────
class GpuLipsyncTaskPayload(BaseModel):
"""下发给 Worker 的任务载荷(含预签名下载 URL)."""
task_id: str
video_url: str = Field(..., description="人物视频预签名下载 URLGET")
audio_url: str = Field(..., description="驱动音频预签名下载 URLGET")
lipsync_job_id: str = ""
user_id: str = ""
project_id: str = ""
created_at: datetime
upload_url: str = Field(..., description="结果视频预签名上传 URLPUT, video/mp4")
upload_method: str = Field("PUT", description="上传方式,目前只支持 PUT")
expires_at: datetime
class GpuLipsyncPollResponse(BaseModel):
"""Worker poll 的返回:200 带任务,204 无任务."""
task: Optional[GpuLipsyncTaskPayload] = None
# ── Worker 上报结果 ──────────────────────────────────────────────
class GpuLipsyncResultRequest(BaseModel):
"""Worker 通过 multipart 上传结果时携带的字段(非文件字段)."""
task_id: str = Field(..., min_length=1, max_length=64)
worker_id: str = Field(..., min_length=1, max_length=100)
success: bool = Field(True, description="true=成功(此时必须上传 result 视频文件);false=失败")
duration_seconds: float = Field(0.0, ge=0, description="合成后视频时长(秒),成功时应填入")
error_msg: str = Field("", max_length=2000, description="失败原因,success=false 时必填")
class GpuLipsyncResultResponse(BaseModel):
ok: bool = True
task_id: str
status: str # done / failed
message: str = "ok"
# ── 业务侧查询任务状态 ────────────────────────────────────────────
class GpuLipsyncStatusResponse(BaseModel):
task_id: str
status: str
result_url: str = ""
result_duration: float = 0.0
error_msg: str = ""
worker_id: str = ""
attempt: int = 0
created_at: datetime
started_at: Optional[datetime] = None
finished_at: Optional[datetime] = None
# ── 创建任务(内部服务调用) ──────────────────────────────────────
class GpuLipsyncCreateRequest(BaseModel):
"""服务层内部创建 GPU 任务用(不通过 HTTP 暴露给 Worker/前端)."""
video_url: str # 已可访问的 OSS key 或公网 URL(API 侧会转预签名)
audio_url: str
lipsync_job_id: str = ""
user_id: str = ""
project_id: str = ""
+2 -13
View File
@@ -29,7 +29,6 @@ class LipsyncJobResponse(BaseModel):
voice_id: str = ""
script_text: str = ""
speed: float = 1.0
style: str = ""
emotion: str = ""
mediakit_task_id: str
status: str
@@ -68,14 +67,9 @@ class CreateLipsyncJobRequest(BaseModel):
voice_id: str = Field("", description="音色 ID(预置音色或克隆音色 profile UUID")
script_text: str = Field("", description="要合成的文案(直生模式必填,最长 5000 字符)")
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0")
style: Optional[str] = Field(
None,
description="语气风格(natural/sweet/excited/professional/news/livestream),可选;优先级高于 emotion",
)
volume: Optional[int] = Field(None, ge=0, le=100, description="音量(0-100),默认 50")
emotion: str = Field(
"",
description="[deprecated] 旧情绪参数,内部映射为 style",
description="情绪(英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted,或中文 中立/开心/难过/生气/惊讶/恐惧/厌恶;空为默认自然)",
)
enable_video_loop: bool = Field(
@@ -129,15 +123,10 @@ class AiAvatarTtsPreviewRequest(BaseModel):
voice_id: str = Field(..., min_length=1, max_length=128, description="音色 ID")
script_text: str = Field(..., min_length=1, max_length=5000, description="要合成的文案")
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0")
style: Optional[str] = Field(
None,
description="语气风格(natural/sweet/excited/professional/news/livestream),可选;优先级高于 emotion",
)
volume: Optional[int] = Field(None, ge=0, le=100, description="音量(0-100),默认 50")
emotion: str = Field(
"neutral",
max_length=32,
description="[deprecated] 旧情绪参数,内部映射为 style;默认 neutral",
description="情绪(英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted,或中文 中立/开心/难过/生气/惊讶/恐惧/厌恶;默认 neutral",
)
+3 -14
View File
@@ -16,15 +16,9 @@ class TTSSynthesizeRequest(BaseModel):
output_name: str = Field("", description="输出文件名")
language: str = Field("zh-CN", description="语言")
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速")
style: Optional[str] = Field(
None,
description="语气风格(natural/sweet/excited/professional/news/livestream),可选;优先级高于 emotion",
)
volume: Optional[int] = Field(None, ge=0, le=100, description="音量(0-100),默认 50")
pitch: Optional[float] = Field(None, ge=0.5, le=2.0, description="音调(0.5-2.0),默认 1.0")
emotion: str = Field(
"",
description="[deprecated] 旧情绪参数,内部映射为 style;新接入请使用 style",
description="情绪(中文/英文:自然/兴奋/沉稳/亲切/开心/悲伤/愤怒/惊讶/恐惧/厌恶 等;通过 instruction 自然语言指令控制)",
)
voice_model: str = Field("", description="语音模型名称")
voice_clone_profile_id: str = Field("", description="关联的音色克隆档案 ID")
@@ -119,14 +113,9 @@ class TTSPreviewRequest(BaseModel):
text: str = Field(..., min_length=1, max_length=200, description="合成文本,限制 200 字")
voice_id: str = Field(..., min_length=1, description="音色 ID")
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速")
style: Optional[str] = Field(
None,
description="语气风格(natural/sweet/excited/professional/news/livestream),可选;优先级高于 emotion",
)
volume: Optional[int] = Field(None, ge=0, le=100, description="音量(0-100),默认 50")
emotion: str = Field("", description="[deprecated] 旧情绪参数,内部映射为 style")
emotion: str = Field("", description="情绪(中文/英文:自然/兴奋/沉稳/亲切/开心/悲伤/愤怒/惊讶/恐惧/厌恶 等)")
language: str = Field("zh-CN", description="语言(zh-CN/en-US 等)")
pitch: float = Field(1.0, ge=0.5, le=2.0, description="音调(0.5-2.0),默认 1.0")
pitch: float = Field(1.0, ge=0.5, le=2.0, description="音调(预留,当前未使用)")
class TTSPreviewResponse(BaseModel):
@@ -61,7 +61,6 @@ class AiAvatarRenderService:
b_roll_segments: list[dict[str, Any]] | None = None,
title_config: dict[str, Any],
cover_config: dict[str, Any],
cover_title_config: dict[str, Any] | None = None,
project_id: str = "",
) -> AiAvatarRenderJob:
"""创建渲染任务.
@@ -113,7 +112,6 @@ class AiAvatarRenderService:
b_roll_segments=[s if isinstance(s, dict) else s.model_dump() for s in (b_roll_segments or [])],
title_config=title_config,
cover_config=cover_config,
cover_title_config=cover_title_config or {},
status="pending",
)
self.db.add(job)
+1 -1
View File
@@ -52,7 +52,7 @@ def _extract_url_from_text(text: str) -> str:
if not text:
return ""
m = re.search(r"https?://\S+", text)
return m.group(0).rstrip("。,!?!?,,;;\"')】") if m else "" # noqa: B005
return m.group(0).rstrip("。,!?!?,,;;\"')】") if m else ""
def _canonicalize_url(url: str, timeout: int = 8) -> str:
+11 -64
View File
@@ -423,7 +423,6 @@ class EditPlanService:
clip_type=clip.clip_type,
order=clip.order,
asset_id=clip.asset_id,
atom_clip_id=clip_item.get("atom_clip_id", ""),
text_content=clip.text_content,
start_time=clip.start_time,
duration=clip.duration,
@@ -475,7 +474,6 @@ class EditPlanService:
voice_duration: float = 0.0,
rng=None,
batch_segments: dict[str, list[tuple[float, float]]] | None = None,
batch_used_atom_ids: set[str] | list[str] | None = None,
) -> EditPlan:
"""为批量变体生成独立 plan:完整重跑单视频选片流程(#1743)。
@@ -610,69 +608,18 @@ class EditPlanService:
st = float(c.start_time or 0.0)
batch_segments_resolved.setdefault(c.asset_id, []).append((st, st + float(c.duration)))
clips_data = None
# #1970 原子片段级变体重选:候选素材已切片时优先按原子片段选片
try:
from packages.adapters.sqlalchemy_impl.asset_atom_clip_repository import (
SQLAlchemyAssetAtomClipRepository,
)
from packages.domain.atom_clip_resolver import flatten_candidates, load_atom_clips_for_assets
from packages.domain.atom_clip_selector import reselect_clips_from_atoms
clips_data = reselect_clips_for_variant(
source_clips_data,
pool_ids,
asset_durations=durations,
asset_scene_points=scene_points,
historical_used_segments=historical,
batch_segments=batch_segments_resolved,
target_durations=target_durations,
rng=rng,
)
atom_repo = SQLAlchemyAssetAtomClipRepository(db)
# 兜底切片只需要时长;本方法已查出 durations,封装一个只读假素材仓储
class _DurationOnlyAssetRepo:
def __init__(self, durations_map: dict[str, float]) -> None:
self._durations = durations_map
def get(self, asset_id: str):
if asset_id not in self._durations:
return None
class _A:
pass
a = _A()
a.duration = self._durations[asset_id]
return a
clips_by_asset = load_atom_clips_for_assets(
pool_ids,
atom_clip_repo=atom_repo,
asset_repo=_DurationOnlyAssetRepo(durations),
)
atom_candidates = flatten_candidates(clips_by_asset)
if atom_candidates:
# 历史成片已用原子片段(降权);批次内前序变体已用(硬避让)
historical_atom_ids = set(
self._clip_repo.list_recent_atom_clip_ids_by_user(
created_by_user_id or source.created_by_user_id or "",
limit=200,
)
)
clips_data = reselect_clips_from_atoms(
source_clips_data,
atom_candidates,
historical_atom_ids=historical_atom_ids,
batch_used_atom_ids=(set(batch_used_atom_ids) if batch_used_atom_ids else None),
rng=rng,
)
except Exception:
logger.warning("原子片段变体重选失败,回退整条素材选片", exc_info=True)
clips_data = None
if clips_data is None:
clips_data = reselect_clips_for_variant(
source_clips_data,
pool_ids,
asset_durations=durations,
asset_scene_points=scene_points,
historical_used_segments=historical,
batch_segments=batch_segments_resolved,
target_durations=target_durations,
rng=rng,
) # 片段区间写回素材 metadata(与落库同事务;replace_all_clips_transactional 内 commit
# 片段区间写回素材 metadata(与落库同事务;replace_all_clips_transactional 内 commit
for item in clips_data:
aid = item.get("asset_id", "")
if aid:
@@ -61,17 +61,10 @@ def writeback_edit_plan_config(
task_id: str,
title_config: dict | None,
db: Session,
dedup_enabled: bool | None = None,
video_index: int | None = None,
assembly_mode: str | None = None,
script_id: str | None = None,
video_ratio: str | None = None,
) -> None:
"""任务入队成功后,回写 EditPlan.configgeneration_task_id + title_config。
用 merge 方式更新,不整体覆盖 config,避免丢失其他字段。
#1970dedup_enabled 非 None 时一并写入,worker 据此决定 edge_crop/微变换;
PR3 叙事模式再写 assembly_mode/script_id/video_ratio(可追溯,不影响渲染)。
失败只记日志,不影响任务创建。
"""
if not plan_id:
@@ -87,16 +80,6 @@ def writeback_edit_plan_config(
current_config = plan_model.config if isinstance(plan_model.config, dict) else {}
merged = dict(current_config)
merged["generation_task_id"] = task_id
if dedup_enabled is not None:
merged["dedup_enabled"] = bool(dedup_enabled)
if video_index is not None:
merged["video_index"] = int(video_index)
if assembly_mode:
merged["assembly_mode"] = assembly_mode
if script_id:
merged["script_id"] = script_id
if video_ratio:
merged["video_ratio"] = video_ratio
if title_config:
# #1901 统一字段名为 "title"worker sync_configs_to_plan 写的是 "title"
@@ -174,33 +157,6 @@ def collect_plan_segments(
return segs
def collect_plan_atom_clip_ids(
plan_id: str,
clip_repo: Any,
*,
page_size: int = 500,
) -> list[str]:
"""分页读取 plan 所有 clips,收集已选用的原子片段 ID(#1970)。
用于批量变体间原子片段级硬避让:同一原子片段在同批次内只用一次。
旧路径 clips 的 atom_clip_id 为空串,自动忽略。
"""
ids: list[str] = []
sk, pg = 0, page_size
while True:
batch = clip_repo.list_by_plan(plan_id, skip=sk, limit=pg)
if not batch:
break
for c in batch:
acid = getattr(c, "atom_clip_id", "") or ""
if acid:
ids.append(acid)
if len(batch) < pg:
break
sk += pg
return ids
def resolve_latest_plan_by_template(
db: Session,
*,
@@ -1,382 +0,0 @@
"""GPU MuseTalk 口型同步服务 — 反向轮询模式.
职责:
1. 创建任务(由 lipsync 业务流程调用),为输入/输出生成预签名 URL,任务入队;
2. Worker 心跳注册(register):登记/刷新 worker 状态;
3. Worker 轮询拉任务(poll):原子地 CLAIM 一条 pending 任务,返回预签名 URL
4. Worker 上报结果(report_result):标记 done/failed,失败可重试;
5. 业务侧查询状态(get_status)。
"""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime, timedelta
from typing import Optional
from app.core.storage import get_storage_service
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import GpuLipsyncTaskModel, GpuWorkerModel
from packages.config import get_api_settings
logger = logging.getLogger(__name__)
# 任务在 processing 超过此时长仍未完成 → 超时回退 pending 或置 failed
MAX_ATTEMPTS = 3
class GpuLipsyncService:
"""GPU 口型同步服务(无状态方法,每次调用从 DI 拿 db/storage."""
RESULT_PREFIX = "gpu-lipsync/results/"
INPUT_SIGN_EXPIRES_PAD = 600 # 输入预签名 URL 在任务超时基础上再加 10min 余量
# ── 公共入口 ────────────────────────────────────────────────────
def __init__(self, db: Session):
self.db = db
self.settings = get_api_settings()
self.storage = get_storage_service()
# ── Worker 注册/心跳 ────────────────────────────────────────────
def register_worker(
self,
worker_id: str,
hostname: str = "",
gpu_name: str = "",
free_vram_mb: int = 0,
capabilities: str = "musetalk",
task_id: Optional[str] = None,
) -> GpuWorkerModel:
"""Worker 注册/心跳。
task_id 非空时(Worker 推理期间的任务级心跳),同步把对应 processing
任务的 last_heartbeat_at 续到当前时间,使长推理不会被
``_recover_timed_out_tasks`` 误回退。任务已结束 / 不属于该 worker
(如已被超时回收重新派发)时忽略,不报错。
"""
now = datetime.now(UTC)
worker = self.db.query(GpuWorkerModel).filter(GpuWorkerModel.worker_id == worker_id).one_or_none()
if worker is None:
worker = GpuWorkerModel(
worker_id=worker_id,
hostname=hostname,
gpu_name=gpu_name,
free_vram_mb=free_vram_mb,
capabilities=capabilities,
last_heartbeat_at=now,
created_at=now,
)
self.db.add(worker)
else:
worker.hostname = hostname or worker.hostname
worker.gpu_name = gpu_name or worker.gpu_name
worker.free_vram_mb = free_vram_mb
worker.capabilities = capabilities or worker.capabilities
worker.last_heartbeat_at = now
if task_id:
self._touch_task_heartbeat(task_id, worker_id, now)
self.db.commit()
return worker
# ── 轮询拉任务(Worker 调用) ──────────────────────────────────
def poll_task(self, worker_id: str) -> Optional[GpuLipsyncTaskModel]:
"""原子地认领一条最早的 pending 任务,返回给 worker;无任务返回 None.
同时会:
- 把 processing 状态且真正超时(任务心跳停滞超过
gpu_task_timeout_secondsWorker 推理期会通过 register(task_id=...)
续心跳,长推理不会误判)的任务回退为 pending(attempt++,超过
MAX_ATTEMPTS 置 failed),让其它 worker 认领。
- 刷新 worker 心跳。
"""
now = datetime.now(UTC)
self._recover_timed_out_tasks(now)
# 更新 worker 心跳
self._touch_worker(worker_id, now)
# 选一条最早 pending 任务(FOR UPDATE SKIP LOCKED 语义:简单起见先查再锁状态)
task = (
self.db.query(GpuLipsyncTaskModel)
.filter(GpuLipsyncTaskModel.status == "pending")
.order_by(GpuLipsyncTaskModel.created_at.asc())
.first()
)
if task is None:
self.db.commit()
return None
# 原子 claim:用 UPDATE WHERE status=pending 避免并发
upd_rows = (
self.db.query(GpuLipsyncTaskModel)
.filter(
GpuLipsyncTaskModel.id == task.id,
GpuLipsyncTaskModel.status == "pending",
)
.update(
{
GpuLipsyncTaskModel.status: "processing",
GpuLipsyncTaskModel.worker_id: worker_id,
GpuLipsyncTaskModel.started_at: now,
GpuLipsyncTaskModel.last_heartbeat_at: now,
GpuLipsyncTaskModel.attempt: GpuLipsyncTaskModel.attempt + 1,
GpuLipsyncTaskModel.updated_at: now,
},
synchronize_session=False,
)
)
self.db.commit()
if upd_rows == 0:
# 被其它 worker 抢先了
return None
self.db.refresh(task)
# 生成预签名输入/输出 URL(在 claim 时动态生成,避免长时间过期)
expires = self.settings.gpu_task_timeout_seconds + self.INPUT_SIGN_EXPIRES_PAD
task._signed_video_url = self.storage.get_download_url(task.video_url, expires_seconds=expires)
task._signed_audio_url = self.storage.get_download_url(task.audio_url, expires_seconds=expires)
task._signed_upload_url = self.storage.get_upload_url(
self._result_key(task.id),
expires_seconds=expires,
content_type="video/mp4",
)
task._upload_expires_at = now + timedelta(seconds=expires)
return task
# ── 上报结果 ──────────────────────────────────────────────────
def report_result(
self,
task_id: str,
worker_id: str,
success: bool,
duration_seconds: float = 0.0,
error_msg: str = "",
) -> GpuLipsyncTaskModel:
task = self.db.get(GpuLipsyncTaskModel, task_id)
if task is None:
raise KeyError(f"task {task_id} not found")
now = datetime.now(UTC)
if success:
task.status = "done"
task.result_url = self._result_key(task_id)
task.result_duration = duration_seconds or 0.0
task.error_msg = ""
task.finished_at = now
else:
# 失败:若仍可重试(已尝试次数 < MAX_ATTEMPTS)→ 回退 pending;否则 → failed
if task.attempt < MAX_ATTEMPTS:
task.status = "pending"
task.worker_id = ""
task.started_at = None
task.error_msg = error_msg[:2000]
logger.warning(
"GPU 任务 %s 在 worker %s 上失败,回退 pending 等待重试(attempt=%d: %s",
task_id,
worker_id,
task.attempt,
error_msg[:200],
)
else:
task.status = "failed"
task.error_msg = error_msg[:2000]
task.finished_at = now
logger.error(
"GPU 任务 %s 失败达到最大重试次数 %d,置为 failed: %s",
task_id,
MAX_ATTEMPTS,
error_msg[:200],
)
task.updated_at = now
task.last_heartbeat_at = now
self._touch_worker(worker_id, now)
self.db.commit()
self.db.refresh(task)
return task
# ── 业务侧查询 ────────────────────────────────────────────────
def get_task(self, task_id: str) -> Optional[GpuLipsyncTaskModel]:
return self.db.get(GpuLipsyncTaskModel, task_id)
def get_by_lipsync_job(self, lipsync_job_id: str) -> Optional[GpuLipsyncTaskModel]:
return (
self.db.query(GpuLipsyncTaskModel)
.filter(GpuLipsyncTaskModel.lipsync_job_id == lipsync_job_id)
.order_by(GpuLipsyncTaskModel.created_at.desc())
.first()
)
# ── 创建任务(业务侧调用) ────────────────────────────────────
def create_task(
self,
video_url: str,
audio_url: str,
lipsync_job_id: str = "",
user_id: str = "",
project_id: str = "",
) -> GpuLipsyncTaskModel:
task_id = str(uuid.uuid4())
now = datetime.now(UTC)
task = GpuLipsyncTaskModel(
id=task_id,
lipsync_job_id=lipsync_job_id,
user_id=user_id,
project_id=project_id,
video_url=video_url,
audio_url=audio_url,
status="pending",
attempt=0,
created_at=now,
updated_at=now,
)
self.db.add(task)
self.db.commit()
self.db.refresh(task)
logger.info(
"创建 GPU 口型任务 %s (lipsync_job=%s, user=%s)",
task_id,
lipsync_job_id,
user_id,
)
return task
# ── 内部辅助 ──────────────────────────────────────────────────
def _result_key(self, task_id: str) -> str:
return f"{self.RESULT_PREFIX}{task_id}.mp4"
def _touch_task_heartbeat(self, task_id: str, worker_id: str, now: datetime) -> None:
"""Worker 推理期间的任务级心跳:只刷新属于该 worker 且仍在 processing 的任务。
任务不存在 / 已被超时回收重新派发 / 已完成 → 静默忽略(此时旧 worker 的
结果上报会被结果接口按最终态处理)。
"""
task = self.db.get(GpuLipsyncTaskModel, task_id)
if task is None:
return
if task.status != "processing" or task.worker_id != worker_id:
logger.info(
"忽略过期任务心跳 task=%s worker=%sstatus=%s owner=%s",
task_id,
worker_id,
task.status,
task.worker_id,
)
return
task.last_heartbeat_at = now
task.updated_at = now
self.db.flush()
def _touch_worker(self, worker_id: str, now: datetime) -> None:
if not worker_id:
return
worker = self.db.query(GpuWorkerModel).filter(GpuWorkerModel.worker_id == worker_id).one_or_none()
if worker is not None:
worker.last_heartbeat_at = now
self.db.flush()
else:
# 自注册(poll 时允许自动建一个空 worker 记录,运维可见)
worker = GpuWorkerModel(
worker_id=worker_id,
hostname="",
gpu_name="",
free_vram_mb=0,
capabilities="musetalk",
last_heartbeat_at=now,
created_at=now,
)
self.db.add(worker)
self.db.flush()
def _recover_timed_out_tasks(self, now: datetime) -> None:
"""扫描 processing 状态且真正超时的任务,回退 pending 或失败。
判定只看任务自身 last_heartbeat_atclaim 时写入,Worker 推理期间通过
/gpu/register(task_id=...) 每 30s 续期。因此仅在 Worker 崩溃/断网
(任务心跳停滞超过 gpu_task_timeout_seconds)时才回收,
不会因 Worker 主循环忙于推理而误回退。
"""
timeout = self.settings.gpu_task_timeout_seconds
cutoff = now - timedelta(seconds=timeout)
stuck_tasks = (
self.db.query(GpuLipsyncTaskModel)
.filter(
GpuLipsyncTaskModel.status == "processing",
GpuLipsyncTaskModel.last_heartbeat_at < cutoff,
)
.all()
)
for t in stuck_tasks:
if t.attempt >= MAX_ATTEMPTS:
t.status = "failed"
t.error_msg = f"worker 心跳超时({timeout}s),重试次数已耗尽"
t.finished_at = now
else:
t.status = "pending"
t.worker_id = ""
t.started_at = None
t.error_msg = f"worker 心跳超时({timeout}s),等待重试"
logger.warning("GPU 任务 %s 心跳超时,回退 pendingattempt=%d", t.id, t.attempt)
t.updated_at = now
if stuck_tasks:
self.db.flush()
# ── 业务侧辅助 ──────────────────────────────────────────────────
def has_available_worker(self) -> bool:
"""判断是否有 Worker 在心跳新鲜窗口内可用."""
stale_cutoff = datetime.now(UTC) - timedelta(seconds=self.settings.gpu_worker_stale_seconds)
return (
self.db.query(GpuWorkerModel).filter(GpuWorkerModel.last_heartbeat_at >= stale_cutoff).first() is not None
)
def wait_for_result(
self,
task_id: str,
timeout_seconds: Optional[int] = None,
poll_interval: Optional[float] = None,
) -> Optional[GpuLipsyncTaskModel]:
"""同步轮询等待 GPU 任务完成。
Args:
task_id: 任务 ID(由 create_task 返回)
timeout_seconds: 总超时,默认取 settings.gpu_lipsync_wait_timeout
poll_interval: 轮询间隔秒,默认取 settings.gpu_lipsync_poll_interval
Returns:
终态 taskstatus=done/failed);超时返回 None(此时调用方应回退 MediaKit)。
等待期间会自动调用 _recover_timed_out_tasks 做超时回收。
"""
import time
timeout = timeout_seconds if timeout_seconds is not None else self.settings.gpu_lipsync_wait_timeout
interval = poll_interval if poll_interval is not None else self.settings.gpu_lipsync_poll_interval
deadline = time.monotonic() + timeout
while True:
now = datetime.now(UTC)
# 顺手回收超时任务
try:
self._recover_timed_out_tasks(now)
self.db.commit()
except Exception as exc: # noqa: BLE001 - 回收失败不阻塞主流程
logger.warning("wait_for_result 回收超时任务异常: %s", exc)
self.db.rollback()
task = self.db.get(GpuLipsyncTaskModel, task_id)
if task is None:
return None
if task.status == "done":
return task
if task.status == "failed":
return task
# pending/processing 继续等
if time.monotonic() >= deadline:
logger.warning("GPU 任务 %s 等待超时(%ds),回退 MediaKit", task_id, timeout)
return None
time.sleep(interval)
+2 -203
View File
@@ -29,7 +29,6 @@ from app.services.mediakit_client import (
MediaKitError,
get_mediakit_client,
)
from app.tasks.lipsync_gpu import lipsync_gpu_process_async
# Celery 异步任务:TTS 合成 + MediaKit 提交(降级路径)
from app.tasks.lipsync_tts import tts_synthesize_and_submit
@@ -37,7 +36,6 @@ from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
from packages.application.cosyvoice_service import CosyVoiceError
from packages.config import get_api_settings
from packages.domain.sentence_timings import (
compute_sentence_timings,
probe_audio_duration,
@@ -65,7 +63,6 @@ class LipsyncService:
self.client = client or get_mediakit_client()
self._cosyvoice = cosyvoice_service
self._voice_clone_repo = voice_clone_repo
self.settings = get_api_settings()
def _get_cosyvoice(self):
"""延迟获取 CosyVoiceService(与 tts 路由一致,含 OSS 预签名配置)."""
@@ -111,8 +108,6 @@ class LipsyncService:
script_text: str,
speed: float,
emotion: str,
style: str = "",
volume: int = 50,
) -> str:
"""TTS 直生:调 CosyVoice 合成音频并转存 OSS,返回可公网访问的音频 URL.
@@ -126,9 +121,7 @@ class LipsyncService:
text=script_text,
voice_id=actual_voice_id,
speed=speed,
style=style,
volume=volume,
emotion=emotion,
emotion=emotion, # normalize 在 CosyVoiceService 内部完成
language="zh",
)
except CosyVoiceError as exc:
@@ -222,57 +215,7 @@ class LipsyncService:
if timings:
job.sentence_timings = timings
# 4. 检查是否走 GPU 路径:开关打开 + 有可用 Worker
use_gpu = False
if self.settings.use_gpu_lipsync:
try:
from app.services.gpu_lipsync_service import GpuLipsyncService
gpu_svc = GpuLipsyncService(self.db)
if gpu_svc.has_available_worker():
use_gpu = True
logger.info("[lipsync] 检测到可用 GPU Worker,优先走 MuseTalk 本地推理: job_id=%s", job.id)
else:
logger.info("[lipsync] GPU 开关已开但无可用 Worker(心跳过期),回退 MediaKit: job_id=%s", job.id)
except Exception as exc:
logger.warning("[lipsync] GPU 服务初始化失败,回退 MediaKit: job_id=%s err=%s", job.id, exc)
if use_gpu:
try:
gpu_task = self._submit_to_gpu_create(job=job, gpu_svc=gpu_svc)
if gpu_task is not None:
# GPU 任务已创建,设为 processing 并异步等待结果
job.mediakit_task_id = f"gpu:{gpu_task.id}"
job.status = "processing"
job.updated_at = datetime.now(UTC)
self.db.commit()
# 派发 Celery 异步任务处理 GPU 等待+结果回写
try:
lipsync_gpu_process_async.apply_async(args=(job.id, job.user_id, gpu_task.id))
logger.info(
"[lipsync] GPU 任务已异步派发: job_id=%s gpu_task=%s",
job.id,
gpu_task.id,
)
except Exception as celery_exc:
logger.warning(
"[lipsync] Celery 派发失败,降级同步等待: job_id=%s err=%s",
job.id,
celery_exc,
)
self._submit_to_gpu_wait(job=job, gpu_svc=gpu_svc, gpu_task=gpu_task)
return
# create 失败 → 回退 MediaKit
logger.warning("[lipsync] GPU 任务创建失败,回退 MediaKit: job_id=%s", job.id)
self.db.rollback()
except Exception as exc:
logger.exception("[lipsync] GPU 路径异常,回退 MediaKit: job_id=%s err=%s", job.id, exc)
try:
self.db.rollback()
except Exception:
pass
# 5. 签名 URL 并提交 MediaKit(兜底路径)
# 4. 签名 URL 并提交 MediaKit
video_url = self._sign_media_url(job.video_url)
signed_audio_url = self._sign_media_url(job.audio_url)
job.audio_url = signed_audio_url
@@ -301,120 +244,6 @@ class LipsyncService:
self.db.commit()
raise
# ── GPU MuseTalk 路径 ────────────────────────────────────────────────
def _is_own_oss_url(self, url: str, storage) -> bool:
"""判断 URL / 存储 key 是否属于自家 OSS。
- 裸存储 key(无 scheme):自家对象
- host 与 storage.public_url host 一致:自家对象
- 其余 http(s) 公网链接(如 dashscope-result 临时地址):外部对象
"""
if not url:
return False
parsed = urlparse(url)
if not parsed.scheme:
return True # 裸存储 key
public_base = getattr(storage, "public_url", "")
own_host = urlparse(public_base).netloc.lower() if public_base else ""
return bool(own_host) and parsed.netloc.lower() == own_host
def _persist_external_audio_for_gpu(self, *, job, storage) -> Optional[str]:
"""GPU 任务创建前,把外部域名的预合成 TTS 音频转存到自家 OSS。
Worker 部署在用户家庭网络,dashscope-result 等第三方临时 OSS 地址
可能无法访问;转存后 gpu_svc 在 poll 时会签自家预签名 URL 给 Worker。
已是自家 OSS 对象(含裸 key)直接返回 None(无需转存);
转存失败返回 None,调用方回退使用原始 URL(最坏情况是 Worker 拉取失败,
服务端重试耗尽后回退 MediaKit,不阻断业务)。
"""
if self._is_own_oss_url(job.audio_url, storage):
return None
try:
audio_data = safe_download_bytes(
job.audio_url,
purpose="lipsync_gpu_tts_audio",
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
timeout=60.0,
)
storage_key = f"lipsync-tts/{job.user_id}/{job.id}.mp3"
permanent_url = storage.upload_file(io.BytesIO(audio_data), storage_key, content_type="audio/mpeg")
logger.info(
"[lipsync] GPU 任务外部音频已转存自家 OSS: job_id=%s key=%s",
job.id,
storage_key,
)
return permanent_url
except Exception as exc:
logger.warning(
"[lipsync] GPU 任务外部音频转存 OSS 失败,回退原始 URL: job_id=%s err=%s",
job.id,
exc,
)
return None
def _submit_to_gpu_create(self, *, job, gpu_svc) -> Optional[object]:
"""创建 GPU 任务并立即返回(异步模式)。
成功返回 gpu_task 对象;创建失败返回 None。
不再同步等待结果,结果由 Celery 异步任务 lipsync_gpu_process_async 回写。
"""
storage = get_shared_storage_service()
persisted_audio_url = self._persist_external_audio_for_gpu(job=job, storage=storage)
audio_url_for_task = persisted_audio_url or job.audio_url
gpu_task = gpu_svc.create_task(
video_url=job.video_url,
audio_url=audio_url_for_task,
lipsync_job_id=job.id,
user_id=job.user_id,
project_id=job.project_id,
)
logger.info(
"[lipsync] 已创建 GPU 任务(异步): job_id=%s gpu_task=%s",
job.id,
gpu_task.id,
)
return gpu_task
def _submit_to_gpu_wait(self, *, job, gpu_svc, gpu_task) -> None:
"""同步等待 GPU 结果(Celery 派发失败时的降级路径)。"""
final_task = gpu_svc.wait_for_result(gpu_task.id)
if final_task is None:
logger.warning("[lipsync] GPU 同步等待超时,回退 MediaKit: gpu_task=%s", gpu_task.id)
return
if final_task.status != "done":
logger.warning(
"[lipsync] GPU 同步等待失败: gpu_task=%s status=%s",
gpu_task.id,
final_task.status,
)
return
try:
storage = get_shared_storage_service()
signed_result_url = storage.get_download_url(
final_task.result_url, expires_seconds=MEDIAKIT_URL_TTL_SECONDS
)
if signed_result_url:
final_task.result_url = signed_result_url
except Exception as exc:
logger.warning(
"[lipsync] GPU 结果签名失败: gpu_task=%s err=%s",
gpu_task.id,
exc,
)
job.mediakit_task_id = ""
job.status = STATUS_COMPLETED
job.output_video_url = final_task.result_url
job.output_duration = final_task.result_duration or 0.0
job.completed_at = datetime.now(UTC)
job.updated_at = datetime.now(UTC)
self.db.commit()
logger.info(
"[lipsync] GPU 同步等待完成: job_id=%s duration=%.2f",
job.id,
job.output_duration,
)
# ── 创建任务 ──────────────────────────────────────────────────────────
def create_job(
@@ -428,8 +257,6 @@ class LipsyncService:
voice_id: str = "",
script_text: str = "",
speed: float = 1.0,
style: str = "",
volume: int = 50,
emotion: str = "",
enable_video_loop: bool = True,
project_id: str = "",
@@ -478,7 +305,6 @@ class LipsyncService:
voice_id=voice_id or "",
script_text=script_text or "",
speed=speed,
style=style or "",
emotion=emotion or "",
# 音频直传(含预合成)直接进入 pending(后续同步改为 submitted);TTS 模式进入 tts_processing
status="tts_processing" if is_tts_mode else "pending",
@@ -500,8 +326,6 @@ class LipsyncService:
voice_id,
script_text,
speed,
style or "",
volume,
emotion or "",
)
)
@@ -536,8 +360,6 @@ class LipsyncService:
voice_id: str,
script_text: str,
speed: float = 1.0,
style: str = "",
volume: int = 50,
emotion: str = "neutral",
) -> dict:
"""同步做 TTS 合成 + 下载 + ffprobe + 句子时间戳计算.
@@ -656,29 +478,6 @@ class LipsyncService:
if job.status in (STATUS_COMPLETED, "failed"):
return job
# GPU 异步路径:mediakit_task_id 以 "gpu:" 开头,由 Celery 任务异步更新
# 不做 MediaKit 轮询,只检查是否卡住太久(>30 分钟)则标失败
if job.mediakit_task_id and job.mediakit_task_id.startswith("gpu:"):
if job.status in ("processing", "gpu_processing"):
_now = datetime.now(UTC)
_upd = job.updated_at
if _upd is not None and _upd.tzinfo is None:
_upd = _upd.replace(tzinfo=UTC)
stale_minutes = 30
if _upd and (_now - _upd).total_seconds() > stale_minutes * 60:
logger.warning(
"GPU 异步任务超时(>%d 分钟),标记失败: job_id=%s",
stale_minutes,
job_id,
)
job.status = "failed"
job.error_message = f"GPU 处理超时(>{stale_minutes} 分钟)"
job.error_code = "GpuTimeout"
job.completed_at = _now
job.updated_at = _now
self.db.commit()
return job
# 未提交的任务不轮询
if not job.mediakit_task_id:
return job
-344
View File
@@ -1,344 +0,0 @@
"""叙事剪辑前置服务 — #1970 PR3.
叙事模式(assembly_mode='narrative')在生成任务入队前同步完成:
1. 按 script_id 读取文案(归属校验);
2. 按 tts_voice_source 解析音色(preset=CosyVoice 音色 idclone=克隆档案 id
解析档案归属并取其 CosyVoice voice_id);
3. 同步 TTS 合成(复用 tts_job 现有 workflow:提交即同步返回,未完成则轮询兜底),
失败直接抛 NarrativeErrorHTTP 层转 4xx,任务不入队);
4. 把合成音频转存为配音库 audio asset(与 /tts/jobs/{id}/save-to-library 同一套
存储路径与元信息约定),返回 asset_id —— 下游仍以 voice_library_id(实为
audio asset id)消费,渲染链路零改动。
积分扣点与 /tts 合成端点保持一致(ai_voice 场景),失败退费。
"""
from __future__ import annotations
import json
import logging
import math
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import ScriptModel
from packages.application.cosyvoice_service import CosyVoiceService
from packages.application.tts_job.use_cases import CreateTTSJobUseCase
from packages.application.tts_job.workflow import TTSWorkflowService
from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, ClassificationStatus
from packages.domain.points_rules import calculate_points_cost
from packages.domain.points_service import PointsService
from packages.shared.storage import SharedStorageService
logger = logging.getLogger(__name__)
_POINTS_SCENE = "ai_voice"
_SYNTH_TIMEOUT = 180.0 # 叙事配音在 HTTP 请求内同步等待,长文案分段合成时留出余量
_CONTENT_TYPE_MAP = {"mp3": "audio/mpeg", "wav": "audio/wav", "pcm": "audio/pcm", "opus": "audio/opus"}
class NarrativeError(Exception):
"""叙事模式前置处理失败(文案/音色/TTS/落库)。"""
def __init__(self, message: str, *, status_code: int = 400) -> None:
super().__init__(message)
self.message = message
self.status_code = status_code
@dataclass(slots=True)
class NarrativeContext:
"""叙事模式前置处理结果。"""
script: ScriptModel
voice_asset_id: str
tts_job_id: str
audio_duration: float
def _find_or_create_voice_library(
*,
user_id: str,
project_repository: Any,
asset_library_repository: Any,
) -> AssetLibrary:
"""找到(或自动创建)用户 voice 素材库;与 tts.py 保存配音库逻辑一致。"""
projects = project_repository.find_accessible_projects(user_id)
if not projects:
raise NarrativeError("没有可用的项目,无法保存叙事配音", status_code=400)
for project in projects:
for lib in asset_library_repository.find_by_project(project.id):
kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if kind == AssetLibraryKind.VOICE.value:
return lib
project = projects[0]
library = AssetLibrary.create(project_id=project.id, name="配音素材库", kind=AssetLibraryKind.VOICE)
from sqlalchemy.exc import IntegrityError
try:
return asset_library_repository.create(library)
except IntegrityError:
session = getattr(asset_library_repository, "session", None)
if session is not None:
try:
session.rollback()
except Exception: # noqa: BLE001 - 回滚失败不影响重查
logger.warning("IntegrityError 后回滚 session 失败", exc_info=True)
for lib in asset_library_repository.find_by_project(project.id):
kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if kind == AssetLibraryKind.VOICE.value:
return lib
raise NarrativeError("配音素材库创建失败,请重试", status_code=500) from None
def _resolve_voice(
*,
user_id: str,
tts_voice_id: str,
tts_voice_source: str,
voice_clone_repository: Any,
) -> tuple[str, str]:
"""解析音色 → (CosyVoice voice_id, voice_clone_profile_id)。"""
if tts_voice_source == "clone":
profile = voice_clone_repository.get(tts_voice_id)
if profile is None:
raise NarrativeError("克隆音色不存在", status_code=404)
if profile.user_id != user_id:
raise NarrativeError("无权使用该克隆音色", status_code=403)
if not profile.voice_id:
raise NarrativeError("音色克隆尚未完成,请稍后再试", status_code=400)
return profile.voice_id, profile.id
# presettts_voice_id 即 CosyVoice 音色 id;与 /tts 端点一致,
# 若前端误传克隆档案 UUID,同样兼容解析。
profile = voice_clone_repository.get(tts_voice_id)
if profile is not None:
if profile.user_id != user_id:
raise NarrativeError("无权使用该音色", status_code=403)
if not profile.voice_id:
raise NarrativeError("音色克隆尚未完成,请稍后再试", status_code=400)
return profile.voice_id, profile.id
return tts_voice_id, ""
def _save_tts_job_as_voice_asset(
*,
job: Any,
user_id: str,
name: str,
project_repository: Any,
asset_library_repository: Any,
asset_repository: Any,
storage_service: SharedStorageService,
) -> Asset:
"""把已完成 TTS job 的音频转存为配音库 audio asset(同 save-to-library 约定)。"""
if not job.output_audio_url and not job.output_audio_key:
raise NarrativeError("TTS 合成缺少输出音频", status_code=502)
library = _find_or_create_voice_library(
user_id=user_id,
project_repository=project_repository,
asset_library_repository=asset_library_repository,
)
audio_format = (job.format or "mp3").strip() or "mp3"
content_type = _CONTENT_TYPE_MAP.get(audio_format, "audio/mpeg")
storage_key = f"uploads/voice/tts/{job.id}.{audio_format}"
tmp_path: Path | None = None
audio_duration: float | None = None
file_size = 0
try:
with tempfile.NamedTemporaryFile(suffix=f".{audio_format}", delete=False) as tmp:
tmp_path = Path(tmp.name)
download_source = job.output_audio_key or job.output_audio_url
downloaded = storage_service.download_asset(download_source, tmp_path)
if not downloaded or not tmp_path.exists() or tmp_path.stat().st_size == 0:
raise NarrativeError("叙事配音音频转存失败", status_code=502)
file_size = tmp_path.stat().st_size
storage_service.upload_file(tmp_path, storage_key, content_type=content_type)
try:
proc = subprocess.run(
[
"ffprobe",
"-v",
"quiet",
"-print_format",
"json",
"-show_format",
str(tmp_path),
],
capture_output=True,
text=True,
timeout=10,
)
if proc.returncode == 0:
dur = float(json.loads(proc.stdout).get("format", {}).get("duration", 0))
if dur > 0:
audio_duration = dur
except Exception: # noqa: BLE001 - ffprobe 仅用于时长兜底
logger.warning("叙事配音 ffprobe 时长提取失败: job_id=%s", job.id, exc_info=True)
except NarrativeError:
raise
except Exception as e: # noqa: BLE001
logger.error("叙事配音转存失败: job_id=%s, error=%s", job.id, e, exc_info=True)
raise NarrativeError("叙事配音音频转存失败", status_code=502) from e
finally:
if tmp_path and tmp_path.exists():
try:
tmp_path.unlink()
except OSError:
pass
metadata_: dict[str, object] = {
"source": "tts_job",
"tts_job_id": job.id,
"narrative": True,
"format": job.format,
"sample_rate": job.sample_rate,
"voice_id": job.voice_id,
"voice_name": job.voice_model or "",
}
if job.metadata:
for key in ("speed", "language"):
if key in job.metadata:
metadata_[key] = job.metadata[key]
asset = Asset.create(
project_id=library.project_id,
library_id=library.id,
name=name or f"叙事配音-{job.id[:8]}",
storage_key=storage_key,
mime_type=content_type,
metadata=metadata_,
file_size=file_size,
duration=job.duration or audio_duration or None,
status=AssetStatus.READY,
classification_status=ClassificationStatus.PENDING,
uploaded_by_user_id=user_id,
)
try:
return asset_repository.create(asset)
except Exception as e: # noqa: BLE001
logger.error("叙事配音 asset 落库失败,清理 OSS: %s, error=%s", storage_key, e, exc_info=True)
try:
storage_service.delete_file(storage_key)
except Exception: # noqa: BLE001
logger.warning("清理孤儿 OSS 文件失败: %s", storage_key, exc_info=True)
raise NarrativeError("叙事配音保存失败,请重试", status_code=502) from e
def prepare_narrative_voice(
*,
db: Session,
user_id: str,
script_id: str,
tts_voice_id: str,
tts_voice_source: str,
tts_repository: Any,
cosyvoice_service: CosyVoiceService,
voice_clone_repository: Any,
asset_repository: Any,
asset_library_repository: Any,
project_repository: Any,
storage_service: SharedStorageService,
points_enabled: bool = False,
is_member: bool = False,
member_type: str | None = None,
) -> NarrativeContext:
"""叙事模式入队前同步合成配音并落为 audio asset。
Raises:
NarrativeError: 文案缺失/归属不符、音色不可用、TTS 失败、转存失败。
"""
script = db.query(ScriptModel).filter(ScriptModel.id == script_id, ScriptModel.user_id == user_id).first()
if script is None:
raise NarrativeError("文案不存在或无权使用", status_code=404)
content = (script.content or "").strip()
if not content:
raise NarrativeError("文案内容为空,无法合成配音", status_code=400)
actual_voice_id, clone_profile_id = _resolve_voice(
user_id=user_id,
tts_voice_id=tts_voice_id,
tts_voice_source=tts_voice_source,
voice_clone_repository=voice_clone_repository,
)
# 积分扣点(与 /tts 合成端点同口径),失败时在合成失败分支退费
points_svc = PointsService() if points_enabled else None
points_deducted = 0
if points_svc is not None:
est_minutes = max(1.0, math.ceil(len(content) / 240))
points_deducted = calculate_points_cost(
_POINTS_SCENE,
is_member=is_member,
duration_minutes=est_minutes,
member_type=member_type,
)
deduct_res = points_svc.deduct_points(user_id, points_deducted, _POINTS_SCENE, db)
if not deduct_res["success"]:
raise NarrativeError(
f"积分不足,需要 {points_deducted} 积分,当前余额 {deduct_res['balance']}",
status_code=402,
)
use_case = CreateTTSJobUseCase(tts_repository)
job = use_case.execute(
user_id=user_id,
input_text=content,
voice_id=actual_voice_id,
voice_clone_profile_id=clone_profile_id,
metadata={"speed": 1.0, "emotion": "", "language": "zh-CN", "narrative": True, "script_id": script_id},
)
workflow = TTSWorkflowService(repository=tts_repository, cosyvoice_service=cosyvoice_service)
try:
job = workflow.start_synthesis(job.id)
if not job.is_completed:
job = workflow.poll_and_process_synthesis(job.id, timeout=_SYNTH_TIMEOUT)
except Exception as e: # noqa: BLE001 - 同步合成异常统一转 NarrativeError
logger.error("叙事配音 TTS 合成失败: job_id=%s, error=%s", job.id, e, exc_info=True)
try:
workflow.process_synthesis_failure(job.id, str(e))
except Exception: # noqa: BLE001
logger.warning("标记叙事 TTS job 失败出错: job_id=%s", job.id, exc_info=True)
if points_deducted and points_svc is not None:
try:
points_svc.refund_points(user_id, points_deducted, _POINTS_SCENE, db, ref_id=job.id)
except Exception: # noqa: BLE001
logger.warning("叙事 TTS 失败退积分异常: job_id=%s", job.id, exc_info=True)
raise NarrativeError(f"配音合成失败:{e}", status_code=502) from e
if not job.is_completed:
if points_deducted and points_svc is not None:
try:
points_svc.refund_points(user_id, points_deducted, _POINTS_SCENE, db, ref_id=job.id)
except Exception: # noqa: BLE001
logger.warning("叙事 TTS 未完成退积分异常: job_id=%s", job.id, exc_info=True)
raise NarrativeError("配音合成未完成,请稍后重试", status_code=504)
asset = _save_tts_job_as_voice_asset(
job=job,
user_id=user_id,
name=(script.title or "叙事配音")[:60],
project_repository=project_repository,
asset_library_repository=asset_library_repository,
asset_repository=asset_repository,
storage_service=storage_service,
)
return NarrativeContext(
script=script,
voice_asset_id=asset.id,
tts_job_id=job.id,
audio_duration=float(job.duration or asset.duration or 0.0),
)
+13 -133
View File
@@ -22,11 +22,6 @@ from packages.adapters.sqlalchemy_impl import (
SQLAlchemyEditPlanClipRepository,
SQLAlchemyEditPlanRepository,
)
from packages.domain.atom_clip_resolver import load_atom_clips_for_assets
from packages.domain.atom_clip_selector import (
estimate_required_clip_count,
select_atom_clips,
)
from packages.domain.config_schemas import normalize_plan_config
from packages.domain.edit_plan import EditPlan
from packages.domain.edit_plan_clip import EditPlanClip
@@ -57,12 +52,10 @@ class PlanGeneratorService:
基于模板 + 素材,自动生成 EditPlan 及 EditPlanClip 列表。
"""
def __init__(self, db: Session, asset_repo=None, atom_clip_repo=None) -> None:
def __init__(self, db: Session, asset_repo=None) -> None:
self._plan_repo = SQLAlchemyEditPlanRepository(db)
self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
self._asset_repo = asset_repo
# #1970 原子化切片:可选注入;未注入时走旧的整条素材选片路径(向后兼容)
self._atom_clip_repo = atom_clip_repo
# ── 公开接口 ─────────────────────────────────────────────────────────────
@@ -128,34 +121,18 @@ class PlanGeneratorService:
# 4. 按 editing_mode 分配素材
if asset_ids:
# #1970 原子化切片:素材 clip 从 atom_clips 表选取(未就绪自动内存兜底)。
# 预览随机模式保持旧路径(整条素材 + 随机起点),与现有预览契约一致。
atom_applied = False
if not random_preview and self._atom_clip_repo is not None:
try:
atom_applied = self._distribute_atom_clips(
clips,
asset_ids,
editing_mode,
user_id=created_by_user_id,
)
except Exception:
logger.warning("原子片段选片失败,回退整条素材选片", exc_info=True)
atom_applied = False
if not atom_applied:
# 获取素材时长信息,用于随机起始时间
asset_durations = None
if self._asset_repo:
asset_durations = self._fetch_asset_durations(asset_ids)
self._distribute_assets(
clips,
asset_ids,
editing_mode,
random_selection=random_preview,
asset_durations=asset_durations,
user_id=created_by_user_id,
)
# 获取素材时长信息,用于随机起始时间
asset_durations = None
if self._asset_repo:
asset_durations = self._fetch_asset_durations(asset_ids)
self._distribute_assets(
clips,
asset_ids,
editing_mode,
random_selection=random_preview,
asset_durations=asset_durations,
user_id=created_by_user_id,
)
# 5. 持久化所有 clips 并计算总时长
created_clips: list[EditPlanClip] = []
@@ -282,103 +259,6 @@ class PlanGeneratorService:
external_used_segments=external_used_segments,
)
def _distribute_atom_clips(
self,
clips: list[EditPlanClip],
asset_ids: list[str],
editing_mode: str,
*,
user_id: str = "",
) -> bool:
"""#1970 原子化切片选片(就地修改 clips,未持久化).
从 ``asset_atom_clips`` 表按原子片段选取;老素材/切片未就绪的素材
内存兜底切片。同一原子片段在一次方案中只用一次;跨视频避让走
edit_plan_clips.atom_clip_id 最近使用记录。
Returns:
True 表示原子片段选片成功;False 表示无可用片段,调用方应回退
到旧的整条素材 distribute_assets。
"""
# 1. 加载候选原子片段(DB + 兜底)
clips_by_asset = load_atom_clips_for_assets(
asset_ids,
atom_clip_repo=self._atom_clip_repo,
asset_repo=self._asset_repo,
)
if not clips_by_asset:
return False
# 2. 最近使用片段(跨视频原子片段级避让)
recently_used: set[str] = set()
if user_id and hasattr(self._clip_repo, "list_recent_atom_clip_ids_by_user"):
try:
recently_used = set(self._clip_repo.list_recent_atom_clip_ids_by_user(user_id, limit=200))
except Exception:
logger.warning("跨视频原子片段避让查询失败", exc_info=True)
# 3. 片段需求估算:无配音时按 clips 数量;voice_over 的配音总时长存于
# clip.config["voice_duration"],按 平均片段时长≈需要片段数 估算
voice_total = 0.0
for c in clips:
cfg_vd = c.config.get("voice_duration") if c.config else None
if cfg_vd:
voice_total += float(cfg_vd)
avg_clip_target = sum(float(c.duration or 0.0) for c in clips) / max(len(clips), 1)
required_count = estimate_required_clip_count(
voice_total or sum(float(c.duration or 0.0) for c in clips),
avg_clip_target or 3.5,
)
required_count = max(required_count, len(clips))
rng = random.Random()
# 4. 正式生成:先按素材 smart_score 对素材池排序,再展开为片段池
# (同素材的片段保持连续,高分素材的片段排在前面优先入选)
if self._asset_repo:
asset_order = self._sort_assets_by_smart_score(list(clips_by_asset.keys()))
ordered: dict[str, list] = {}
for aid in asset_order:
if aid in clips_by_asset:
ordered[aid] = clips_by_asset[aid]
clips_by_asset = ordered
candidates: list = []
for asset_clips in clips_by_asset.values():
candidates.extend(asset_clips)
# 5. 逐虚拟片段选片:评分排序,同片段不重复使用
used_atom_ids: set[str] = set()
asset_usage: dict[str, int] = {}
assigned = 0
for clip in clips:
# 对每个虚拟片段重新评分(usage_count 随选择动态变化)
scored = select_atom_clips(
candidates,
target_duration=float(clip.duration or 0.0),
used_atom_clip_ids=used_atom_ids,
asset_usage_counts=asset_usage,
recently_used_atom_ids=recently_used,
required_count=required_count,
limit=1,
rng=rng,
)
if not scored:
# 候选耗尽(同片段不可重复),交由调用方回退或留白
continue
picked = scored[0]
clip.asset_id = picked.asset_id
clip.atom_clip_id = picked.atom_clip_id
clip.start_time = round(picked.start_time, 3)
clip.duration = round(picked.duration, 3)
used_atom_ids.add(picked.atom_clip_id)
asset_usage[picked.asset_id] = asset_usage.get(picked.asset_id, 0) + 1
assigned += 1
if assigned == 0:
return False
return True
def _fetch_asset_scene_points(self, asset_ids: list[str]) -> dict[str, list[float]]:
"""从素材 metadata 读取场景切换点缓存(无缓存的素材不包含在结果中)。"""
points_map: dict[str, list[float]] = {}
+1 -6
View File
@@ -38,12 +38,7 @@ def transcribe_to_text(media_path: str | Path) -> str:
ASRTranscriptionError: ASR 调用失败
"""
# 延迟导入,避免循环依赖和启动时副作用
try:
from apps.worker.services.asr_service_factory import get_asr_service
except ImportError as exc:
# API 镜像未打包 worker 代码(本地 ASR 依赖 worker 的 asr_service_factory
logger.warning("本地 ASR 不可用(apps.worker 未安装): %s", exc)
raise ASRNotConfiguredError("本地 ASR 服务不可用(worker 模块未安装)") from exc
from apps.worker.services.asr_service_factory import get_asr_service
asr = get_asr_service()
if asr is None:
-190
View File
@@ -1,190 +0,0 @@
"""GPU MuseTalk 异步推理任务 — 将 GPU 推理等待从 HTTP 请求移至 Celery 后台执行.
优化目标:将 POST /lipsync/jobs 的 API 响应时间从 >200s 降到 <1s。
任务流程:
1. 加载 LipsyncJob,获取 gpu_task_id
2. 调用 GpuLipsyncService.wait_for_result 轮询等待 GPU 完成
3. 签名结果 URL7 天),更新 job 为 completed
4. 失败/超时时:尝试 MediaKit 兜底,若仍失败则标记 job 为 failed
使用 @shared_task 确保被 Worker 侧 celery_app 正确注册。
"""
import logging
from datetime import UTC, datetime
from celery import shared_task
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
from packages.shared.storage import get_shared_storage_service
logger = logging.getLogger(__name__)
# 与 LipsyncService 保持一致
_MEDIAKIT_URL_TTL_SECONDS = 7 * 24 * 3600
def _get_db_session() -> Session:
"""获取 DB session(兼容 API 和 Worker 两种运行时)."""
try:
from worker_app.db import SessionLocal # type: ignore
except ImportError:
from app.db import SessionLocal # type: ignore
return SessionLocal()
def _sign_media_url(url: str) -> str:
"""对自家 OSS URL 签 7 天预签名。"""
if not url:
return url
try:
from urllib.parse import urlparse
storage = get_shared_storage_service()
public_base = getattr(storage, "public_url", "")
if not isinstance(public_base, str) or not public_base:
return url
own_host = urlparse(public_base).netloc.lower()
host = urlparse(url).netloc.lower()
if not own_host or host != own_host:
return url
return storage.get_download_url(url, expires_seconds=_MEDIAKIT_URL_TTL_SECONDS)
except Exception:
return url
@shared_task(
name="lipsync_gpu_process_async",
bind=True,
max_retries=0,
acks_late=True,
)
def lipsync_gpu_process_async(self, job_id: str, user_id: str, gpu_task_id: str) -> None:
"""异步处理 GPU MuseTalk 推理。
Args:
job_id: LipsyncJob 的 ID
user_id: 用户 ID
gpu_task_id: GpuLipsyncTask 的 ID
"""
db: Session = _get_db_session()
try:
job = db.query(LipsyncJobModel).filter_by(id=job_id, user_id=user_id).first()
if job is None:
logger.error("[lipsync_gpu_async] job 不存在: job_id=%s", job_id)
return
# 确保状态为 processing
if job.status not in ("processing", "gpu_processing"):
logger.warning(
"[lipsync_gpu_async] job 状态异常,跳过: job_id=%s status=%s",
job_id,
job.status,
)
return
from app.services.gpu_lipsync_service import GpuLipsyncService
gpu_svc = GpuLipsyncService(db)
final_task = gpu_svc.wait_for_result(gpu_task_id)
if final_task is None:
logger.warning(
"[lipsync_gpu_async] GPU 超时,回退 MediaKit: job_id=%s gpu_task=%s",
job_id,
gpu_task_id,
)
_fallback_to_mediakit(db, job)
return
if final_task.status != "done":
logger.warning(
"[lipsync_gpu_async] GPU 失败,回退 MediaKit: job_id=%s gpu_task=%s status=%s",
job_id,
gpu_task_id,
final_task.status,
)
_fallback_to_mediakit(db, job)
return
# 签名结果 URL
result_url = final_task.result_url or ""
try:
storage = get_shared_storage_service()
signed = storage.get_download_url(result_url, expires_seconds=_MEDIAKIT_URL_TTL_SECONDS)
if signed:
result_url = signed
except Exception as exc:
logger.warning(
"[lipsync_gpu_async] 签名失败,用原 URL: job_id=%s err=%s",
job_id,
exc,
)
job.status = "completed"
job.output_video_url = result_url
job.output_duration = final_task.result_duration or 0.0
job.completed_at = datetime.now(UTC)
job.updated_at = datetime.now(UTC)
db.commit()
logger.info(
"[lipsync_gpu_async] GPU 完成: job_id=%s duration=%.2f",
job_id,
job.output_duration,
)
except Exception as exc:
logger.exception("[lipsync_gpu_async] 异常: job_id=%s err=%s", job_id, exc)
try:
job = db.query(LipsyncJobModel).filter_by(id=job_id).first()
if job:
job.status = "failed"
job.error_message = f"GPU 异步处理异常: {exc}"
job.error_code = "GpuAsyncError"
job.updated_at = datetime.now(UTC)
db.commit()
except Exception:
pass
finally:
db.close()
def _fallback_to_mediakit(db: Session, job: LipsyncJobModel) -> None:
"""GPU 失败时回退到 MediaKit 云端渲染。"""
try:
from app.services.mediakit_client import MediaKitError, get_mediakit_client
client = get_mediakit_client()
video_url = _sign_media_url(job.video_url)
audio_url = _sign_media_url(job.audio_url)
result = client.submit_lipsync(
video_url=video_url,
audio_url=audio_url,
enable_video_loop=job.enable_video_loop,
client_token=job.id,
)
job.mediakit_task_id = result["task_id"]
job.status = "submitted"
job.submitted_at = datetime.now(UTC)
job.updated_at = datetime.now(UTC)
db.commit()
logger.info(
"[lipsync_gpu_async] 已回退 MediaKit: job_id=%s task_id=%s",
job.id,
result["task_id"],
)
except MediaKitError as exc:
job.status = "failed"
job.error_message = str(exc)
job.error_code = exc.code
job.updated_at = datetime.now(UTC)
db.commit()
logger.error("[lipsync_gpu_async] MediaKit 也失败: job_id=%s err=%s", job.id, exc)
except Exception as exc:
job.status = "failed"
job.error_message = f"GPU+MediaKit 均失败: {exc}"
job.error_code = "FallbackFailed"
job.updated_at = datetime.now(UTC)
db.commit()
logger.error("[lipsync_gpu_async] 兜底异常: job_id=%s err=%s", job.id, exc)
+1 -5
View File
@@ -82,9 +82,7 @@ def tts_synthesize_and_submit(
voice_id: str,
script_text: str,
speed: float,
style: str = "",
volume: int = 50,
emotion: str = "",
emotion: str,
):
"""异步执行 TTS 合成 + OSS 转存 + MediaKit 提交.
@@ -161,8 +159,6 @@ def tts_synthesize_and_submit(
text=script_text,
voice_id=voice_id,
speed=speed,
style=style,
volume=volume,
emotion=emotion,
language="zh",
)
-117
View File
@@ -1,117 +0,0 @@
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
const PASSWORD = "SmokePass123!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
async function routeBrowserApiToTestApi(page: Page) {
if (!apiOrigin) return
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url())
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
})
await route.fulfill({ response })
})
}
async function loginWithRetry(request: APIRequestContext, email: string, password: string) {
for (let i = 0; i <= 2; i++) {
const r = await request.post(`${apiBase}/auth/login`, { data: { email, password } })
if (r.status() !== 429) {
expect(r.ok(), `login: ${await r.text()}`).toBeTruthy()
return (await r.json()).access_token as string
}
console.log(`[douyin] 429 retry ${i + 1}/2`)
await new Promise((res) => setTimeout(res, 65000))
}
throw new Error("Login retries exhausted")
}
/**
* #1972 抖音文案提取冒烟
*
* 路径:文案库页面 → 点「🎬 从抖音提取」→ 粘贴分享文案 → 点「开始提取」
* → mock /api/v1/scripts/extract-from-douyin 返回稳定文案 → 断言「新建文案」弹窗中预填了非空文案
*/
test.describe("Douyin Script Extraction (#1972)", () => {
test("extract flow: open modal, paste link, text prefilled in create modal", async ({
page,
request,
}) => {
test.setTimeout(180_000)
await page.setViewportSize({ width: 1440, height: 900 })
const suffix = Math.random().toString(36).slice(2, 8)
const email = `e2e-douyin-${suffix}@example.com`
await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username: `e2e_dy_${suffix}` },
})
const token = await loginWithRetry(request, email, PASSWORD)
const authHeader = { Authorization: `Bearer ${token}` }
const proj = await request.post(`${apiBase}/projects`, {
headers: authHeader,
data: { name: `Smoke Douyin ${suffix}` },
})
const projectId = (await proj.json()).id ?? (await proj.json()).project_id
await request.post(`${apiBase}/asset-libraries`, {
headers: authHeader,
data: { project_id: projectId, name: "Smoke", kind: "video" },
})
await page.addInitScript((t: string) => {
window.localStorage.setItem("access_token", t)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({ state: { token: t, user: null } }),
)
}, token)
await routeBrowserApiToTestApi(page)
// Mock 抖音提取接口返回稳定文案
const extractedText = "大家好,今天给大家推荐一款超好用的产品,性价比非常高,快来看看吧!"
await page.route("**/api/v1/scripts/extract-from-douyin", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ text: extractedText, duration_seconds: 15 }),
}),
)
// 文案列表空态
await page.route(
(url) => url.pathname.endsWith("/scripts") && !url.pathname.includes("extract-from-douyin"),
(route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [], total: 0, page: 1, page_size: 20 }),
}),
)
await page.goto("/app/scripts")
// 文案库页面加载
await expect(page.getByText(/文案库|文案/).first()).toBeVisible({ timeout: 30000 })
// 点「🎬 从抖音提取」按钮
await page.getByRole("button", { name: /从抖音提取/ }).click()
await expect(page.getByText("从抖音视频提取文案")).toBeVisible({ timeout: 5000 })
// 在 TextArea 粘贴"抖音分享文案"
const textarea = page.locator(".ant-modal textarea").first()
await expect(textarea).toBeVisible()
await textarea.fill("8.88 复制打开抖音,看看【推荐视频】https://v.douyin.com/abcDEF/")
// 点「开始提取」
await page.getByRole("button", { name: "开始提取" }).click()
await expect(page.getByText(/提取中/)).toBeVisible({ timeout: 3000 })
// 等待抖音弹窗关闭,「新建文案」弹窗打开并预填提取文案
await expect(page.getByText("从抖音视频提取文案")).not.toBeVisible({ timeout: 15000 })
await expect(page.getByText("新建文案")).toBeVisible({ timeout: 5000 })
const createTextarea = page.locator(".ant-modal textarea").first()
await expect(createTextarea).toBeVisible()
await expect(createTextarea).toHaveValue(new RegExp(extractedText.slice(0, 10)))
console.log("[douyin] Extraction flow completed ✓, text length:", extractedText.length)
})
})
+238 -321
View File
@@ -1,4 +1,4 @@
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
import { expect, test, type APIRequestContext } from "@playwright/test"
import * as fs from "node:fs"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
@@ -8,8 +8,7 @@ const PASSWORD = "SmokePass123!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
/** 将浏览器侧 /api/v1 请求路由到 Playwright request 源(支持跨域) */
async function routeBrowserApiToTestApi(page: Page) {
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
if (!apiOrigin) return
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url())
@@ -25,358 +24,276 @@ async function loginWithRetry(
email: string,
password: string,
maxRetries = 2,
): Promise<string> {
) {
for (let i = 0; i <= maxRetries; i++) {
const resp = await request.post(`${apiBase}/auth/login`, { data: { email, password } })
if (resp.status() !== 429) {
expect(resp.ok(), `Login should succeed: ${await resp.text()}`).toBeTruthy()
const data = await resp.json()
return data.access_token
}
console.log(`[login] 429 rate limited, retry ${i + 1}/${maxRetries} after 65s`)
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
})
if (response.status() !== 429) return response
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
await new Promise((r) => setTimeout(r, 65000))
}
throw new Error("Login failed after retries")
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
})
}
/**
* 注册新用户 + 建项目/视频库/上传 sample.mp4,等素材 ready。返回 { token, projectId, libraryId, assetId }。
*/
async function setupFreshUser(
request: APIRequestContext,
label: string,
): Promise<{ token: string; libraryId: string; assetId: string; suffix: string }> {
const suffix = Math.random().toString(36).slice(2, 8)
const email = `e2e-${label}-${suffix}@example.com`
await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username: `e2e_${label}_${suffix}` },
})
const token = await loginWithRetry(request, email, PASSWORD)
const auth = { Authorization: `Bearer ${token}` }
const proj = await request.post(`${apiBase}/projects`, {
headers: auth,
data: { name: `Smoke ${label} ${suffix}` },
})
expect(proj.ok(), `create project: ${await proj.text()}`).toBeTruthy()
const projectId = (await proj.json()).id ?? (await proj.json()).project_id
const lib = await request.post(`${apiBase}/asset-libraries`, {
headers: auth,
data: { project_id: projectId, name: "Smoke", kind: "video" },
})
expect(lib.ok(), `create library: ${await lib.text()}`).toBeTruthy()
const libraryId = (await lib.json()).id
const samplePath = path.join(__dirname, "fixtures", "sample.mp4")
const sampleBuf = fs.readFileSync(samplePath)
const up = await request.post(`${apiBase}/upload`, {
headers: auth,
multipart: {
project_id: projectId,
library_id: libraryId,
file: {
name: "sample.mp4",
mimeType: "video/mp4",
buffer: sampleBuf,
},
},
})
expect(up.ok(), `upload sample: ${await up.text()}`).toBeTruthy()
const assetId = (await up.json()).asset_id
await expect
.poll(
async () => {
const r = await request.get(`${apiBase}/assets/${assetId}`, { headers: auth })
return r.ok() ? (await r.json()).status : "pending"
},
{ timeout: 90_000, intervals: [3000, 3000, 5000] },
)
.toBe("ready")
return { token, libraryId, assetId, suffix }
type ProjectResponse = { id: string }
type LibraryResponse = { id: string }
type AssetListResponse = {
items: Array<{
id: string
name: string
status: string
}>
}
/**
* #1970 智能剪辑核心冒烟(新 5 步向导)
*
* 新流程:选择模式 → 选择素材 → 选择标题 → 确认生成 → 选择封面
*
* 两条路径:
* 1) 随机混剪(默认)→ Step1 下一步 → 配音选择弹窗 → Step2 选素材 → 数量弹窗
* → Step3 标题 → Step4 确认生成 → 断言任务创建
* 2) 叙事剪辑 → Step1 切模式 → 下一步 → 文案选择弹窗 → TTS 弹窗选音色(mock 合成)
* → Step2 AI 提示卡可见 + 选素材 → 数量弹窗 → Step3 标题 → Step4 确认生成
* → 断言任务创建
*/
test.describe("Core Smart-Edit Flow (#1970)", () => {
test("random mode: 5-step wizard creates generation task", async ({ page, request }) => {
test.setTimeout(600_000)
await page.setViewportSize({ width: 1440, height: 1000 })
const { token, suffix } = await setupFreshUser(request, "random")
const authHeader = { Authorization: `Bearer ${token}` }
test.describe("Core generation flow", () => {
test.describe.configure({ timeout: 360_000 })
// 确保默认模板存在(智能剪辑页依赖模板)
const tmpls = await request.get(`${apiBase}/templates`, { headers: authHeader })
const tmplsJson = await tmpls.json()
const templates = Array.isArray(tmplsJson)
? tmplsJson
: Array.isArray(tmplsJson.items)
? tmplsJson.items
: []
expect(templates.length).toBeGreaterThan(0)
test("walks through wizard with count modal and starts generation", async ({ page, request }) => {
test.setTimeout(360_000)
// 注入登录态 + 路由 API
await page.addInitScript((t: string) => {
window.localStorage.setItem("access_token", t)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({ state: { token: t, user: null } }),
)
}, token)
await routeBrowserApiToTestApi(page)
const suffix = Date.now().toString(36)
const email = `e2e-gen-${suffix}@example.com`
const username = `e2e_gen_${suffix}`
const libraryName = `E2E Gen Lib ${suffix}`
// ── 提前 mock 配音列表(VoiceSelectModal 查询 /assets?kind=voice ──
await page.route(
(url) => url.pathname.endsWith("/assets") && url.searchParams.get("kind") === "voice",
(route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
id: `asset-voice-${suffix}`,
name: "测试配音.mp3",
file_url: "data:audio/mpeg;base64,",
duration: 10,
file_size: 1024,
kind: "voice",
status: "ready",
},
],
total: 1,
// Register
const register = await request.post(`${apiBase}/auth/register`, {
data: { email, username, password: PASSWORD, display_name: username },
})
expect(register.status()).toBe(201)
const registerData = (await register.json()) as { user_id: string }
// Login
const login = await loginWithRetry(request, email, PASSWORD)
expect(login.status()).toBe(200)
const loginData = (await login.json()) as { access_token: string }
const headers = { Authorization: `Bearer ${loginData.access_token}` }
// Create project
const project = await request.post(`${apiBase}/projects`, {
headers,
data: { name: `E2E Gen Proj ${suffix}` },
})
expect(project.status()).toBe(200)
const projectData = (await project.json()) as ProjectResponse
// Create asset library
const library = await request.post(`${apiBase}/asset-libraries`, {
headers,
data: { project_id: projectData.id, name: libraryName, kind: "video" },
})
expect(library.status()).toBe(200)
const libraryData = (await library.json()) as LibraryResponse
// Upload source video
const sourceFileName = "e2e-gen-source.mp4"
const sampleVideoPath = path.join(__dirname, "fixtures", "sample.mp4")
const sampleVideoBuffer = fs.readFileSync(sampleVideoPath)
const upload = await request.post(`${apiBase}/upload`, {
headers,
multipart: {
project_id: projectData.id,
library_id: libraryData.id,
file: {
name: sourceFileName,
mimeType: "video/mp4",
buffer: sampleVideoBuffer,
},
},
})
expect(upload.status()).toBe(200)
// Wait for asset to be ready
await expect
.poll(
async () => {
const assets = await request.get(`${apiBase}/assets`, {
headers,
params: { library_id: libraryData.id },
})
if (!assets.ok()) return `http_${assets.status()}`
const data = (await assets.json()) as AssetListResponse
const asset = data.items.find((a) => a.name === sourceFileName)
if (!asset) return "missing"
return asset.status
},
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] },
)
.toBe("ready")
// GET /templates auto-creates a default template for new users
const templatesResp = await request.get(`${apiBase}/templates`, { headers })
expect(templatesResp.status(), await templatesResp.text()).toBe(200)
const templatesData = (await templatesResp.json()) as {
items: Array<{ id: string }>
}
expect(Array.isArray(templatesData.items)).toBe(true)
expect(templatesData.items.length).toBeGreaterThan(0)
const templateId = templatesData.items[0].id
expect(templateId).toBeTruthy()
// Set auth in localStorage
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token)
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
}),
}),
)
},
{
token: loginData.access_token,
user: {
id: registerData.user_id,
user_id: registerData.user_id,
email,
username,
display_name: username,
is_email_verified: true,
email_verified: true,
},
},
)
// Navigate to generate page
await page.goto("/app/generate")
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
timeout: 30000,
timeout: 20_000,
})
// ── Step 1:默认随机混剪选中,点下一步 ──────────────────────────
await expect(page.getByText("选择模式", { exact: true })).toBeVisible()
await expect(page.getByText("随机混剪")).toBeVisible()
await page.getByRole("button", { name: /下一步/ }).click()
// 5步向导:素材(1)→配音(2)→标题(3)→确认生成(4)→封面(5)
// ── 配音选择弹窗:选第一个配音 → 确认 ─────────────────────────
await expect(page.getByText("🎙️ 选择配音")).toBeVisible({ timeout: 5000 })
await page.getByText("测试配音.mp3").first().click()
await page.getByRole("button", { name: "确认选择" }).click()
await expect(page.getByText("🎙️ 选择配音")).not.toBeVisible()
// ── Step 1: 素材选择 ──
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible()
const librarySelect = page.locator("select").first()
await librarySelect.selectOption({ label: libraryName })
const materialCard = page.getByTestId("material-card").filter({ hasText: sourceFileName })
await expect(materialCard).toBeVisible({ timeout: 10_000 })
await materialCard.click({ position: { x: 15, y: 15 } })
await expect(materialCard.getByTestId("material-card-check")).toBeVisible({ timeout: 5_000 })
await page.getByRole("button", { name: "下一步" }).click()
// ── Step 2:选择素材 ──────────────────────────────────────────
await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 })
await page.getByTestId("material-card").first().click()
await page.getByRole("button", { name: /下一步/ }).click()
// ── 数量弹窗:默认 1 个 → 确认 ───────────────────────────────
await expect(page.getByText("要生成几个视频?")).toBeVisible({ timeout: 5000 })
// ── 数量弹窗(PreviewCountModal ──
await expect(page.getByRole("heading", { name: "要生成几个视频?" })).toBeVisible({
timeout: 5_000,
})
await page.getByRole("button", { name: "生成 1 个视频" }).click()
// ── Step 3:填写标题 ──────────────────────────────────────────
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
const titleInput = page.getByPlaceholder("输入或从标题库选择")
// ── Step 2: 配音(新注册用户无配音素材,跳过) ──
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible({ timeout: 15000 })
await page.getByRole("button", { name: "下一步" }).click()
// ── Step 3: 标题设置 ──
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible({ timeout: 15000 })
await page.waitForTimeout(2000)
const titleInput = page.locator(".ant-select-auto-complete input")
await expect(titleInput).toBeVisible({ timeout: 5000 })
await titleInput.fill(`测试随机剪辑 ${suffix}`)
await page.getByRole("button", { name: /下一步/ }).click()
await titleInput.fill(`E2E Test ${suffix}`)
// ── Step 4确认生成 ──────────────────────────────────────────
await expect(page.getByText("📋 生成配置")).toBeVisible({ timeout: 10000 })
await expect(page.getByText("随机混剪")).toBeVisible()
const confirmBtn = page.getByRole("button", { name: /确认生成视频/ })
await expect(confirmBtn).toBeEnabled({ timeout: 5000 })
// Step 3 底部是「下一步 →」,点击进入 Step 4确认生成
await page.getByRole("button", { name: "下一步" }).click()
const createTask = page.waitForResponse(
(r) => r.url().includes("/generation/tasks") && r.request().method() === "POST",
{ timeout: 30000 },
// ── Step 4: 确认生成 ──
// 等待实时预览就绪(占位消失)
await page
.getByText("准备预览素材")
.waitFor({ state: "detached", timeout: 30_000 })
.catch(() => {})
// Step 4 底部是「✨ 确认生成视频」
const confirmBtn = page.locator(".xx-step-actions .xx-btn-primary").first()
await expect(confirmBtn).toBeVisible({ timeout: 15_000 })
// 先挂 API 监听再点击
const generatePromise = page.waitForResponse(
(response) => {
const url = response.url()
const path = new URL(url).pathname
return response.request().method() === "POST" && path.endsWith("/generation/tasks")
},
{ timeout: 30_000 },
)
await confirmBtn.click()
const taskResp = await createTask
expect(taskResp.ok(), `Create task: ${await taskResp.text()}`).toBeTruthy()
const taskId = (await taskResp.json()).id ?? (await taskResp.json()).task_id
console.log("[random] Generation task created:", taskId)
await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 })
console.log("[random] Wizard flow completed ✓")
// 验证生成 API 被调用
const genResp = await generatePromise.catch(() => null)
if (!genResp) {
// staging 预览未就绪导致按钮校验拦截,未触发 API — 向导导航仍通过
console.log(
"[E2E] Generation API not triggered (preview not ready) — wizard navigation verified",
)
} else if (genResp.ok()) {
const genData = (await genResp.json()) as {
items: Array<{ id: string; status: string }>
total: number
}
expect(genData.items.length).toBeGreaterThan(0)
// race:渲染完成 vs 生成失败/超时
const downloadReady = page
.getByText("视频生成完成")
.isVisible({ timeout: 180_000 })
.then((v) => (v ? "completed" : null))
const generationFailed = page
.getByText(/生成失败|重新生成/)
.isVisible({ timeout: 180_000 })
.then((v) => (v ? "failed" : null))
const outcome = await Promise.any([downloadReady, generationFailed]).catch(() => "timeout")
if (outcome === "completed") {
await page.getByRole("button", { name: /下一步:选择封面/ }).click()
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({
timeout: 30_000,
})
} else {
console.log(`[E2E] Video rendering ${outcome} on staging — wizard flow verified`)
}
} else {
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
}
// 验证成品库页面加载
await page.goto("/app/products")
await expect(page).toHaveURL(/\/app\/products/)
await expect(page.locator(".xx-products-page")).toBeVisible({ timeout: 15_000 })
await page.unrouteAll({ behavior: "ignoreErrors" })
})
test("narrative mode: select script + mock TTS, create generation task", async ({
page,
request,
}) => {
test.setTimeout(600_000)
await page.setViewportSize({ width: 1440, height: 1000 })
const { token, suffix } = await setupFreshUser(request, "narrative")
test("generation task API creates and lists tasks", async ({ request }) => {
const suffix = Date.now().toString(36)
const email = `e2e-gen-api-${suffix}@example.com`
const username = `e2e_gen_api_${suffix}`
await page.addInitScript((t: string) => {
window.localStorage.setItem("access_token", t)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({ state: { token: t, user: null } }),
)
}, token)
await routeBrowserApiToTestApi(page)
// ── Mock 文案列表、音色、TTS 合成(避免真实合成) ──────────────
const mockScriptId = `script-mock-${suffix}`
const mockVoiceId = `preset-voice-${suffix}`
const mockJobId = `tts-job-${suffix}`
// 文案列表(ScriptSelectModal 查询 /scripts
await page.route("**/api/v1/scripts**", (route) => {
const url = new URL(route.request().url())
if (url.pathname.includes("/extract-from-douyin")) {
route.continue()
return
}
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
id: mockScriptId,
title: "测试带货文案",
content: "这是一段测试用的带货文案内容,用于 E2E 冒烟测试。",
tags: ["带货"],
title_category: "daihuo",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
],
total: 1,
page: 1,
page_size: 200,
}),
})
const register = await request.post(`${apiBase}/auth/register`, {
data: { email, username, password: PASSWORD, display_name: username },
})
expect(register.status()).toBe(201)
// 预设音色(TtsVoiceModal 查询 GET /voices/presets
await page.route("**/api/v1/voices/presets**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
voice_id: mockVoiceId,
name: "晓晓(女声)",
description: "温柔女声",
gender: "female",
language: "zh-CN",
preview_url: null,
tags: ["温柔"],
},
],
total: 1,
}),
}),
)
const login = await loginWithRetry(request, email, PASSWORD)
expect(login.status()).toBe(200)
const loginData = (await login.json()) as { access_token: string }
const headers = { Authorization: `Bearer ${loginData.access_token}` }
// 克隆音色:空列表
await page.route(
(url) => url.pathname.endsWith("/voice-clones"),
(route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [] }),
}),
)
// TTS 合成:直接返回 completed 任务
await page.route("**/api/v1/tts/synthesize", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ job_id: mockJobId, status: "queued" }),
}),
)
await page.route(`**/api/v1/tts/jobs/${mockJobId}/status`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
job_id: mockJobId,
status: "completed",
progress: 100,
audio_url: "data:audio/mpeg;base64,",
duration: 5,
}),
}),
)
await page.route(`**/api/v1/tts/jobs/${mockJobId}/save-to-library`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: `tts-asset-${suffix}`, name: "AI合成配音" }),
}),
)
await page.goto("/app/generate")
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
timeout: 30000,
const project = await request.post(`${apiBase}/projects`, {
headers,
data: { name: `E2E API Proj ${suffix}` },
})
expect(project.status()).toBe(200)
// ── Step 1:切到叙事剪辑 → 下一步 ────────────────────────────
await expect(page.getByText("选择模式", { exact: true })).toBeVisible()
await page.getByText("叙事剪辑").click()
await page.getByRole("button", { name: /下一步/ }).click()
// ── 文案选择弹窗:选第一条 → 确认 ─────────────────────────────
await expect(page.getByText("📝 选择文案")).toBeVisible({ timeout: 5000 })
await page.getByText("测试带货文案").first().click()
await page.getByRole("button", { name: "确认选择" }).click()
await expect(page.getByText("📝 选择文案")).not.toBeVisible()
// ── TTS 音色弹窗:选系统音色 → 合成 ─────────────────────────
await expect(page.getByText("🎙️ 合成配音")).toBeVisible({ timeout: 5000 })
await page.getByText("晓晓(女声)").first().click()
await page.getByRole("button", { name: "🎧 合成配音" }).click()
await expect(page.getByText("🎙️ 合成配音")).not.toBeVisible({ timeout: 30000 })
// ── Step 2:AI 匹配提示卡可见 + 选素材 ────────────────────────
await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 })
await expect(page.getByText(/AI智能匹配/)).toBeVisible()
await page.getByTestId("material-card").first().click()
await page.getByRole("button", { name: /下一步/ }).click()
// ── 数量弹窗 ─────────────────────────────────────────────────
await expect(page.getByText("要生成几个视频?")).toBeVisible({ timeout: 5000 })
await page.getByRole("button", { name: "生成 1 个视频" }).click()
// ── Step 3:填写标题(handleScriptModalConfirm 已预填 script.title,但我们再覆盖一次) ─
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
const titleInput2 = page.getByPlaceholder("输入或从标题库选择")
await expect(titleInput2).toBeVisible({ timeout: 5000 })
await titleInput2.fill(`测试叙事剪辑 ${suffix}`)
await page.getByRole("button", { name: /下一步/ }).click()
// ── Step 4:确认生成 ──────────────────────────────────────────
await expect(page.getByText("📋 生成配置")).toBeVisible({ timeout: 10000 })
await expect(page.getByText("叙事剪辑")).toBeVisible()
const confirmBtn2 = page.getByRole("button", { name: /确认生成视频/ })
await expect(confirmBtn2).toBeEnabled({ timeout: 5000 })
const createTask2 = page.waitForResponse(
(r) => r.url().includes("/generation/tasks") && r.request().method() === "POST",
{ timeout: 30000 },
)
await confirmBtn2.click()
const taskResp2 = await createTask2
expect(taskResp2.ok(), `Create task: ${await taskResp2.text()}`).toBeTruthy()
console.log("[narrative] Generation task created:", (await taskResp2.json()).id)
await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 })
console.log("[narrative] Wizard flow completed ✓")
const tasks = await request.get(`${apiBase}/tasks`, { headers })
expect(tasks.status()).toBe(200)
const tasksData = await tasks.json()
expect(Array.isArray(tasksData.items)).toBe(true)
})
})
-105
View File
@@ -1,105 +0,0 @@
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
const PASSWORD = "SmokePass123!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
async function routeBrowserApiToTestApi(page: Page) {
if (!apiOrigin) return
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url())
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
})
await route.fulfill({ response })
})
}
async function loginWithRetry(request: APIRequestContext, email: string, password: string) {
for (let i = 0; i <= 2; i++) {
const r = await request.post(`${apiBase}/auth/login`, { data: { email, password } })
if (r.status() !== 429) {
expect(r.ok(), `login: ${await r.text()}`).toBeTruthy()
return (await r.json()).access_token as string
}
console.log(`[nav] 429 retry ${i + 1}/2`)
await new Promise((res) => setTimeout(res, 65000))
}
throw new Error("Login retries exhausted")
}
/**
* 核心页面导航冒烟:侧边栏主要入口能访问、文案库/配音库页面能正常加载(不出白屏/无致命 js error)
*/
test.describe("Core Navigation", () => {
let authToken: string
test.beforeAll(async ({ request }) => {
const suffix = Math.random().toString(36).slice(2, 8)
const email = `e2e-nav-${suffix}@example.com`
await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username: `e2e_nav_${suffix}` },
})
authToken = await loginWithRetry(request, email, PASSWORD)
const authHeader = { Authorization: `Bearer ${authToken}` }
const proj = await request.post(`${apiBase}/projects`, {
headers: authHeader,
data: { name: `Smoke Nav ${suffix}` },
})
if (proj.ok()) {
const projectId = (await proj.json()).id ?? (await proj.json()).project_id
await request.post(`${apiBase}/asset-libraries`, {
headers: authHeader,
data: { project_id: projectId, name: "Nav Lib", kind: "video" },
})
}
})
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await page.addInitScript((t: string) => {
window.localStorage.setItem("access_token", t)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({ state: { token: t, user: null } }),
)
}, authToken)
await routeBrowserApiToTestApi(page)
})
const navCases = [
{ path: "/app/dashboard", marker: /概览|工作台|最近/i, name: "概览" },
{ path: "/app/generate", marker: /智能剪辑|剪辑/, name: "智能剪辑" },
{ path: "/app/assets", marker: /视频库|素材/, name: "视频库" },
{ path: "/app/scripts", marker: /文案/, name: "文案库" },
{ path: "/app/voices", marker: /配音|我的音色|配音库/, name: "配音库" },
{ path: "/app/products", marker: /成品|作品/, name: "成品库" },
{ path: "/app/history", marker: /历史|任务/, name: "任务历史" },
{ path: "/app/tasks", marker: /任务中心|任务列表/, name: "任务中心" },
{ path: "/app/points", marker: /积分|我的积分/, name: "积分中心" },
]
for (const c of navCases) {
test(`visit ${c.name} (${c.path}) loads without fatal pageerror`, async ({ page }) => {
const errors: Error[] = []
page.on("pageerror", (e) => errors.push(e))
await page.goto(c.path)
await expect(page.locator("body")).not.toBeEmpty({ timeout: 20000 })
// 过滤掉常见第三方/非致命错误
const fatal = errors.filter(
(e) =>
!/ResizeObserver|Loading chunk|network error|Failed to fetch|chunkLoadError/i.test(
e.message,
),
)
expect(fatal, `${c.name} pageerrors: ${fatal.map((e) => e.message).join("; ")}`).toHaveLength(
0,
)
await expect(
page.getByText(c.marker).first(),
`${c.name} should show relevant text`,
).toBeVisible({ timeout: 15000 })
console.log(`[nav] ${c.name} loaded ✓`)
})
}
})
+2 -13
View File
@@ -7,19 +7,8 @@ export interface GenerateCoverTitleConfig {
font_color?: string
position?: string
bold?: boolean
italic?: boolean
stroke?: boolean | { enabled?: boolean; width?: number; color?: string }
shadow?:
| boolean
| { enabled?: boolean; offset_x?: number; offset_y?: number; blur?: number; color?: string }
line_height?: number
margin_top?: number
max_chars_per_line?: number
background?: { enabled?: boolean; color?: string; padding?: number; radius?: number }
line_overrides?: Array<Record<string, unknown>>
cover_title_config?: Record<string, unknown>
pos_x?: number
pos_y?: number
stroke?: boolean
shadow?: boolean
}
export interface GenerateCoverRequest {
+3 -29
View File
@@ -81,7 +81,7 @@ export interface CreateGenerationTaskRequest {
tts_voice_source?: "preset" | "clone"
/** #1970:智能降重开关(默认 true) */
dedup_enabled?: boolean
/** 标题烧录配置(#2001 扩展:描边/阴影参数/行距/自动换行/背景/逐行/封面) */
/** 标题烧录配置 */
title_config?: {
text?: string
font?: string
@@ -89,34 +89,8 @@ export interface CreateGenerationTaskRequest {
font_color?: string
position?: string
bold?: boolean
italic?: boolean
stroke?: boolean | { enabled?: boolean; width?: number; color?: string }
shadow?:
| boolean
| {
enabled?: boolean
offset_x?: number
offset_y?: number
blur?: number
color?: string
}
line_height?: number
margin_top?: number
max_chars_per_line?: number
background?: { enabled?: boolean; color?: string; padding?: number; radius?: number }
line_overrides?: Array<{
line_index: number
text?: string
size?: number
color?: string
bold?: boolean
italic?: boolean
stroke?: boolean
highlights?: Array<{ word: string; color?: string; bold?: boolean; scale?: number }>
}>
cover_title_config?: Record<string, unknown>
pos_x?: number
pos_y?: number
stroke?: boolean
shadow?: boolean
}
/** 关联的草稿 ID(编辑流程数据链路用) */
source_edit_plan_id?: string
@@ -54,8 +54,6 @@ export interface SegmentTtsConfig {
pitch: number
volume: number
subtitle_sync: boolean
/** 配音风格预设(natural/excited/professional/sweet/news/livestream */
style?: string
}
/** 片段裁剪配置 */
-3
View File
@@ -18,9 +18,6 @@ export type {
TTSPreviewResponse,
} from "./types"
export type { TtsStyle, TtsStyleOption } from "./styles"
export { TTS_STYLE_OPTIONS, DEFAULT_TTS_STYLE, getTtsStyle } from "./styles"
// API 函数
export {
synthesizeSpeech,
-71
View File
@@ -1,71 +0,0 @@
/**
* TTS 配音风格预设(情感/语气风格)
* - key:传给后端的 style 标识,便于后端按策略合成
* - 未传 style 时后端默认自然亲切
*
* 注:与原 emotionCosyVoice 7 种基础情绪枚举)解耦;
* style 是更高层的"说话风格预设",后端可能映射到 emotion + speed + prompt 组合。
*/
export interface TtsStyleOption {
/** 传给后端的风格标识 */
value: string
/** 展示名 */
label: string
/** emoji 图标 */
emoji: string
/** 给用户/后端的风格描述(prompt 风格) */
description: string
}
export const TTS_STYLE_OPTIONS: readonly TtsStyleOption[] = [
{
value: "natural",
label: "自然亲切",
emoji: "😊",
description: "亲切自然,像朋友聊天",
},
{
value: "excited",
label: "激动兴奋",
emoji: "🤩",
description: "激动兴奋,语速稍快,充满活力",
},
{
value: "professional",
label: "沉稳专业",
emoji: "🧑‍💼",
description: "沉稳专业,语速适中,正式可靠",
},
{
value: "sweet",
label: "温柔甜美",
emoji: "🌸",
description: "温柔甜美,语速轻柔",
},
{
value: "news",
label: "新闻播报",
emoji: "📰",
description: "字正腔圆,严肃正式",
},
{
value: "livestream",
label: "直播带货",
emoji: "🎤",
description: "热情有感染力,有节奏感",
},
] as const
export type TtsStyle = (typeof TTS_STYLE_OPTIONS)[number]["value"]
/** 默认风格:自然亲切 */
export const DEFAULT_TTS_STYLE: TtsStyle = "natural"
/** 根据 value 查找风格选项(容错:找不到回退 natural) */
export function getTtsStyle(value: string | null | undefined): TtsStyleOption {
return (
(TTS_STYLE_OPTIONS as readonly TtsStyleOption[]).find((o) => o.value === value) ??
(TTS_STYLE_OPTIONS as readonly TtsStyleOption[])[0]
)
}
-4
View File
@@ -17,8 +17,6 @@ export interface TTSSynthesizeRequest {
output_name?: string
language?: string
emotion?: string
/** 配音风格预设(自然亲切/激动兴奋/沉稳专业/温柔甜美/新闻播报/直播带货),不传默认 natural */
style?: string
speed?: number
voice_model?: string
voice_clone_profile_id?: string
@@ -108,8 +106,6 @@ export interface TTSPreviewRequest {
pitch?: number
language?: string
emotion?: string // 情绪参数:neutral/happy/sad/angry/surprised/fearful/disgusted(后端 normalize_emotion() 兼容旧 natural/excited/calm/friendly 与中文标签)
/** 配音风格预设 */
style?: string
}
/** TTS 试听响应 */
@@ -17,7 +17,6 @@ import {
} from "@ant-design/icons"
import { useNavigate } from "react-router-dom"
import { usePointsStore } from "@/store/pointsStore"
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
import "./PointsBadge.css"
const { Text, Paragraph } = Typography
@@ -33,13 +32,9 @@ const PointsBadge: React.FC = () => {
const { balance, membership, subscription, dailyUsage, init, loading } = usePointsStore()
useEffect(() => {
if (!ENABLE_CREDIT_SYSTEM) return
if (!balance) init()
}, [balance, init])
// 功能开关:积分系统关闭时直接隐藏徽章
if (!ENABLE_CREDIT_SYSTEM) return null
// 余额:优先用 membership.points_balance(冗余字段),降级 balance.balance
const bal = membership?.points_balance ?? balance?.balance ?? 0
const lowBalance = bal > 0 && bal < 10
@@ -15,7 +15,6 @@ import React, { useMemo } from "react"
import { Tooltip } from "antd"
import { WarningOutlined } from "@ant-design/icons"
import { usePointsStore } from "@/store/pointsStore"
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
import type { PointsSource } from "@/api/points/types"
import "./PointsCost.css"
@@ -54,7 +53,7 @@ const PointsCost: React.FC<Props> = ({
compact = false,
showRechargeHint = true,
className = "",
}: Props) => {
}) => {
const { balance, dailyUsage, rules, membership } = usePointsStore()
const qty = quantity ?? units ?? 1
@@ -119,9 +118,6 @@ const PointsCost: React.FC<Props> = ({
}
}, [rules, balance, dailyUsage, membership, scene, qty, durationMinutes])
// 积分系统关闭时不展示消耗提示(组件保留,hooks 必须在 return 前调用)
if (!ENABLE_CREDIT_SYSTEM) return null
if (!rule || !balance) {
return <span className={`xx-points-cost ${className}`} />
}
+25 -38
View File
@@ -21,7 +21,6 @@ import { useLogout } from "@/hooks/useAuth"
import type { MenuProps } from "antd"
import { NAV_ITEMS } from "@/config/navigation"
import PointsBadge from "@/components/common/PointsBadge"
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
import { usePointsStore } from "@/store/pointsStore"
import "./Header.css"
@@ -58,36 +57,30 @@ const Header: React.FC = () => {
label: "订阅管理",
onClick: () => navigate("/app/subscription"),
},
// 积分系统开关关闭时隐藏积分相关菜单项(代码保留不删除)
...(ENABLE_CREDIT_SYSTEM
? [
{
key: "points-center",
icon: <ThunderboltOutlined />,
label: (
<Space>
{balance && (
<span style={{ color: "#8b5cf6", fontWeight: 700 }}>{balance.balance}</span>
)}
</Space>
),
onClick: () => navigate("/app/points"),
},
{
key: "points-history",
icon: <HistoryOutlined />,
label: "积分明细",
onClick: () => navigate("/app/points/transactions"),
},
{
key: "recharge",
icon: <WalletOutlined />,
label: "充值积分",
onClick: () => navigate("/app/points/recharge"),
},
]
: []),
// v2: 我的积分入口
{
key: "points-center",
icon: <ThunderboltOutlined />,
label: (
<Space>
{balance && <span style={{ color: "#8b5cf6", fontWeight: 700 }}>{balance.balance}</span>}
</Space>
),
onClick: () => navigate("/app/points"),
},
{
key: "points-history",
icon: <HistoryOutlined />,
label: "积分明细",
onClick: () => navigate("/app/points/transactions"),
},
{
key: "recharge",
icon: <WalletOutlined />,
label: "充值积分",
onClick: () => navigate("/app/points/recharge"),
},
{ type: "divider" },
{
key: "logout",
@@ -137,13 +130,7 @@ const Header: React.FC = () => {
{/* v2: 升级会员入口(仅免费用户显示) */}
{!isMember && (
<Tooltip
title={
ENABLE_CREDIT_SYSTEM
? "升级会员解锁无限混剪、批量导出,积分 8 折起"
: "升级会员解锁无限混剪、批量导出"
}
>
<Tooltip title="升级会员解锁无限混剪、批量导出,积分 8 折起">
<Button
type="primary"
size="small"
-361
View File
@@ -1,361 +0,0 @@
/**
* 标题样式相关常量(#2001
* - 字体列表(新增4款爆款字体)
* - 色板(常用标题字色/描边色/背景色)
* - 预设样式方案(10 个,含抖音爆款黄)
*/
import type { TitleStyleConfig } from "./types"
/* ── 字体选项(#2001:新增优设标题黑/阿里普惠体Bold/抖音美好体/思源黑体Heavy ── */
export interface FontOption {
value: string
label: string
/** CSS font-family 栈 */
family: string
/** 爆款/常用标签 */
tag?: "hot" | "new"
}
export const FONT_OPTIONS: FontOption[] = [
{
value: "优设标题黑",
label: "优设标题黑",
family:
'"YouShe Title Black","YouSheBiaoTiHei","Source Han Sans SC Heavy","Noto Sans SC","PingFang SC",sans-serif',
tag: "hot",
},
{
value: "阿里普惠体Bold",
label: "阿里普惠体Bold",
family:
'"Alibaba PuHuiTi Bold","Alibaba PuHuiTi","Source Han Sans SC","PingFang SC",sans-serif',
tag: "hot",
},
{
value: "抖音美好体",
label: "抖音美好体",
family: '"Douyin Sans","DouyinSans","Source Han Sans SC","PingFang SC",sans-serif',
tag: "hot",
},
{
value: "思源黑体Heavy",
label: "思源黑体Heavy",
family:
'"Source Han Sans SC Heavy","Noto Sans SC","Source Han Sans CN Heavy","PingFang SC",sans-serif',
tag: "new",
},
{
value: "思源黑体",
label: "思源黑体",
family: '"Source Han Sans SC","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif',
},
{
value: "思源宋体",
label: "思源宋体",
family: '"Source Han Serif SC","Noto Serif SC","Songti SC","SimSun",serif',
},
{
value: "苹方",
label: "苹方",
family: '"PingFang SC",-apple-system,"Helvetica Neue",sans-serif',
},
{
value: "微软雅黑",
label: "微软雅黑",
family: '"Microsoft YaHei","PingFang SC",sans-serif',
},
{
value: "楷体",
label: "楷体",
family: '"KaiTi","STKaiti","DFKai-SB",serif',
},
]
/** 根据中文名取 font-family 栈(找不到回退思源黑体) */
export function getFontFamily(font: string): string {
const f = FONT_OPTIONS.find((x) => x.value === font)
if (f) return f.family
return FONT_OPTIONS[4].family // 思源黑体
}
/* ── 色板 ── */
/** 标题字色(常用爆款色) */
export const TITLE_COLOR_PALETTE: string[] = [
"#ffffff",
"#000000",
"#ffd700", // 抖音黄
"#ff2d55", // 抖音红
"#ff4081",
"#00e5ff",
"#d4a843",
"#ffa500",
"#52c41a",
"#1890ff",
"#7c3aed",
"#ff6b35",
]
/** 描边色(黑/白/灰为主) */
export const STROKE_COLOR_PALETTE: string[] = [
"#000000",
"#ffffff",
"#333333",
"#555555",
"#8b0000",
"#001f3f",
]
/** 背景色(带透明度) */
export const BG_COLOR_PALETTE: string[] = [
"rgba(0,0,0,0.5)",
"rgba(0,0,0,0.7)",
"rgba(0,0,0,0.3)",
"rgba(255,215,0,0.9)",
"rgba(255,45,85,0.85)",
"rgba(124,58,237,0.85)",
"rgba(24,144,255,0.85)",
"rgba(82,196,26,0.85)",
]
/* ── 预设样式方案(10 个,含抖音爆款黄) ── */
export interface TitlePreset {
key: string
label: string
emoji: string
/** 应用时覆盖到 TitleStyleConfig 的字段(其他字段保持当前值) */
style: Partial<TitleStyleConfig>
}
const BASE: Partial<TitleStyleConfig> = {
line_overrides: [],
cover_title_config: null,
}
export const TITLE_PRESETS: TitlePreset[] = [
{
key: "douyin_hot",
label: "抖音爆款黄",
emoji: "🔥",
style: {
...BASE,
font: "优设标题黑",
size: 80,
color: "#ffd700",
bold: true,
italic: false,
stroke: true,
stroke_width: 8,
stroke_color: "#000000",
shadow: true,
shadow_offset_x: 3,
shadow_offset_y: 3,
shadow_blur: 6,
shadow_color: "rgba(0,0,0,0.6)",
bg_enabled: false,
line_height: 1.25,
max_chars_per_line: 8,
},
},
{
key: "classic_white",
label: "经典白字黑描边",
emoji: "⚪",
style: {
...BASE,
font: "思源黑体Heavy",
size: 56,
color: "#ffffff",
bold: true,
italic: false,
stroke: true,
stroke_width: 5,
stroke_color: "#000000",
shadow: false,
bg_enabled: false,
line_height: 1.2,
max_chars_per_line: 10,
},
},
{
key: "red_bold",
label: "醒目红字",
emoji: "🔴",
style: {
...BASE,
font: "优设标题黑",
size: 72,
color: "#ff2d55",
bold: true,
italic: false,
stroke: true,
stroke_width: 6,
stroke_color: "#ffffff",
shadow: true,
shadow_offset_x: 2,
shadow_offset_y: 2,
shadow_blur: 5,
shadow_color: "rgba(0,0,0,0.5)",
bg_enabled: false,
line_height: 1.2,
max_chars_per_line: 9,
},
},
{
key: "black_gold",
label: "黑金质感",
emoji: "🟡",
style: {
...BASE,
font: "思源宋体",
size: 52,
color: "#d4a843",
bold: true,
italic: false,
stroke: false,
shadow: true,
shadow_offset_x: 2,
shadow_offset_y: 2,
shadow_blur: 8,
shadow_color: "rgba(0,0,0,0.8)",
bg_enabled: false,
line_height: 1.25,
max_chars_per_line: 10,
},
},
{
key: "neon_blue",
label: "霓虹发光",
emoji: "💙",
style: {
...BASE,
font: "阿里普惠体Bold",
size: 60,
color: "#00e5ff",
bold: true,
italic: false,
stroke: false,
shadow: true,
shadow_offset_x: 0,
shadow_offset_y: 0,
shadow_blur: 16,
shadow_color: "#00e5ff",
bg_enabled: false,
line_height: 1.2,
max_chars_per_line: 10,
},
},
{
key: "bg_black",
label: "黑底白字",
emoji: "⬛",
style: {
...BASE,
font: "思源黑体Heavy",
size: 52,
color: "#ffffff",
bold: true,
italic: false,
stroke: false,
shadow: false,
bg_enabled: true,
bg_color: "rgba(0,0,0,0.7)",
bg_padding: 16,
bg_radius: 8,
line_height: 1.3,
max_chars_per_line: 10,
},
},
{
key: "bg_yellow",
label: "黄底黑字",
emoji: "🟨",
style: {
...BASE,
font: "抖音美好体",
size: 56,
color: "#000000",
bold: true,
italic: false,
stroke: false,
shadow: false,
bg_enabled: true,
bg_color: "rgba(255,215,0,0.95)",
bg_padding: 14,
bg_radius: 6,
line_height: 1.2,
max_chars_per_line: 9,
},
},
{
key: "sweet_pink",
label: "温柔甜美粉",
emoji: "🌸",
style: {
...BASE,
font: "阿里普惠体Bold",
size: 50,
color: "#ff4081",
bold: false,
italic: false,
stroke: true,
stroke_width: 4,
stroke_color: "#ffffff",
shadow: true,
shadow_offset_x: 2,
shadow_offset_y: 2,
shadow_blur: 4,
shadow_color: "rgba(255,64,129,0.4)",
bg_enabled: false,
line_height: 1.3,
max_chars_per_line: 11,
},
},
{
key: "business_dark",
label: "商务深色",
emoji: "💼",
style: {
...BASE,
font: "思源黑体",
size: 44,
color: "#ffffff",
bold: false,
italic: false,
stroke: false,
shadow: true,
shadow_offset_x: 1,
shadow_offset_y: 1,
shadow_blur: 3,
shadow_color: "rgba(0,0,0,0.8)",
bg_enabled: true,
bg_color: "rgba(24,144,255,0.85)",
bg_padding: 12,
bg_radius: 4,
line_height: 1.3,
max_chars_per_line: 12,
},
},
{
key: "minimal_clean",
label: "极简无描边",
emoji: "✨",
style: {
...BASE,
font: "苹方",
size: 48,
color: "#ffffff",
bold: true,
italic: false,
stroke: false,
shadow: false,
bg_enabled: false,
line_height: 1.3,
max_chars_per_line: 10,
},
},
]
/** 根据 key 获取预设 */
export function getTitlePreset(key: string): TitlePreset | undefined {
return TITLE_PRESETS.find((p) => p.key === key)
}
-118
View File
@@ -1,118 +0,0 @@
/**
* 共享标题样式配置类型(#2001 爆款标题样式配置面板升级)
*
* 设计原则:
* 1. 向后兼容:保留旧的 bold/stroke/shadow 布尔字段,新增细粒度字段
* stroke_width/stroke_color/shadow_offset_x-y-blur-color/bg_enabled-color-padding-radius/line_height/margin_top/max_chars_per_line)。
* 2. 后端契约:字段名使用 snake_case,与 title_config dict 直接对齐。
* 3. line_overrides 支持逐行覆盖(选中某行单独设置颜色/字号/关键词高亮/加粗/斜体)。
* 4. cover_title_config 为封面独立标题样式,null 表示封面沿用主标题样式。
*/
/** 关键词高亮配置 */
export interface TitleKeywordHighlight {
/** 要高亮的词 */
word: string
/** 高亮颜色(可选,默认主色反转) */
color?: string
/** 是否加粗(默认 true */
bold?: boolean
/** 额外字号放大倍数(1.0=不变,1.3=放大 30% */
scale?: number
}
/** 单行覆盖配置 */
export interface TitleLineOverride {
/** 行索引(0-based,按 / 或自动换行后的行序) */
line_index: number
/** 覆盖后的文字(可选,默认沿用原行) */
text?: string
/** 覆盖字号(可选) */
size?: number
/** 覆盖字色(可选) */
color?: string
/** 覆盖加粗(可选) */
bold?: boolean
/** 覆盖斜体(可选) */
italic?: boolean
/** 覆盖描边开关(可选) */
stroke?: boolean
/** 关键词高亮列表 */
highlights?: TitleKeywordHighlight[]
}
/**
* 标题样式配置(不含 title 文字本身,不含 auto_subtitle)。
*
* cover_title_config 使用 Partial<Omit<...,"cover_title_config">> 递归避免无限类型。
*/
export interface TitleStyleConfig {
/* ── 基础 ── */
font: string
size: number
color: string
bold: boolean
italic: boolean
position: "top" | "center" | "bottom" | "custom"
pos_x?: number
pos_y?: number
/* ── 排版(P0 ── */
/** 行距倍数(默认 1.2 */
line_height: number
/** 顶部边距(position=top 时距画面顶部距离,px @720p,默认 24 */
margin_top: number
/** 每行最大字符数(4-20,超出自动换行;0=不自动换行,使用 / 手动分行) */
max_chars_per_line: number
/* ── 描边参数化(P0) ── */
stroke: boolean
stroke_width: number
stroke_color: string
/* ── 阴影参数化(P1) ── */
shadow: boolean
shadow_offset_x: number
shadow_offset_y: number
shadow_blur: number
shadow_color: string
/* ── 背景色块(P1) ── */
bg_enabled: boolean
bg_color: string
bg_padding: number
bg_radius: number
/* ── 逐行独立样式(P1) ── */
line_overrides: TitleLineOverride[]
/* ── 封面独立标题配置(P1):null=沿用主标题样式 ── */
cover_title_config: null | Partial<Omit<TitleStyleConfig, "cover_title_config">>
}
/** 默认样式(经典白字黑描边,保持老版本观感) */
export const DEFAULT_TITLE_STYLE: TitleStyleConfig = {
font: "思源黑体",
size: 48,
color: "#ffffff",
bold: true,
italic: false,
position: "bottom",
line_height: 1.2,
margin_top: 24,
max_chars_per_line: 0,
stroke: true,
stroke_width: 4,
stroke_color: "#000000",
shadow: false,
shadow_offset_x: 2,
shadow_offset_y: 2,
shadow_blur: 4,
shadow_color: "rgba(0,0,0,0.8)",
bg_enabled: false,
bg_color: "rgba(0,0,0,0.5)",
bg_padding: 12,
bg_radius: 8,
line_overrides: [],
cover_title_config: null,
}
@@ -1,151 +0,0 @@
/**
* TTS 配音风格选择器
* - 6 种预设风格卡片(自然亲切 / 激动兴奋 / 沉稳专业 / 温柔甜美 / 新闻播报 / 直播带货)
* - 卡片单选,选中高亮紫色
* - 默认 natural
*
* 复用方式:
* <TtsStyleSelector value={style} onChange={setStyle} />
* <TtsStyleSelector value={style} onChange={setStyle} compact /> // 紧凑模式(小尺寸)
*/
import React from "react"
import { TTS_STYLE_OPTIONS, DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles"
export interface TtsStyleSelectorProps {
value?: TtsStyle | string
onChange: (style: TtsStyle) => void
/** 紧凑模式(小卡片),适合与其他参数并排 */
compact?: boolean
/** 是否显示"配音风格"标签 */
showLabel?: boolean
}
const TtsStyleSelector: React.FC<TtsStyleSelectorProps> = ({
value,
onChange,
compact = false,
showLabel = true,
}) => {
const current = value || DEFAULT_TTS_STYLE
if (compact) {
return (
<div>
{showLabel && (
<div
style={{
fontSize: 13,
color: "var(--text-secondary, #6b7280)",
marginBottom: 6,
}}
>
</div>
)}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 6,
}}
>
{TTS_STYLE_OPTIONS.map((opt) => {
const selected = current === opt.value
return (
<button
type="button"
key={opt.value}
onClick={() => onChange(opt.value as TtsStyle)}
title={opt.description}
style={{
padding: "6px 4px",
borderRadius: 6,
border: selected ? "2px solid #7c3aed" : "1px solid #e5e7eb",
background: selected ? "#faf5ff" : "#fff",
color: selected ? "#6d28d9" : "#374151",
cursor: "pointer",
fontSize: 12,
fontWeight: selected ? 600 : 400,
textAlign: "center",
transition: "all 0.15s",
lineHeight: 1.3,
}}
>
<span style={{ marginRight: 3 }}>{opt.emoji}</span>
{opt.label}
</button>
)
})}
</div>
</div>
)
}
return (
<div>
{showLabel && (
<div
style={{
fontSize: 13,
color: "var(--text-secondary, #6b7280)",
marginBottom: 8,
fontWeight: 500,
}}
>
</div>
)}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 8,
}}
>
{TTS_STYLE_OPTIONS.map((opt) => {
const selected = current === opt.value
return (
<button
type="button"
key={opt.value}
onClick={() => onChange(opt.value as TtsStyle)}
title={opt.description}
style={{
padding: "10px 8px",
borderRadius: 8,
border: selected ? "2px solid #7c3aed" : "1px solid #e5e7eb",
background: selected ? "#faf5ff" : "#fff",
color: selected ? "#6d28d9" : "#111",
cursor: "pointer",
textAlign: "center",
transition: "all 0.15s",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 4,
}}
>
<span style={{ fontSize: 22, lineHeight: 1 }}>{opt.emoji}</span>
<span style={{ fontSize: 13, fontWeight: selected ? 600 : 500 }}>{opt.label}</span>
<span
style={{
fontSize: 10,
color: "#9ca3af",
lineHeight: 1.2,
maxWidth: "100%",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{opt.description}
</span>
</button>
)
})}
</div>
</div>
)
}
export default TtsStyleSelector
-13
View File
@@ -1,13 +0,0 @@
/**
* 功能开关配置
* 集中管理前端特性的启用/隐藏,便于灰度与回滚。
* 注意:仅控制 UI 展示与前端校验,后端扣减逻辑由后端对应开关控制。
*/
/**
* 积分系统 UI 开关(默认 false = 隐藏)
* - false:隐藏所有积分相关入口/余额/消耗提示/不足弹窗/充值入口;会员标识保留;
* 功能流程不做积分预校验,直接走生成。
* - true:展示完整积分系统 UI。
*/
export const ENABLE_CREDIT_SYSTEM = false
+12 -23
View File
@@ -3,7 +3,6 @@
* Header.tsx 和 Sidebar.tsx 共享此数据源,避免路由配置重复
*/
import React from "react"
import { ENABLE_CREDIT_SYSTEM } from "./features"
import {
DashboardOutlined,
FileOutlined,
@@ -106,17 +105,12 @@ export const NAV_ITEMS: NavItem[] = [
path: "/app/subscription",
icon: React.createElement(CrownOutlined),
},
// 积分系统开关关闭时隐藏积分中心入口(代码保留不删除)
...(ENABLE_CREDIT_SYSTEM
? [
{
key: "points",
label: "积分中心",
path: "/app/points",
icon: React.createElement(ThunderboltOutlined),
},
]
: []),
{
key: "points",
label: "积分中心",
path: "/app/points",
icon: React.createElement(ThunderboltOutlined),
},
]
/** 侧边栏导航分组(Sidebar 分组列表使用) */
@@ -206,17 +200,12 @@ export const NAV_GROUPS: NavGroup[] = [
path: "/app/subscription",
icon: React.createElement(CrownOutlined),
},
// 积分系统开关关闭时隐藏积分中心入口(代码保留不删除)
...(ENABLE_CREDIT_SYSTEM
? [
{
key: "points",
label: "积分中心",
path: "/app/points",
icon: React.createElement(ThunderboltOutlined),
},
]
: []),
{
key: "points",
label: "积分中心",
path: "/app/points",
icon: React.createElement(ThunderboltOutlined),
},
],
},
]
+12 -9
View File
@@ -30,7 +30,11 @@ import {
} from "./api/aiAvatar"
import { getOrCreateDefaultProject } from "@/api/projects"
import type { RenderJob, SentenceTiming } from "./types"
import { buildTitleConfigPayload, buildCoverConfigPayload } from "./utils/contract"
import {
normalizeEmotion,
buildTitleConfigPayload,
buildCoverConfigPayload,
} from "./utils/contract"
import { renderTitleToPngDataUrl, getVideoResolution } from "./utils/titleCanvas"
/** 面板折叠状态 */
@@ -90,7 +94,7 @@ const AiAvatarPage: React.FC = () => {
state.resetTtsPreview()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.scriptText, state.selectedVoice?.voice_id, state.speed, state.style])
}, [state.scriptText, state.selectedVoice?.voice_id, state.speed, state.emotion])
const _clearTtsProgressTimer = useCallback(() => {
if (ttsProgressTimerRef.current) {
@@ -144,7 +148,7 @@ const AiAvatarPage: React.FC = () => {
voice_id: state.selectedVoice!.voice_id,
script_text: state.scriptText,
speed: state.speed,
style: state.style,
emotion: normalizeEmotion(state.emotion),
})
_clearTtsProgressTimer()
setTtsProgress(100)
@@ -171,7 +175,7 @@ const AiAvatarPage: React.FC = () => {
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.style])
}, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.emotion])
const handleRetryTts = useCallback(() => {
handleGenerateTts()
@@ -250,7 +254,7 @@ const AiAvatarPage: React.FC = () => {
script_text: state.scriptText,
video_url: videoUrl,
speed: state.speed,
style: state.style,
emotion: normalizeEmotion(state.emotion),
}
}
const job = await createLipsyncJob(payload)
@@ -294,8 +298,7 @@ const AiAvatarPage: React.FC = () => {
state.selectedVoice,
state.scriptText,
state.speed,
state.style,
state.emotion,
state.ttsPreview,
])
@@ -595,8 +598,8 @@ const AiAvatarPage: React.FC = () => {
onVoiceSourceChange={state.setVoiceSource}
selectedVoice={state.selectedVoice}
onSelectVoice={state.setSelectedVoice}
style={state.style}
onStyleChange={state.setStyle}
emotion={state.emotion}
onEmotionChange={state.setEmotion}
speed={state.speed}
onSpeedChange={state.setSpeed}
language={state.language}
+1 -5
View File
@@ -41,13 +41,10 @@ export const createLipsyncJob = async (data: {
speed?: number
/** 情绪英文枚举:neutral/happy/sad/angry/surprised/fearful/disgustedTTS 直生模式用;前端经 normalizeEmotion 归一化) */
emotion?: string
/** 配音风格预设(natural/excited/professional/sweet/news/livestream */
style?: string
enable_video_loop?: boolean
project_id?: string
}): Promise<LipsyncJob> => {
// GPU 口型同步推理约 20s,留足余量到 120s 防止 10s 默认超时
const response = await apiClient.post<LipsyncJob>("/lipsync/jobs", data, { timeout: 120_000 })
const response = await apiClient.post<LipsyncJob>("/lipsync/jobs", data)
return response.data
}
@@ -57,7 +54,6 @@ export const previewTts = async (data: {
script_text: string
speed?: number
emotion?: string
style?: string
}): Promise<{
audio_url: string
duration: number
@@ -29,7 +29,15 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, "0")}`
}
import { getFontFamily as getFontFamilyByKey } from "@/components/title/constants"
/** 字体名 → CSS font-family 映射(与 titleCanvas 字体链对齐) */
const FONT_FAMILY_MAP: Record<string, string> = {
:
"'Noto Sans CJK SC', 'Source Han Sans CN', 'PingFang SC', 'Microsoft YaHei', sans-serif",
: "'Noto Serif SC', 'Source Han Serif SC', 'SimSun', serif",
: "KaiTi, 'STKaiti', serif",
: "'Heiti SC', 'SimHei', 'Microsoft YaHei', sans-serif",
}
const getFontFamily = (font: string): string => FONT_FAMILY_MAP[font] || FONT_FAMILY_MAP["思源黑体"]
export function PanelLipsyncPreview({
lipsyncJob,
@@ -75,105 +83,63 @@ export function PanelLipsyncPreview({
const previewScale = containerWidth > 0 ? containerWidth / 720 : 0.35
const ps = useCallback((v: number) => Math.round(v * previewScale * 100) / 100, [previewScale])
/** 标题叠加样式(新字段全支持:描边宽色/阴影参数化/背景块/行距/顶部边距/自动换行 */
const titleOverlayData =
/** 标题叠加样式(字号/padding/描边/阴影均按 previewScale 缩放,保持与成片视觉一致 */
const titleOverlayStyle: React.CSSProperties | null =
titleConfig?.title && containerWidth > 0
? (() => {
const c = titleConfig as AiAvatarTitleConfig & {
stroke_width?: number
stroke_color?: string
shadow_offset_x?: number
shadow_offset_y?: number
shadow_blur?: number
shadow_color?: string
line_height?: number
margin_top?: number
max_chars_per_line?: number
bg_enabled?: boolean
bg_color?: string
bg_padding?: number
bg_radius?: number
cover_title_config?: Record<string, unknown> | null
line_overrides?: unknown[]
}
const baseSize = titleConfig.size || 48
const fontSize = ps(baseSize)
const strokeW = c.stroke ? ps(c.stroke_width ?? 4) : 0
const strokeC = c.stroke_color || "#000000"
const shBlur = ps(c.shadow_blur ?? 4)
const shOffX = ps(c.shadow_offset_x ?? 2)
const shOffY = ps(c.shadow_offset_y ?? 2)
const shColor = c.shadow_color || "rgba(0,0,0,0.8)"
const lh = c.line_height ?? 1.2
const mTop = ps(c.margin_top ?? 24)
const bgPad = ps(c.bg_padding ?? 12)
const bgR = ps(c.bg_radius ?? 8)
const maxChars = c.max_chars_per_line ?? 0
const rawText = titleConfig.title || ""
const lines = (() => {
const manual = rawText
.split(/[/]/)
.map((l) => l.trim())
.filter(Boolean)
if (!maxChars || maxChars <= 0) return manual
const out: string[] = []
manual.forEach((seg) => {
for (let i = 0; i < seg.length; i += maxChars) out.push(seg.slice(i, i + maxChars))
})
return out
})()
const padV = ps(16) * 0.5
const textShadow = titleConfig.shadow
? `${shOffX}px ${shOffY}px ${shBlur}px ${shColor}`
: undefined
// 描边宽度基准 ≈ size * 0.06,最小 1.5px @720p
const strokeW = Math.max(ps(1.5), +(baseSize * 0.06 * previewScale).toFixed(2))
// 阴影按比例缩放
const shadowBlur = ps(4)
const shadowOffsetY = ps(2)
// padding / top 边距按比例(基准 8px 对应预览小窗,成片基准 16px,这里 8px 对应约 0.33 缩放)
const padV = ps(16) * 0.5 // ≈ 8px in ~240px container
const padH = ps(24) * 0.5
const style: React.CSSProperties = {
position: "absolute",
color: titleConfig.color || "#ffffff",
fontFamily: getFontFamilyByKey(titleConfig.font || "source_sans_sc"),
fontFamily: getFontFamily(titleConfig.font || "思源黑体"),
fontSize: `${fontSize}px`,
fontWeight: titleConfig.bold ? 700 : 400,
fontStyle: titleConfig.italic ? "italic" : "normal",
textAlign: "center",
lineHeight: lh,
WebkitTextStroke:
titleConfig.stroke && strokeW > 0 ? `${strokeW}px ${strokeC}` : undefined,
paintOrder: "stroke fill",
textShadow,
whiteSpace: "pre-wrap",
padding: c.bg_enabled ? `${bgPad}px ${bgPad}px` : 0,
background: c.bg_enabled ? c.bg_color || "rgba(0,0,0,0.5)" : "transparent",
borderRadius: c.bg_enabled ? `${bgR}px` : 0,
width: "90%",
lineHeight: 1.2,
padding: `${ps(4)}px ${padH}px`,
textShadow: titleConfig.shadow
? `0 ${shadowOffsetY}px ${shadowBlur}px rgba(0,0,0,0.8), 0 0 ${ps(2)}px rgba(0,0,0,0.5)`
: undefined,
WebkitTextStroke: titleConfig.stroke ? `${strokeW}px #000` : undefined,
boxSizing: "border-box",
display: "inline-block",
maxWidth: "94%",
}
const wrap: React.CSSProperties = {
position: "absolute",
left: "50%",
width: "100%",
display: "flex",
justifyContent: "center",
pointerEvents: onTitlePositionChange ? "auto" : "none",
wordBreak: "break-word",
whiteSpace: "pre-wrap",
}
if (
titleConfig.position === "custom" &&
titleConfig.pos_x != null &&
titleConfig.pos_y != null
) {
wrap.left = `${titleConfig.pos_x}%`
wrap.top = `${titleConfig.pos_y}%`
wrap.transform = "translate(-50%, -50%)"
style.left = `${titleConfig.pos_x}%`
style.top = `${titleConfig.pos_y}%`
style.transform = "translateX(-50%) translateY(-50%)"
} else if (titleConfig.position === "top") {
wrap.top = `${padV + mTop}px`
wrap.transform = "translateX(-50%)"
style.left = "50%"
style.top = padV
style.transform = "translateX(-50%)"
} else if (titleConfig.position === "bottom") {
wrap.bottom = `${padV}px`
wrap.transform = "translateX(-50%)"
style.left = "50%"
style.bottom = padV
style.transform = "translateX(-50%)"
} else {
wrap.top = "50%"
wrap.transform = "translate(-50%, -50%)"
style.left = "50%"
style.top = "50%"
style.transform = "translateX(-50%) translateY(-50%)"
}
return { style, wrap, lines }
return style
})()
: null
@@ -286,23 +252,25 @@ export function PanelLipsyncPreview({
{isDone && lipsyncJob?.output_video_url ? (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<video src={lipsyncJob.output_video_url} controls />
{titleOverlayData && (
{titleOverlayStyle && (
<div
ref={titleDragRef}
style={{
...titleOverlayData.wrap,
...titleOverlayStyle,
cursor: onTitlePositionChange ? "grab" : "default",
pointerEvents: onTitlePositionChange ? "auto" : "none",
}}
onPointerDown={handleTitlePointerDown}
onPointerMove={handleTitlePointerMove}
onPointerUp={handleTitlePointerUp}
onPointerCancel={handleTitlePointerUp}
>
<div style={titleOverlayData.style}>
{titleOverlayData.lines.map((part: string, i: number) => (
<div key={i}>{part}</div>
))}
</div>
{titleConfig!.title.split(/[/]/).map((part, i) => (
<span key={i}>
{i > 0 && <br />}
{part}
</span>
))}
</div>
)}
</div>
@@ -13,8 +13,7 @@ import TitleStylePanel from "@/pages/generate/components/title/TitleStylePanel"
import TitleLibraryAutoComplete from "@/pages/generate/components/title/TitleLibraryAutoComplete"
import type { TitleOption } from "@/pages/generate/components/title/TitleLibraryAutoComplete"
import type { TitleSettings } from "@/pages/generate/types"
import { POSITION_OPTIONS } from "@/pages/generate/constants"
import { FONT_OPTIONS, TITLE_PRESETS } from "@/components/title/constants"
import { POSITION_OPTIONS, FONT_OPTIONS, TITLE_PRESETS } from "@/pages/generate/constants"
import type { AiAvatarTitleConfig } from "../types"
// #1894: 标题数据源切换到文案库,取 script.title 作为候选
import { getScripts } from "@/api/scripts"
@@ -50,60 +49,9 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
.catch(() => setTitleOptions([]))
}, [])
/** AiAvatarTitleConfig (snake_case) → TitleSettings (camelCase) */
const titleSettings: TitleSettings = useMemo(() => {
const c = titleConfig as AiAvatarTitleConfig & {
stroke_width?: number
stroke_color?: string
shadow_offset_x?: number
shadow_offset_y?: number
shadow_blur?: number
shadow_color?: string
line_height?: number
margin_top?: number
max_chars_per_line?: number
bg_enabled?: boolean
bg_color?: string
bg_padding?: number
bg_radius?: number
cover_title_config?: {
title?: string
font?: string
size?: number
font_size?: number
color?: string
font_color?: string
bold?: boolean
italic?: boolean
position?: string
stroke?: { enabled: boolean; width?: number; color?: string } | boolean
stroke_width?: number
stroke_color?: string
shadow?:
| {
enabled: boolean
offset_x?: number
offset_y?: number
blur?: number
color?: string
}
| boolean
shadow_offset_x?: number
shadow_offset_y?: number
shadow_blur?: number
shadow_color?: string
background?: { enabled: boolean; color?: string; padding?: number; radius?: number }
bg_enabled?: boolean
bg_color?: string
bg_padding?: number
bg_radius?: number
line_height?: number
margin_top?: number
max_chars_per_line?: number
} | null
line_overrides?: unknown[]
}
return {
/** AiAvatarTitleConfig → TitleSettings(补齐 aiAutoSelect / 自由坐标字段) */
const titleSettings: TitleSettings = useMemo(
() => ({
aiAutoSelect: false,
title: titleConfig.title,
position: titleConfig.position,
@@ -116,219 +64,24 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
color: titleConfig.color,
posX: null,
posY: null,
lineHeight: c.line_height ?? 1.2,
marginTop: c.margin_top ?? 24,
maxCharsPerLine: c.max_chars_per_line ?? 0,
strokeWidth: c.stroke_width ?? 4,
strokeColor: c.stroke_color ?? "#000000",
shadowOffsetX: c.shadow_offset_x ?? 2,
shadowOffsetY: c.shadow_offset_y ?? 2,
shadowBlur: c.shadow_blur ?? 4,
shadowColor: c.shadow_color ?? "rgba(0,0,0,0.8)",
bgEnabled: !!c.bg_enabled,
bgColor: c.bg_color ?? "rgba(0,0,0,0.5)",
bgPadding: c.bg_padding ?? 12,
bgRadius: c.bg_radius ?? 8,
lineOverrides: Array.isArray(c.line_overrides) ? c.line_overrides : [],
coverTitle: (() => {
const ct = c.cover_title_config as
| null
| (AiAvatarTitleConfig & {
font_size?: number
font_color?: string
stroke?: { enabled?: boolean; width?: number; color?: string } | boolean
stroke_width?: number
stroke_color?: string
shadow?:
| {
enabled?: boolean
offset_x?: number
offset_y?: number
blur?: number
color?: string
}
| boolean
shadow_offset_x?: number
shadow_offset_y?: number
shadow_blur?: number
shadow_color?: string
background?: { enabled?: boolean; color?: string; padding?: number; radius?: number }
bg_enabled?: boolean
bg_color?: string
bg_padding?: number
bg_radius?: number
})
if (!ct) return null
const ctStroke = ct.stroke as
{ enabled?: boolean; width?: number; color?: string } | boolean | undefined
const ctShadow = ct.shadow as
| {
enabled?: boolean
offset_x?: number
offset_y?: number
blur?: number
color?: string
}
| boolean
| undefined
const ctBg = ct.background as
{ enabled?: boolean; color?: string; padding?: number; radius?: number } | undefined
return {
title: ct.title,
font: ct.font,
size: ct.font_size ?? ct.size,
color: ct.font_color ?? ct.color,
bold: ct.bold,
italic: ct.italic,
position: ct.position,
stroke:
typeof ctStroke === "object" && ctStroke ? ctStroke.enabled !== false : !!ctStroke,
strokeWidth:
(typeof ctStroke === "object" && ctStroke ? ctStroke.width : undefined) ??
ct.stroke_width ??
4,
strokeColor:
(typeof ctStroke === "object" && ctStroke ? ctStroke.color : undefined) ??
ct.stroke_color ??
"#000000",
shadow:
typeof ctShadow === "object" && ctShadow ? ctShadow.enabled !== false : !!ctShadow,
shadowOffsetX:
(typeof ctShadow === "object" && ctShadow ? ctShadow.offset_x : undefined) ??
ct.shadow_offset_x ??
2,
shadowOffsetY:
(typeof ctShadow === "object" && ctShadow ? ctShadow.offset_y : undefined) ??
ct.shadow_offset_y ??
2,
shadowBlur:
(typeof ctShadow === "object" && ctShadow ? ctShadow.blur : undefined) ??
ct.shadow_blur ??
4,
shadowColor:
(typeof ctShadow === "object" && ctShadow ? ctShadow.color : undefined) ??
ct.shadow_color ??
"rgba(0,0,0,0.8)",
bgEnabled: ctBg?.enabled ?? !!ct.bg_enabled,
bgColor: ctBg?.color ?? ct.bg_color ?? "rgba(0,0,0,0.5)",
bgPadding: ctBg?.padding ?? ct.bg_padding ?? 12,
bgRadius: ctBg?.radius ?? ct.bg_radius ?? 8,
}
})(),
}
}, [titleConfig])
}),
[titleConfig],
)
/** 应用预设:覆盖新细粒度字段(颜色/描边/阴影/字号/字体等) */
/** 应用预设:与智能剪辑一致,只覆盖 color/bold/italic/stroke/shadow,不改变字号 */
const handleApplyPreset = (presetKey: string) => {
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
if (!preset) return
setActivePreset(presetKey)
const st = preset.style || {}
onUpdate({
font: st.font,
size: st.size,
color: st.color,
bold: st.bold,
italic: st.italic,
stroke: st.stroke,
stroke_width: st.stroke_width,
stroke_color: st.stroke_color,
shadow: st.shadow,
shadow_offset_x: st.shadow_offset_x,
shadow_offset_y: st.shadow_offset_y,
shadow_blur: st.shadow_blur,
shadow_color: st.shadow_color,
bg_enabled: st.bg_enabled,
bg_color: st.bg_color,
bg_padding: st.bg_padding,
bg_radius: st.bg_radius,
line_overrides: [],
cover_title_config: null,
color: preset.style.color,
bold: preset.style.bold,
italic: preset.style.italic,
stroke: preset.style.stroke,
shadow: preset.style.shadow,
})
}
/** 字段 patch 透传:TitleStylePanel 的 onUpdateStylecamelCase → snake_case */
const handleUpdateStyle = (patch: Partial<TitleSettings>) => {
const snake: Record<string, unknown> = {}
const map: Record<string, string> = {
lineHeight: "line_height",
marginTop: "margin_top",
maxCharsPerLine: "max_chars_per_line",
strokeWidth: "stroke_width",
strokeColor: "stroke_color",
shadowOffsetX: "shadow_offset_x",
shadowOffsetY: "shadow_offset_y",
shadowBlur: "shadow_blur",
shadowColor: "shadow_color",
bgEnabled: "bg_enabled",
bgColor: "bg_color",
bgPadding: "bg_padding",
bgRadius: "bg_radius",
lineOverrides: "line_overrides",
coverTitle: "cover_title_config",
}
Object.entries(patch).forEach(([k, v]) => {
if (k === "coverTitle" && v && typeof v === "object") {
const ct = v as {
title?: string
font?: string
size?: number
color?: string
bold?: boolean
italic?: boolean
position?: string
stroke?: boolean
strokeWidth?: number
strokeColor?: string
shadow?: boolean
shadowOffsetX?: number
shadowOffsetY?: number
shadowBlur?: number
shadowColor?: string
bgEnabled?: boolean
bgColor?: string
bgPadding?: number
bgRadius?: number
lineHeight?: number
marginTop?: number
maxCharsPerLine?: number
}
snake.cover_title_config = {
title: ct.title,
font: ct.font,
font_size: ct.size,
font_color: ct.color,
bold: ct.bold,
italic: ct.italic,
position: ct.position,
stroke: ct.stroke
? { enabled: true, width: ct.strokeWidth ?? 4, color: ct.strokeColor ?? "#000" }
: { enabled: false },
shadow: ct.shadow
? {
enabled: true,
offset_x: ct.shadowOffsetX ?? 2,
offset_y: ct.shadowOffsetY ?? 2,
blur: ct.shadowBlur ?? 4,
color: ct.shadowColor ?? "rgba(0,0,0,0.8)",
}
: { enabled: false },
background: ct.bgEnabled
? { enabled: true, color: ct.bgColor, padding: ct.bgPadding, radius: ct.bgRadius }
: { enabled: false },
line_height: ct.lineHeight,
margin_top: ct.marginTop,
max_chars_per_line: ct.maxCharsPerLine,
}
} else if (map[k]) {
snake[map[k]] = v
} else {
snake[k] = v
}
})
onUpdate(snake)
}
return (
<div className="aa-title-config">
{/* 主标题输入 — TextArea 多行 + 标题库选择 */}
@@ -372,13 +125,8 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
onToggleStroke={() => onUpdate({ stroke: !titleConfig.stroke })}
onToggleShadow={() => onUpdate({ shadow: !titleConfig.shadow })}
onApplyPreset={handleApplyPreset}
onUpdateStyle={handleUpdateStyle}
showCoverToggle
previewWidth={280}
activePreset={activePreset}
titlePresets={
TITLE_PRESETS as unknown as React.ComponentProps<typeof TitleStylePanel>["titlePresets"]
}
titlePresets={TITLE_PRESETS}
POSITION_OPTIONS={POSITION_OPTIONS}
FONT_OPTIONS={FONT_OPTIONS}
/>
@@ -1,17 +1,18 @@
/**
* AI数字人 — 配音库面板(面板3)
* 音色来源切换(系统预设 / 我的音色)、音色选择与试听、风格/语速/语言参数
* 音色来源切换(系统预设 / 我的音色)、音色选择与试听、情绪/语速/语言参数
*/
import { useEffect, useRef, useState } from "react"
import { message } from "antd"
import { fetchVoices } from "@/api/voices/voices"
import { previewTts } from "@/api/tts"
import TtsStyleSelector from "@/components/voice/TtsStyleSelector"
import type { TtsStyle } from "@/api/tts/styles"
import { normalizeEmotion } from "../utils/contract"
import type { UnifiedVoiceItem } from "@/api/voices/types"
import {
type VoiceSource,
type VoiceEmotion,
type VoiceLanguage,
VOICE_EMOTION_OPTIONS,
PRESET_VOICE_LANGUAGE_OPTIONS,
CLONE_VOICE_LANGUAGE_OPTIONS,
} from "../types"
@@ -21,8 +22,8 @@ interface PanelVoiceSelectorProps {
onVoiceSourceChange: (source: VoiceSource) => void
selectedVoice: UnifiedVoiceItem | null
onSelectVoice: (voice: UnifiedVoiceItem) => void
style: TtsStyle
onStyleChange: (s: TtsStyle) => void
emotion: VoiceEmotion
onEmotionChange: (e: VoiceEmotion) => void
speed: number
onSpeedChange: (s: number) => void
language: VoiceLanguage
@@ -34,8 +35,8 @@ export function PanelVoiceSelector({
onVoiceSourceChange,
selectedVoice,
onSelectVoice,
style,
onStyleChange,
emotion,
onEmotionChange,
speed,
onSpeedChange,
language,
@@ -138,30 +139,31 @@ export function PanelVoiceSelector({
/* 克隆音色:preview_url/audio_url 通常为空,需走 POST /tts/preview
* 现合成示例文案再播放,对齐配音库 useAudioPlayer 行为 */
if (voice.type === "clone") {
const cacheKey = `${voice.voice_clone_profile_id || voice.id}::${style}`
const cached = previewCacheRef.current.get(cacheKey)
const cached = previewCacheRef.current.get(voice.voice_clone_profile_id || voice.id)
if (cached) {
playAudioUrl(voice.id, cached)
return
}
const targetId = voice.voice_clone_profile_id || voice.id
// DEBUG: 打印请求参数,帮助定位 /tts/preview 失败原因
setPreviewingId(voice.id)
try {
const res = await previewTts({
text: VOICE_PREVIEW_TEXT,
voice_id: targetId,
speed: speed, // 透传用户选择的语速(#1822)
style,
emotion: normalizeEmotion(emotion), // 情绪中文→英文枚举
})
if (!res.audio_url) {
setPreviewingId(null)
message.error("合成试听失败:未返回音频")
return
}
previewCacheRef.current.set(cacheKey, res.audio_url)
previewCacheRef.current.set(targetId, res.audio_url)
playAudioUrl(voice.id, res.audio_url)
} catch (err) {
setPreviewingId(null)
// DEBUG: 打印详细错误信息
console.error("[AI数字人-克隆试听] previewTts 失败:", {
status: (err as { response?: { status?: number } })?.response?.status,
data: (err as { response?: { data?: unknown } })?.response?.data,
@@ -270,6 +272,23 @@ export function PanelVoiceSelector({
{/* 配音参数 */}
<div className="aa-voice-params">
<div className="aa-voice-params__row">
<div className="aa-voice-params__field">
<label className="aa-label" htmlFor="aa-voice-emotion">
</label>
<select
id="aa-voice-emotion"
className="aa-select"
value={emotion}
onChange={(e) => onEmotionChange(e.target.value as VoiceEmotion)}
>
{VOICE_EMOTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="aa-voice-params__field">
<label className="aa-label" htmlFor="aa-voice-language">
@@ -305,9 +324,6 @@ export function PanelVoiceSelector({
onChange={(e) => handleSpeedChange(e.target.value)}
/>
</div>
<div className="aa-voice-params__field">
<TtsStyleSelector value={style} onChange={onStyleChange} compact />
</div>
</div>
</div>
)
@@ -6,6 +6,7 @@ import type { AssetItem } from "@/api/assets"
import type { UnifiedVoiceItem } from "@/api/voices/types"
import {
type VoiceSource,
type VoiceEmotion,
type VoiceLanguage,
type Script,
type LipsyncJob,
@@ -16,7 +17,6 @@ import {
DEFAULT_TITLE_CONFIG,
DEFAULT_COVER_CONFIG,
} from "../types"
import { DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles"
const DEFAULT_TTS_PREVIEW: TtsPreviewResult = {
audioUrl: null,
@@ -34,7 +34,7 @@ export function useAiAvatar() {
/* ── 面板2:配音库 ── */
const [voiceSource, setVoiceSource] = useState<VoiceSource>("preset")
const [selectedVoice, setSelectedVoice] = useState<UnifiedVoiceItem | null>(null)
const [style, setStyle] = useState<TtsStyle>(DEFAULT_TTS_STYLE)
const [emotion, setEmotion] = useState<VoiceEmotion>("neutral")
const [speed, setSpeed] = useState(1.0)
const [language, setLanguage] = useState<VoiceLanguage>("zh")
@@ -113,8 +113,8 @@ export function useAiAvatar() {
setVoiceSource,
selectedVoice,
setSelectedVoice,
style,
setStyle,
emotion,
setEmotion,
speed,
setSpeed,
language,
+2 -53
View File
@@ -100,7 +100,7 @@ export interface BRollSegment {
pip_scale: number
}
/* ── 标题配置(#2001 升级:细粒度描边/阴影/背景/排版/逐行/封面独立标题) ── */
/* ── 标题配置 ── */
export interface AiAvatarTitleConfig {
title: string
position: string
@@ -115,42 +115,6 @@ export interface AiAvatarTitleConfig {
/** 自定义位置坐标(position=custom 时生效,百分比 0-100 */
pos_x?: number
pos_y?: number
/* ── 排版 ── */
line_height: number
margin_top: number
max_chars_per_line: number
/* ── 描边参数化 ── */
stroke_width: number
stroke_color: string
/* ── 阴影参数化 ── */
shadow_offset_x: number
shadow_offset_y: number
shadow_blur: number
shadow_color: string
/* ── 背景色块 ── */
bg_enabled: boolean
bg_color: string
bg_padding: number
bg_radius: number
/* ── 逐行覆盖 ── */
line_overrides: Array<{
line_index: number
text?: string
size?: number
color?: string
bold?: boolean
italic?: boolean
stroke?: boolean
highlights?: Array<{ word: string; color?: string; bold?: boolean; scale?: number }>
}>
/* ── 封面独立标题(null=沿用主标题) ── */
cover_title_config: null | Partial<AiAvatarTitleConfig>
}
/* ── 封面配置 ── */
@@ -185,27 +149,12 @@ export const DEFAULT_TITLE_CONFIG: AiAvatarTitleConfig = {
size: 48,
bold: true,
italic: false,
stroke: true,
stroke: false,
shadow: false,
color: "#ffffff",
auto_subtitle: true,
pos_x: undefined,
pos_y: undefined,
line_height: 1.2,
margin_top: 24,
max_chars_per_line: 0,
stroke_width: 4,
stroke_color: "#000000",
shadow_offset_x: 2,
shadow_offset_y: 2,
shadow_blur: 4,
shadow_color: "rgba(0,0,0,0.8)",
bg_enabled: false,
bg_color: "rgba(0,0,0,0.5)",
bg_padding: 12,
bg_radius: 8,
line_overrides: [],
cover_title_config: null,
}
export const DEFAULT_COVER_CONFIG: AiAvatarCoverConfig = {
+2 -85
View File
@@ -67,37 +67,6 @@ export function buildTitleConfigPayload(
const text = (cfg.title || "").trim()
if (!text) return {}
const position = cfg.position || "bottom"
const anyCfg = cfg as AiAvatarTitleConfig & {
stroke_width?: number
stroke_color?: string
shadow_offset_x?: number
shadow_offset_y?: number
shadow_blur?: number
shadow_color?: string
line_height?: number
margin_top?: number
max_chars_per_line?: number
bg_enabled?: boolean
bg_color?: string
bg_padding?: number
bg_radius?: number
line_overrides?: unknown[]
cover_title_config?: Record<string, unknown> | null
}
const strokeWidth = anyCfg.stroke_width != null ? anyCfg.stroke_width : 4
const strokeColor = anyCfg.stroke_color || "#000000"
const shadowOffsetX = anyCfg.shadow_offset_x != null ? anyCfg.shadow_offset_x : 2
const shadowOffsetY = anyCfg.shadow_offset_y != null ? anyCfg.shadow_offset_y : 2
const shadowBlur = anyCfg.shadow_blur != null ? anyCfg.shadow_blur : 4
const shadowColor = anyCfg.shadow_color || "rgba(0,0,0,0.8)"
const lineHeight = anyCfg.line_height != null ? anyCfg.line_height : 1.2
const marginTop = anyCfg.margin_top != null ? anyCfg.margin_top : 24
const maxCharsPerLine = anyCfg.max_chars_per_line ?? 0
const bgEnabled = !!anyCfg.bg_enabled
const bgColor = anyCfg.bg_color || "rgba(0,0,0,0.5)"
const bgPadding = anyCfg.bg_padding != null ? anyCfg.bg_padding : 12
const bgRadius = anyCfg.bg_radius != null ? anyCfg.bg_radius : 8
const payload: Record<string, unknown> = {
text,
enabled: true,
@@ -106,68 +75,16 @@ export function buildTitleConfigPayload(
font_color: cfg.color || "#ffffff",
position,
bold: !!cfg.bold,
italic: !!cfg.italic,
stroke: cfg.stroke
? { enabled: true, width: strokeWidth, color: strokeColor }
: { enabled: false },
stroke: cfg.stroke ? { enabled: true, width: 2, color: "#000000" } : { enabled: false },
shadow: cfg.shadow
? {
enabled: true,
color: shadowColor,
offset_x: shadowOffsetX,
offset_y: shadowOffsetY,
blur: shadowBlur,
}
? { enabled: true, color: "#000000", offset_x: 2, offset_y: 2 }
: { enabled: false },
line_height: lineHeight,
margin_top: marginTop,
max_chars_per_line: maxCharsPerLine,
background: bgEnabled
? { enabled: true, color: bgColor, padding: bgPadding, radius: bgRadius }
: { enabled: false },
line_overrides: Array.isArray(anyCfg.line_overrides) ? anyCfg.line_overrides : [],
}
// 自定义坐标(custom 位置)
if (position === "custom" && typeof cfg.pos_x === "number" && typeof cfg.pos_y === "number") {
payload.pos_x = cfg.pos_x
payload.pos_y = cfg.pos_y
}
// 封面独立标题配置
if (anyCfg.cover_title_config) {
const ctc = anyCfg.cover_title_config
payload.cover_title_config = {
title: ctc.title,
font: ctc.font,
font_size: ctc.size,
font_color: ctc.color,
position: ctc.position,
bold: ctc.bold,
italic: ctc.italic,
stroke: ctc.stroke
? { enabled: true, width: ctc.stroke_width ?? 4, color: ctc.stroke_color ?? "#000000" }
: { enabled: false },
shadow: ctc.shadow
? {
enabled: true,
color: ctc.shadow_color ?? shadowColor,
offset_x: ctc.shadow_offset_x ?? 2,
offset_y: ctc.shadow_offset_y ?? 2,
blur: ctc.shadow_blur ?? 4,
}
: { enabled: false },
line_height: ctc.line_height ?? lineHeight,
margin_top: ctc.margin_top ?? marginTop,
max_chars_per_line: ctc.max_chars_per_line ?? maxCharsPerLine,
background: ctc.bg_enabled
? {
enabled: true,
color: ctc.bg_color ?? bgColor,
padding: ctc.bg_padding ?? bgPadding,
radius: ctc.bg_radius ?? bgRadius,
}
: { enabled: false },
}
}
// 前端 Canvas 渲染好的 PNG dataURL(所见即所得,后端优先 overlay 此图片图层)
if (titleImageDataUrl) {
payload.title_image_dataurl = titleImageDataUrl
+76 -211
View File
@@ -9,78 +9,37 @@
* 按 videoWidth / 720 得到 scale,所有长度类参数乘以 scale,
* 保证 1080p / 4K 成片里标题视觉大小与预览一致。
*/
import { getFontFamily } from "@/components/title/constants"
import type { AiAvatarTitleConfig } from "../types"
export interface RenderTitlePngOptions {
/** 标题配置 */
titleConfig: AiAvatarTitleConfig
/** 视频宽度(像素),默认 720 */
videoWidth?: number
/** 视频高度(像素),默认 1280 */
videoHeight?: number
useCoverTitle?: boolean
}
function autoWrapLines(rawTitle: string, maxCharsPerLine: number): string[] {
const manual = rawTitle
/**
* 将标题渲染为透明背景 PNG 的 dataURLdata:image/png;base64,...
* Canvas 尺寸与视频一致,保证叠加时 1:1 像素对齐。
*
* 标题为空时返回 null。
*/
export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | null {
const { titleConfig, videoWidth = 720, videoHeight = 1280 } = opts
if (!titleConfig) return null
const rawTitle = (titleConfig.title || "").trim()
if (!rawTitle) return null
// 按 / 或 分割为多行
const lines = rawTitle
.split(/[/]/)
.map((l) => l.trim())
.filter((l) => l.length > 0)
if (!maxCharsPerLine || maxCharsPerLine <= 0) return manual
const out: string[] = []
manual.forEach((seg) => {
for (let i = 0; i < seg.length; i += maxCharsPerLine) {
out.push(seg.slice(i, i + maxCharsPerLine))
}
})
return out
}
export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | null {
const { titleConfig, videoWidth = 720, videoHeight = 1280, useCoverTitle } = opts
if (!titleConfig) return null
type TitleCfgExt = AiAvatarTitleConfig & {
stroke_width?: number
stroke_color?: string
shadow_offset_x?: number
shadow_offset_y?: number
shadow_blur?: number
shadow_color?: string
line_height?: number
margin_top?: number
max_chars_per_line?: number
bg_enabled?: boolean
bg_color?: string
bg_padding?: number
bg_radius?: number
line_overrides?: Array<{
line_index: number
text?: string
size?: number
color?: string
bold?: boolean
italic?: boolean
stroke?: boolean
highlights?: Array<{ word: string; color?: string; bold?: boolean; scale?: number }>
}>
cover_title_config?: Partial<AiAvatarTitleConfig> | null
pos_x?: number
pos_y?: number
}
const cfg: TitleCfgExt =
useCoverTitle && titleConfig.cover_title_config
? ({
...(titleConfig as TitleCfgExt),
...(titleConfig.cover_title_config as object),
} as TitleCfgExt)
: (titleConfig as TitleCfgExt)
const rawTitle = (cfg.title || "").trim()
if (!rawTitle) return null
const maxCharsPerLine = cfg.max_chars_per_line ?? 0
const lines = autoWrapLines(rawTitle, maxCharsPerLine)
if (lines.length === 0) return null
// 分辨率缩放系数:基准 720p,所有长度类参数乘以 scale
const scale = videoWidth / 720
const r = (v: number) => Math.round(v * scale)
@@ -90,183 +49,86 @@ export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | n
const ctx = canvas.getContext("2d")
if (!ctx) return null
const baseSize = Math.max(12, Math.round(cfg.size || 48))
const baseSize = Math.max(12, Math.round(titleConfig.size || 48))
const size = r(baseSize)
const bold = !!cfg.bold
const italic = !!cfg.italic
const color = cfg.color || "#ffffff"
const stroke = !!cfg.stroke
const shadow = !!cfg.shadow
const bold = !!titleConfig.bold
const italic = !!titleConfig.italic
const color = titleConfig.color || "#ffffff"
const stroke = !!titleConfig.stroke
const shadow = !!titleConfig.shadow
const strokeWidthBase = cfg.stroke_width != null ? cfg.stroke_width : 4
const strokeColor = cfg.stroke_color || "#000000"
const shadowOffsetXBase = cfg.shadow_offset_x != null ? cfg.shadow_offset_x : 2
const shadowOffsetYBase = cfg.shadow_offset_y != null ? cfg.shadow_offset_y : 2
const shadowBlurBase = cfg.shadow_blur != null ? cfg.shadow_blur : 4
const shadowColor = cfg.shadow_color || "rgba(0,0,0,0.8)"
const lineHeightScale = cfg.line_height != null ? cfg.line_height : 1.2
const marginTopBase = cfg.margin_top != null ? cfg.margin_top : 24
const bgEnabled = !!cfg.bg_enabled
const bgColor = cfg.bg_color || "rgba(0,0,0,0.5)"
const bgPaddingBase = cfg.bg_padding != null ? cfg.bg_padding : 12
const bgRadiusBase = cfg.bg_radius != null ? cfg.bg_radius : 8
const fontKey = cfg.font || "思源黑体"
const fontFamily = getFontFamily(fontKey)
const setFont = (sz: number, bd: boolean, it: boolean) => {
const parts: string[] = []
if (it) parts.push("italic")
if (bd) parts.push("bold")
parts.push(`${sz}px`, fontFamily)
ctx.font = parts.join(" ")
}
setFont(size, bold, italic)
// 字体族 fallback 链:优先中文字体
const fontFamily =
'"Noto Sans CJK SC","Source Han Sans CN","PingFang SC","Microsoft YaHei",sans-serif'
const fontParts: string[] = []
if (italic) fontParts.push("italic")
if (bold) fontParts.push("bold")
fontParts.push(`${size}px`, fontFamily)
ctx.font = fontParts.join(" ")
ctx.fillStyle = color
ctx.textAlign = "center"
ctx.textBaseline = "middle"
const lineGap = size * lineHeightScale
const totalTextH = lines.length * lineGap - (lineGap - size)
let maxLineW = 0
lines.forEach((l: string) => {
const m = ctx.measureText(l).width
if (m > maxLineW) maxLineW = m
})
// 阴影(shadow=true 时开启)——按 scale 缩放
if (shadow) {
ctx.shadowColor = "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(4)
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = r(2)
}
// 位置计算:与 PanelLipsyncPreview 的 CSS 对齐(按 scale 缩放 PAD
const PAD = r(16)
let centerX = videoWidth / 2
const position = cfg.position || "bottom"
const position = titleConfig.position || "bottom"
const lineGap = size * 1.2
const totalTextH = lines.length * lineGap - (lineGap - size) // 所有行的总高度
// 文本块顶部 ytextBaseline=middle 时首行基线)
let firstLineY: number
if (position === "custom" && typeof cfg.pos_x === "number" && typeof cfg.pos_y === "number") {
centerX = (Math.max(0, Math.min(100, cfg.pos_x)) / 100) * videoWidth
const centerY = (Math.max(0, Math.min(100, cfg.pos_y)) / 100) * videoHeight
if (
position === "custom" &&
typeof titleConfig.pos_x === "number" &&
typeof titleConfig.pos_y === "number"
) {
centerX = (Math.max(0, Math.min(100, titleConfig.pos_x)) / 100) * videoWidth
const centerY = (Math.max(0, Math.min(100, titleConfig.pos_y)) / 100) * videoHeight
firstLineY = centerY - totalTextH / 2 + size / 2
} else if (position === "top") {
firstLineY = size / 2 + PAD + r(marginTopBase)
// 顶部:y = size/2 + PAD
firstLineY = size / 2 + PAD
} else if (position === "center") {
firstLineY = videoHeight / 2 - totalTextH / 2 + size / 2
} else {
// bottom(默认)
firstLineY = videoHeight - totalTextH - PAD + size / 2
}
if (shadow) {
ctx.shadowColor = shadowColor
ctx.shadowBlur = r(shadowBlurBase)
ctx.shadowOffsetX = r(shadowOffsetXBase)
ctx.shadowOffsetY = r(shadowOffsetYBase)
} else {
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
}
const bgPad = r(bgPaddingBase)
const bgR = r(bgRadiusBase)
const bgW = maxLineW + bgPad * 2
const bgH = totalTextH + bgPad * 2
const bgX = centerX - bgW / 2
const bgY = firstLineY - size / 2 - bgPad
if (bgEnabled) {
ctx.save()
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
ctx.fillStyle = bgColor
if (
bgR > 0 &&
(
ctx as CanvasRenderingContext2D & {
roundRect?: (x: number, y: number, w: number, h: number, r: number) => void
}
).roundRect
) {
;(
ctx as CanvasRenderingContext2D & {
roundRect?: (x: number, y: number, w: number, h: number, r: number) => void
}
).roundRect(bgX, bgY, bgW, bgH, bgR)
ctx.fill()
} else {
ctx.fillRect(bgX, bgY, bgW, bgH)
}
ctx.restore()
}
const sw = stroke ? Math.max(r(1), r(strokeWidthBase)) : 0
const lineOverrides = cfg.line_overrides || []
lines.forEach((line: string, idx: number) => {
// 描边参数:描边 lineWidth 按 scale 缩放(基准 size * 0.06,最小 2px @720p
const doStroke = stroke
const strokeWidth = Math.max(r(2), Math.round(size * 0.06))
// 逐行绘制
lines.forEach((line, idx) => {
const y = firstLineY + idx * lineGap
const override = lineOverrides.find((lo) => lo.line_index === idx)
const lineSize = override?.size ? r(Math.max(12, Math.round(override.size))) : size
const lineColor = override?.color || color
const lineBold = override?.bold != null ? !!override.bold : bold
const lineItalic = override?.italic != null ? !!override.italic : italic
const lineStroke = override?.stroke != null ? !!override.stroke : stroke
setFont(lineSize, lineBold, lineItalic)
ctx.fillStyle = lineColor
if (shadow) {
ctx.shadowColor = shadowColor
ctx.shadowBlur = r(shadowBlurBase)
ctx.shadowOffsetX = r(shadowOffsetXBase)
ctx.shadowOffsetY = r(shadowOffsetYBase)
} else {
if (doStroke) {
const prevShadowColor = ctx.shadowColor
const prevShadowBlur = ctx.shadowBlur
// 描边不要带阴影(避免黑色描边发虚)
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
}
const lineSw = override?.size
? Math.max(r(1), Math.round(lineSize * (strokeWidthBase / baseSize)))
: sw
if (lineStroke && lineSw > 0) {
ctx.save()
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
ctx.lineWidth = lineSw
ctx.strokeStyle = strokeColor
ctx.lineWidth = strokeWidth
ctx.strokeStyle = "#000000"
ctx.lineJoin = "round"
ctx.strokeText(line, centerX, y)
ctx.restore()
// 恢复阴影
if (shadow) {
ctx.shadowColor = "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(4)
} else {
ctx.shadowColor = prevShadowColor
ctx.shadowBlur = prevShadowBlur
}
}
ctx.fillText(line, centerX, y)
if (override?.highlights?.length) {
const fullW = ctx.measureText(line).width
const charW = line.length > 0 ? fullW / line.length : lineSize
override.highlights.forEach((hl) => {
if (!hl.word) return
const pos = line.indexOf(hl.word)
if (pos < 0) return
const hlX = centerX - fullW / 2 + pos * charW + (charW * hl.word.length) / 2
const hlColor = hl.color || "#ffd700"
const hlScale = hl.scale || 1
const hlSize = lineSize * hlScale
const hlBold = hl.bold != null ? !!hl.bold : true
ctx.save()
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
setFont(hlSize, hlBold, lineItalic)
ctx.fillStyle = hlColor
if (lineStroke && lineSw > 0) {
ctx.lineWidth = Math.max(r(1), Math.round(hlSize * (strokeWidthBase / baseSize)))
ctx.strokeStyle = strokeColor
ctx.lineJoin = "round"
ctx.strokeText(hl.word, hlX, y)
}
ctx.fillText(hl.word, hlX, y)
ctx.restore()
})
}
})
try {
@@ -276,6 +138,9 @@ export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | n
}
}
/**
* 获取视频真实分辨率(HTMLVideoElement + loadedmetadata,超时 3 秒兜底 720×1280)。
*/
export function getVideoResolution(
videoUrl: string,
timeoutMs = 3000,
+27 -43
View File
@@ -33,7 +33,6 @@ import { getAssetsByKind } from "@/api/assets"
import { previewTts } from "@/api/tts"
import { usePointsStore } from "@/store/pointsStore"
import { hasEnoughPoints } from "./hooks/pointsCost"
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
import "./generate.css"
import "./generate-points.css"
@@ -77,8 +76,6 @@ const GeneratePage: React.FC = () => {
setTtsVoiceId,
ttsVoiceSource,
setTtsVoiceSource,
ttsStyle,
setTtsStyle,
ttsVoiceAssetId,
setTtsVoiceAssetId,
dedupEnabled,
@@ -331,7 +328,6 @@ const GeneratePage: React.FC = () => {
selectedScript,
ttsVoiceId,
ttsVoiceSource,
ttsStyle,
ttsVoiceAssetId,
dedupEnabled,
style,
@@ -413,15 +409,9 @@ const GeneratePage: React.FC = () => {
)
const handleTtsSynthesized = useCallback(
(payload: {
voiceAssetId: string
ttsVoiceId: string
ttsVoiceSource: "preset" | "clone"
ttsStyle?: string
}) => {
(payload: { voiceAssetId: string; ttsVoiceId: string; ttsVoiceSource: "preset" | "clone" }) => {
setTtsVoiceId(payload.ttsVoiceId)
setTtsVoiceSource(payload.ttsVoiceSource)
if (payload.ttsStyle) setTtsStyle(payload.ttsStyle)
setTtsVoiceAssetId(payload.voiceAssetId)
if (payload.ttsVoiceSource === "clone") {
setSelectedClonedVoice(payload.ttsVoiceId)
@@ -437,7 +427,6 @@ const GeneratePage: React.FC = () => {
[
setTtsVoiceId,
setTtsVoiceSource,
setTtsStyle,
setTtsVoiceAssetId,
setSelectedVoice,
setSelectedClonedVoice,
@@ -448,22 +437,19 @@ const GeneratePage: React.FC = () => {
/* ── 步骤3「确认生成视频」:校验通过 → 创建正式生成任务 → 跳步骤4看实时进展 ── */
const handleConfirmGenerate = useCallback(async () => {
// 积分预检查(积分系统关闭时跳过,直接走生成流程)
let check: ReturnType<typeof hasEnoughPoints> = { sufficient: true, cost: 0 }
if (ENABLE_CREDIT_SYSTEM) {
const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
check = hasEnoughPoints(
balance ?? null,
units,
dailyUsage ?? null,
[],
"free",
rules?.free_user_multiplier ?? 1.15,
)
if (!check.sufficient) {
message.error(check.reason ?? "积分不足,请充值")
return
}
// 积分预检查
const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
const check = hasEnoughPoints(
balance ?? null,
units,
dailyUsage ?? null,
[],
"free",
rules?.free_user_multiplier ?? 1.15,
)
if (!check.sufficient) {
message.error(check.reason ?? "积分不足,请充值")
return
}
if (isBatch) {
if (selectedVariantIds.length === 0) {
@@ -534,18 +520,19 @@ const GeneratePage: React.FC = () => {
/* ── 积分消耗估算(步骤3确认生成展示用) ── */
const unitsForCost = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
const pointsEstimate = useMemo(() => {
if (!ENABLE_CREDIT_SYSTEM) return { sufficient: true, cost: 0 }
return hasEnoughPoints(
balance ?? null,
unitsForCost,
dailyUsage ?? null,
[],
"free",
rules?.free_user_multiplier ?? 1.15,
)
}, [unitsForCost, balance, dailyUsage, rules])
const insufficientPoints = ENABLE_CREDIT_SYSTEM && !pointsEstimate.sufficient
const pointsEstimate = useMemo(
() =>
hasEnoughPoints(
balance ?? null,
unitsForCost,
dailyUsage ?? null,
[],
"free",
rules?.free_user_multiplier ?? 1.15,
),
[unitsForCost, balance, dailyUsage, rules],
)
const insufficientPoints = !pointsEstimate.sufficient
/* ================================================================
渲染
@@ -640,7 +627,6 @@ const GeneratePage: React.FC = () => {
onToggleStroke={styleUpdaters.toggleStroke}
onToggleShadow={styleUpdaters.toggleShadow}
onApplyPreset={styleUpdaters.applyPreset}
onUpdateStyle={styleUpdaters.updateStyle}
activePreset={styleUpdaters.activePreset}
titlePresets={styleUpdaters.titlePresets}
bgm={bgm}
@@ -802,8 +788,6 @@ const GeneratePage: React.FC = () => {
open={ttsModalOpen}
scriptText={selectedScript?.content ?? ""}
scriptTitle={selectedScript?.title ?? ""}
style={ttsStyle}
onStyleChange={setTtsStyle}
onCancel={() => setTtsModalOpen(false)}
onSynthesized={handleTtsSynthesized}
/>
@@ -51,14 +51,8 @@ export interface GenerateStepContentProps {
onToggleStroke: () => void
onToggleShadow: () => void
onApplyPreset: (presetKey: string) => void
onUpdateStyle?: (patch: Partial<TitleSettings>) => void
activePreset: string | null
titlePresets: Array<{
key: string
label: string
emoji?: string
style: Record<string, unknown>
}>
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
/* ── 封面 ── */
coverSettings: CoverConfig
onCoverSettingsChange: (settings: CoverConfig) => void
@@ -125,7 +119,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onToggleStroke,
onToggleShadow,
onApplyPreset,
onUpdateStyle,
activePreset,
titlePresets,
coverSettings,
@@ -198,7 +191,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onToggleStroke={onToggleStroke}
onToggleShadow={onToggleShadow}
onApplyPreset={onApplyPreset}
onUpdateStyle={onUpdateStyle}
activePreset={activePreset}
titlePresets={titlePresets}
previewCount={previewCount}
@@ -212,50 +212,52 @@ const Step1EditMode: React.FC<Step1EditModeProps> = ({
className="xx-form-field"
style={{
marginTop: 16,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 16px",
background: "#f9fafb",
borderRadius: 8,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 14, fontWeight: 500, color: "#111" }}>
<div>
<div style={{ fontSize: 14, fontWeight: 500, color: "#111" }}>
🎯 {dedupEnabled ? "已开启" : "已关闭"}
</span>
<button
type="button"
onClick={() => onDedupEnabledChange(!dedupEnabled)}
</div>
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 2 }}>
</div>
</div>
<button
type="button"
onClick={() => onDedupEnabledChange(!dedupEnabled)}
style={{
width: 44,
height: 24,
borderRadius: 12,
border: "none",
background: dedupEnabled ? PURPLE : "#d1d5db",
position: "relative",
cursor: "pointer",
transition: "background 0.2s",
padding: 0,
}}
aria-label="toggle dedup"
>
<span
style={{
width: 44,
height: 24,
borderRadius: 12,
border: "none",
background: dedupEnabled ? PURPLE : "#d1d5db",
position: "relative",
cursor: "pointer",
transition: "background 0.2s",
padding: 0,
flexShrink: 0,
position: "absolute",
top: 2,
left: dedupEnabled ? 22 : 2,
width: 20,
height: 20,
borderRadius: "50%",
background: "#fff",
transition: "left 0.2s",
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
}}
aria-label="toggle dedup"
>
<span
style={{
position: "absolute",
top: 2,
left: dedupEnabled ? 22 : 2,
width: 20,
height: 20,
borderRadius: "50%",
background: "#fff",
transition: "left 0.2s",
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
}}
/>
</button>
</div>
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 4 }}>
</div>
/>
</button>
</div>
</div>
)
@@ -12,8 +12,7 @@ import React, { useMemo, useState } from "react"
import { Input, message } from "antd"
import { LoadingOutlined } from "@ant-design/icons"
import type { TitleSettings } from "../types"
import { POSITION_OPTIONS } from "../constants"
import { FONT_OPTIONS } from "@/components/title/constants"
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
import { useStep4Title } from "../hooks/useStep4Title"
import AiTitleGenerator from "./title/AiTitleGenerator"
import TitleLibraryAutoComplete from "./title/TitleLibraryAutoComplete"
@@ -34,15 +33,8 @@ interface Step4TitleSettingsProps {
onToggleStroke: () => void
onToggleShadow: () => void
onApplyPreset: (presetKey: string) => void
onUpdateStyle?: (patch: Partial<TitleSettings>) => void
activePreset: string | null
titlePresets: Array<{
key: string
label: string
emoji?: string
style?: Record<string, unknown>
previewStyle?: React.CSSProperties
}>
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
/* ── 批量生成(#1677)── */
/** 生成数量 */
previewCount?: number
@@ -92,7 +84,6 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
onToggleStroke,
onToggleShadow,
onApplyPreset,
onUpdateStyle,
activePreset,
titlePresets,
previewCount = 1,
@@ -294,8 +285,6 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
onToggleStroke={onToggleStroke}
onToggleShadow={onToggleShadow}
onApplyPreset={onApplyPreset}
onUpdateStyle={onUpdateStyle}
showCoverToggle
activePreset={activePreset}
titlePresets={titlePresets}
POSITION_OPTIONS={POSITION_OPTIONS}
@@ -19,8 +19,6 @@ import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
import type { PresetVoiceItem } from "@/api/voices"
import type { VoiceClone } from "@/api/voice-clone"
import { VOICE_GENDER_ICON } from "../constants"
import TtsStyleSelector from "@/components/voice/TtsStyleSelector"
import { DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles"
interface TtsVoiceModalProps {
open: boolean
@@ -33,11 +31,7 @@ interface TtsVoiceModalProps {
voiceAssetId: string
ttsVoiceId: string
ttsVoiceSource: "preset" | "clone"
ttsStyle: TtsStyle
}) => void
/** 当前风格 */
style?: TtsStyle
onStyleChange?: (s: TtsStyle) => void
}
type TtsSynthStatus = "idle" | "synthesizing" | "saving" | "done" | "error"
@@ -48,15 +42,7 @@ const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
scriptTitle,
onCancel,
onSynthesized,
style: externalStyle,
onStyleChange,
}) => {
const [internalStyle, setInternalStyle] = useState<TtsStyle>(DEFAULT_TTS_STYLE)
const currentStyle: TtsStyle = externalStyle ?? internalStyle
const handleStyleChange = (s: TtsStyle) => {
setInternalStyle(s)
onStyleChange?.(s)
}
const [activeTab, setActiveTab] = useState<"preset" | "clone">("preset")
const [selectedVoiceId, setSelectedVoiceId] = useState<string>("")
const [status, setStatus] = useState<TtsSynthStatus>("idle")
@@ -91,7 +77,6 @@ const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
setStatus("idle")
setError(null)
setActiveTab("preset")
setInternalStyle(externalStyle ?? DEFAULT_TTS_STYLE)
} else {
if (timerRef.current) {
clearInterval(timerRef.current)
@@ -106,7 +91,6 @@ const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
return () => {
if (timerRef.current) clearInterval(timerRef.current)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const handlePreview = useCallback(
@@ -159,7 +143,6 @@ const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
text: textToSynth,
speed: 1.0,
language: "zh-CN",
style: currentStyle,
}
if (isClone) {
payload.voice_clone_profile_id = selectedVoiceId
@@ -204,14 +187,13 @@ const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
voiceAssetId: jobId,
ttsVoiceId: selectedVoiceId,
ttsVoiceSource: isClone ? "clone" : "preset",
ttsStyle: currentStyle,
})
} catch (err: unknown) {
setStatus("error")
const msg = err instanceof Error ? err.message : "合成失败,请稍后重试"
setError(msg)
}
}, [selectedVoiceId, textToSynth, activeTab, scriptTitle, onSynthesized, currentStyle])
}, [selectedVoiceId, textToSynth, activeTab, scriptTitle, onSynthesized])
const renderVoiceCard = (v: {
id: string
@@ -411,10 +393,6 @@ const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
{textToSynth.length}
</div>
<div style={{ marginBottom: 12 }}>
<TtsStyleSelector value={currentStyle} onChange={handleStyleChange} compact />
</div>
<Tabs
activeKey={activeTab}
onChange={(k) => {
@@ -1,221 +0,0 @@
/**
* 标题迷你 Canvas 预览(#2001
*
* 渲染一张指定宽度的小 Canvas 预览标题效果,用于:
* - 预设卡片缩略图
* - 样式面板顶部的实时预览
*
* 与 titleCanvas.ts 渲染逻辑保持一致,但:
* - 固定分辨率(width × 宽高比约 2:1)
* - 不调用 ffmpeg,只做视觉预览
* - 支持背景色块、描边宽度/颜色、阴影参数化、行距、自动换行
*/
import React, { useEffect, useRef } from "react"
import type { TitleSettings } from "../../types"
import { getFontFamily } from "../../constants"
interface Props {
settings: TitleSettings
width?: number
sampleText?: string
/** 背景(预览用,默认深色渐变模拟视频底) */
background?: string
/** 高度(可选,默认 width/2 */
height?: number
}
/** 按 maxCharsPerLine 自动换行 */
function wrapLines(text: string, maxChars: number): string[] {
const manual = text
.split(/[/\n]/)
.map((l) => l.trim())
.filter(Boolean)
if (!maxChars || maxChars <= 0) return manual
const out: string[] = []
for (const line of manual) {
if (line.length <= maxChars) {
out.push(line)
continue
}
let cur = ""
for (const ch of line) {
cur += ch
if (cur.length >= maxChars) {
out.push(cur)
cur = ""
}
}
if (cur) out.push(cur)
}
return out
}
const TitleMiniPreview: React.FC<Props> = ({
settings,
width = 200,
sampleText,
background = "linear-gradient(135deg,#1f2937,#111827)",
height,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const h = height ?? Math.round(width / 1.8)
const text = (sampleText || settings.title || "预览标题").trim() || "预览标题"
useEffect(() => {
const cvs = canvasRef.current
if (!cvs) return
const dpr = window.devicePixelRatio || 1
cvs.width = width * dpr
cvs.height = h * dpr
cvs.style.width = `${width}px`
cvs.style.height = `${h}px`
const ctx = cvs.getContext("2d")
if (!ctx) return
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, width, h)
// 背景
ctx.fillStyle = "#111827"
ctx.fillRect(0, 0, width, h)
// 分辨率缩放:以 360 宽为基准(对应 720p 的一半)
const scale = width / 360
const r = (v: number) => Math.round(v * scale)
// 字体
const size = r(settings.size)
const ff = getFontFamily(settings.font)
const parts: string[] = []
if (settings.italic) parts.push("italic")
if (settings.bold) parts.push("bold")
parts.push(`${size}px`, ff)
ctx.font = parts.join(" ")
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillStyle = settings.color
ctx.lineJoin = "round"
// 阴影
const shadowEnabled = !!settings.shadow
const prevShadow = {
c: ctx.shadowColor,
b: ctx.shadowBlur,
ox: ctx.shadowOffsetX,
oy: ctx.shadowOffsetY,
}
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
// 换行
const lines = wrapLines(text, settings.maxCharsPerLine ?? 0)
const lineH = size * (settings.lineHeight ?? 1.2)
const totalH = lines.length * lineH
let startY: number
if (settings.position === "top") {
startY = size / 2 + r(settings.marginTop ?? 24)
} else if (settings.position === "center") {
startY = h / 2 - totalH / 2 + size / 2
} else {
// bottom
startY = h - totalH - r(16) + size / 2
}
let centerX = width / 2
if (settings.position === "custom" && settings.posX != null) {
centerX = (settings.posX / 100) * width
}
// 背景块
if (settings.bgEnabled) {
const pad = r(settings.bgPadding ?? 12)
const rad = r(settings.bgRadius ?? 8)
let maxLineW = 0
for (const l of lines) {
const m = ctx.measureText(l)
if (m.width > maxLineW) maxLineW = m.width
}
const bw = maxLineW + pad * 2
const bh = totalH + pad * 2
const bx = centerX - bw / 2
const by = startY - size / 2 - pad + (size - lineH) / 2
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.fillStyle = settings.bgColor ?? "rgba(0,0,0,0.5)"
roundRect(ctx, bx, by, bw, bh, rad)
ctx.fill()
// 恢复阴影
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
}
// 描边(先画,再画填充)
const strokeEnabled = !!settings.stroke && (settings.strokeWidth ?? 0) > 0
lines.forEach((line, i) => {
const y = startY + i * lineH
if (strokeEnabled) {
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.lineWidth = r(settings.strokeWidth ?? 4)
ctx.strokeStyle = settings.strokeColor ?? "#000000"
ctx.strokeText(line, centerX, y)
// 恢复阴影
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
}
ctx.fillText(line, centerX, y)
})
// 恢复
ctx.shadowColor = prevShadow.c
ctx.shadowBlur = prevShadow.b
ctx.shadowOffsetX = prevShadow.ox
ctx.shadowOffsetY = prevShadow.oy
}, [settings, width, h, text])
return (
<canvas
ref={canvasRef}
style={{
borderRadius: 6,
display: "block",
maxWidth: "100%",
background,
}}
/>
)
}
function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
) {
const rr = Math.min(r, w / 2, h / 2)
ctx.beginPath()
ctx.moveTo(x + rr, y)
ctx.lineTo(x + w - rr, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + rr)
ctx.lineTo(x + w, y + h - rr)
ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h)
ctx.lineTo(x + rr, y + h)
ctx.quadraticCurveTo(x, y + h, x, y + h - rr)
ctx.lineTo(x, y + rr)
ctx.quadraticCurveTo(x, y, x + rr, y)
ctx.closePath()
}
export default TitleMiniPreview
@@ -190,255 +190,3 @@
border-color: var(--primary-color);
color: #fff;
}
/* ============================================================
#2001 爆款标题样式面板升级 — 新增样式(ts- 前缀)
============================================================ */
.ts-panel {
position: relative;
}
/* 预览 */
.ts-preview-wrap {
margin-bottom: 14px;
display: flex;
justify-content: center;
padding: 10px;
background: #0f172a;
border-radius: 8px;
}
/* 表单字段 */
.ts-form-field {
margin-bottom: 12px;
}
.ts-form-field label {
display: block;
font-weight: 600;
margin-bottom: 6px;
font-size: 12px;
color: var(--text-primary, #1f2937);
}
.ts-field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.ts-field-value {
font-size: 12px;
font-weight: 600;
color: var(--primary-color, #7c3aed);
}
.ts-row-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.ts-half {
margin-bottom: 0;
}
.ts-select {
width: 100%;
height: 34px;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
background: var(--bg-primary, #fff);
padding: 0 10px;
font-size: 13px;
outline: 0;
color: var(--text-primary, #1f2937);
}
.ts-select:focus {
border-color: var(--primary-color, #7c3aed);
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.1);
}
.ts-input {
width: 100%;
height: 34px;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
padding: 0 10px;
font-size: 13px;
outline: 0;
}
.ts-slider {
width: 100%;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: #e5e7eb;
border-radius: 2px;
outline: none;
}
.ts-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #7c3aed;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.ts-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #7c3aed;
cursor: pointer;
border: 2px solid #fff;
}
/* 样式按钮 B/I/S/☁ */
.ts-style-btns {
display: flex;
gap: 6px;
}
.ts-style-btn {
width: 34px;
height: 34px;
border-radius: 6px;
border: 1px solid #e5e7eb;
background: #fff;
cursor: pointer;
font-size: 14px;
transition: 0.15s;
color: #374151;
display: inline-flex;
align-items: center;
justify-content: center;
}
.ts-style-btn:hover {
border-color: #7c3aed;
color: #7c3aed;
}
.ts-style-btn.active {
background: #faf5ff;
color: #6d28d9;
border-color: #7c3aed;
font-weight: 700;
}
/* 色板 */
.ts-color-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.ts-color-swatch {
width: 24px;
height: 24px;
border-radius: 4px;
border: 2px solid #fff;
box-shadow: 0 0 0 1px #e5e7eb;
cursor: pointer;
padding: 0;
transition: 0.15s;
}
.ts-color-swatch:hover {
transform: scale(1.1);
}
.ts-color-swatch.active {
box-shadow: 0 0 0 2px #7c3aed;
transform: scale(1.1);
}
.ts-color-custom {
background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 50%/8px 8px;
color: #666;
font-size: 14px;
line-height: 20px;
}
.ts-color-native {
width: 0;
height: 0;
border: 0;
padding: 0;
}
/* 预设网格 10个 - 5列 */
.ts-presets-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
}
.ts-preset-card {
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #fff;
padding: 4px;
cursor: pointer;
transition: 0.15s;
display: flex;
flex-direction: column;
gap: 4px;
}
.ts-preset-card:hover {
border-color: #7c3aed;
}
.ts-preset-card.active {
border-color: #7c3aed;
background: #faf5ff;
box-shadow: 0 0 0 1px #7c3aed;
}
.ts-preset-preview {
height: 34px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 4px;
background: #0f172a;
}
.ts-preset-preview canvas {
max-width: 100%;
max-height: 100%;
}
.ts-preset-meta {
display: flex;
align-items: center;
gap: 2px;
font-size: 10px;
color: #4b5563;
justify-content: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 2px 2px;
}
.ts-preset-emoji {
font-size: 11px;
}
.ts-preset-label {
overflow: hidden;
text-overflow: ellipsis;
}
.ts-toggle-row label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
margin-bottom: 10px;
}
.ts-toggle-row input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: #7c3aed;
}
/* Tabs 紧凑样式 */
.xx-title-style-section .ant-tabs-nav {
margin-bottom: 10px;
}
.xx-title-style-section .ant-tabs-tab {
font-size: 12px !important;
padding: 6px 8px !important;
}
@@ -1,26 +1,12 @@
/**
* 标题样式设置面板(#2001 升级)
*
* P0:描边宽度滑块 / 描边颜色选择器 / 每行最大字符数 / 行距+顶部边距 /
* 4款爆款字体 / 抖音爆款黄预设
* P1:阴影参数化 / 背景色块 / Canvas 实时迷你预览 /
* 封面独立标题配置入口
*
* 向后兼容:旧的 onToggleBold/Italic/Stroke/Shadow/onUpdatePosition/onUpdateFont/
* onUpdateSize/onApplyPreset props 全部保留;新增字段通过 onUpdateStyle 统一回写。
* 标题样式设置
* 位置/字体/字号/样式按钮/预设
*/
import React, { useState } from "react"
import { Tabs } from "antd"
import React from "react"
import type { TitleSettings } from "../../types"
import {
FONT_OPTIONS as NEW_FONT_OPTIONS,
TITLE_PRESETS,
TITLE_COLOR_PALETTE,
STROKE_COLOR_PALETTE,
BG_COLOR_PALETTE,
} from "@/components/title/constants"
import TitleMiniPreview from "./TitleMiniPreview"
import TitlePresetsGrid from "./TitlePresetsGrid"
// 标题样式面板共用样式(#1809 ⑦):智能剪辑与 AI数字人复用同一组件,
// 由组件自带样式,避免 AI数字人页面重复引入整个 generate.css
import "./TitleStylePanel.css"
interface PositionOption {
@@ -28,17 +14,14 @@ interface PositionOption {
label: string
}
interface LegacyPreset {
interface TitlePresetItem {
key: string
label: string
emoji?: string
style?: Record<string, unknown>
previewStyle?: React.CSSProperties
previewStyle: React.CSSProperties
}
interface TitleStylePanelProps {
settings: TitleSettings
/* 旧 props(兼容) */
onUpdatePosition: (position: string) => void
onUpdateFont: (font: string) => void
onUpdateSize: (size: number) => void
@@ -48,130 +31,9 @@ interface TitleStylePanelProps {
onToggleShadow: () => void
onApplyPreset: (presetKey: string) => void
activePreset: string | null
titlePresets: LegacyPreset[]
titlePresets: TitlePresetItem[]
POSITION_OPTIONS: PositionOption[]
FONT_OPTIONS?: Array<{ value: string; label: string; family?: string; tag?: string }>
/* 新增:统一字段更新 */
onUpdateStyle?: (patch: Partial<TitleSettings>) => void
/* 是否显示封面独立标题切换 */
showCoverToggle?: boolean
/** 画布预览宽度(默认 200) */
previewWidth?: number
}
/* ── 通用 Slider + Label 行 ── */
const SliderRow: React.FC<{
label: string
value: number
min: number
max: number
step?: number
unit?: string
onChange: (v: number) => void
}> = ({ label, value, min, max, step = 1, unit = "px", onChange }) => (
<div className="ts-form-field">
<div className="ts-field-label-row">
<label>{label}</label>
<span className="ts-field-value">
{value}
{unit}
</span>
</div>
<input
type="range"
className="ts-slider"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
</div>
)
/* ── 色板 + 自定义颜色选择 ── */
const ColorPicker: React.FC<{
label?: string
value: string
palette: string[]
onChange: (c: string) => void
}> = ({ label, value, palette, onChange }) => {
const [customOpen, setCustomOpen] = useState(false)
return (
<div className="ts-form-field">
{label && <label>{label}</label>}
<div className="ts-color-row">
{palette.map((c) => (
<button
key={c}
type="button"
className={`ts-color-swatch${value.toLowerCase() === c.toLowerCase() ? " active" : ""}`}
style={{ background: c }}
onClick={() => onChange(c)}
title={c}
/>
))}
<button
type="button"
className="ts-color-swatch ts-color-custom"
onClick={() => setCustomOpen((v) => !v)}
title="自定义颜色"
>
+
</button>
<input
type="color"
className="ts-color-native"
value={value.startsWith("rgba") ? "#000000" : value}
onChange={(e) => onChange(e.target.value)}
style={{
opacity: customOpen ? 1 : 0,
position: customOpen ? "static" : "absolute",
pointerEvents: customOpen ? "auto" : "none",
width: 0,
height: 0,
}}
/>
</div>
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 2 }}>
<code style={{ fontSize: 11 }}>{value}</code>
</div>
</div>
)
}
/* ── 预设网格(含爆款黄,10 个 + 迷你 Canvas 缩略) ── */
const PresetGrid: React.FC<{
activePreset: string | null
onApply: (key: string) => void
settings: TitleSettings
}> = ({ activePreset, onApply, settings }) => {
return (
<div className="ts-presets-grid">
{TITLE_PRESETS.map((p) => {
const isActive = activePreset === p.key
// 合并当前 style 与 preset.style 用于预览(仅预览时覆盖)
const previewStyle: TitleSettings = { ...settings, ...(p.style as Partial<TitleSettings>) }
return (
<button
key={p.key}
type="button"
className={`ts-preset-card${isActive ? " active" : ""}`}
onClick={() => onApply(p.key)}
title={p.label}
>
<div className="ts-preset-preview">
<TitleMiniPreview settings={previewStyle} width={100} sampleText="标题" />
</div>
<div className="ts-preset-meta">
<span className="ts-preset-emoji">{p.emoji}</span>
<span className="ts-preset-label">{p.label}</span>
</div>
</button>
)
})}
</div>
)
FONT_OPTIONS: string[]
}
const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
@@ -185,352 +47,107 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
onToggleShadow,
onApplyPreset,
activePreset,
titlePresets: _titlePresets,
titlePresets,
POSITION_OPTIONS,
showCoverToggle = false,
previewWidth = 220,
onUpdateStyle,
FONT_OPTIONS,
}) => {
const upd = (patch: Partial<TitleSettings>) => {
onUpdateStyle?.(patch)
}
/* 封面独立标题切换 */
const [coverOpen, setCoverOpen] = useState(!!settings.coverTitle)
return (
<div className="xx-title-style-section ts-panel">
{/* 实时迷你预览 */}
<div className="ts-preview-wrap">
<TitleMiniPreview
settings={settings}
width={previewWidth}
sampleText={settings.title || "预览标题文字"}
<div className="xx-title-style-section">
<h4 className="xx-section-subtitle"></h4>
{/* 位置 + 字体 一行 */}
<div className="xx-title-style-row">
<div className="xx-form-field xx-half-field">
<label></label>
<select
className="xx-form-select"
value={settings.position}
onChange={(e) => onUpdatePosition(e.target.value)}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="xx-form-field xx-half-field">
<label></label>
<select
className="xx-form-select"
value={settings.font}
onChange={(e) => onUpdateFont(e.target.value)}
>
{FONT_OPTIONS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
</div>
{/* 字号滑块 */}
<div className="xx-form-field">
<div className="xx-field-label-row">
<label></label>
<span className="xx-field-value">{settings.size}px</span>
</div>
<input
className="xx-slider"
type="range"
min={12}
max={128}
value={settings.size}
onChange={(e) => onUpdateSize(Number(e.target.value))}
/>
</div>
{/* 预设样式10个,含抖音爆款黄) */}
<div className="ts-form-field">
<label></label>
<PresetGrid activePreset={activePreset} onApply={onApplyPreset} settings={settings} />
{/* 预设样式 */}
<div className="xx-form-field">
<label></label>
<TitlePresetsGrid
presets={titlePresets}
activePreset={activePreset}
onApply={onApplyPreset}
fontFamily={settings.font}
/>
</div>
<Tabs
size="small"
defaultActiveKey="basic"
items={[
{
key: "basic",
label: "基础",
children: (
<>
{/* 位置 + 字体 */}
<div className="ts-row-2">
<div className="ts-form-field ts-half">
<label></label>
<select
className="ts-select"
value={settings.position}
onChange={(e) => onUpdatePosition(e.target.value)}
>
{POSITION_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div className="ts-form-field ts-half">
<label></label>
<select
className="ts-select"
value={settings.font}
onChange={(e) => onUpdateFont(e.target.value)}
>
{NEW_FONT_OPTIONS.map((f) => (
<option key={f.value} value={f.value}>
{f.tag === "hot" ? "🔥 " : f.tag === "new" ? "🆕 " : ""}
{f.label}
</option>
))}
</select>
</div>
</div>
<SliderRow
label="字号"
value={settings.size}
min={16}
max={120}
onChange={onUpdateSize}
/>
{/* 样式按钮 */}
<div className="ts-form-field">
<label></label>
<div className="ts-style-btns">
<button
type="button"
className={`ts-style-btn${settings.bold ? " active" : ""}`}
onClick={onToggleBold}
>
<b>B</b>
</button>
<button
type="button"
className={`ts-style-btn${settings.italic ? " active" : ""}`}
onClick={onToggleItalic}
>
<i>I</i>
</button>
<button
type="button"
className={`ts-style-btn${settings.stroke ? " active" : ""}`}
onClick={() => {
onToggleStroke()
// 如果之前 strokeWidth 为 0,启用时给个默认值
if (!settings.stroke && (settings.strokeWidth ?? 0) < 2) {
upd({ strokeWidth: 4 })
}
}}
title="描边"
>
S
</button>
<button
type="button"
className={`ts-style-btn${settings.shadow ? " active" : ""}`}
onClick={() => {
onToggleShadow()
if (!settings.shadow) {
upd({
shadowOffsetX: 2,
shadowOffsetY: 2,
shadowBlur: 4,
shadowColor: "rgba(0,0,0,0.8)",
})
}
}}
title="阴影"
>
</button>
</div>
</div>
{/* 字色 */}
<ColorPicker
label="字色"
value={settings.color}
palette={TITLE_COLOR_PALETTE}
onChange={(c) => upd({ color: c })}
/>
</>
),
},
{
key: "stroke",
label: "描边",
children: (
<>
<div className="ts-toggle-row">
<label>
<input type="checkbox" checked={settings.stroke} onChange={onToggleStroke} />
</label>
</div>
{settings.stroke && (
<>
<SliderRow
label="描边宽度"
value={settings.strokeWidth ?? 4}
min={0}
max={20}
onChange={(v) => upd({ strokeWidth: v })}
/>
<ColorPicker
label="描边颜色"
value={settings.strokeColor ?? "#000000"}
palette={STROKE_COLOR_PALETTE}
onChange={(c) => upd({ strokeColor: c })}
/>
</>
)}
</>
),
},
{
key: "shadow",
label: "阴影",
children: (
<>
<div className="ts-toggle-row">
<label>
<input type="checkbox" checked={settings.shadow} onChange={onToggleShadow} />
</label>
</div>
{settings.shadow && (
<>
<SliderRow
label="X偏移"
value={settings.shadowOffsetX ?? 2}
min={-20}
max={20}
onChange={(v) => upd({ shadowOffsetX: v })}
/>
<SliderRow
label="Y偏移"
value={settings.shadowOffsetY ?? 2}
min={-20}
max={20}
onChange={(v) => upd({ shadowOffsetY: v })}
/>
<SliderRow
label="模糊半径"
value={settings.shadowBlur ?? 4}
min={0}
max={30}
onChange={(v) => upd({ shadowBlur: v })}
/>
<div className="ts-form-field">
<label></label>
<input
type="text"
className="ts-input"
value={settings.shadowColor ?? "rgba(0,0,0,0.8)"}
onChange={(e) => upd({ shadowColor: e.target.value })}
placeholder="rgba(0,0,0,0.8)"
/>
</div>
</>
)}
</>
),
},
{
key: "bg",
label: "背景",
children: (
<>
<div className="ts-toggle-row">
<label>
<input
type="checkbox"
checked={settings.bgEnabled}
onChange={() => upd({ bgEnabled: !settings.bgEnabled })}
/>
</label>
</div>
{settings.bgEnabled && (
<>
<ColorPicker
label="背景颜色(含透明度)"
value={settings.bgColor}
palette={BG_COLOR_PALETTE}
onChange={(c) => upd({ bgColor: c })}
/>
<SliderRow
label="内边距"
value={settings.bgPadding}
min={0}
max={40}
onChange={(v) => upd({ bgPadding: v })}
/>
<SliderRow
label="圆角"
value={settings.bgRadius}
min={0}
max={30}
onChange={(v) => upd({ bgRadius: v })}
/>
</>
)}
</>
),
},
{
key: "layout",
label: "排版",
children: (
<>
<SliderRow
label="每行最大字符数"
value={settings.maxCharsPerLine ?? 0}
min={0}
max={20}
unit=""
onChange={(v) => upd({ maxCharsPerLine: v })}
/>
<div
className="ts-form-field"
style={{ fontSize: 11, color: "#9ca3af", marginTop: -4 }}
>
0 = /
</div>
<SliderRow
label="行距倍数"
value={Math.round((settings.lineHeight ?? 1.2) * 100) / 100}
min={1}
max={2}
step={0.05}
unit=""
onChange={(v) => upd({ lineHeight: Number(v.toFixed(2)) })}
/>
<SliderRow
label="顶部边距"
value={settings.marginTop ?? 24}
min={0}
max={200}
onChange={(v) => upd({ marginTop: v })}
/>
</>
),
},
...(showCoverToggle
? [
{
key: "cover",
label: "封面",
children: (
<>
<div className="ts-toggle-row">
<label>
<input
type="checkbox"
checked={coverOpen}
onChange={(e) => {
setCoverOpen(e.target.checked)
if (!e.target.checked) {
upd({ coverTitle: null })
} else {
upd({
coverTitle: {
font: settings.font,
size: Math.round(settings.size * 0.9),
color: settings.color,
bold: settings.bold,
},
})
}
}}
/>
使
</label>
</div>
{coverOpen && settings.coverTitle && (
<div style={{ fontSize: 12, color: "#6b7280", lineHeight: 1.6 }}>
//
</div>
)}
</>
),
},
]
: []),
]}
/>
{/* 样式按钮:粗体/斜体/描边/阴影 */}
<div className="xx-form-field">
<label></label>
<div className="xx-style-btns">
<button
className={`xx-style-btn ${settings.bold ? "active" : ""}`}
onClick={onToggleBold}
title="粗体"
>
<b>B</b>
</button>
<button
className={`xx-style-btn ${settings.italic ? "active" : ""}`}
onClick={onToggleItalic}
title="斜体"
>
<i>I</i>
</button>
<button
className={`xx-style-btn ${settings.stroke ? "active" : ""}`}
onClick={onToggleStroke}
title="描边"
>
S
</button>
<button
className={`xx-style-btn ${settings.shadow ? "active" : ""}`}
onClick={onToggleShadow}
title="阴影"
>
</button>
</div>
</div>
</div>
)
}
+2 -19
View File
@@ -54,28 +54,11 @@ export const POSITION_OPTIONS = [
{ value: "custom", label: "自定义" },
]
/* ── 标题字体选项#2001:新增 4 款爆款字体) ── */
export const FONT_OPTIONS = [
"优设标题黑",
"阿里普惠体Bold",
"抖音美好体",
"思源黑体Heavy",
"思源黑体",
"思源宋体",
"苹方",
"微软雅黑",
"楷体",
]
/* ── 标题字体选项 ── */
export const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "微软雅黑", "楷体"]
/* ── 标题字体 CSS font-family 映射(中文显示名 → 浏览器可识别的字体栈) ── */
export const FONT_FAMILY_MAP: Record<string, string> = {
:
'"YouSheBiaoTiHei","YouShe Title Black","Source Han Sans SC Heavy","Noto Sans SC","PingFang SC",sans-serif',
Bold:
'"Alibaba PuHuiTi Bold","Alibaba PuHuiTi","Source Han Sans SC","PingFang SC",sans-serif',
: '"Douyin Sans","DouyinSansBold","Source Han Sans SC Heavy","PingFang SC",sans-serif',
Heavy:
'"Source Han Sans SC Heavy","Noto Sans SC Heavy","Source Han Sans CN Heavy","PingFang SC",sans-serif',
: '"Source Han Sans SC", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif',
: '"Source Han Serif SC", "Noto Serif SC", "Songti SC", "SimSun", serif',
: '"PingFang SC", -apple-system, "Helvetica Neue", sans-serif',
@@ -22,8 +22,6 @@ export interface UseGenerateVideoProps {
ttsVoiceId?: string
/** TTS 音色来源 */
ttsVoiceSource?: "preset" | "clone"
/** TTS 配音风格 */
ttsStyle?: string
/** 合成后保存到配音库的 asset id / job id(叙事模式) */
ttsVoiceAssetId?: string
/** 智能降重开关(默认 true) */
@@ -30,23 +30,8 @@ interface UseBatchCoversOptions {
color: string
position: string
bold: boolean
italic?: boolean
stroke: boolean
strokeWidth?: number
strokeColor?: string
shadow: boolean
shadowOffsetX?: number
shadowOffsetY?: number
shadowBlur?: number
shadowColor?: string
lineHeight?: number
marginTop?: number
maxCharsPerLine?: number
bgEnabled?: boolean
bgColor?: string
bgPadding?: number
bgRadius?: number
lineOverrides?: unknown[]
}
covers: string[]
onCoversChange: CoversChangeFn
@@ -115,37 +100,8 @@ export function useBatchCovers({
font_color: titleStyle.color,
position: titleStyle.position,
bold: titleStyle.bold,
italic: titleStyle.italic,
stroke: titleStyle.stroke
? {
enabled: true,
width: titleStyle.strokeWidth ?? 4,
color: titleStyle.strokeColor ?? "#000000",
}
: { enabled: false },
shadow: titleStyle.shadow
? {
enabled: true,
offset_x: titleStyle.shadowOffsetX ?? 2,
offset_y: titleStyle.shadowOffsetY ?? 2,
blur: titleStyle.shadowBlur ?? 4,
color: titleStyle.shadowColor ?? "rgba(0,0,0,0.8)",
}
: { enabled: false },
line_height: titleStyle.lineHeight ?? 1.2,
margin_top: titleStyle.marginTop ?? 24,
max_chars_per_line: titleStyle.maxCharsPerLine ?? 0,
background: titleStyle.bgEnabled
? {
enabled: true,
color: titleStyle.bgColor,
padding: titleStyle.bgPadding,
radius: titleStyle.bgRadius,
}
: { enabled: false },
line_overrides: (titleStyle.lineOverrides ?? []) as Array<
Record<string, unknown>
>,
stroke: titleStyle.stroke,
shadow: titleStyle.shadow,
},
}
: {}),
@@ -13,7 +13,6 @@ import type { EditPlanClip } from "@/api/template-editor"
import type { CoverConfig } from "../../types/cover"
import type { PresetVoiceItem } from "@/api/voices"
import type { ScriptItem } from "@/api/scripts"
import { DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles"
import { DEFAULT_COVER_SETTINGS, DEFAULT_CLIP_COUNT } from "../../constants"
import type { TitleSettings } from "../../types"
import { usePlanConfigLoader } from "./usePlanConfigLoader"
@@ -34,21 +33,6 @@ const DEFAULT_TITLE_SETTINGS: TitleSettings = {
color: "#ffffff",
posX: null,
posY: null,
lineHeight: 1.2,
marginTop: 24,
maxCharsPerLine: 0,
strokeWidth: 4,
strokeColor: "#000000",
shadowOffsetX: 2,
shadowOffsetY: 2,
shadowBlur: 4,
shadowColor: "rgba(0,0,0,0.8)",
bgEnabled: false,
bgColor: "rgba(0,0,0,0.5)",
bgPadding: 12,
bgRadius: 8,
lineOverrides: [],
coverTitle: null,
}
export interface GenerateFormState {
@@ -111,9 +95,6 @@ export interface GenerateFormState {
/** TTS 音色来源:preset 系统 / clone 克隆 */
ttsVoiceSource: "preset" | "clone"
setTtsVoiceSource: (src: "preset" | "clone") => void
/** TTS 配音风格 */
ttsStyle: TtsStyle
setTtsStyle: (s: TtsStyle) => void
/** 合成后配音库 asset id(叙事模式保存到库后获得;随机模式 = selectedVoice */
ttsVoiceAssetId: string
setTtsVoiceAssetId: (id: string) => void
@@ -253,7 +234,6 @@ export const useGenerateFormState = (): GenerateFormState => {
const [selectedScript, setSelectedScript] = useState<ScriptItem | null>(null)
const [ttsVoiceId, setTtsVoiceId] = useState<string>("")
const [ttsVoiceSource, setTtsVoiceSource] = useState<"preset" | "clone">("preset")
const [ttsStyle, setTtsStyle] = useState<TtsStyle>(DEFAULT_TTS_STYLE)
const [ttsVoiceAssetId, setTtsVoiceAssetId] = useState<string>("")
const [dedupEnabled, setDedupEnabled] = useState<boolean>(true)
@@ -331,8 +311,6 @@ export const useGenerateFormState = (): GenerateFormState => {
setTtsVoiceId,
ttsVoiceSource,
setTtsVoiceSource,
ttsStyle,
setTtsStyle,
ttsVoiceAssetId,
setTtsVoiceAssetId,
dedupEnabled,
@@ -1,7 +1,6 @@
import { useEffect } from "react"
import type { CoverConfig } from "../../types/cover"
import type { TitleSettings } from "../../types"
import type { TitleLineOverride } from "@/components/title/types"
import type { TitleConfig } from "@/api/template-editor"
import { getEditPlan } from "@/api/template-editor"
@@ -13,156 +12,6 @@ interface UsePlanConfigLoaderOptions {
setSelectedMaterials: (ids: string[]) => void
}
/** #2001:统一归一化 title_config snake_case -> camelCase TitleSettings */
function mapTitleCfgToSettings(
prev: TitleSettings,
tc: TitleConfig & Record<string, unknown>,
): TitleSettings {
const stroke = tc.stroke as
boolean | { enabled?: boolean; width?: number; color?: string } | undefined
const strokeEnabled: boolean | undefined =
typeof stroke === "object" && stroke ? stroke.enabled !== false : !!stroke || undefined
const strokeW: number | undefined =
typeof stroke === "object" && stroke
? (stroke.width ?? (tc.stroke_width as number | undefined))
: (tc.stroke_width as number | undefined)
const strokeC: string | undefined =
typeof stroke === "object" && stroke
? (stroke.color ?? (tc.stroke_color as string | undefined))
: (tc.stroke_color as string | undefined)
const shadow = tc.shadow as
| boolean
| { enabled?: boolean; offset_x?: number; offset_y?: number; blur?: number; color?: string }
| undefined
const shadowEnabled: boolean | undefined =
typeof shadow === "object" && shadow ? shadow.enabled !== false : !!shadow || undefined
const shOffX: number | undefined =
typeof shadow === "object" && shadow
? (shadow.offset_x ?? (tc.shadow_offset_x as number | undefined))
: (tc.shadow_offset_x as number | undefined)
const shOffY: number | undefined =
typeof shadow === "object" && shadow
? (shadow.offset_y ?? (tc.shadow_offset_y as number | undefined))
: (tc.shadow_offset_y as number | undefined)
const shBlur: number | undefined =
typeof shadow === "object" && shadow
? (shadow.blur ?? (tc.shadow_blur as number | undefined))
: (tc.shadow_blur as number | undefined)
const shColor: string | undefined =
typeof shadow === "object" && shadow
? (shadow.color ?? (tc.shadow_color as string | undefined))
: (tc.shadow_color as string | undefined)
const bg = tc.background as
{ enabled?: boolean; color?: string; padding?: number; radius?: number } | undefined
const bgEnabled: boolean | undefined =
(bg && typeof bg === "object" ? bg.enabled : undefined) ??
(tc.bg_enabled as boolean | undefined)
const bgColor: string | undefined =
(bg && typeof bg === "object" ? bg.color : undefined) ?? (tc.bg_color as string | undefined)
const bgPadding: number | undefined =
(bg && typeof bg === "object" ? bg.padding : undefined) ?? (tc.bg_padding as number | undefined)
const bgRadius: number | undefined =
(bg && typeof bg === "object" ? bg.radius : undefined) ?? (tc.bg_radius as number | undefined)
const ct = (tc.cover_title_config ?? null) as null | Record<string, unknown>
let coverTitle: TitleSettings["coverTitle"] = prev.coverTitle
if (ct) {
const ctStroke = ct.stroke as
boolean | { enabled?: boolean; width?: number; color?: string } | undefined
const ctShadow = ct.shadow as
| boolean
| { enabled?: boolean; offset_x?: number; offset_y?: number; blur?: number; color?: string }
| undefined
const ctBg = ct.background as
{ enabled?: boolean; color?: string; padding?: number; radius?: number } | undefined
coverTitle = {
title: (ct.title as string | undefined) ?? prev.coverTitle?.title ?? "",
font: (ct.font as string | undefined) ?? prev.coverTitle?.font,
size:
(ct.font_size as number | undefined) ??
(ct.size as number | undefined) ??
prev.coverTitle?.size,
color:
(ct.font_color as string | undefined) ??
(ct.color as string | undefined) ??
prev.coverTitle?.color,
bold: (ct.bold as boolean | undefined) ?? prev.coverTitle?.bold,
italic: (ct.italic as boolean | undefined) ?? prev.coverTitle?.italic,
position: (ct.position as string | undefined) ?? prev.coverTitle?.position,
stroke:
typeof ctStroke === "object" && ctStroke
? ctStroke.enabled !== false
: ((ctStroke as boolean | undefined) ?? prev.coverTitle?.stroke),
strokeWidth:
(typeof ctStroke === "object" && ctStroke ? ctStroke.width : undefined) ??
(ct.stroke_width as number | undefined) ??
prev.coverTitle?.strokeWidth,
strokeColor:
(typeof ctStroke === "object" && ctStroke ? ctStroke.color : undefined) ??
(ct.stroke_color as string | undefined) ??
prev.coverTitle?.strokeColor,
shadow:
typeof ctShadow === "object" && ctShadow
? ctShadow.enabled !== false
: ((ctShadow as boolean | undefined) ?? prev.coverTitle?.shadow),
shadowOffsetX:
(typeof ctShadow === "object" && ctShadow ? ctShadow.offset_x : undefined) ??
(ct.shadow_offset_x as number | undefined) ??
prev.coverTitle?.shadowOffsetX,
shadowOffsetY:
(typeof ctShadow === "object" && ctShadow ? ctShadow.offset_y : undefined) ??
(ct.shadow_offset_y as number | undefined) ??
prev.coverTitle?.shadowOffsetY,
shadowBlur:
(typeof ctShadow === "object" && ctShadow ? ctShadow.blur : undefined) ??
(ct.shadow_blur as number | undefined) ??
prev.coverTitle?.shadowBlur,
shadowColor:
(typeof ctShadow === "object" && ctShadow ? ctShadow.color : undefined) ??
(ct.shadow_color as string | undefined) ??
prev.coverTitle?.shadowColor,
bgEnabled:
ctBg?.enabled ?? (ct.bg_enabled as boolean | undefined) ?? prev.coverTitle?.bgEnabled,
bgColor: ctBg?.color ?? (ct.bg_color as string | undefined) ?? prev.coverTitle?.bgColor,
bgPadding:
ctBg?.padding ?? (ct.bg_padding as number | undefined) ?? prev.coverTitle?.bgPadding,
bgRadius: ctBg?.radius ?? (ct.bg_radius as number | undefined) ?? prev.coverTitle?.bgRadius,
}
}
const result: TitleSettings = {
...prev,
title: (tc.content as string | undefined) || prev.title,
aiAutoSelect: (tc.ai_auto_select as boolean | undefined) || false,
position: prev.position,
font: (tc.font_preset as string | undefined) || prev.font,
size: (tc.font_size as number | undefined) || prev.size,
color: (tc.font_color as string | undefined) || prev.color,
bold: (tc.bold as boolean | undefined) ?? prev.bold,
italic: (tc.italic as boolean | undefined) ?? prev.italic,
stroke: strokeEnabled ?? prev.stroke,
strokeWidth: strokeW ?? prev.strokeWidth,
strokeColor: strokeC ?? prev.strokeColor,
shadow: shadowEnabled ?? prev.shadow,
shadowOffsetX: shOffX ?? prev.shadowOffsetX,
shadowOffsetY: shOffY ?? prev.shadowOffsetY,
shadowBlur: shBlur ?? prev.shadowBlur,
shadowColor: shColor ?? prev.shadowColor,
lineHeight: (tc.line_height as number | undefined) ?? prev.lineHeight,
marginTop: (tc.margin_top as number | undefined) ?? prev.marginTop,
maxCharsPerLine: (tc.max_chars_per_line as number | undefined) ?? prev.maxCharsPerLine,
bgEnabled: bgEnabled ?? prev.bgEnabled,
bgColor: bgColor ?? prev.bgColor,
bgPadding: bgPadding ?? prev.bgPadding,
bgRadius: bgRadius ?? prev.bgRadius,
lineOverrides: ((tc.line_overrides as unknown[] | undefined) ?? []) as TitleLineOverride[],
coverTitle,
}
return result
}
/**
* 从 URL 参数或编辑计划 ID 加载表单配置
*/
@@ -178,7 +27,14 @@ export function usePlanConfigLoader({
if (!planConfigStr) return
try {
const config = JSON.parse(planConfigStr) as {
title_config?: Record<string, unknown>
title_config?: {
content?: string
ai_auto_select?: boolean
position?: string
font_preset?: string
font_size?: number
font_color?: string
}
subtitle_config?: { enabled?: boolean }
bgm_config?: { enabled?: boolean; music_id?: string }
mode?: string
@@ -187,8 +43,16 @@ export function usePlanConfigLoader({
}
if (config.title_config) {
const tc = config.title_config as TitleConfig & Record<string, unknown>
setTitleSettings((prev: TitleSettings) => mapTitleCfgToSettings(prev, tc))
const tc = config.title_config as TitleConfig
setTitleSettings((prev: TitleSettings) => ({
...prev,
title: tc.content || "",
aiAutoSelect: tc.ai_auto_select || false,
position: prev.position, // 强制保留默认/用户选择,不从草稿配置同步位置
font: tc.font_preset || prev.font,
size: tc.font_size || prev.size,
color: tc.font_color || prev.color,
}))
}
if (config.segments && config.segments.length > 0) {
const assetIds = config.segments
@@ -212,8 +76,15 @@ export function usePlanConfigLoader({
if (plan.name) setTitleSettings((prev: TitleSettings) => ({ ...prev, title: plan.name }))
const cfg = plan.config
if (cfg?.title_config) {
const tc2 = cfg.title_config as unknown as TitleConfig & Record<string, unknown>
setTitleSettings((prev: TitleSettings) => mapTitleCfgToSettings(prev, tc2))
setTitleSettings((prev: TitleSettings) => ({
...prev,
aiAutoSelect: cfg.title_config!.ai_auto_select,
title: cfg.title_config!.content || prev.title,
position: prev.position, // 强制保留默认/用户选择,不从远程草稿同步位置
font: cfg.title_config!.font_preset || prev.font,
size: cfg.title_config!.font_size || prev.size,
color: cfg.title_config!.font_color || prev.color,
}))
}
if (cfg?.cover_config) {
const cc = cfg.cover_config as CoverConfig
@@ -208,7 +208,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
script_id: props.selectedScript.id,
tts_voice_id: props.ttsVoiceId || undefined,
tts_voice_source: props.ttsVoiceSource || undefined,
tts_style: props.ttsStyle || undefined,
}
: {}),
dedup_enabled: dedupEnabled,
@@ -241,91 +240,8 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
}
: {}),
bold: props.titleSettings.bold,
italic: props.titleSettings.italic,
stroke: props.titleSettings.stroke
? {
enabled: true,
width: props.titleSettings.strokeWidth ?? 4,
color: props.titleSettings.strokeColor ?? "#000000",
}
: { enabled: false },
shadow: props.titleSettings.shadow
? {
enabled: true,
offset_x: props.titleSettings.shadowOffsetX ?? 2,
offset_y: props.titleSettings.shadowOffsetY ?? 2,
blur: props.titleSettings.shadowBlur ?? 4,
color: props.titleSettings.shadowColor ?? "rgba(0,0,0,0.8)",
}
: { enabled: false },
line_height: props.titleSettings.lineHeight ?? 1.2,
margin_top: props.titleSettings.marginTop ?? 24,
max_chars_per_line: props.titleSettings.maxCharsPerLine ?? 0,
...(props.titleSettings.bgEnabled
? {
background: {
enabled: true,
color: props.titleSettings.bgColor,
padding: props.titleSettings.bgPadding,
radius: props.titleSettings.bgRadius,
},
}
: { background: { enabled: false } }),
line_overrides: (props.titleSettings.lineOverrides ?? []).map((lo) => ({
line_index: lo.line_index,
text: lo.text,
size: lo.size,
color: lo.color,
bold: lo.bold,
italic: lo.italic,
stroke: lo.stroke,
highlights: lo.highlights?.map((h) => ({
word: h.word,
color: h.color,
bold: h.bold,
scale: h.scale,
})),
})),
...(props.titleSettings.coverTitle
? {
cover_title_config: {
title: props.titleSettings.coverTitle.title,
font: props.titleSettings.coverTitle.font,
font_size: props.titleSettings.coverTitle.size,
font_color: props.titleSettings.coverTitle.color,
bold: props.titleSettings.coverTitle.bold,
italic: props.titleSettings.coverTitle.italic,
position: props.titleSettings.coverTitle.position,
stroke: props.titleSettings.coverTitle.stroke
? {
enabled: true,
width: props.titleSettings.coverTitle.strokeWidth ?? 4,
color: props.titleSettings.coverTitle.strokeColor ?? "#000000",
}
: { enabled: false },
shadow: props.titleSettings.coverTitle.shadow
? {
enabled: true,
offset_x: props.titleSettings.coverTitle.shadowOffsetX ?? 2,
offset_y: props.titleSettings.coverTitle.shadowOffsetY ?? 2,
blur: props.titleSettings.coverTitle.shadowBlur ?? 4,
color:
props.titleSettings.coverTitle.shadowColor ?? "rgba(0,0,0,0.8)",
}
: { enabled: false },
...(props.titleSettings.coverTitle.bgEnabled
? {
background: {
enabled: true,
color: props.titleSettings.coverTitle.bgColor,
padding: props.titleSettings.coverTitle.bgPadding,
radius: props.titleSettings.coverTitle.bgRadius,
},
}
: { background: { enabled: false } }),
},
}
: {}),
stroke: props.titleSettings.stroke,
shadow: props.titleSettings.shadow,
},
}
: {}),
@@ -1,6 +1,5 @@
import { useCallback, useMemo } from "react"
import { TITLE_PRESETS } from "../../constants"
import { TITLE_PRESETS as NEW_TITLE_PRESETS } from "@/components/title/constants"
import type { TitleSettings } from "../../types"
interface UseTitleStyleUpdatersOptions {
@@ -98,45 +97,26 @@ export function useTitleStyleUpdaters({
onTitleSettingsChange({ ...titleSettings, shadow: !titleSettings.shadow })
}, [titleSettings, onTitleSettingsChange])
/** 应用预设(支持新预设细粒度字段) */
/** 应用预设:只覆盖 color/bold/italic/stroke/shadow,不改变字号 */
const applyPreset = useCallback(
(presetKey: string) => {
// 优先匹配新预设(10个爆款预设),fallback 旧预设
const newPreset = NEW_TITLE_PRESETS.find((p) => p.key === presetKey)
const oldPreset = TITLE_PRESETS.find((p) => p.key === presetKey)
if (newPreset) {
onTitleSettingsChange({
...titleSettings,
...(newPreset.style as Partial<TitleSettings>),
// 清除逐行覆盖
lineOverrides: [],
})
return
}
if (!oldPreset) return
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
if (!preset) return
onTitleSettingsChange({
...titleSettings,
color: oldPreset.style.color,
bold: oldPreset.style.bold,
italic: oldPreset.style.italic,
stroke: oldPreset.style.stroke,
shadow: oldPreset.style.shadow,
color: preset.style.color,
bold: preset.style.bold,
italic: preset.style.italic,
stroke: preset.style.stroke,
shadow: preset.style.shadow,
})
},
[titleSettings, onTitleSettingsChange],
)
/** 通用字段更新(patch */
const updateStyle = useCallback(
(patch: Partial<TitleSettings>) => {
onTitleSettingsChange({ ...titleSettings, ...patch })
},
[titleSettings, onTitleSettingsChange],
)
return {
activePreset,
titlePresets: NEW_TITLE_PRESETS,
titlePresets: TITLE_PRESETS,
updateTitle,
toggleAiAutoSelect,
updatePosition,
@@ -149,6 +129,5 @@ export function useTitleStyleUpdaters({
toggleStroke,
toggleShadow,
applyPreset,
updateStyle,
}
}
+25 -83
View File
@@ -3,9 +3,8 @@
*/
import type { AssetItem } from "@/api/assets"
import type { TitleLineOverride } from "@/components/title/types"
/* ── 标题设置(#2001 升级:新增描边/阴影/背景/逐行/封面独立样式/排版字段) ── */
/* ── 标题设置 ── */
export interface TitleSettings {
aiAutoSelect: boolean
title: string
@@ -20,58 +19,6 @@ export interface TitleSettings {
/** 自由位置坐标(PlayRes 像素),仅当 position="custom" 时有效 */
posX: number | null
posY: number | null
/* ── 排版(P0 ── */
/** 行距倍数,默认 1.2 */
lineHeight: number
/** 顶部边距(position=toppx @720p */
marginTop: number
/** 每行最大字符数(4-20),0=不自动换行 */
maxCharsPerLine: number
/* ── 描边参数化(P0) ── */
strokeWidth: number
strokeColor: string
/* ── 阴影参数化(P1) ── */
shadowOffsetX: number
shadowOffsetY: number
shadowBlur: number
shadowColor: string
/* ── 背景色块(P1) ── */
bgEnabled: boolean
bgColor: string
bgPadding: number
bgRadius: number
/* ── 逐行独立样式(P1) ── */
lineOverrides: TitleLineOverride[]
/* ── 封面独立标题(P1):null=沿用主标题 ── */
coverTitle: null | {
title?: string
font?: string
size?: number
color?: string
bold?: boolean
italic?: boolean
position?: string
stroke?: boolean
strokeWidth?: number
strokeColor?: string
shadow?: boolean
shadowOffsetX?: number
shadowOffsetY?: number
shadowBlur?: number
shadowColor?: string
bgEnabled?: boolean
bgColor?: string
bgPadding?: number
bgRadius?: number
lineHeight?: number
maxCharsPerLine?: number
}
}
/* ── 智能匹配结果 ── */
@@ -102,33 +49,28 @@ export interface StepDef {
label: string
}
/** 旧版 TitleSettings 的默认值字段(P0/P1 新字段补齐默认值) */
export const DEFAULT_TITLE_SETTINGS_FULL: TitleSettings = {
aiAutoSelect: false,
title: "",
position: "top",
font: "思源黑体",
size: 28,
bold: true,
italic: false,
stroke: true,
shadow: false,
color: "#ffffff",
posX: null,
posY: null,
lineHeight: 1.2,
marginTop: 24,
maxCharsPerLine: 0,
strokeWidth: 4,
strokeColor: "#000000",
shadowOffsetX: 2,
shadowOffsetY: 2,
shadowBlur: 4,
shadowColor: "rgba(0,0,0,0.8)",
bgEnabled: false,
bgColor: "rgba(0,0,0,0.5)",
bgPadding: 12,
bgRadius: 8,
lineOverrides: [],
coverTitle: null,
/* ── 标题预设样式 ── */
export interface TitlePresetStyle {
size: number
color: string
bold: boolean
italic: boolean
stroke: boolean
shadow: boolean
}
export interface TitlePreset {
key: string
label: string
style: TitlePresetStyle
previewStyle: Record<string, string | number>
}
/* ── 生成结果视频 ── */
export interface GeneratedVideoResult {
id: string
url: string
thumbnail: string
duration: number
title: string
}
+88 -101
View File
@@ -39,7 +39,6 @@ import { getDiscountPriceCents } from "@/api/points/types"
import type { SubscriptionPlan } from "@/api/subscription/types"
import { PLAN_LABEL, BILLING_CYCLE_LABEL } from "@/api/subscription/types"
import "./Plans.css"
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
const { Title, Text, Paragraph } = Typography
@@ -250,23 +249,17 @@ const Plans: React.FC = () => {
return (
<div className="xx-plans-page">
<PageHead
title={ENABLE_CREDIT_SYSTEM ? "会员与积分" : "会员订阅"}
description={
ENABLE_CREDIT_SYSTEM
? "开通会员解锁全部功能,按需充值积分灵活使用 AI 能力"
: "开通会员解锁全部功能"
}
title="会员与积分"
description="开通会员解锁全部功能,按需充值积分灵活使用 AI 能力"
actions={
ENABLE_CREDIT_SYSTEM ? (
<Space>
<Button
icon={<ThunderboltOutlined />}
onClick={() => navigate("/app/points/transactions")}
>
</Button>
</Space>
) : null
<Space>
<Button
icon={<ThunderboltOutlined />}
onClick={() => navigate("/app/points/transactions")}
>
</Button>
</Space>
}
/>
@@ -303,15 +296,13 @@ const Plans: React.FC = () => {
)}
</div>
</div>
{ENABLE_CREDIT_SYSTEM && (
<div>
<Text type="secondary"></Text>
<div className="xx-current-balance">
<ThunderboltOutlined style={{ color: "#8b5cf6" }} />
<span className="xx-current-balance-val">{bal}</span>
</div>
<div>
<Text type="secondary"></Text>
<div className="xx-current-balance">
<ThunderboltOutlined style={{ color: "#8b5cf6" }} />
<span className="xx-current-balance-val">{bal}</span>
</div>
)}
</div>
{!isMember && freeLimit > 0 && (
<div>
<Text type="secondary"></Text>
@@ -328,20 +319,18 @@ const Plans: React.FC = () => {
)}
</Space>
</Col>
{ENABLE_CREDIT_SYSTEM && (
<Col>
<Button
type="primary"
icon={<ThunderboltOutlined />}
onClick={() => {
const el = document.getElementById("points-packages")
el?.scrollIntoView({ behavior: "smooth" })
}}
>
</Button>
</Col>
)}
<Col>
<Button
type="primary"
icon={<ThunderboltOutlined />}
onClick={() => {
const el = document.getElementById("points-packages")
el?.scrollIntoView({ behavior: "smooth" })
}}
>
</Button>
</Col>
</Row>
</Card>
@@ -472,71 +461,69 @@ const Plans: React.FC = () => {
</Col>
</Row>
{/* 积分充值(积分系统关闭时隐藏,代码保留不删除) */}
{ENABLE_CREDIT_SYSTEM && (
<div id="points-packages">
<Title level={4} style={{ marginTop: 40 }}>
<ThunderboltOutlined style={{ color: "#8b5cf6", marginRight: 8 }} />
<Tooltip title="积分永久有效,可用于所有 AI 功能;付费会员享折扣">
<Text type="secondary" style={{ fontSize: 13, marginLeft: 8, fontWeight: "normal" }}>
</Text>
</Tooltip>
</Title>
{/* 积分充值 */}
<div id="points-packages">
<Title level={4} style={{ marginTop: 40 }}>
<ThunderboltOutlined style={{ color: "#8b5cf6", marginRight: 8 }} />
<Tooltip title="积分永久有效,可用于所有 AI 功能;付费会员享折扣">
<Text type="secondary" style={{ fontSize: 13, marginLeft: 8, fontWeight: "normal" }}>
</Text>
</Tooltip>
</Title>
<Row gutter={[16, 16]}>
{packages.map((pkg) => {
const priceCents = getDiscountPriceCents(pkg, userDiscount)
const originalCents = pkg.price_cents
const discount =
priceCents < originalCents ? Math.round((1 - priceCents / originalCents) * 100) : 0
const unit = priceCents / 100 / pkg.points
const isHot = pkg.unit_price < 0.1
return (
<Col xs={24} sm={8} key={pkg.code}>
<Card
className={`xx-pkg-card ${discount > 0 ? "has-discount" : ""} ${isHot ? "recommended" : ""}`}
hoverable
>
{isHot && <div className="xx-pkg-badge"></div>}
<Row gutter={[16, 16]}>
{packages.map((pkg) => {
const priceCents = getDiscountPriceCents(pkg, userDiscount)
const originalCents = pkg.price_cents
const discount =
priceCents < originalCents ? Math.round((1 - priceCents / originalCents) * 100) : 0
const unit = priceCents / 100 / pkg.points
const isHot = pkg.unit_price < 0.1
return (
<Col xs={24} sm={8} key={pkg.code}>
<Card
className={`xx-pkg-card ${discount > 0 ? "has-discount" : ""} ${isHot ? "recommended" : ""}`}
hoverable
>
{isHot && <div className="xx-pkg-badge"></div>}
{discount > 0 && (
<Tag color="gold" className="xx-pkg-discount">
{Math.round((priceCents / originalCents) * 10) / 1}
</Tag>
)}
<div className="xx-pkg-name">{pkg.name}</div>
<div className="xx-pkg-points">
<ThunderboltOutlined /> {pkg.points.toLocaleString()}
</div>
<div className="xx-pkg-price">
<span className="currency">¥</span>
<span className="amount">
{(priceCents / 100)
.toFixed(priceCents % 100 === 0 ? 0 : 1)
.replace(/\.0$/, "")}
</span>
{discount > 0 && (
<Tag color="gold" className="xx-pkg-discount">
{Math.round((priceCents / originalCents) * 10) / 1}
</Tag>
<span className="xx-pkg-origin">¥{(originalCents / 100).toFixed(0)}</span>
)}
<div className="xx-pkg-name">{pkg.name}</div>
<div className="xx-pkg-points">
<ThunderboltOutlined /> {pkg.points.toLocaleString()}
</div>
<div className="xx-pkg-price">
<span className="currency">¥</span>
<span className="amount">
{(priceCents / 100)
.toFixed(priceCents % 100 === 0 ? 0 : 1)
.replace(/\.0$/, "")}
</span>
{discount > 0 && (
<span className="xx-pkg-origin">¥{(originalCents / 100).toFixed(0)}</span>
)}
</div>
<div className="xx-pkg-unit">¥{unit.toFixed(3)}/</div>
<Button
block
type={isHot ? "primary" : "default"}
loading={buying === pkg.code}
onClick={() => handleBuyPoints(pkg)}
style={{ marginTop: 12 }}
>
</Button>
</Card>
</Col>
)
})}
</Row>
</div>
)}
</div>
<div className="xx-pkg-unit">¥{unit.toFixed(3)}/</div>
<Button
block
type={isHot ? "primary" : "default"}
loading={buying === pkg.code}
onClick={() => handleBuyPoints(pkg)}
style={{ marginTop: 12 }}
>
</Button>
</Card>
</Col>
)
})}
</Row>
</div>
</div>
)
}
@@ -98,7 +98,6 @@ const VoiceMaterialLibrary: React.FC = () => {
ttsText,
ttsVoiceId,
ttsSpeed,
ttsStyle,
ttsStatus,
ttsAudioUrl,
ttsError,
@@ -108,7 +107,6 @@ const VoiceMaterialLibrary: React.FC = () => {
setTtsText,
setTtsVoiceId,
setTtsSpeed,
setTtsStyle,
handleTtsSynthesize,
handleTtsSave,
handleTtsClose,
@@ -317,7 +315,6 @@ const VoiceMaterialLibrary: React.FC = () => {
text={ttsText}
voiceId={ttsVoiceId}
speed={ttsSpeed}
style={ttsStyle}
status={ttsStatus}
audioUrl={ttsAudioUrl ?? ""}
error={ttsError ?? ""}
@@ -327,7 +324,6 @@ const VoiceMaterialLibrary: React.FC = () => {
onTextChange={setTtsText}
onVoiceChange={setTtsVoiceId}
onSpeedChange={setTtsSpeed}
onStyleChange={setTtsStyle}
onSynthesize={handleTtsSynthesize}
onSave={handleTtsSave}
/>
@@ -1,8 +1,6 @@
import React from "react"
import { RobotOutlined, LoadingOutlined, PlusOutlined } from "@ant-design/icons"
import { Button } from "@/components/ui"
import TtsStyleSelector from "@/components/voice/TtsStyleSelector"
import type { TtsStyle } from "@/api/tts/styles"
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
@@ -22,8 +20,6 @@ interface TtsModalProps {
text: string
voiceId: string
speed: number
style: TtsStyle
onStyleChange: (style: TtsStyle) => void
status: TtsStatus
audioUrl: string
error: string
@@ -43,8 +39,6 @@ const TtsModal: React.FC<TtsModalProps> = ({
text,
voiceId,
speed,
style,
onStyleChange,
status,
audioUrl,
error,
@@ -149,9 +143,6 @@ const TtsModal: React.FC<TtsModalProps> = ({
/>
</div>
{/* 配音风格 */}
<TtsStyleSelector value={style} onChange={onStyleChange} compact />
{/* 合成按钮 */}
<Button
buttonType="primary"
@@ -2,7 +2,6 @@ import { useState, useRef, useCallback, useEffect } from "react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
import { DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles"
import { fetchPresetVoices, type PresetVoiceItem } from "@/api/voices"
import { getVoiceClonesWithTotal, toVoiceClone } from "@/api/voice-clone"
@@ -19,7 +18,6 @@ export function useTtsSynthesize() {
const [ttsText, setTtsText] = useState("")
const [ttsVoiceId, setTtsVoiceId] = useState<string>("")
const [ttsSpeed, setTtsSpeed] = useState(1.0)
const [ttsStyle, setTtsStyle] = useState<TtsStyle>(DEFAULT_TTS_STYLE)
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
const [ttsStatus, setTtsStatus] = useState<TtsStatus>("idle")
const [ttsAudioUrl, setTtsAudioUrl] = useState<string | null>(null)
@@ -61,7 +59,6 @@ export function useTtsSynthesize() {
text: ttsText.trim(),
voice_id: ttsVoiceId || undefined,
speed: ttsSpeed,
style: ttsStyle,
})
setTtsJobId(resp.job_id)
@@ -92,7 +89,7 @@ export function useTtsSynthesize() {
setTtsStatus("error")
setTtsError(msg)
}
}, [ttsText, ttsVoiceId, ttsSpeed, ttsStyle])
}, [ttsText, ttsVoiceId, ttsSpeed])
/** 保存 TTS 结果到素材库 */
const handleTtsSave = useCallback(async () => {
@@ -134,7 +131,6 @@ export function useTtsSynthesize() {
ttsText,
ttsVoiceId,
ttsSpeed,
ttsStyle,
ttsJobId,
ttsStatus,
ttsAudioUrl,
@@ -145,7 +141,6 @@ export function useTtsSynthesize() {
setTtsText,
setTtsVoiceId,
setTtsSpeed,
setTtsStyle,
handleTtsSynthesize,
handleTtsSave,
handleTtsClose,
@@ -133,7 +133,6 @@ const VoiceLibrary: React.FC = () => {
ttsVoiceId,
ttsSpeed,
ttsEmotion,
ttsStyle,
ttsLanguage,
ttsStatus,
ttsAudioUrl,
@@ -141,7 +140,6 @@ const VoiceLibrary: React.FC = () => {
setTtsText,
setTtsSpeed,
setTtsEmotion,
setTtsStyle,
setTtsLanguage,
setTtsOpen,
handleVoiceChange,
@@ -370,7 +368,6 @@ const VoiceLibrary: React.FC = () => {
ttsVoiceId={ttsVoiceId}
ttsSpeed={ttsSpeed}
ttsEmotion={ttsEmotion}
ttsStyle={ttsStyle}
ttsLanguage={ttsLanguage}
ttsStatus={ttsStatus}
ttsAudioUrl={ttsAudioUrl}
@@ -384,7 +381,6 @@ const VoiceLibrary: React.FC = () => {
onTtsVoiceChange={handleVoiceChange}
onTtsSpeedChange={setTtsSpeed}
onTtsEmotionChange={setTtsEmotion}
onTtsStyleChange={setTtsStyle}
onTtsLanguageChange={setTtsLanguage}
onTtsSynthesize={handleTtsSynthesize}
onTtsSave={handleTtsSave}
@@ -9,7 +9,6 @@ import LanguageControl from "./tts-modal/LanguageControl"
import SynthesizeButton from "./tts-modal/SynthesizeButton"
import ErrorAlert from "./tts-modal/ErrorAlert"
import ResultPanel from "./tts-modal/ResultPanel"
import TtsStyleSelector from "@/components/voice/TtsStyleSelector"
import { PRESET_TTS_LANGUAGE_OPTIONS, CLONE_TTS_LANGUAGE_OPTIONS } from "./tts-modal/constants"
/** AI 配音弹窗 */
@@ -19,7 +18,6 @@ const TtsModal: React.FC<TtsModalProps> = ({
ttsVoiceId,
ttsSpeed,
ttsEmotion,
ttsStyle,
ttsLanguage,
ttsStatus,
ttsAudioUrl,
@@ -31,7 +29,6 @@ const TtsModal: React.FC<TtsModalProps> = ({
onVoiceChange,
onSpeedChange,
onEmotionChange,
onStyleChange,
onLanguageChange,
onSynthesize,
onSave,
@@ -75,7 +72,6 @@ const TtsModal: React.FC<TtsModalProps> = ({
/>
</div>
<SpeedControl speed={ttsSpeed} onChange={onSpeedChange} />
<TtsStyleSelector value={ttsStyle} onChange={onStyleChange} compact />
<SynthesizeButton status={ttsStatus} text={ttsText} onClick={onSynthesize} />
{ttsError && <ErrorAlert error={ttsError} />}
{ttsStatus === "done" && ttsAudioUrl && (
@@ -7,7 +7,6 @@ import type { VoiceClone } from "@/api/voice-clone"
import type { TtsStatus } from "./TtsModal"
import type { TtsClonedVoiceOption } from "./tts-modal/VoiceSelector"
import type { TtsEmotion, TtsLanguage } from "./tts-modal/constants"
import type { TtsStyle } from "@/api/tts/styles"
import CloneModal from "@/components/voice/CloneModal"
import CloneDetailModal from "./CloneDetailModal"
import UploadVoiceModal from "./UploadVoiceModal"
@@ -45,7 +44,6 @@ export interface VoiceModalsProps {
ttsVoiceId: string
ttsSpeed: number
ttsEmotion: TtsEmotion
ttsStyle: TtsStyle
ttsLanguage: TtsLanguage
ttsStatus: TtsStatus
ttsAudioUrl: string | null
@@ -58,7 +56,6 @@ export interface VoiceModalsProps {
onTtsVoiceChange: (id: string) => void
onTtsSpeedChange: (speed: number) => void
onTtsEmotionChange: (emotion: TtsEmotion) => void
onTtsStyleChange: (style: TtsStyle) => void
onTtsLanguageChange: (language: TtsLanguage) => void
onTtsSynthesize: () => void
onTtsSave: () => void
@@ -89,7 +86,6 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
ttsVoiceId,
ttsSpeed,
ttsEmotion,
ttsStyle,
ttsLanguage,
ttsStatus,
ttsAudioUrl,
@@ -101,7 +97,6 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
onTtsVoiceChange,
onTtsSpeedChange,
onTtsEmotionChange,
onTtsStyleChange,
onTtsLanguageChange,
onTtsSynthesize,
onTtsSave,
@@ -144,7 +139,6 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
ttsVoiceId={ttsVoiceId}
ttsSpeed={ttsSpeed}
ttsEmotion={ttsEmotion}
ttsStyle={ttsStyle}
ttsLanguage={ttsLanguage}
ttsStatus={ttsStatus}
ttsAudioUrl={ttsAudioUrl}
@@ -156,7 +150,6 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
onVoiceChange={onTtsVoiceChange}
onSpeedChange={onTtsSpeedChange}
onEmotionChange={onTtsEmotionChange}
onStyleChange={onTtsStyleChange}
onLanguageChange={onTtsLanguageChange}
onSynthesize={onTtsSynthesize}
onSave={onTtsSave}
@@ -1,7 +1,6 @@
import { type PresetVoiceDisplay } from "@/pages/voices/types"
import type { TtsClonedVoiceOption } from "./VoiceSelector"
import type { TtsEmotion, TtsLanguage } from "./constants"
import type { TtsStyle } from "@/api/tts/styles"
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
@@ -11,7 +10,6 @@ export interface TtsModalProps {
ttsVoiceId: string
ttsSpeed: number
ttsEmotion: TtsEmotion
ttsStyle: TtsStyle
ttsLanguage: TtsLanguage
ttsStatus: TtsStatus
ttsAudioUrl: string | null
@@ -24,7 +22,6 @@ export interface TtsModalProps {
onVoiceChange: (voiceId: string) => void
onSpeedChange: (speed: number) => void
onEmotionChange: (emotion: TtsEmotion) => void
onStyleChange: (style: TtsStyle) => void
onLanguageChange: (language: TtsLanguage) => void
onSynthesize: () => void
onSave: () => void
@@ -11,7 +11,6 @@ import {
type TtsEmotion,
type TtsLanguage,
} from "../components/tts-modal/constants"
import { DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles"
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
@@ -43,7 +42,6 @@ export function useTtsSynthesize({
const [ttsVoiceId, setTtsVoiceId] = useState<string>("")
const [ttsSpeed, setTtsSpeed] = useState(1.0)
const [ttsEmotion, setTtsEmotion] = useState<TtsEmotion>(DEFAULT_TTS_EMOTION)
const [ttsStyle, setTtsStyle] = useState<TtsStyle>(DEFAULT_TTS_STYLE)
const [ttsLanguage, setTtsLanguage] = useState<TtsLanguage>(DEFAULT_TTS_LANGUAGE)
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
const [ttsStatus, setTtsStatus] = useState<TtsStatus>("idle")
@@ -85,7 +83,6 @@ export function useTtsSynthesize({
voice_id: ttsVoiceId || undefined,
speed: ttsSpeed,
emotion: ttsEmotion,
style: ttsStyle,
language: effectiveLang,
})
setTtsJobId(resp.job_id)
@@ -117,7 +114,7 @@ export function useTtsSynthesize({
setTtsStatus("error")
setTtsError(msg)
}
}, [ttsText, ttsVoiceId, ttsSpeed, ttsEmotion, ttsStyle, ttsLanguage, clonedVoices])
}, [ttsText, ttsVoiceId, ttsSpeed, ttsEmotion, ttsLanguage, clonedVoices])
/** 保存 TTS 结果到素材库 */
const handleTtsSave = useCallback(async () => {
@@ -140,7 +137,6 @@ export function useTtsSynthesize({
setTtsVoiceId("")
setTtsSpeed(1.0)
setTtsEmotion(DEFAULT_TTS_EMOTION)
setTtsStyle(DEFAULT_TTS_STYLE)
setTtsLanguage(DEFAULT_TTS_LANGUAGE)
setTtsStatus("idle")
setTtsAudioUrl(null)
@@ -172,7 +168,6 @@ export function useTtsSynthesize({
ttsVoiceId,
ttsSpeed,
ttsEmotion,
ttsStyle,
ttsLanguage,
ttsJobId,
ttsStatus,
@@ -186,7 +181,6 @@ export function useTtsSynthesize({
setTtsVoiceId,
setTtsSpeed,
setTtsEmotion,
setTtsStyle,
setTtsLanguage,
setTtsOpen,
// 覆写 onVoiceChange(带语言回退)
+4 -20
View File
@@ -8,7 +8,6 @@
* - subscription: GET /subscription/currentplan_id + billing_cycle
*/
import { create } from "zustand"
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
import { getPointsBalance, getPointsRules, getDailyUsage, getMembership } from "@/api/points"
import { getCurrentSubscription } from "@/api/subscription"
import type {
@@ -50,29 +49,14 @@ export const usePointsStore = create<PointsState>((set, get) => ({
init: async () => {
// 已加载过不重复拉取
// 积分系统关闭时:只要 subscription/membership 已有值就跳过;开启时需 balance+rules+subscription 齐了才跳过
if (ENABLE_CREDIT_SYSTEM) {
if (get().balance && get().rules && get().subscription) return
} else {
if (get().subscription && get().membership) return
}
if (get().balance && get().rules && get().subscription) return
set({ loading: true, error: null })
try {
// 积分系统关闭时不拉取余额/规则/每日额度,但仍拉会员/订阅用于 VIP 标识展示
const balancePromise = ENABLE_CREDIT_SYSTEM
? getPointsBalance().catch(() => null)
: Promise.resolve(null)
const rulesPromise = ENABLE_CREDIT_SYSTEM
? getPointsRules().catch(() => null)
: Promise.resolve(null)
const dailyUsagePromise = ENABLE_CREDIT_SYSTEM
? getDailyUsage().catch(() => null)
: Promise.resolve(null)
const [balance, rules, subscription, dailyUsage, membership] = await Promise.all([
balancePromise,
rulesPromise,
getPointsBalance().catch(() => null),
getPointsRules().catch(() => null),
getCurrentSubscription().catch(() => null),
dailyUsagePromise,
getDailyUsage().catch(() => null),
getMembership().catch(() => null),
])
set({
@@ -1,176 +0,0 @@
"""智能降重微变换纯逻辑模块 — #1970 PR2.
所有函数均为纯函数:不调用 FFmpeg、不读写文件,只负责按可复现种子
生成每个片段 / 整片的微变换参数与 filter_complex 片段。
6 个维度:
1. hflip 水平翻转(每片段 50%,有字幕/文字的片段不翻转)
2. 播放速度 0.97~1.03x(视频 setpts + 音频 atempo
3. 亮度 ±2%eq=brightness
4. 对比度 ±2%eq=contrast
5. 饱和度 ±2%eq=saturation
6. BGM 起始偏移 2~8 秒(音频 atrim 起点)
随机种子 = hash(task_id + video_index) % 10000,保证同一任务同一视频
可复现;dedup_enabled=False 时不生成本模块任何输出。
"""
from __future__ import annotations
import random
from dataclasses import dataclass, field
# ── 常量(与需求文档 §2 对齐)──────────────────────────────────────────────────
SPEED_MIN = 0.97
SPEED_MAX = 1.03
COLOR_DELTA = 0.02
HFLIP_PROBABILITY = 0.5
BGM_OFFSET_MIN = 2.0
BGM_OFFSET_MAX = 8.0
SEED_MODULO = 10000
def make_video_seed(task_id: str, video_index: int) -> int:
"""生成视频级可复现种子:hash(task_id+video_index) % 10000。
用 sha256 而非内置 hash():内置 hash 对字符串带进程级随机盐(PYTHONHASHSEED),
跨进程不可复现。结果映射到 0~9999。
"""
import hashlib
raw = f"{task_id or ''}:{int(video_index)}"
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
return int(digest[:8], 16) % SEED_MODULO
@dataclass(slots=True)
class ClipMicroTransform:
"""单个片段的微变换参数。"""
clip_index: int
hflip: bool = False
speed: float = 1.0
brightness: float = 0.0
contrast: float = 1.0
saturation: float = 1.0
has_text: bool = False
def video_filter_suffix(self) -> str:
"""返回追加在片段视频处理链上的 filter 后缀(无末尾标签)。
顺序:trim/setpts(已有)→ 调速 setpts → hflip → eq → format。
调速的 setpts 必须位于 trim 之后;hflip/eq 在缩放之后即可,
concat_engine 按「调速 → hflip → eq」顺序拼接到 scale/fps 之前的
trim 之后、scale 之后均可,这里只产出独立步骤、由引擎决定插入点。
"""
parts: list[str] = []
# 速度:setpts=PTS/speedspeed>1 时画面加速,时间戳变小)
if abs(self.speed - 1.0) > 1e-4:
parts.append(f"setpts=PTS/{self.speed:.5f}")
# 水平翻转:有文字/字幕片段不翻转
if self.hflip and not self.has_text:
parts.append("hflip")
# 色彩微调:brightness 取值 -1~1(±0.02),contrast/saturation 围绕 1.0
if abs(self.brightness) > 1e-4 or abs(self.contrast - 1.0) > 1e-4 or abs(self.saturation - 1.0) > 1e-4:
parts.append(
f"eq=brightness={self.brightness:+.4f}:"
f"contrast={self.contrast:.4f}:saturation={self.saturation:.4f}"
)
return ",".join(parts)
def audio_filter_suffix(self) -> str:
"""返回片段音频链上的调速 filter(atempo),无调速时返回空串。"""
if abs(self.speed - 1.0) <= 1e-4:
return ""
return f"atempo={self.speed:.5f}"
@dataclass(slots=True)
class VideoMicroTransformPlan:
"""一个成片视频的全部微变换参数。"""
task_id: str
video_index: int
seed: int
clips: list[ClipMicroTransform] = field(default_factory=list)
bgm_start_offset: float = 0.0
def clip(self, index: int) -> ClipMicroTransform | None:
for c in self.clips:
if c.clip_index == index:
return c
return None
def _draw_speed(rng: random.Random) -> float:
return round(rng.uniform(SPEED_MIN, SPEED_MAX), 5)
def _draw_signed_delta(rng: random.Random) -> float:
return round(rng.uniform(-COLOR_DELTA, COLOR_DELTA), 4)
def build_micro_transform_plan(
task_id: str,
video_index: int,
clip_count: int,
*,
clip_has_text: list[bool] | None = None,
enable_bgm_offset: bool = True,
) -> VideoMicroTransformPlan:
"""按可复现种子生成整片的微变换计划。
Args:
task_id: 生成任务 ID(种子输入)
video_index: 视频在批次中的序号(0 起)
clip_count: 片段数量
clip_has_text: 每个片段是否有字幕/文字轨道(True 的片段不翻转);
None 时按 P1 约定视为无可靠文字检测——保守起见 hflip 一律关闭
enable_bgm_offset: 是否生成 BGM 起始偏移(无 BGM 时调用方可忽略该值)
Returns:
VideoMicroTransformPlan
"""
seed = make_video_seed(task_id, video_index)
rng = random.Random(seed)
# P1 字幕检测约定:无法判断片段是否有文字时,一律不翻转(宁可少一个维度也不误翻字幕)
safe_has_text = clip_has_text if clip_has_text is not None else [True] * max(clip_count, 0)
clips: list[ClipMicroTransform] = []
for i in range(max(clip_count, 0)):
has_text = bool(safe_has_text[i]) if i < len(safe_has_text) else True
do_hflip = (not has_text) and rng.random() < HFLIP_PROBABILITY
clips.append(
ClipMicroTransform(
clip_index=i,
hflip=do_hflip,
speed=_draw_speed(rng),
brightness=_draw_signed_delta(rng),
contrast=round(1.0 + _draw_signed_delta(rng), 4),
saturation=round(1.0 + _draw_signed_delta(rng), 4),
has_text=has_text,
)
)
bgm_offset = rng.uniform(BGM_OFFSET_MIN, BGM_OFFSET_MAX) if enable_bgm_offset else 0.0
return VideoMicroTransformPlan(
task_id=task_id,
video_index=video_index,
seed=seed,
clips=clips,
bgm_start_offset=round(bgm_offset, 3),
)
def build_bgm_offset_trim(start_offset: float, bgm_duration: float) -> str:
"""生成 BGM 起始偏移的 atrim 片段。
偏移超出 BGM 长度时回退为 0(从头播放),避免空输入。
返回的字符串形如 "atrim=start=3.200,",可拼到 BGM filter chain 最前面;
无需偏移时返回空串。
"""
if start_offset <= 0 or bgm_duration <= 0 or start_offset >= bgm_duration - 0.5:
return ""
return f"atrim=start={start_offset:.3f},"
@@ -493,41 +493,6 @@ class RenderAdapter:
logger.warning("ASR 服务初始化失败,自动字幕将不可用: %s", e)
return None
def _resolve_clip_has_text(self, clips: list[Any]) -> list[bool] | None:
"""#1970:按源视频片段顺序解析 atom_clip.ai_tags.has_text。
顺序与 UnifiedRenderService 的「非 audio 源片段」口径一致。
仅当 atom_clip 存在 ai_tags 字典且 has_text 显式为 False 时标记为
无文字(允许 hflip);atom_clip_id 缺失、ai_tags 未生成、has_text 为
true/null/非布尔值时一律按有文字处理(保守不翻转)。
查询失败时返回 None,渲染层回退到全保守路径。
"""
video_clips = [c for c in clips if getattr(c, "clip_type", "main") != "audio"]
atom_ids: list[str] = []
seen: set[str] = set()
for c in video_clips:
atom_id = getattr(c, "atom_clip_id", "") or ""
if atom_id and atom_id not in seen:
seen.add(atom_id)
atom_ids.append(atom_id)
if not atom_ids:
return None
try:
from packages.adapters.sqlalchemy_impl.asset_atom_clip_repository import (
SQLAlchemyAssetAtomClipRepository,
)
atom_clips = SQLAlchemyAssetAtomClipRepository(self._db).find_by_ids(atom_ids)
except Exception as exc:
logger.warning("[render-adapter] atom_clip ai_tags 查询失败,hflip 全量保守处理: %s", exc)
return None
has_text_map: dict[str, bool] = {}
for ac in atom_clips:
ai_tags = getattr(ac, "ai_tags", None)
no_text = isinstance(ai_tags, dict) and ai_tags.get("has_text") is False
has_text_map[ac.id] = not no_text
return [has_text_map.get((getattr(c, "atom_clip_id", "") or ""), True) for c in video_clips]
def _do_render(
self,
plan: Any,
@@ -577,7 +542,6 @@ class RenderAdapter:
)
# 4. 执行统一渲染
clip_has_text = self._resolve_clip_has_text(clips)
render_svc = UnifiedRenderService(
plan=plan,
clips=clips,
@@ -588,7 +552,6 @@ class RenderAdapter:
bgm_path=bgm_path,
asr_service=asr_service,
voiceover_audio_path=voiceover_audio_path,
clip_has_text=clip_has_text,
)
result = render_svc.render()
+2 -9
View File
@@ -98,7 +98,6 @@ def mix_audio(
bgm_path: str | None = None,
bgm_config: dict | None = None,
audio_tracks_config: dict | None = None,
bgm_start_offset: float = 0.0,
) -> Path | None:
"""音频后处理混音.
@@ -158,10 +157,7 @@ def mix_audio(
if bgm_path and bgm_config and isinstance(bgm_config, dict) and bgm_config.get("enabled", False):
from video_processing.bgm_mixer import BGMConfig, build_bgm_only
_bgm_cfg_dict = dict(bgm_config or {})
if bgm_start_offset and not _bgm_cfg_dict.get("audio_offset"):
_bgm_cfg_dict["audio_offset"] = round(float(bgm_start_offset), 3)
bgm_cfg = BGMConfig.from_config_dict(bgm_path, _bgm_cfg_dict)
bgm_cfg = BGMConfig.from_config_dict(bgm_path, bgm_config)
try:
return build_bgm_only(ctx, bgm_cfg, video_duration)
except Exception:
@@ -191,10 +187,7 @@ def mix_audio(
if bgm_path and bgm_config and isinstance(bgm_config, dict) and bgm_config.get("enabled", False):
from video_processing.bgm_mixer import BGMConfig, mix_bgm_with_main
_bgm_cfg_dict = dict(bgm_config or {})
if bgm_start_offset and not _bgm_cfg_dict.get("audio_offset"):
_bgm_cfg_dict["audio_offset"] = round(float(bgm_start_offset), 3)
bgm_cfg = BGMConfig.from_config_dict(bgm_path, _bgm_cfg_dict)
bgm_cfg = BGMConfig.from_config_dict(bgm_path, bgm_config)
try:
# 这里 main_audio 就是 output_path,先有主音频再混 BGM
@@ -155,7 +155,6 @@ class UnifiedRenderService:
asr_service: Any = None, # ASRService 实例,用于自动生成字幕
bgm_path: str | None = None, # BGM 本地文件路径
voiceover_audio_path: str | None = None, # 配音素材库音频本地路径
clip_has_text: list[bool] | None = None, # 源视频片段是否有文字(来自 atom_clip.ai_tags.has_text
):
self.plan = plan
self.clips = clips
@@ -168,98 +167,10 @@ class UnifiedRenderService:
self.asr_service = asr_service
self.bgm_path = bgm_path
self.voiceover_audio_path = voiceover_audio_path
# #1970:片段级文字检测(顺序与非 audio 的源视频片段一致);None 表示无可靠检测,保守不翻转
self._clip_has_text = clip_has_text
self._transition_engine = TransitionEngine(default_duration=transition_duration)
self._speed_engine = SpeedEngine()
self._asr_timeline_cache: Any = None # ASR 字幕结果缓存,避免重复调用
self._asr_timeline_cached = False
# #1970 PR2:片段级微变换计划缓存(懒构建,dedup_enabled=False 时为 None
self._micro_plan_cache: Any = None
self._micro_plan_loaded = False
# ── #1970 PR2 智能降重:片段级微变换 ───────────────────────────────────
def _dedup_enabled(self) -> bool:
"""读取 plan.config.dedup_enabled,缺省视为 True(向后兼容)。"""
cfg = self.plan.config or {}
return bool(cfg.get("dedup_enabled", True))
def _get_micro_transform_plan(self, clip_count: int) -> Any:
"""按 task_id+视频序号构建可复现的片段级微变换计划。
种子 hash(generation_task_id + video_index)%10000,同一任务重渲结果一致。
dedup_enabled=False 时返回 None,调用方不注入任何微变换。
hflip 放开(#1970):clip_has_text 来自 atom_clip.ai_tags.has_text
仅 AI 明确判定无文字的片段可参与 50% 翻转;未打标签 / has_text 为
true/null 或缺位时一律视为有文字,保持保守不翻转。
"""
if self._micro_plan_loaded:
return self._micro_plan_cache
self._micro_plan_loaded = True
if not self._dedup_enabled() or clip_count <= 0:
self._micro_plan_cache = None
return None
try:
from video_processing.micro_transform_pure import build_micro_transform_plan
cfg = self.plan.config or {}
task_id = str(cfg.get("generation_task_id", "") or "")
video_index = int(cfg.get("video_index", 0) or 0)
# self._clip_has_text 顺序与非 audio 源片段一致;
# None(未提供检测,如内存直渲/旧任务)→ 纯函数层按全有文字保守处理;
# 列表短于片段数时缺位片段同样按有文字处理
self._micro_plan_cache = build_micro_transform_plan(
task_id,
video_index,
clip_count,
clip_has_text=self._clip_has_text,
enable_bgm_offset=bool(cfg.get("bgm")),
)
except Exception as e:
logger.warning("[unified-render] 微变换计划构建失败,本次不注入: %s", e)
self._micro_plan_cache = None
return self._micro_plan_cache
@staticmethod
def _apply_micro_transform_video(filters: list[str], mt: Any) -> None:
"""把片段视频微变换就地追加到 filter 链(post-scale 阶段调用)。
顺序:hflip 在 pre-scale 阶段由 _apply_micro_hflip 处理,这里只加
eq 亮度/对比度/饱和度。速度 setpts 与既有 clip speed 相乘(见调用点),
避免出现两条 setpts 互相覆盖。
"""
if mt is None:
return
if abs(mt.brightness) > 1e-4 or abs(mt.contrast - 1.0) > 1e-4 or abs(mt.saturation - 1.0) > 1e-4:
filters.append(
f"eq=brightness={mt.brightness:+.4f}:" f"contrast={mt.contrast:.4f}:saturation={mt.saturation:.4f}"
)
@staticmethod
def _apply_micro_hflip(filters: list[str], mt: Any) -> None:
"""片段级水平翻转(pre-scale 阶段)。P1 有文字/无法判定时 mt.hflip=False。"""
if mt is not None and mt.hflip and not mt.has_text:
filters.append("hflip")
@staticmethod
def _micro_speed_factor(mt: Any) -> float:
"""片段微变换速度因子(0.97~1.03),无计划返回 1.0。"""
if mt is None:
return 1.0
return float(getattr(mt, "speed", 1.0) or 1.0)
def _get_micro_bgm_offset(self) -> float:
"""#1970 PR2:读取本视频 BGM 起始偏移(秒),无 BGM/禁用时为 0。"""
if not self.plan.config:
return 0.0
try:
count = len([c for c in (self.plan.clips or []) if getattr(c, "clip_type", "main") != "audio"])
plan = self._get_micro_transform_plan(count)
if plan:
return round(float(plan.bgm_start_offset or 0.0), 3)
except Exception:
logger.debug("微变换 BGM 偏移读取失败,按 0 处理: plan_id=%s", getattr(self.plan, "id", "?"))
return 0.0
def render(self) -> RenderResult:
"""执行渲染,返回 RenderResult.
@@ -405,9 +316,6 @@ class UnifiedRenderService:
ctx = RenderContext(work_dir=self.work_dir, plan_id=self.plan.id)
from video_processing.bgm_mixer import BGMConfig, mix_bgm_with_main
_bgm_off = self._get_micro_bgm_offset()
if _bgm_off and not (bgm_config or {}).get("audio_offset"):
bgm_config = {**bgm_config, "audio_offset": _bgm_off}
bgm_cfg = BGMConfig.from_config_dict(self.bgm_path, bgm_config)
# 从直通输出中提取音频
main_audio_path = self.work_dir / f"pass_through_audio_{self.plan.id}.aac"
@@ -457,7 +365,6 @@ class UnifiedRenderService:
bgm_path=self.bgm_path,
bgm_config=bgm_config,
audio_tracks_config=audio_tracks_config,
bgm_start_offset=self._get_micro_bgm_offset(),
)
t_audio_end = time.time()
audio_mix_ms = int((t_audio_end - t_audio_start) * 1000)
@@ -1205,28 +1112,6 @@ class UnifiedRenderService:
if ass_path is not None:
return False, "有字幕叠加"
# #1970 PR2:片段级微变换(变速/hflip/亮度/对比度/饱和度)需要重编码
try:
_video_sources = [c for c in (self.clips or []) if getattr(c, "clip_type", "main") != "audio"]
_ordinal = -1
for _i, _c in enumerate(_video_sources):
if getattr(_c, "id", None) == getattr(clip, "clip_id", None):
_ordinal = _i
break
_mt_plan = self._get_micro_transform_plan(len(_video_sources))
if _mt_plan and 0 <= _ordinal < len(_mt_plan.clips):
_mt = _mt_plan.clips[_ordinal]
if (
abs(UnifiedRenderService._micro_speed_factor(_mt) - 1.0) >= 1e-6
or (_mt.hflip and not _mt.has_text)
or abs(_mt.brightness) > 1e-4
or abs(_mt.contrast - 1.0) > 1e-4
or abs(_mt.saturation - 1.0) > 1e-4
):
return False, "启用了片段级微变换"
except Exception:
logger.debug("stream copy 微变换门控检查异常,按可 copy 处理", exc_info=True)
# 有调速 → 需要重编码 → 不能 copy
speed = UnifiedRenderService._clip_speed(clip)
if abs(speed - 1.0) >= 1e-6:
@@ -1433,16 +1318,11 @@ class UnifiedRenderService:
# 视觉扰动(plan 级别,直通模式同样适用)
vp = self._get_visual_perturbation()
# #1970 PR2:单片段直通;计划按源视频片段数构建,序号取 config._micro_index
_src_video_count = len([c for c in (self.clips or []) if getattr(c, "clip_type", "main") != "audio"])
mt_plan = self._get_micro_transform_plan(max(1, _src_video_count))
_mi = int(clip.config.get("_micro_index", 0)) if isinstance(clip.config, dict) else 0
mt = mt_plan.clips[_mi] if mt_plan and 0 <= _mi < len(mt_plan.clips) else None
# 调速 — 与 filter_complex 路径一致(叠加视觉扰动 speed_factor 与 #1970 微变换速度
# 调速 — 与 filter_complex 路径一致(叠加视觉扰动 speed_factor
speed = UnifiedRenderService._clip_speed(clip)
vp_speed = vp.get("speed_factor", 1.0) if vp else 1.0
effective_speed = speed * vp_speed # 微变换速度已烘焙进 playback_speed
effective_speed = speed * vp_speed
if abs(effective_speed - 1.0) >= 1e-6:
filters.append(f"setpts=PTS/{effective_speed:.4f}")
@@ -1456,8 +1336,6 @@ class UnifiedRenderService:
# 视觉扰动:hflip(在 scale 之前)
if vp:
self._apply_visual_perturbation_pre_scale(filters, vp)
# #1970 PR2:片段级 hflip(P1 保守:有文字/无法判定时不翻转)
UnifiedRenderService._apply_micro_hflip(filters, mt)
# scale + pad(等比缩放+留黑边)
if role in ("overlay", "corner_voice"):
@@ -1476,8 +1354,6 @@ class UnifiedRenderService:
# 视觉扰动:zoom + brightness(在 scale+pad 之后、调色之前)
if vp:
self._apply_visual_perturbation_post_scale(filters, vp)
# #1970 PR2:片段级亮度/对比度/饱和度微调
UnifiedRenderService._apply_micro_transform_video(filters, mt)
# 调色滤镜
color_grade = ColorGradeConfig.from_dict(clip.config.get("color_grade"))
@@ -1574,8 +1450,7 @@ class UnifiedRenderService:
# 音频调速(在降噪之后、音量之前,与 render_audio.py concat 路径保持一致)
# SpeedEngine.build_audio_filter 内部已实现多级 atempo 串联,
# 自动处理超出 [0.5, 2.0] 范围的速度(如 0.25x → atempo=0.5,atempo=0.5)。
# #1970 PR2:叠加片段微变换速度因子,保持音画同步。
speed = UnifiedRenderService._clip_speed(clip) # 微变换速度已烘焙进 playback_speed
speed = UnifiedRenderService._clip_speed(clip)
if abs(speed - 1.0) >= 1e-6:
try:
from video_processing.speed_engine import SpeedConfig, SpeedEngine
@@ -1647,20 +1522,11 @@ class UnifiedRenderService:
支持多段裁剪:一个 clip 配置了 trim_segments 时会展开为多个 ResolvedClip。
"""
resolved: list[ResolvedClip] = []
# #1970 PR2:预建片段级微变换计划,按源视频片段序号取速度因子,
# 烘焙进 playback_speed,保证视频 setpts 与音频 atempo 一致。
video_source_clips = [c for c in self.clips if getattr(c, "clip_type", "main") != "audio"]
mt_plan = self._get_micro_transform_plan(len(video_source_clips))
_video_ordinal = {id(c): i for i, c in enumerate(video_source_clips)}
for clip in self.clips:
asset_id = clip.asset_id
if not asset_id:
logger.warning("片段无素材: clip_id=%s", clip.id)
continue
_mt_idx = _video_ordinal.get(id(clip), -1)
_mt = mt_plan.clips[_mt_idx] if mt_plan and 0 <= _mt_idx < len(mt_plan.clips) else None
_micro_speed = UnifiedRenderService._micro_speed_factor(_mt)
local_path = self.asset_path_map.get(asset_id)
if local_path is None or not local_path.exists():
@@ -1689,7 +1555,7 @@ class UnifiedRenderService:
seg_duration = seg.trim.duration
# 多段裁剪:如果段的时长超过素材实际时长,减速补偿
seg_speed = configured_speed * _micro_speed
seg_speed = configured_speed
if actual_duration > 0 and seg_duration > actual_duration + 0.05:
seg_speed = max(0.25, round(configured_speed * actual_duration / seg_duration, 4))
logger.info(
@@ -1712,7 +1578,7 @@ class UnifiedRenderService:
transition_effect=clip.transition_effect or "cut",
transition_duration=getattr(clip, "transition_duration", 0.0) or 0.0,
playback_speed=seg_speed,
config={**clip_config, "_segment_id": seg.segment_id, "_micro_index": _mt_idx},
config={**clip_config, "_segment_id": seg.segment_id},
actual_duration=actual_duration,
trim_config=seg.trim,
)
@@ -1767,13 +1633,12 @@ class UnifiedRenderService:
avail_in_asset,
freeze_seconds,
)
final_speed = configured_speed * _micro_speed
final_speed = configured_speed
# freeze 标记写入 config,供视频 tpad / 音频 apad 读取
resolved_config = dict(clip_config)
if freeze_seconds > 0:
resolved_config["_freeze_seconds"] = freeze_seconds
resolved_config["_micro_index"] = _mt_idx
rc = ResolvedClip(
clip_id=clip.id,
@@ -1889,15 +1754,9 @@ class UnifiedRenderService:
preprocessed_labels: list[str] = []
# 视觉扰动(plan 级别,所有 clip 共享同一套扰动参数)
vp = self._get_visual_perturbation()
# #1970 PR2:片段级微变换(每片段独立参数,dedup_enabled=False 时为 None
# 计划按源视频片段数构建,trim 多段展开时各段通过 config._micro_index 找参数
_src_video_count = len([c for c in (self.clips or []) if getattr(c, "clip_type", "main") != "audio"])
mt_plan = self._get_micro_transform_plan(_src_video_count)
for i, clip in enumerate(all_clips):
label = f"v{i}"
role = _resolve_layer_role(clip.clip_type, clip.config)
_mi = int(clip.config.get("_micro_index", i)) if isinstance(clip.config, dict) else i
mt = mt_plan.clips[_mi] if mt_plan and 0 <= _mi < len(mt_plan.clips) else None
filters: list[str] = []
@@ -1915,10 +1774,10 @@ class UnifiedRenderService:
filters.append(f"trim=duration={trim_dur:.3f}")
filters.append("setpts=PTS-STARTPTS")
# 调速 — 基于 setpts 改变播放速度(叠加视觉扰动 speed_factor 与 #1970 微变换速度
# 调速 — 基于 setpts 改变播放速度(叠加视觉扰动 speed_factor
speed = UnifiedRenderService._clip_speed(clip)
vp_speed = vp.get("speed_factor", 1.0) if vp else 1.0
effective_speed = speed * vp_speed # 微变换速度已烘焙进 playback_speed
effective_speed = speed * vp_speed
if abs(effective_speed - 1.0) >= 1e-6:
filters.append(f"setpts=PTS/{effective_speed:.4f}")
@@ -1932,8 +1791,6 @@ class UnifiedRenderService:
# 视觉扰动:hflip(在 scale 之前,翻转原始画面)
if vp:
self._apply_visual_perturbation_pre_scale(filters, vp)
# #1970 PR2:片段级 hflip(P1 保守:有文字/无法判定时不翻转)
UnifiedRenderService._apply_micro_hflip(filters, mt)
# scale
if role in ("overlay", "corner_voice"):
@@ -1952,8 +1809,6 @@ class UnifiedRenderService:
# 视觉扰动:zoom + brightness(在 scale+pad 之后、调色之前)
if vp:
self._apply_visual_perturbation_post_scale(filters, vp)
# #1970 PR2:片段级亮度/对比度/饱和度微调
UnifiedRenderService._apply_micro_transform_video(filters, mt)
# 调色滤镜(每个 clip 独立的 color grade 配置)
color_grade = ColorGradeConfig.from_dict(clip.config.get("color_grade"))
-8
View File
@@ -27,11 +27,6 @@ celery_app.conf.broker_transport_options = {"visibility_timeout": 4 * 60 * 60}
celery_app.conf.imports = (
"worker_app.tasks.health",
"worker_app.tasks.ingest",
"worker_app.tasks.atom_clips",
# #1970 片段级 AI 标签:必须显式 import 注册,否则 worker 报
# "Received unregistered task of type 'worker.tag_atom_clip'"
"worker_app.tasks.atom_clip_tagging",
"worker_app.tasks.backfill_atom_clip_tags",
"worker_app.tasks.classification",
"worker_app.tasks.generation",
"worker_app.tasks.voice_extraction",
@@ -48,9 +43,6 @@ celery_app.conf.imports = (
# PYTHONPATH=/app/apps/api 下,app.tasks.lipsync_tts 可直接导入且不触发 apps/api/__init__.py
# apps/api/__init__.py 会 from .main import app,级联加载整个 FastAPI 栈,Worker 中不需要且会导致注册失败)
"app.tasks.lipsync_tts",
# #1998 GPU MuseTalk 异步推理:wait_for_result→签名 URL→回写 lipsync_jobs
# 必须在 Worker 侧注册,否则 apply_async 消息无人消费,job 永远卡在 processing
"app.tasks.lipsync_gpu",
)
# Celery Beat 定时任务调度
-13
View File
@@ -53,25 +53,12 @@ def __getattr__(name: str):
from .batch_thumbnail import batch_generate_thumbnails
return batch_generate_thumbnails
elif name == "generate_atom_clips":
from .atom_clips import generate_atom_clips
return generate_atom_clips
elif name == "tag_atom_clip_task":
from .atom_clip_tagging import tag_atom_clip_task
return tag_atom_clip_task
elif name == "backfill_atom_clip_tags":
from .backfill_atom_clip_tags import backfill_atom_clip_tags
return backfill_atom_clip_tags
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
"batch_generate_thumbnails",
"classify_asset",
"generate_atom_clips",
"generate_video",
"healthcheck",
"ingest_asset",
@@ -1,98 +0,0 @@
"""片段级 AI 标签 Celery 任务 — #1970 智能剪辑流程重构 P2.
为单个 atom_clip 调用视觉 AI 生成结构化标签并更新到 ai_tags 字段
失败不阻断流程降级为仅继承素材标签
任务名worker.tag_atom_clip
"""
from __future__ import annotations
from celery.utils.log import get_task_logger
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.asset_atom_clip_repository import (
SQLAlchemyAssetAtomClipRepository,
)
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.domain.atom_clip_tagger import tag_atom_clip
from packages.shared.ai_client import get_doubao_client
from packages.shared.mediakit_client import get_mediakit_client
from packages.shared.storage import get_shared_storage_service
logger = get_task_logger(__name__)
@celery_app.task(name="worker.tag_atom_clip", bind=True, max_retries=2, default_retry_delay=10)
def tag_atom_clip_task(self, atom_clip_id: str, force: bool = False) -> dict:
"""为单个原子片段生成 AI 标签.
Args:
atom_clip_id: 原子片段 ID
force: True 时允许覆盖只有 inherited_tags 的降级记录
视觉 API 曾失败写入的占位标签#1970)。
已有完整标签 has_text始终跳过保证幂等
Returns:
任务结果 dictstatus / clip_id / ai_tags部分字段
"""
db = SessionLocal()
try:
atom_repo = SQLAlchemyAssetAtomClipRepository(db)
asset_repo = SQLAlchemyAssetRepository(db)
clip = atom_repo.find_by_id(atom_clip_id)
if clip is None:
return {"status": "skipped", "reason": "clip not found", "clip_id": atom_clip_id}
# 已有完整标签则跳过(幂等);force 仅放行缺失 has_text 的降级记录
if clip.ai_tags is not None:
has_real_tags = isinstance(clip.ai_tags, dict) and "has_text" in clip.ai_tags
if has_real_tags or not force:
return {"status": "skipped", "reason": "already tagged", "clip_id": atom_clip_id}
# 获取素材信息
asset = asset_repo.find_by_id(clip.asset_id)
if asset is None:
return {"status": "skipped", "reason": "asset not found", "clip_id": atom_clip_id}
# 获取视频可访问 URL
storage = get_shared_storage_service()
video_url = storage.get_download_url(asset.storage_key, expires_seconds=3600)
# 初始化客户端
doubao_client = get_doubao_client()
mediakit_client = get_mediakit_client()
# 调用 tagger
ai_tags = tag_atom_clip(
clip=clip,
video_url=video_url,
doubao_client=doubao_client,
mediakit_client=mediakit_client,
storage=storage,
)
# 更新数据库
atom_repo.update_ai_tags(atom_clip_id, ai_tags)
logger.info(
"[atom_clip_tagging] clip_id=%s ai_tags=%s",
atom_clip_id,
{k: v for k, v in ai_tags.items() if k != "inherited_tags"},
)
return {
"status": "completed",
"clip_id": atom_clip_id,
"has_ai_tags": any(v for k, v in ai_tags.items() if k != "inherited_tags" and v),
}
except Exception as exc:
db.rollback()
logger.exception("[atom_clip_tagging] clip_id=%s 失败: %s", atom_clip_id, exc)
# 可重试异常
if self.request.retries < self.max_retries:
raise self.retry(exc=exc) from None
return {"status": "failed", "clip_id": atom_clip_id, "error": str(exc)}
finally:
db.close()
-109
View File
@@ -1,109 +0,0 @@
"""素材原子切片 Celery 任务 — #1970 智能剪辑流程重构 P1.
素材入库预处理完成ingest READY后异步触发
根据素材时长和已缓存的 scdet 切换点计算原子片段并落库
失败不阻断素材入库主流程atom_clips 未就绪时选片有内存兜底
P2 增强切片完成后自动链式触发 AI 标签任务每个 clip 一个 tag_atom_clip 任务
"""
from __future__ import annotations
from celery.utils.log import get_task_logger
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.asset_atom_clip_repository import (
SQLAlchemyAssetAtomClipRepository,
)
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.domain.atom_clip_service import compute_atom_clips
from packages.domain.plan_generator_utils import extract_scene_points_from_metadata
logger = get_task_logger(__name__)
@celery_app.task(name="worker.generate_atom_clips")
def generate_atom_clips(asset_id: str) -> dict:
"""为单条视频素材生成原子片段。
Returns:
任务结果 dictstatus / asset_id / clips_count
"""
db = SessionLocal()
try:
asset_repo = SQLAlchemyAssetRepository(db)
atom_repo = SQLAlchemyAssetAtomClipRepository(db)
asset = asset_repo.find_by_id(asset_id)
if asset is None:
return {"status": "skipped", "reason": "asset not found", "asset_id": asset_id}
# 仅视频素材切片
if asset.mime_type and not asset.mime_type.startswith("video/"):
return {"status": "skipped", "reason": "not a video", "asset_id": asset_id}
if not asset.duration or asset.duration <= 0:
return {"status": "skipped", "reason": "invalid duration", "asset_id": asset_id}
# 已生成过则幂等跳过(重新切片需先显式删除)
existing = atom_repo.count_by_asset(asset_id)
if existing > 0:
return {
"status": "skipped",
"reason": "already generated",
"asset_id": asset_id,
"clips_count": existing,
}
scene_points = extract_scene_points_from_metadata(asset.metadata)
# P1 阶段继承素材的标签 ID;片段级语义标签是 P2 功能
tags = list(getattr(asset, "tag_ids", []) or [])
clips = compute_atom_clips(
asset_id=asset_id,
duration=float(asset.duration),
scene_change_points=scene_points,
tags=tags,
)
if not clips:
return {"status": "skipped", "reason": "no clips computed", "asset_id": asset_id}
atom_repo.batch_create(clips)
logger.info(
"[atom_clips] asset_id=%s 生成 %d 个原子片段",
asset_id,
len(clips),
)
# P2 增强:链式触发 AI 标签任务(每个 clip 一个异步任务)
_dispatch_tagging_tasks(clips)
return {"status": "completed", "asset_id": asset_id, "clips_count": len(clips)}
except Exception as exc: # noqa: BLE001 - 后台任务兜底,失败不阻断主流程
db.rollback()
logger.exception("[atom_clips] asset_id=%s 生成失败: %s", asset_id, exc)
return {"status": "failed", "asset_id": asset_id, "error": str(exc)}
finally:
db.close()
def _dispatch_tagging_tasks(clips: list) -> None:
"""为每个新建片段发送 AI 标签异步任务.
失败不阻断标签任务是锦上添花不影响核心流程
"""
try:
for clip in clips:
celery_app.send_task(
"worker.tag_atom_clip",
args=[clip.id],
)
logger.info(
"[atom_clips] 已发送 %d 个 AI 标签任务",
len(clips),
)
except Exception as e:
logger.warning(
"[atom_clips] 发送 AI 标签任务失败(不影响切片结果): %s",
e,
)
@@ -1,106 +0,0 @@
"""批量回填 AI 标签 Celery 任务 — #1970 智能剪辑流程重构 P2.
查找所有 ai_tags IS NULL atom_clips分批触发 tag_atom_clip 任务
可通过 API 路由触发管理员权限
任务名worker.backfill_atom_clip_tags
"""
from __future__ import annotations
import time
from celery.utils.log import get_task_logger
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.asset_atom_clip_repository import (
SQLAlchemyAssetAtomClipRepository,
)
logger = get_task_logger(__name__)
# 默认批量参数
DEFAULT_BATCH_SIZE = 10
DEFAULT_BATCH_INTERVAL = 5 # 秒
@celery_app.task(name="worker.backfill_atom_clip_tags")
def backfill_atom_clip_tags(
batch_size: int = DEFAULT_BATCH_SIZE,
batch_interval: int = DEFAULT_BATCH_INTERVAL,
max_clips: int = 0,
force: bool = False,
) -> dict:
"""批量回填未打标的 atom_clips.
Args:
batch_size: 每批处理数量默认 10
batch_interval: 每批间隔秒数默认 5
max_clips: 最大处理总数0 表示不限
force: True 时连同只有 inherited_tags 的降级记录一起强制重打
视觉 API 曾失败DOUBAO_VISION_MODEL 修复后重跑用#1970)。
Returns:
任务结果 dicttotal_submitted / batches
"""
db = SessionLocal()
try:
atom_repo = SQLAlchemyAssetAtomClipRepository(db)
total_submitted = 0
batches = 0
while True:
# 查找未打标的片段
remaining = max_clips - total_submitted if max_clips > 0 else batch_size
fetch_limit = min(batch_size, remaining) if max_clips > 0 else batch_size
untagged = atom_repo.find_untagged(limit=fetch_limit, include_downgraded=force)
if not untagged:
break
# 逐个发送 tag 任务
for clip in untagged:
try:
celery_app.send_task(
"worker.tag_atom_clip",
args=[clip.id],
kwargs={"force": force},
)
total_submitted += 1
except Exception as e:
logger.warning(
"[backfill] 提交任务失败 clip_id=%s: %s",
clip.id,
e,
)
batches += 1
logger.info(
"[backfill] 第 %d 批完成,已提交 %d 个任务",
batches,
total_submitted,
)
# 检查是否达到上限
if max_clips > 0 and total_submitted >= max_clips:
break
# 批间间隔
time.sleep(batch_interval)
logger.info(
"[backfill] 回填完成: total_submitted=%d batches=%d",
total_submitted,
batches,
)
return {
"status": "completed",
"total_submitted": total_submitted,
"batches": batches,
}
except Exception as exc:
logger.exception("[backfill] 回填失败: %s", exc)
return {"status": "failed", "error": str(exc)}
finally:
db.close()
+13 -39
View File
@@ -890,51 +890,25 @@ def generate_video(self, task_id: str) -> dict:
_flush_logs(task_id, gen_task)
_update_task_progress(task_id, 80, "渲染完成")
# ── 3.5 随机边缘裁剪降重(#1664#1970 dedup_enabled=False 时跳过) ──
_dedup_enabled = True
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
# ── 3.5 随机边缘裁剪降重(#1664) ──────────────────────────
from video_processing.ffmpeg_utils import random_edge_crop
with SessionLocal() as _dedup_db:
_plan_row = (
_dedup_db.query(EditPlanModel.config)
.filter(EditPlanModel.id == current_plan_id)
.first()
)
if _plan_row is not None:
_cfg = _plan_row[0] if isinstance(_plan_row[0], dict) else {}
_dedup_enabled = bool(_cfg.get("dedup_enabled", True))
except Exception:
try:
cropped_path = random_edge_crop(output_path)
if cropped_path != output_path:
output_path = cropped_path
if gen_task and render_attempt == 0:
gen_task.append_log("边缘裁剪", "已应用随机 2-5% 边缘裁剪降重")
_flush_logs(task_id, gen_task)
logger.info("[task_id=%s] 随机边缘裁剪完成: %s", task_id, output_path)
except Exception as crop_err:
logger.warning(
"[task_id=%s] 读取 plan dedup_enabled 失败,按开启处理",
"[task_id=%s] 随机边缘裁剪失败,使用原始视频继续: %s",
task_id,
crop_err,
exc_info=True,
)
if not _dedup_enabled:
logger.info("[task_id=%s] dedup_enabled=False,跳过边缘裁剪与微变换", task_id)
if gen_task and render_attempt == 0:
gen_task.append_log("降重", "已关闭边缘裁剪与微变换(确定性渲染)")
_flush_logs(task_id, gen_task)
else:
from video_processing.ffmpeg_utils import random_edge_crop
try:
cropped_path = random_edge_crop(output_path)
if cropped_path != output_path:
output_path = cropped_path
if gen_task and render_attempt == 0:
gen_task.append_log("边缘裁剪", "已应用随机 2-5% 边缘裁剪降重")
_flush_logs(task_id, gen_task)
logger.info("[task_id=%s] 随机边缘裁剪完成: %s", task_id, output_path)
except Exception as crop_err:
logger.warning(
"[task_id=%s] 随机边缘裁剪失败,使用原始视频继续: %s",
task_id,
crop_err,
exc_info=True,
)
# ── 4. 上传 OSS(不落库) ───────────────────────────────
_update_task_progress(task_id, 85, "开始上传")
file_url, _storage_key = _upload_rendered_video(
-15
View File
@@ -808,21 +808,6 @@ def ingest_asset(job_id: str) -> dict:
db.commit()
# ── #1970 素材原子切片:视频 READY 后异步触发,失败不阻断入库 ──
# atom_clips 未就绪时选片逻辑有内存兜底(compute_fallback_clips)。
try:
if media_type == "video" and float(asset.duration or 0) > 0:
celery_app.send_task(
"worker.generate_atom_clips",
args=[asset.id],
)
except Exception as atom_err: # noqa: BLE001
logger.warning(
"触发原子切片任务失败(不影响入库): asset_id=%s err=%s",
asset.id,
atom_err,
)
return {
"status": "completed",
"job_id": job.id,
-11
View File
@@ -234,9 +234,6 @@ DOUBAO_TIMEOUT=60
# 最大重试次数
DOUBAO_MAX_RETRIES=2
# 视觉模型 Endpoint ID(支持图片/视频理解的模型)
DOUBAO_VISION_MODEL=${DOUBAO_VISION_MODEL}
# ==================== 微信开放平台 OAuth(网页扫码登录)====================
# 回调域名:xiaoxiajianji.com(微信开放平台已配置)
@@ -255,11 +252,3 @@ DOUYIN_DEBUG_ERRORS=false
TIKHUB_API_KEY=${TIKHUB_API_KEY}
# P2: apizero.cn(国内付费,https://apizero.cn
APIZERO_API_KEY=${APIZERO_API_KEY}
# ==================== GPU MuseTalk Worker(反向轮询) ====================
GPU_WORKER_TOKEN=${GPU_WORKER_TOKEN}
GPU_TASK_TIMEOUT_SECONDS=900
USE_GPU_LIPSYNC=false
GPU_LIPSYNC_POLL_INTERVAL=5
GPU_LIPSYNC_WAIT_TIMEOUT=1200
GPU_WORKER_STALE_SECONDS=300
-11
View File
@@ -251,9 +251,6 @@ DOUBAO_TIMEOUT=60
# 最大重试次数
DOUBAO_MAX_RETRIES=2
# 视觉模型 Endpoint ID(支持图片/视频理解的模型)
DOUBAO_VISION_MODEL=${DOUBAO_VISION_MODEL}
# ==================== 微信开放平台 OAuth(网页扫码登录)====================
# 回调域名:xiaoxiajianji.com(微信开放平台已配置)
@@ -272,11 +269,3 @@ DOUYIN_DEBUG_ERRORS=false
TIKHUB_API_KEY=${TIKHUB_API_KEY}
# P2: apizero.cn(国内付费,https://apizero.cn
APIZERO_API_KEY=${APIZERO_API_KEY}
# ==================== GPU MuseTalk Worker(反向轮询) ====================
GPU_WORKER_TOKEN=${GPU_WORKER_TOKEN}
GPU_TASK_TIMEOUT_SECONDS=900
USE_GPU_LIPSYNC=true
GPU_LIPSYNC_POLL_INTERVAL=5
GPU_LIPSYNC_WAIT_TIMEOUT=1200
GPU_WORKER_STALE_SECONDS=300
-30
View File
@@ -1,30 +0,0 @@
# ============================================================
# MuseTalk GPU Worker 环境变量
# 部署到 RTX2060 电脑后,复制为 .env 并修改值
# ============================================================
# SaaS API 基础 URLstaging / production
API_BASE_URL=https://staging-api.xiaoxiajianji.com
# API_BASE_URL=https://api.xiaoxiajianji.com # 生产
# 长期 API Token,必须与服务端 GPU_WORKER_TOKEN 一致(找后端拿)
GPU_WORKER_TOKEN=replace-with-real-token
# 本机 Worker 唯一 ID(默认自动生成 hostname+MAC 后4位,可手动指定)
# WORKER_ID=rtx2060-0193
# 本地 MuseTalk 地址(默认 http://127.0.0.1:7861
MUSE_TALK_URL=http://127.0.0.1:7861
# 轮询/心跳/超时(秒)
POLL_INTERVAL=5
HEARTBEAT_INTERVAL=15
# 下载/推理/上传 HTTP 超时,需与服务端 GPU_TASK_TIMEOUT_SECONDS 对齐(默认 900
REQUEST_TIMEOUT=900
# 单个任务本地最大重试次数(仅网络/MuseTalk 瞬时错误才重试,默认 1)
TASK_MAX_RETRY=1
# 推理期间任务心跳间隔(秒,独立线程,无需改动)
TASK_HEARTBEAT_INTERVAL=30
# 输入视频最短时长(秒),小于则直接上报失败,不调用 MuseTalk
MIN_VIDEO_DURATION_SECONDS=3
-353
View File
@@ -1,353 +0,0 @@
# MuseTalk GPU Worker 部署指南
本目录包含两个组件:
1. **gpu_worker.py**:反向轮询客户端,部署在 RTX2060 本地,轮询 SaaS API 拉取口型任务,调用本地 MuseTalk 服务推理,上传结果回 SaaS。
2. **musetalk_server.py**MuseTalk Flask HTTP 服务端,接收 gpu_worker.py 的推理请求,调用 MuseTalk 模型生成口型同步视频。
---
## 一、环境准备
### 1.1 硬件要求
- GPU: NVIDIA RTX 2060 或更高(显存 ≥ 6GB
- CUDA: 11.8+
- Python: 3.10+
- ffmpeg: 需安装并加入 PATH
### 1.2 安装依赖
```bash
cd deploy/gpu_worker
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
---
## 二、MuseTalk 服务端部署(musetalk_server.py
### 2.1 配置环境变量
复制 `.env.example``.env`,修改配置:
```bash
cp .env.example .env
vim .env
```
关键配置:
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `MUSE_PORT` | 监听端口 | `7861` |
| `MUSE_INFERENCE_TIMEOUT` | 推理超时秒数 | `600` |
| `MUSE_VIDEO_MAX_MB` | 视频上传大小限制 MB | `100` |
| `MUSE_AUDIO_MAX_MB` | 音频上传大小限制 MB | `20` |
| `MUSE_DEFAULT_FPS` | 视频 fps 兜底值 | `25.0` |
| `MUSE_TEMP_DIR` | 临时文件目录 | `/tmp/musetalk_$$` |
| `MUSE_VIDEO_ENCODER` | 兜底循环视频时的编码器:`auto`(优先 h264_nvenc,失败回退 libx264/`h264_nvenc`/`libx264` | `auto` |
### 2.2 更新部署(v2 性能修复,必做)
> ⚠️ 2026-09-20 v2 架构:修复 16 倍性能回归。旧版在推理前 loop 视频导致 MuseTalk 处理帧数翻倍、RTX2060 推理 >200s、nginx 504。**必须重新拉取并重启**:
```bash
# 在 RTX2060 上备份旧文件并拉取新版本
cp ~/projects/MuseTalk/musetalk_server.py ~/projects/MuseTalk/musetalk_server.py.bak
wget -O ~/projects/MuseTalk/musetalk_server.py \
"https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/raw/branch/develop/deploy/gpu_worker/musetalk_server.py"
# 重启服务
sudo systemctl restart musetalk-server
sudo systemctl status musetalk-server
curl http://127.0.0.1:7861/health
```
v2 架构核心变化:
- **MuseTalk 直传全量音频**:不再在推理前用 ffmpeg 循环视频。MuseTalk 原生支持长音频输入,内部自动循环视频帧。推理时间不变(~14s/5s 视频)
- **ffmpeg 只做快速封装**`-c:v copy -c:a aac -shortest`,秒级完成,不重编码
- **循环仅兜底**:仅当 MuseTalk 输出画面短于音频时(极端情况),才 `-stream_loop` + NVENC 兜底
- **删除 `MUSE_ENABLE_VIDEO_LOOP`**:不再需要此开关,MuseTalk 原生处理
### 2.3 启动服务
```bash
# 前台运行(调试用)
python musetalk_server.py
# 后台运行(生产用 systemd
sudo systemctl start musetalk-server
sudo systemctl enable musetalk-server
```
### 2.4 验证健康检查
```bash
curl http://127.0.0.1:7861/health
```
应返回:
```json
{
"status": "healthy",
"gpu": {
"gpu_name": "NVIDIA GeForce RTX 2060",
"memory_total_mb": 6144,
"memory_used_mb": 1024,
"memory_free_mb": 5120
},
"current_task": {
"task_id": null,
"running": false,
"elapsed_seconds": 0.0
},
"timestamp": 1700000000.0
}
```
---
## 三、GPU Worker 客户端部署(gpu_worker.py
### 3.1 配置环境变量
复制 `.env.example``.env`,修改配置:
```bash
cp .env.example .env
vim .env
```
关键配置:
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `API_BASE_URL` | SaaS API 基础 URL | `https://staging-api.xiaoxiajianji.com` |
| `GPU_WORKER_TOKEN` | 长期 API Token(与服务端一致) | - |
| `MUSE_TALK_URL` | 本地 MuseTalk 服务地址 | `http://127.0.0.1:7861` |
| `POLL_INTERVAL` | 轮询间隔秒 | `5` |
| `HEARTBEAT_INTERVAL` | 空闲心跳间隔秒 | `15` |
| `REQUEST_TIMEOUT` | HTTP 请求超时秒 | `900` |
| `TASK_MAX_RETRY` | 本地最大重试次数 | `1` |
| `TASK_HEARTBEAT_INTERVAL` | 推理期间任务心跳间隔秒 | `30` |
| `MIN_VIDEO_DURATION_SECONDS` | 最短输入视频时长秒 | `3` |
### 3.2 启动 Worker
```bash
# 前台运行(调试用)
python gpu_worker.py
# 后台运行(生产用 systemd
sudo systemctl start xiaoxia-gpu-worker
sudo systemctl enable xiaoxia-gpu-worker
```
### 3.3 验证启动日志
应看到:
```
============================================================
MuseTalk GPU Worker 启动
worker_id = rtx2060-xxxx
api_base = https://staging-api.xiaoxiajianji.com
muse_talk = http://127.0.0.1:7861
poll = 5.0s / heartbeat = 15.0s
============================================================
MuseTalk 健康检查通过: {...}
注册/心跳成功
```
---
## 四、常见问题排查
| 现象 | 可能原因 / 排查 |
|---|---|
| 日志 401 `Invalid GPU worker token` | `.env``GPU_WORKER_TOKEN` 与服务端不一致 |
| 日志 `MuseTalk 健康检查未通过` | 本地 MuseTalk 没启动,或端口不是 7861;`curl http://127.0.0.1:7861/health` 验证 |
| 任务长时间不被拉取 | Worker 和服务端连不上;检查 API_BASE_URL 是否可达、Token 是否正确 |
| 推理后上传 OSS 失败 | 本地出口网络被防火墙拦截 OSS 域名(oss-cn-hangzhou.aliyuncs.com |
| 服务端看到任务回退到 pending 重试 | 任务心跳真正超时(默认 900s):Worker 进程崩溃/断网,或推理彻底卡死;正常长推理期间心跳线程每 30s 续期,不会回退 |
| 日志 `MuseTalk 推理超时或连接失败` | 视频太长或显存不足;可临时调大 REQUEST_TIMEOUT(服务端 GPU_TASK_TIMEOUT_SECONDS 需同步调大),或限制输入视频时长 |
| 日志 `视频过短(x.xxs < 3s` | 输入视频不足 3sMuseTalk 对短视频会 division by zero,已在本地直接上报失败;可用 MIN_VIDEO_DURATION_SECONDS 调整阈值 |
| MuseTalk 服务端 503 `GPU 正在处理其他任务` | 并发请求被锁拒绝,等当前推理完成即可 |
| MuseTalk 服务端 504 `推理超时` | 推理超过 MUSE_INFERENCE_TIMEOUT,客户端会调 /cancel 终止服务端任务 |
---
## 五、安全注意事项
- `.env` 包含长期 Token,文件权限设为 600(`chmod 600 .env`
- Token 泄露要立即在服务端更换 `GPU_WORKER_TOKEN` 并重启 Worker
- Worker 只需要出站访问 SaaS API 和 OSS,不需要开放任何入站端口
- MuseTalk 服务端只监听本地 127.0.0.1(或 0.0.0.0 但通过防火墙限制),不暴露到公网
- 临时文件自动清理(推理完成/失败后),无需手动维护
---
## 六、工程改进记录(musetalk_server.py
相比原 `worker.py`,修复了以下 8 个 bug
1. **Flask 单线程阻塞**`app.run(threaded=True)`,推理时 `/health` 仍可响应
2. **fps=0 除零崩溃**`_get_video_fps()` 兜底 `MUSE_DEFAULT_FPS`
3. **ffmpeg 不检查返回码**`subprocess.run(check=True)` + 超时检查,失败立即报错
4. **无并发锁**`threading.Lock` 控制并发,第二请求立即 503
5. **无推理超时**:线程 join timeout,超时返回 504 并调 `/cancel`
6. **结果文件不清理**:推理完成/失败后自动删除临时目录
7. **无人脸检测兜底**MuseTalk 推理内部处理(TODO: 可在 `_run_inference` 前置检查)
8. **上传无大小限制**`_check_file_size()` 校验,超限返回 413
新增:
- `/cancel` 端点:终止当前推理任务,清理临时文件
- `/health` 端点:返回 GPU 显存信息和当前任务状态
2026-09-20 追加修复(音轨正确性,上线阻断级):
9. **音轨未替换(严重)**:旧最终封装让 ffmpeg 默认选流,结果保留了源视频自带音轨(与画面相关系数 0.9998,与 TTS 无关)。改为 `_mux_video_with_audio()` 统一封装,强制 `-map 0:v:0 -map 1:a:0`,画面取 MuseTalk 无声产物、音轨只取驱动音频
10. **音视频时长不对齐**TTS 长于原视频时 `-shortest` 会截短语音。改为探测双方时长,音频更长时 `-stream_loop -1` 循环画面 + `h264_nvenc` 硬件重编码(`MUSE_VIDEO_ENCODER=auto`,失败回退 libx264+ `-t <音频时长>`;不循环时 `-c:v copy` 秒封装
- 开关 `MUSE_ENABLE_VIDEO_LOOP=0` 可关闭循环;请求也支持 form 参数 `enable_video_loop` 单任务覆盖
2026-09-20 v2 架构重构(性能回归修复,上线阻断级):
11. **16 倍性能回归**#9/#10 的实现虽然音轨正确,但在某些集成场景下(推理前 loop 视频再喂 MuseTalk)导致推理帧数 ×2.2 + 叠加 ffmpeg 软编码预处理,5s 视频 +11s 音频推理 >200snginx 60s 超时 504
- **正确架构**:MuseTalk 原生支持长音频输入,内部自动循环视频帧。把【原视频】+【全量音频】直传 MuseTalk,输出时长=音频时长
- **ffmpeg 后置快速封装**`-c:v copy -c:a aac -shortest` 秒级完成,不重编码
- **循环仅兜底**:仅当 MuseTalk 输出画面短于音频时(极端情况),才 `-stream_loop` + NVENC 兜底补齐
- **业务侧异步化**POST /lipsync/jobs 创建 GPU 任务后立即返回 `job.status="processing"`,Celery 异步等待结果回写。前端 GET /jobs/{id} 轮询。避免同步阻塞 HTTP 请求 >200s
- **删除 `MUSE_ENABLE_VIDEO_LOOP`**:不再需要此开关
---
## 七、自动部署
从 2026-09-20 起,GPU 节点配置文件和脚本全部入库到 `deploy/gpu_worker/`,支持一键初始化新节点 + develop 分支 push 后 30 秒内自动拉取更新。
### 7.1 服务架构
每个 GPU 渲染节点运行三个 systemd 单元:
| 单元 | 类型 | 作用 |
|---|---|---|
| `musetalk-worker.service` | simple(常驻) | MuseTalk Flask 推理 API(监听 127.0.0.1:7861 |
| `xiaoxia-gpu-worker.service` | simple(常驻) | 反向轮询 SaaS API 拉口型任务的 Worker 客户端 |
| `gpu-poll.timer` + `gpu-poll.service` | timer(每 30s 触发 oneshot | 轮询 Gitea `deploy/gpu_worker/` 最新 commit,有变更自动执行 update 脚本 |
脚本目录(节点本地):
| 路径 | 来源 | 作用 |
|---|---|---|
| `~/projects/update-gpu-worker.sh` | `scripts/update-gpu-worker.sh` | 备份 → 拉代码 → 重启两个服务 → 健康检查 → 失败回滚 |
| `~/projects/gpu-webhook/poll_and_update.sh` | `scripts/poll_and_update.sh` | 轮询 Gitea API 比对 SHA,有新 commit 时触发 update |
### 7.2 新节点部署步骤
**前置准备**(手动,首次部署必做):
1. 安装 NVIDIA 驱动 + CUDA 11.8+`nvidia-smi` 能看到 GPU
2. 克隆 MuseTalk 代码到 `~/projects/MuseTalk/`,下载模型权重到 `~/projects/MuseTalk/models/musetalk/`(权重约几 GB,不适合自动下载)
3. 创建 Python 虚拟环境 `~/projects/MuseTalk/venv/` 并安装 MuseTalk 依赖(PyTorch CUDA 版等)
4. 创建 Worker 虚拟环境 `/opt/xiaoxia-gpu-worker/venv/``pip install -r requirements.txt`
5. 准备 `.env` 文件(Worker 端):`/opt/xiaoxia-gpu-worker/.env`,填好 `API_BASE_URL``GPU_WORKER_TOKEN``MUSE_TALK_URL` 等(参考 `.env.example`
> ⚠️ 模型权重和 Python 虚拟环境(含 CUDA 版 PyTorch)体积大、安装慢,首次部署必须手动准备;后续脚本只更新 `.py` 文件和配置,不碰权重和 venv。
**一键初始化**
```bash
# 从仓库拉取 setup 脚本并执行(在全新 GPU 机器上以 ying 用户执行)
wget -q -O /tmp/setup-gpu-node.sh \
"https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/raw/branch/develop/deploy/gpu_worker/scripts/setup-gpu-node.sh"
bash /tmp/setup-gpu-node.sh
```
脚本自动完成:
1. apt 安装系统依赖(python3、ffmpeg、wget、curl、git
2. 创建必要目录(`~/projects/MuseTalk``~/projects/gpu-webhook``/opt/xiaoxia-gpu-worker`
3. 从仓库拉取三个 systemd 单元文件 + update/poll 脚本到本地
4. 安装 systemd 服务到 `/etc/systemd/system/`
5. 配置 sudo 免密(仅允许 `ying` 用户免密 restart 两个服务、status、journalctl、cp、chmod、tee
6. 首次执行 update 脚本拉取最新 `musetalk_server.py``gpu_worker.py`
7. `systemctl daemon-reload` + enable + start 三个单元
**初始化后检查**
```bash
sudo systemctl status musetalk-worker # 应 active (running)
sudo systemctl status xiaoxia-gpu-worker # 应 active (running)
sudo systemctl status gpu-poll.timer # 应 active (waiting)
curl http://127.0.0.1:7861/health # 应返回 healthy + GPU 显存信息
```
### 7.3 自动更新机制
push 到 `develop` 分支且修改了 `deploy/gpu_worker/` 下任何文件后:
1. `gpu-poll.timer` 每 30 秒触发 `gpu-poll.service`
2. `poll_and_update.sh` 调用 Gitea API 取 `deploy/gpu_worker/` 路径最新 commit SHA
3. 与本地 `~/projects/gpu-webhook/.last_commit` 比对,无变更直接退出
4. 有变更:写入新 SHA → 执行 `update-gpu-worker.sh`
5. `update-gpu-worker.sh` 执行流程:
- 备份当前 `musetalk_server.py` / `gpu_worker.py`(带时间戳后缀)
- wget 拉取最新 `musetalk_server.py``gpu_worker.py`
- 比对 `requirements.txt`,有变化则 pip install
- `sudo systemctl restart musetalk-worker`,等 5 秒
- `sudo systemctl restart xiaoxia-gpu-worker`,等 8 秒
- `curl http://127.0.0.1:7861/health` 健康检查
- 健康 → 写日志退出 0
- 不健康 → 回滚到最新备份 → 重启 → 退出 1(日志记录 rolled back
端到端延迟:从 push 到节点拉到新代码并重启,约 30~60 秒。
### 7.4 手动更新命令
```bash
# 立即手动触发一次更新(不依赖 timer)
bash ~/projects/update-gpu-worker.sh
# 查看更新日志
tail -f /tmp/gpu-worker-update.log
# 查看轮询日志
tail -f /tmp/gpu-poll.log
# 查看服务运行日志
journalctl -u musetalk-worker -f # MuseTalk 推理服务日志
journalctl -u xiaoxia-gpu-worker -f # GPU Worker 客户端日志
journalctl -u gpu-poll.service -f # 轮询/更新触发日志
```
### 7.5 仓库文件清单(自动部署相关)
```
deploy/gpu_worker/
├── musetalk-worker.service # MuseTalk 推理 API 的 systemd 服务
├── gpu-poll.service # 自动更新轮询 oneshot service
├── gpu-poll.timer # 每 30 秒触发轮询的 timer
├── xiaoxia-gpu-worker.service # GPU Worker 客户端 systemd 服务(已有)
├── gpu_worker.py # GPU Worker 客户端脚本(已有,自动更新)
├── musetalk_server.py # MuseTalk Flask 服务端(已有,自动更新)
├── requirements.txt # Worker Python 依赖(已有)
├── .env.example # Worker 环境变量模板(已有)
├── README.md # 本文档
└── scripts/
├── update-gpu-worker.sh # 更新脚本:备份→拉取→重启→健康检查→回滚
├── poll_and_update.sh # 轮询脚本:SHA 比对→触发更新
└── setup-gpu-node.sh # 新节点一键初始化脚本
```
### 7.6 注意事项
- **首次部署必须手动准备**:MuseTalk 代码仓库、模型权重(`models/musetalk/`,几 GB)、MuseTalk 的 Python 虚拟环境(`venv/`,含 CUDA 版 PyTorch)。这些体积大、安装耗时长,不在自动更新范围内。
- **脚本路径写死**:当前脚本路径固定为 `/home/ying/projects/``/opt/xiaoxia-gpu-worker/`,用户名固定 `ying`。后续如有多节点/多用户需求再做参数化。
- **sudo 免密范围最小化**setup 脚本写入 `/etc/sudoers.d/ying-gpu-update`,仅放行 restart/status 两个 GPU 相关服务、daemon-reload、journalctl、cp、chmod、tee,不开放全量 root。
- **回滚只回滚 .py 文件**:健康检查失败只回滚 `musetalk_server.py``gpu_worker.py`,不回滚 pip 依赖(requirements.txt 变化概率低,且 pip 操作本身可能失败)。如需完全回滚,手动 `pip install -r requirements.txt` 指定旧版本。
- **poll 脚本容错**:Gitea API 请求失败直接跳过,不触发更新,不会因为网络抖动误重启服务。
-9
View File
@@ -1,9 +0,0 @@
[Unit]
Description=GPU Worker Auto-Update Poller
[Service]
Type=oneshot
User=ying
ExecStart=/bin/bash /home/ying/projects/gpu-webhook/poll_and_update.sh
StandardOutput=journal
StandardError=journal

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