Compare commits
158 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95d24537cb | |||
| a34a9a7844 | |||
| 7babea3d24 | |||
| a9f596fca3 | |||
| 9ba036abb3 | |||
| 3424e55a32 | |||
| 8c715474f4 | |||
| 93cb3e12a0 | |||
| e7bf85ca86 | |||
| 38ffa0b98b | |||
| c9876d70e4 | |||
| 8413713315 | |||
| 9d8c6260e3 | |||
| 0764a7820c | |||
| db621b4fcb | |||
| 60cacdf280 | |||
| 08de0d9946 | |||
| b0b81a5d60 | |||
| d959dd874f | |||
| dcd0c56827 | |||
| 585bab9313 | |||
| 4a449ae496 | |||
| 112f0eb277 | |||
| 2e2d1cd73e | |||
| 32473485d7 | |||
| 1591259bb8 | |||
| a1f25a4426 | |||
| 65a77e3fb6 | |||
| 9a57b0d5b8 | |||
| 4d98e98b57 | |||
| 81e1eb47fb | |||
| d3e4d6a07d | |||
| 0d6ce433d0 | |||
| eb2b009b33 | |||
| fbd89b4089 | |||
| 9b50e0696e | |||
| 9af73dcd86 | |||
| 6002f7a5e4 | |||
| 7e88440ca9 | |||
| fbf8844f25 | |||
| 34ffe14aae | |||
| 4fa3e4eb92 | |||
| a59a6a588a | |||
| f1621ace9f | |||
| f9daa08b2e | |||
| 66409fde6f | |||
| 3c016af076 | |||
| 428b9eeb3b | |||
| 43dfd6d425 | |||
| 73b9f7f97c | |||
| ed208ba3f7 | |||
| 99d6a47ec5 | |||
| 9e0959cb85 | |||
| 8fb75162d4 | |||
| 01f3e7d4c1 | |||
| 283f48d3f3 | |||
| f262b0cdd1 | |||
| 08362f556b | |||
| c2d8ebab4b | |||
| 1c9d5f81bb | |||
| 62720a4c70 | |||
| abf9a7fbdf | |||
| 54a3c50035 | |||
| f2bc951903 | |||
| bd2ac2d8c6 | |||
| 3c13b3837d | |||
| 3a757afbd3 | |||
| 7f44e82a27 | |||
| a61bda738a | |||
| da0b310102 | |||
| ea4b74216f | |||
| f8cf74f42d | |||
| fac88318bd | |||
| 46bce0dee8 | |||
| 20751428a8 | |||
| f655462fd6 | |||
| 787bb0ee31 | |||
| 7a2d235685 | |||
| 78c7fcca5c | |||
| 14b9a9d0fc | |||
| 132ca6bb70 | |||
| b2ba78c16c | |||
| 605a3eb841 | |||
| bf6c66d71c | |||
| 30bf66b307 | |||
| b68c29c69b | |||
| 4da3eae11a | |||
| c7a34fb297 | |||
| 115b428cb3 | |||
| cd4274553c | |||
| d825756c67 | |||
| b4724a866f | |||
| 731d3297b3 | |||
| ef6766dd58 | |||
| 0c41816a6d | |||
| ebb3c79d63 | |||
| 4f5ae52a40 | |||
| a425103b4f | |||
| 018e1bcb9b | |||
| 221eed2a25 | |||
| 35c00ccbb7 | |||
| 67a1ed6430 | |||
| ae733312db | |||
| 43a584d041 | |||
| c7662f0515 | |||
| 2d7f1c3a71 | |||
| 8637ed1576 | |||
| 6bcd255e85 | |||
| 17c6b0e3bd | |||
| 78cab46578 | |||
| 9e87781a85 | |||
| 57545ab694 | |||
| 51694cbd0c | |||
| fca943428b | |||
| 3eb2fcf3ec | |||
| 50b413a6fa | |||
| 410487fef5 | |||
| fc99b5a080 | |||
| 300f4b2abd | |||
| bb47e89a47 | |||
| 6155b4d21e | |||
| 41cd9cdc56 | |||
| 799a7f7367 | |||
| 13c302c037 | |||
| 1e8ab91984 | |||
| 3f27d199f5 | |||
| 67b270bce2 | |||
| 0432629aef | |||
| 3ad48335f6 | |||
| 1bf0e73fd2 | |||
| 7d2fbfa49f | |||
| 7564b50f7e | |||
| 7bf135789e | |||
| f8c8d4320e | |||
| b73cd1f22d | |||
| 0918d347cf | |||
| ba7e056232 | |||
| c455e33110 | |||
| d706a76205 | |||
| 8b69a6e18b | |||
| 3c41115b31 | |||
| 7715b789a8 | |||
| 29cdd32203 | |||
| 00045131f6 | |||
| de56a67457 | |||
| 371be8034d | |||
| d0af26116c | |||
| ab90a5ec9f | |||
| d0125a1da2 | |||
| f30fe14ff8 | |||
| 509b8db3a3 | |||
| dc0a5834f1 | |||
| 59fbbd8e26 | |||
| b64d384b91 | |||
| 6a4913a3b9 | |||
| bc85c79f39 | |||
| 08cad1f1ee | |||
| 29a127c7f1 |
+1
-1
@@ -1,2 +1,2 @@
|
||||
CI trigger file - safe to delete
|
||||
updated!
|
||||
retrigger at 2026-09-15 20:31:24 UTC
|
||||
|
||||
@@ -196,3 +196,40 @@ DOUBAO_MODEL=doubao-seed-1-6-250615
|
||||
DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
DOUBAO_TIMEOUT=30
|
||||
DOUBAO_MAX_RETRIES=2
|
||||
|
||||
# ==================== 积分/会员系统 (#1895) ====================
|
||||
# 积分系统总开关:默认 false(暂停积分系统)。
|
||||
# - false:生成视频/口型同步/数字人/AI标题/TTS/克隆音色等所有功能对登录
|
||||
# 用户免费放行,不扣积分、不做余额拦截;积分余额/流水/会员状态查询接口
|
||||
# 保留可用,但数据不再变动。积分相关的表、代码、接口均保留不删除。
|
||||
# - 恢复积分:设置 ENABLE_CREDIT_SYSTEM=true 即可,无需改代码。
|
||||
ENABLE_CREDIT_SYSTEM=false
|
||||
# 旧开关名(兼容别名):与 ENABLE_CREDIT_SYSTEM 任一为 true 即启用。
|
||||
POINTS_ENABLED=false
|
||||
|
||||
# ==================== 抖音解析多源轮询 (#1963) ====================
|
||||
# 无需配置 Key 也可使用(P0 免费源可用),配置 Key 可增加兜底能力
|
||||
|
||||
# TikHub API Key (https://tikhub.io) — $0.001/次起,注册送$0.05
|
||||
TIKHUB_API_KEY=
|
||||
|
||||
# apizero.cn API Key (https://v1.apizero.cn) — 国内抖音解析服务
|
||||
APIZERO_API_KEY=
|
||||
|
||||
# ==================== GPU MuseTalk Worker(反向轮询口型同步)====================
|
||||
# GPU Worker 长期鉴权 Token,Worker 端 .env 的 GPU_WORKER_TOKEN 必须与此一致
|
||||
# 留空时 development 环境允许匿名访问(仅本地调试),staging/production 必须配置
|
||||
GPU_WORKER_TOKEN=
|
||||
# 单任务超时(秒),processing 超过此时长无任务心跳才回退 pending 或标记 failed
|
||||
# #1970:RTX2060 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
|
||||
|
||||
|
||||
+476
-459
File diff suppressed because it is too large
Load Diff
@@ -494,3 +494,5 @@
|
||||
- [Fixed] Bug 修复
|
||||
- [Security] 安全相关更新
|
||||
- [Performance] 性能优化
|
||||
---
|
||||
- 2026-09-16: fix extract-from-douyin 异常路径全部返回业务码(消除500) #1963
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""#1894: merge title_libraries into scripts — add title_text/title_category/title_config
|
||||
|
||||
Revision ID: 077_merge_title_libs
|
||||
Revises: 076_membership_points
|
||||
Create Date: 2026-09-15
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision = "077_merge_title_libs"
|
||||
down_revision = "076_membership_points"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("scripts") as batch:
|
||||
batch.add_column(
|
||||
sa.Column("title_text", sa.String(500), nullable=False, server_default=""),
|
||||
)
|
||||
batch.add_column(
|
||||
sa.Column("title_category", sa.String(50), nullable=False, server_default=""),
|
||||
)
|
||||
batch.add_column(
|
||||
sa.Column("title_config", sa.JSON, nullable=False, server_default="{}"),
|
||||
)
|
||||
|
||||
if context.get_context().dialect.name == "postgresql":
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(sa.text("SELECT to_regclass('public.title_libraries')"))
|
||||
if result.scalar() is not None:
|
||||
conn.execute(sa.text("""
|
||||
INSERT INTO scripts
|
||||
(id, user_id, title, content, segments, tags,
|
||||
title_text, title_category, title_config,
|
||||
created_at, updated_at)
|
||||
SELECT
|
||||
gen_random_uuid()::TEXT,
|
||||
tl.user_id,
|
||||
COALESCE(tl.name, '迁移标题'),
|
||||
COALESCE(tl.text, ''),
|
||||
'[]'::JSONB,
|
||||
COALESCE(tl.tags, '[]'::JSONB),
|
||||
COALESCE(tl.text, ''),
|
||||
COALESCE(tl.category, ''),
|
||||
COALESCE(tl."metadata", '{}'::JSONB),
|
||||
tl.created_at,
|
||||
tl.updated_at
|
||||
FROM title_libraries tl
|
||||
WHERE tl.is_active = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM scripts s
|
||||
WHERE s.user_id = tl.user_id
|
||||
AND s.title_text = COALESCE(tl.text, '')
|
||||
AND s.title_category = COALESCE(tl.category, '')
|
||||
AND s.created_at = tl.created_at
|
||||
)
|
||||
"""))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("scripts") as batch:
|
||||
batch.drop_column("title_config")
|
||||
batch.drop_column("title_category")
|
||||
batch.drop_column("title_text")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""#1894: drop obsolete script title fields (title_text/title_category/title_config)
|
||||
|
||||
Revision ID: 078_drop_script_title_fields
|
||||
Revises: 077_merge_title_libs
|
||||
Create Date: 2026-09-16
|
||||
|
||||
口播文案(scripts)不再自带配套标题、标题分类和标题样式字段。
|
||||
智能剪辑 / AI 数字人等生成场景各自通过入参配置标题,不再从文案读取。
|
||||
保留字段:title(名称)、content(正文)、segments(分段)、tags(标签)。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "078_drop_script_title_fields"
|
||||
down_revision = "077_merge_title_libs"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("scripts") as batch:
|
||||
batch.drop_column("title_config")
|
||||
batch.drop_column("title_category")
|
||||
batch.drop_column("title_text")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("scripts") as batch:
|
||||
batch.add_column(sa.Column("title_text", sa.String(500), nullable=False, server_default=""))
|
||||
batch.add_column(sa.Column("title_category", sa.String(50), nullable=False, server_default=""))
|
||||
batch.add_column(sa.Column("title_config", sa.JSON, nullable=False, server_default="{}"))
|
||||
@@ -0,0 +1,58 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,58 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""#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")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""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")
|
||||
@@ -14,6 +14,7 @@ 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
|
||||
@@ -211,3 +212,8 @@ api_router.include_router(
|
||||
prefix="/usage",
|
||||
tags=["Usage"],
|
||||
)
|
||||
api_router.include_router(
|
||||
gpu_lipsync_router,
|
||||
prefix="/gpu",
|
||||
tags=["GPU Worker"],
|
||||
)
|
||||
|
||||
@@ -25,12 +25,27 @@ def check_project_access(project_id: str, user_id: str, project_repository) -> N
|
||||
raise HTTPException(status_code=403, detail="无权访问该项目")
|
||||
|
||||
|
||||
_LEGACY_PLANS = {"standard", "pro", "enterprise", "basic", "premium"}
|
||||
|
||||
|
||||
def get_user_plan(user_id: str, user_repository: UserRepository) -> str:
|
||||
"""获取用户的订阅计划名称。"""
|
||||
"""获取用户的会员类型,兼容旧档位值。
|
||||
|
||||
旧档位 standard/pro/enterprise/basic/premium 统一映射到当前体系:
|
||||
- standard/basic → monthly
|
||||
- pro/premium/enterprise → quarterly
|
||||
"""
|
||||
user = user_repository.find_by_id(user_id)
|
||||
if user is None:
|
||||
return "free"
|
||||
return getattr(user, "subscription_plan", "free") or "free"
|
||||
plan = getattr(user, "subscription_plan", "free") or "free"
|
||||
if plan in {"standard", "basic"}:
|
||||
return "monthly"
|
||||
if plan in {"pro", "premium", "enterprise"}:
|
||||
return "quarterly"
|
||||
if plan not in {"free", "monthly", "quarterly", "yearly"}:
|
||||
return "free"
|
||||
return plan
|
||||
|
||||
|
||||
def require_project_and_library(
|
||||
|
||||
@@ -29,6 +29,8 @@ from app.services.ai_avatar_render_service import (
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.middleware.points_gate import points_gate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -42,10 +44,12 @@ def _get_service(db: Session = Depends(get_db_session)) -> AiAvatarRenderService
|
||||
|
||||
|
||||
@router.post("", response_model=AiAvatarRenderJobResponse, status_code=201)
|
||||
@points_gate("ai_digital_human", per_unit=15)
|
||||
def create_render_job(
|
||||
body: CreateAiAvatarRenderRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: AiAvatarRenderService = Depends(_get_service),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""提交 AI 数字人渲染任务.
|
||||
|
||||
@@ -59,6 +63,7 @@ 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:
|
||||
|
||||
@@ -13,7 +13,7 @@ from typing import Optional
|
||||
import jwt
|
||||
from app.auth import AuthenticatedUser, blacklist_token, get_current_user
|
||||
from app.config import settings
|
||||
from app.dependencies import get_auth_email_service, get_auth_session_store, get_user_repository
|
||||
from app.dependencies import get_auth_email_service, get_auth_session_store, get_db_session, get_user_repository
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
@@ -126,6 +126,7 @@ async def register(
|
||||
request: RegisterRequest,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
email_service=Depends(get_auth_email_service),
|
||||
db=Depends(get_db_session),
|
||||
) -> RegisterResponse:
|
||||
use_case = RegisterUserUseCase(
|
||||
user_repository=user_repository,
|
||||
@@ -143,6 +144,22 @@ async def register(
|
||||
if error or response is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=_translate_auth_error(error))
|
||||
|
||||
# 新用户注册赠送 50 积分(失败不影响注册)
|
||||
if settings.points_enabled:
|
||||
try:
|
||||
from packages.domain.points_service import PointsService
|
||||
_svc = PointsService()
|
||||
_svc.add_points(
|
||||
user_id=response.user_id,
|
||||
amount=50,
|
||||
source="task_reward",
|
||||
db=db,
|
||||
description="新用户注册赠送",
|
||||
)
|
||||
except Exception as _bonus_err:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("注册送积分失败: user_id=%s err=%s", response.user_id, _bonus_err)
|
||||
|
||||
return RegisterResponse(
|
||||
user_id=response.user_id,
|
||||
email=response.email,
|
||||
|
||||
@@ -27,6 +27,7 @@ from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
)
|
||||
from packages.application import ListGeneratedVideosByTaskUseCase
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.middleware.points_gate import points_gate
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
from .templates_editor.dependencies import get_draft_plan_id, get_editor_services
|
||||
@@ -331,6 +332,7 @@ def _is_trusted_media_url(url: str) -> bool:
|
||||
|
||||
|
||||
@router.post("/generate-cover", response_model=GenerateCoverResponse)
|
||||
@points_gate("ai_cover")
|
||||
def generate_cover(
|
||||
body: GenerateCoverRequest,
|
||||
template_id: str = Query(..., description="模板 ID"),
|
||||
|
||||
@@ -43,6 +43,7 @@ from packages.application import (
|
||||
GetGenerationTaskUseCase,
|
||||
ListGeneratedVideosByTaskUseCase,
|
||||
)
|
||||
from packages.middleware.points_gate import points_gate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -271,6 +272,7 @@ def _variant_value(values: list[str], index: int, fallback: str = "") -> str:
|
||||
|
||||
|
||||
@router.post("/preview", response_model=BatchPreviewGenerationTaskResponse, status_code=201)
|
||||
@points_gate("ai_video", quantity_field="preview_count")
|
||||
def create_preview_generation_task(
|
||||
request: CreatePreviewGenerationTaskRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
|
||||
@@ -16,10 +16,12 @@ 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,
|
||||
@@ -42,6 +44,7 @@ from packages.application import (
|
||||
ListGeneratedVideosByTaskUseCase,
|
||||
)
|
||||
from packages.domain.smart_match import smart_select_assets
|
||||
from packages.middleware.points_gate import points_gate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -131,6 +134,8 @@ 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。
|
||||
|
||||
@@ -140,6 +145,8 @@ def _select_assets_from_library(
|
||||
count: 选取数量,0 表示全部(仅 smart 模式有效)
|
||||
rng: 可选随机源(smart 模式排序噪声用),生产环境不传则内部随机;
|
||||
测试可注入固定种子或零噪声随机源获得确定性结果。
|
||||
script_tags: #1970 叙事模式文案标签;非空时标签命中素材优先,不足再用其余素材兜底。
|
||||
tag_names_by_id: asset_id → 素材标签名列表(素材只存 tag_ids 时由调用方查名称注入)。
|
||||
|
||||
Returns:
|
||||
选中的素材 ID 列表
|
||||
@@ -149,6 +156,20 @@ 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%)
|
||||
@@ -161,16 +182,78 @@ def _select_assets_from_library(
|
||||
return [a.id for a in ready_video_assets]
|
||||
|
||||
|
||||
# #1970 PR3:video_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)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_project_and_library(
|
||||
@@ -211,6 +294,7 @@ def _resolve_project_and_library(
|
||||
|
||||
|
||||
@router.post("/tasks", response_model=BatchGenerationTaskResponse)
|
||||
@points_gate("ai_video", quantity_field="count")
|
||||
def create_generation_task(
|
||||
request: CreateGenerationTaskRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -219,16 +303,63 @@ 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, count=%d",
|
||||
"[生成任务] 接收请求: user_id=%s, template_id=%s, asset_count=%d, mode=%s, assembly=%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
|
||||
@@ -254,19 +385,29 @@ 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",):
|
||||
# 项目级模式:未指定 asset_ids 且选择了 smart 模式时,也自动选取
|
||||
elif project_id and not resolved_asset_ids and (request.asset_select_mode in ("smart",) or narrative_script_tags):
|
||||
# 项目级模式:未指定 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(
|
||||
@@ -330,6 +471,10 @@ 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(
|
||||
@@ -474,9 +619,12 @@ 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):
|
||||
@@ -491,6 +639,7 @@ 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:
|
||||
@@ -527,6 +676,8 @@ 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)
|
||||
|
||||
@@ -670,6 +821,11 @@ 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(
|
||||
@@ -760,6 +916,7 @@ 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,
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -12,9 +12,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import UTC
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.config import settings
|
||||
from app.dependencies import (
|
||||
get_db_session,
|
||||
get_voice_clone_profile_repository,
|
||||
@@ -30,6 +32,9 @@ from app.services.mediakit_client import MediaKitError
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
from packages.domain.points_service import PointsService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -53,8 +58,40 @@ def _get_service(
|
||||
def create_lipsync_job(
|
||||
body: CreateLipsyncJobRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
user_id = current_user.user.id
|
||||
|
||||
# ── 积分扣点(#1895 P2) ──
|
||||
_points_deducted = 0
|
||||
_points_scene = "ai_digital_human"
|
||||
_points_svc = PointsService() if settings.points_enabled else None
|
||||
if _points_svc is not None:
|
||||
# 口型同步:TTS 模式按 script_text 估时长(240字/分钟);音频直传按 audio_duration(秒→分钟)
|
||||
if body.audio_url and body.audio_duration and body.audio_duration > 0:
|
||||
est_minutes = max(1.0, math.ceil(body.audio_duration / 60.0))
|
||||
elif body.script_text:
|
||||
est_minutes = max(1.0, math.ceil(len(body.script_text) / 240))
|
||||
else:
|
||||
est_minutes = 1.0
|
||||
_points_deducted = calculate_points_cost(
|
||||
_points_scene,
|
||||
is_member=getattr(current_user.user, "is_member", False),
|
||||
duration_minutes=est_minutes,
|
||||
member_type=getattr(current_user.user, "member_type", None),
|
||||
)
|
||||
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
|
||||
if not _deduct_res["success"]:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
|
||||
"required": _points_deducted,
|
||||
"balance": _deduct_res["balance"],
|
||||
},
|
||||
)
|
||||
"""提交对口型任务.
|
||||
|
||||
三种模式:
|
||||
@@ -66,7 +103,7 @@ def create_lipsync_job(
|
||||
"""
|
||||
try:
|
||||
job = svc.create_job(
|
||||
user_id=current_user.user.id,
|
||||
user_id=user_id,
|
||||
video_url=body.video_url,
|
||||
audio_url=body.audio_url,
|
||||
audio_duration=body.audio_duration,
|
||||
@@ -74,13 +111,25 @@ 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,
|
||||
)
|
||||
except ValueError as exc:
|
||||
if _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"对口型 ValueError 退积分异常: err={refund_err}")
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except MediaKitError as exc:
|
||||
if _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"对口型 MediaKitError 退积分异常: err={refund_err}")
|
||||
status_code = 502
|
||||
if exc.code in ("VoiceForbidden",):
|
||||
status_code = 403
|
||||
@@ -96,11 +145,24 @@ def create_lipsync_job(
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
logger.error("创建对口型任务异常: %s", exc, exc_info=True)
|
||||
if _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"对口型异常退积分异常: err={refund_err}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"创建对口型任务失败: {exc}",
|
||||
) from exc
|
||||
|
||||
# 创建成功但状态为 failed(同步路径失败已抛异常到上面 except;此处处理 Celery 调度失败等)
|
||||
# 若任务已创建且状态为 failed,退费
|
||||
if _points_deducted > 0 and _points_svc is not None and getattr(job, "status", None) == "failed":
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"对口型任务失败退积分异常: job_id={job.id}, err={refund_err}")
|
||||
|
||||
return job
|
||||
|
||||
|
||||
@@ -111,8 +173,34 @@ def create_lipsync_job(
|
||||
def preview_tts(
|
||||
body: AiAvatarTtsPreviewRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
user_id = current_user.user.id
|
||||
|
||||
# ── 积分扣点(#1895 P2) ──
|
||||
_points_deducted = 0
|
||||
_points_scene = "ai_digital_human"
|
||||
_points_svc = PointsService() if settings.points_enabled else None
|
||||
if _points_svc is not None:
|
||||
est_minutes = max(1.0, math.ceil(len(body.script_text or "") / 240)) if body.script_text else 1.0
|
||||
_points_deducted = calculate_points_cost(
|
||||
_points_scene,
|
||||
is_member=getattr(current_user.user, "is_member", False),
|
||||
duration_minutes=est_minutes,
|
||||
member_type=getattr(current_user.user, "member_type", None),
|
||||
)
|
||||
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
|
||||
if not _deduct_res["success"]:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
|
||||
"required": _points_deducted,
|
||||
"balance": _deduct_res["balance"],
|
||||
},
|
||||
)
|
||||
"""步骤1「生成配音」同步 TTS 预合成.
|
||||
|
||||
同步执行 TTS 合成 → 下载音频 → ffprobe 时长 → 句子时间戳计算,
|
||||
@@ -121,13 +209,20 @@ def preview_tts(
|
||||
"""
|
||||
try:
|
||||
result = svc.preview_tts(
|
||||
user_id=current_user.user.id,
|
||||
user_id=user_id,
|
||||
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:
|
||||
if _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"TTS 预合成 MediaKitError 退积分异常: err={refund_err}")
|
||||
status_code = 400
|
||||
if exc.code in ("VoiceForbidden",):
|
||||
status_code = 403
|
||||
@@ -142,6 +237,11 @@ def preview_tts(
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
logger.error("TTS 预合成异常: %s", exc, exc_info=True)
|
||||
if _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"TTS 预合成异常退积分异常: err={refund_err}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"TTS 合成失败: {exc}",
|
||||
@@ -199,7 +299,14 @@ def get_lipsync_job(
|
||||
from datetime import datetime as _dt
|
||||
|
||||
_now = _dt.now(UTC)
|
||||
_stale = job.updated_at is None or (_now - job.updated_at).total_seconds() > 30
|
||||
_upd = job.updated_at
|
||||
# DB 返回的 DateTime 列可能是 naive(取决于方言/驱动):代码写入统一用
|
||||
# datetime.now(UTC),经 SQLAlchemy 存入 TIMESTAMP WITHOUT TIMEZONE 后再
|
||||
# 读回就是 UTC wall clock 的 naive datetime,直接补 UTC tz 即可;避免
|
||||
# TypeError: can't subtract offset-naive and offset-aware datetimes。
|
||||
if _upd is not None and _upd.tzinfo is None:
|
||||
_upd = _upd.replace(tzinfo=UTC)
|
||||
_stale = _upd is None or (_now - _upd).total_seconds() > 30
|
||||
if _stale:
|
||||
try:
|
||||
refreshed = svc.refresh_job_status(job_id, current_user.user.id)
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
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,
|
||||
@@ -44,6 +45,12 @@ 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()
|
||||
@@ -125,6 +132,7 @@ def get_rules(
|
||||
base_points=scene_data["base_points"],
|
||||
unit=scene_data["unit"],
|
||||
extra_per_30s=scene_data.get("extra_per_30s"),
|
||||
description=scene_data.get("description", ""),
|
||||
)
|
||||
)
|
||||
return PointsRulesResponse(
|
||||
@@ -161,7 +169,29 @@ def check_points(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""消费前检查余额是否足够。"""
|
||||
"""消费前检查余额是否足够。未知 scene_key 返回 400(而非 500)。"""
|
||||
if body.scene_key not in POINTS_SCENES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "UNKNOWN_SCENE",
|
||||
"message": f"未知场景: {body.scene_key}",
|
||||
"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)
|
||||
|
||||
@@ -199,8 +229,19 @@ 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,
|
||||
@@ -233,11 +274,7 @@ 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:
|
||||
@@ -267,7 +304,7 @@ def create_recharge_order(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""创建积分充值订单。"""
|
||||
"""创建积分充值订单。pay_params 在支付通道接入后填入 prepay_id/payment_url;当前为空 dict。"""
|
||||
svc = _get_service()
|
||||
try:
|
||||
order = svc.create_order(
|
||||
@@ -278,6 +315,14 @@ def create_recharge_order(
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from None
|
||||
|
||||
package = POINTS_PACKAGES.get(body.package_id, {})
|
||||
now = datetime.now(timezone.utc)
|
||||
expire_at = now + timedelta(hours=48)
|
||||
# TODO: 接入微信/支付宝后填充真实 prepay_id / payment_url
|
||||
order["points_amount"] = package.get("points", 0)
|
||||
order["pay_params"] = {}
|
||||
order["expire_at"] = expire_at.isoformat()
|
||||
return PointsOrderResponse(**order)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"""Scripts AI 能力路由 — Issue #1893.
|
||||
"""Scripts AI 能力路由 — Issue #1893/#1963.
|
||||
|
||||
三个 AI 工具接口(均挂载在 /api/v1/scripts 前缀下):
|
||||
- POST /extract-from-douyin 从抖音视频提取文案(yt-dlp 下载 + ASR 转写)
|
||||
- POST /extract-from-douyin 从抖音视频提取文案
|
||||
- 入口自动从分享文本中正则提取 http(s) URL,兼容 "复制链接" 粘贴场景
|
||||
- 多源轮询解析(douyin_resolver):App Feed API → TikHub → apizero
|
||||
- 拿到 MP4 直链后优先走火山 MediaKit ASR,失败回退下载+本地 ASR
|
||||
- ASR 空结果时使用 Feed desc 兜底,图文视频直接返回 desc
|
||||
- 所有源均失败时返回具体错误信息(不暴露内部细节)
|
||||
- POST /ai-rewrite AI 文案改写(复用豆包 LLM)
|
||||
- POST /ai-generate-titles AI 标题生成(复用 generate_smart_titles)
|
||||
"""
|
||||
@@ -9,10 +14,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.scripts_ai import (
|
||||
AiGenerateTitlesRequest,
|
||||
AiGenerateTitlesResponse,
|
||||
@@ -21,214 +30,525 @@ from app.schemas.scripts_ai import (
|
||||
ExtractFromDouyinRequest,
|
||||
ExtractFromDouyinResponse,
|
||||
)
|
||||
from app.services.douyin_resolver import available_providers, resolve_douyin_video
|
||||
from app.services.mediakit_client import (
|
||||
MediaKitClient,
|
||||
MediaKitError,
|
||||
get_mediakit_client,
|
||||
)
|
||||
from app.services.script_asr_service import (
|
||||
ASRNotConfiguredError,
|
||||
ASRTranscriptionError,
|
||||
transcribe_to_text,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.middleware.points_gate import points_gate
|
||||
from packages.shared.ai_client import get_doubao_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 抖音 URL 校验:支持短链 v.douyin.com 和长链 www.douyin.com/video/
|
||||
_DOUYIN_URL_RE = re.compile(
|
||||
r"^(https?://)?(v\.douyin\.com/\S+|www\.douyin\.com/video/\S+)$",
|
||||
_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")
|
||||
|
||||
_TAIL_PUNCT = ".,;:!?,。;:!?))]》" + chr(34) + chr(39) + "<>"
|
||||
_URL_EXTRACT_RE = re.compile(r"https?://\S+", re.IGNORECASE)
|
||||
_DOUYIN_HOST_RE = re.compile(
|
||||
r"(^|\.)(douyin\.com|iesdouyin\.com|amemv\.com)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ANY_SCHEME_RE = re.compile(r"^[a-z][a-z0-9+.-]*://\S+", re.IGNORECASE)
|
||||
|
||||
|
||||
def _validate_douyin_url(url: str) -> None:
|
||||
"""校验抖音 URL 格式,不合法时抛 HTTPException(400)."""
|
||||
if not url or not url.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="链接不能为空",
|
||||
)
|
||||
if not _DOUYIN_URL_RE.match(url.strip()):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无效的抖音链接,仅支持 v.douyin.com 短链或 www.douyin.com/video/ 长链",
|
||||
def _dbg(key, val):
|
||||
logger.debug("douyin_extract %s=%s", key, str(val)[:200])
|
||||
|
||||
|
||||
def _extract_url_from_text(raw):
|
||||
if not raw:
|
||||
return None
|
||||
m = _URL_EXTRACT_RE.search(raw)
|
||||
if m:
|
||||
return m.group(0).rstrip(_TAIL_PUNCT)
|
||||
short = re.search(
|
||||
r"(?:^|(?<![a-z0-9/:]))((?:v|www)\.douyin\.com/\S+|douyin\.com/(?:video|note)/\S+)",
|
||||
raw,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if short:
|
||||
return "https://" + short.group(1).rstrip(_TAIL_PUNCT)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_and_validate_douyin_url(raw_input):
|
||||
raw = (raw_input or "").strip()
|
||||
if not raw:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="链接不能为空")
|
||||
|
||||
url = _extract_url_from_text(raw)
|
||||
|
||||
if not url:
|
||||
if _ANY_SCHEME_RE.search(raw):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无效的抖音链接,仅支持 http(s) 协议",
|
||||
)
|
||||
short = re.search(
|
||||
r"(?:^|(?<![a-z0-9]))((?:v|www)\.douyin\.com/\S+|douyin\.com/(?:video|note)/\S+)",
|
||||
raw,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if short:
|
||||
url = "https://" + short.group(1).rstrip(_TAIL_PUNCT)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="未在输入中找到有效抖音链接,请粘贴包含 v.douyin.com 或 www.douyin.com 的分享文本",
|
||||
)
|
||||
|
||||
if not re.match(r"^https?://", url, re.IGNORECASE):
|
||||
url = "https://" + url
|
||||
|
||||
# ── 1. 从抖音视频提取文案 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/extract-from-douyin",
|
||||
response_model=ExtractFromDouyinResponse,
|
||||
)
|
||||
def extract_from_douyin(
|
||||
request: ExtractFromDouyinRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> ExtractFromDouyinResponse:
|
||||
"""从抖音视频下载无水印视频并通过 ASR 提取文案."""
|
||||
source_url = request.url.strip()
|
||||
_validate_douyin_url(source_url)
|
||||
|
||||
# 确保 URL 有 scheme(yt-dlp 需要完整 URL)
|
||||
url_for_download = source_url
|
||||
if not re.match(r"^https?://", url_for_download, re.IGNORECASE):
|
||||
url_for_download = "https://" + url_for_download
|
||||
|
||||
# 使用临时目录下载视频,退出时自动清理
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="douyin_extract_") as temp_dir:
|
||||
import yt_dlp
|
||||
parsed = urlparse(url)
|
||||
host = parsed.hostname or ""
|
||||
scheme = (parsed.scheme or "").lower()
|
||||
except Exception:
|
||||
host = ""
|
||||
scheme = ""
|
||||
if scheme not in ("http", "https"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无效的抖音链接,仅支持 http(s) 协议",
|
||||
)
|
||||
if not _DOUYIN_HOST_RE.search(host):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无效的抖音链接,仅支持 douyin.com 域名(v.douyin.com 短链或 www.douyin.com 长链)",
|
||||
)
|
||||
return url
|
||||
|
||||
ydl_opts = {
|
||||
"format": "best[ext=mp4]/best",
|
||||
"outtmpl": f"{temp_dir}/%(id)s.%(ext)s",
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"noplaylist": True,
|
||||
}
|
||||
|
||||
try:
|
||||
ydl = yt_dlp.YoutubeDL(ydl_opts)
|
||||
info = ydl.extract_info(url_for_download, download=True)
|
||||
except Exception as exc:
|
||||
logger.error("抖音视频下载失败: url=%s error=%s", source_url, exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"视频下载失败: {exc}",
|
||||
) from exc
|
||||
# ── MediaKitClient ASR 扩展(monkey patch) ────────────────────────────
|
||||
|
||||
if info is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无法解析该抖音链接",
|
||||
)
|
||||
|
||||
video_path = ydl.prepare_filename(info)
|
||||
duration = float(info.get("duration") or 0)
|
||||
def _mk_post_json(self, path, payload):
|
||||
import httpx
|
||||
|
||||
# ASR 转写
|
||||
try:
|
||||
text = transcribe_to_text(video_path)
|
||||
except ASRNotConfiguredError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except ASRTranscriptionError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
if not self.is_available:
|
||||
raise MediaKitError("MediaKit API Key 未配置", code="NotConfigured")
|
||||
url = self._base_url + path
|
||||
try:
|
||||
with httpx.Client(timeout=self._timeout) as http:
|
||||
resp = http.post(url, headers=self._headers(), json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.TimeoutException as exc:
|
||||
raise MediaKitError("MediaKit API 超时 (%ss)" % self._timeout, code="Timeout") from exc
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise MediaKitError(
|
||||
"MediaKit API HTTP %s: %s" % (exc.response.status_code, exc.response.text[:300]),
|
||||
code="HttpError",
|
||||
) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise MediaKitError("MediaKit API 网络错误: %s" % exc, code="NetworkError") from exc
|
||||
if data.get("success") is False and data.get("error"):
|
||||
err = data["error"] if isinstance(data["error"], dict) else {"message": str(data["error"])}
|
||||
raise MediaKitError(
|
||||
err.get("message", "请求失败"),
|
||||
code=err.get("code", "RequestFailed"),
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
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
|
||||
try:
|
||||
with httpx.Client(timeout=self._timeout) as http:
|
||||
resp = http.get(url, headers=self._headers())
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except httpx.TimeoutException as exc:
|
||||
raise MediaKitError("MediaKit API 超时 (%ss)" % self._timeout, code="Timeout") from exc
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise MediaKitError(
|
||||
"MediaKit API HTTP %s: %s" % (exc.response.status_code, exc.response.text[:300]),
|
||||
code="HttpError",
|
||||
) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise MediaKitError("MediaKit API 网络错误: %s" % exc, code="NetworkError") from exc
|
||||
|
||||
|
||||
def _mediakit_asr_submit(self, video_url):
|
||||
"""提交语音转字幕任务(POST /tools/asr-subtitles)。返回 task_id。"""
|
||||
data = self._post_json(
|
||||
"/tools/asr-subtitles",
|
||||
{"video_url": video_url, "language": "cmn-Hans-CN"},
|
||||
)
|
||||
task_id = data.get("task_id")
|
||||
if not task_id:
|
||||
raise MediaKitError("MediaKit ASR 提交响应缺少 task_id: %s" % str(data)[:200])
|
||||
return task_id
|
||||
|
||||
|
||||
def _mediakit_asr_poll(self, task_id, poll_interval=2.0, max_attempts=90):
|
||||
"""轮询 ASR 任务直到 completed/failed。返回 (text, duration)。"""
|
||||
for attempt in range(max_attempts):
|
||||
time.sleep(poll_interval)
|
||||
try:
|
||||
data = self._get_json("/tasks/" + task_id)
|
||||
except MediaKitError as exc:
|
||||
if attempt < max_attempts - 1 and getattr(exc, "code", "") in ("Timeout", "NetworkError"):
|
||||
logger.warning("MediaKit ASR 轮询异常(第%d次),将重试: %s", attempt + 1, exc)
|
||||
continue
|
||||
raise
|
||||
st = data.get("status")
|
||||
if st in ("completed", "success"):
|
||||
result = data.get("result") or {}
|
||||
subs = result.get("subtitles") or []
|
||||
text = "".join(s.get("subtitle_text", "") for s in subs if isinstance(s, dict))
|
||||
duration = float(result.get("duration") or 0.0)
|
||||
return text.strip(), duration
|
||||
if st == "failed":
|
||||
err = data.get("error")
|
||||
if isinstance(err, dict):
|
||||
msg = err.get("message") or "unknown"
|
||||
code = err.get("code") or "TaskFailed"
|
||||
elif isinstance(err, str):
|
||||
msg, code = err, "TaskFailed"
|
||||
else:
|
||||
msg, code = "unknown", "TaskFailed"
|
||||
raise MediaKitError("MediaKit ASR 任务失败: %s" % msg, code=code)
|
||||
raise MediaKitError(
|
||||
"MediaKit ASR 超时(%ss 未完成)" % int(poll_interval * max_attempts),
|
||||
code="Timeout",
|
||||
)
|
||||
|
||||
|
||||
# 绑定到类(零侵入)
|
||||
if not hasattr(MediaKitClient, "_post_json"):
|
||||
MediaKitClient._post_json = _mk_post_json
|
||||
if not hasattr(MediaKitClient, "_get_json"):
|
||||
MediaKitClient._get_json = _mk_get_json
|
||||
if not hasattr(MediaKitClient, "asr_submit"):
|
||||
MediaKitClient.asr_submit = _mediakit_asr_submit
|
||||
if not hasattr(MediaKitClient, "asr_poll"):
|
||||
MediaKitClient.asr_poll = _mediakit_asr_poll
|
||||
|
||||
|
||||
# ── 下载 + 本地 ASR 兜底 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _direct_url_download_and_local_asr(direct_url, page_url, temp_dir):
|
||||
"""通过直链下载 MP4,再做本地 ASR。返回 (text, duration)。"""
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
video_path = os.path.join(temp_dir, "video.mp4")
|
||||
try:
|
||||
with httpx.Client(timeout=90, follow_redirects=True, verify=False) as http:
|
||||
with http.stream(
|
||||
"GET",
|
||||
direct_url,
|
||||
headers={
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/128.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Referer": "https://www.douyin.com/",
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
},
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
downloaded = 0
|
||||
with open(video_path, "wb") as f:
|
||||
for chunk in resp.iter_bytes(chunk_size=65536):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if downloaded == 0:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="直链下载为空")
|
||||
except HTTPException:
|
||||
raise
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("直链下载超时: %s", page_url)
|
||||
raise HTTPException(status_code=status.HTTP_504_GATEWAY_TIMEOUT, detail="视频下载超时,请稍后重试") from None
|
||||
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
|
||||
|
||||
try:
|
||||
text = transcribe_to_text(video_path)
|
||||
return text.strip(), 0.0
|
||||
except ASRNotConfiguredError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
|
||||
except ASRTranscriptionError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
logger.exception("直链下载后 ASR 转写异常: path=%s", video_path)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="语音识别失败: " + str(exc)[:200],
|
||||
) from exc
|
||||
|
||||
|
||||
# ── 1. 从抖音视频提取文案 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/douyin/__debug_diag")
|
||||
def douyin_diag():
|
||||
"""[Staging/Dev only] 抖音解析源诊断。"""
|
||||
import time as _t
|
||||
|
||||
import httpx as _httpx
|
||||
from app.services.douyin_resolver import APIZERO_API_KEY as _api_key_apizero
|
||||
from app.services.douyin_resolver import TIKHUB_API_KEY as _api_key_tikhub
|
||||
|
||||
results = {
|
||||
"providers": available_providers(),
|
||||
"env": {
|
||||
"APP_ENV": os.environ.get("APP_ENV", ""),
|
||||
"MEDIAKIT_CONFIGURED": bool(os.environ.get("MEDIAKIT_API_KEY", "")),
|
||||
},
|
||||
}
|
||||
|
||||
test_url = "https://v.douyin.com/hb-giW8cC1Q/"
|
||||
|
||||
t0 = _t.time()
|
||||
try:
|
||||
r = resolve_douyin_video(test_url)
|
||||
results["resolver"] = {
|
||||
"ok": bool(r),
|
||||
"source": r.source if r else None,
|
||||
"desc_len": len(r.desc) if r else 0,
|
||||
"has_video_url": bool(r.video_url) if r else False,
|
||||
"url_domain": r.video_url.split("/")[2] if r and r.video_url and "/" in r.video_url else None,
|
||||
"time": round(_t.time() - t0, 2),
|
||||
}
|
||||
except Exception as e:
|
||||
results["resolver"] = {"ok": False, "error": str(e)[:200], "time": round(_t.time() - t0, 2)}
|
||||
|
||||
if _api_key_apizero:
|
||||
t0 = _t.time()
|
||||
try:
|
||||
with _httpx.Client(timeout=8, verify=False) as c:
|
||||
r = c.get(
|
||||
"https://v1.apizero.cn/api/video-parse",
|
||||
params={"url": test_url, "flat": 2},
|
||||
headers={"Authorization": f"Bearer {_api_key_apizero}"},
|
||||
)
|
||||
results["apizero"] = {"status": r.status_code, "prefix": r.text[:200], "time": round(_t.time() - t0, 2)}
|
||||
except Exception as e:
|
||||
results["apizero"] = {"error": str(e)[:200], "time": round(_t.time() - t0, 2)}
|
||||
|
||||
if _api_key_tikhub:
|
||||
t0 = _t.time()
|
||||
try:
|
||||
with _httpx.Client(timeout=8, verify=False) as c:
|
||||
r = c.get(
|
||||
"https://api.tikhub.io/api/v1/douyin/web/get_aweme_id",
|
||||
params={"url": test_url},
|
||||
headers={"Authorization": f"Bearer {_api_key_tikhub}"},
|
||||
)
|
||||
results["tikhub"] = {"status": r.status_code, "prefix": r.text[:200], "time": round(_t.time() - t0, 2)}
|
||||
except Exception as e:
|
||||
results["tikhub"] = {"error": str(e)[:200], "time": round(_t.time() - t0, 2)}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@router.post("/extract-from-douyin", response_model=ExtractFromDouyinResponse)
|
||||
@points_gate("douyin_extract")
|
||||
def extract_from_douyin(
|
||||
request: ExtractFromDouyinRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
page_url = _extract_and_validate_douyin_url(request.url)
|
||||
_dbg("page_url", page_url)
|
||||
|
||||
# ── Phase A:多源轮询解析 MP4 直链 ──
|
||||
last_err_stage = "parse"
|
||||
t0 = time.time()
|
||||
result = resolve_douyin_video(page_url)
|
||||
resolve_elapsed = time.time() - t0
|
||||
logger.info("抖音解析耗时: %.2fs providers=%s", resolve_elapsed, available_providers())
|
||||
|
||||
direct_url = result.video_url if result else None
|
||||
feed_desc = (result.desc or "").strip() if result else ""
|
||||
|
||||
# 图文视频(无 video_url 但有 desc)直接返回文案,跳过 ASR
|
||||
if result and not direct_url and feed_desc:
|
||||
logger.info("图文视频直接返回文案: source=%s desc_len=%d", result.source, len(feed_desc))
|
||||
return ExtractFromDouyinResponse(
|
||||
text=feed_desc,
|
||||
duration_seconds=0.0,
|
||||
source_url=page_url,
|
||||
)
|
||||
|
||||
if not direct_url:
|
||||
if _DOUYIN_DEBUG_ERRORS:
|
||||
detail = f"抖音视频链接解析失败,请检查链接是否正确或稍后重试 [debug: providers={available_providers()}]"
|
||||
else:
|
||||
detail = "抖音视频链接解析失败,请检查链接是否正确或稍后重试"
|
||||
logger.warning("抖音解析全部失败: url=%s providers=%s", page_url, available_providers())
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=detail)
|
||||
|
||||
# ── Phase B:ASR 转文字 ──
|
||||
mk_client = get_mediakit_client()
|
||||
text = ""
|
||||
duration = 0.0
|
||||
|
||||
# B1:MediaKit 云端 ASR(不下载视频,最快)
|
||||
if mk_client.is_available:
|
||||
last_err_stage = "asr"
|
||||
try:
|
||||
task_id = mk_client.asr_submit(direct_url)
|
||||
text, duration = mk_client.asr_poll(task_id)
|
||||
text = text.strip()
|
||||
if text:
|
||||
logger.info(
|
||||
"抖音 MediaKit ASR 成功: source=%s text_len=%d duration=%.1f total_time=%.1fs",
|
||||
result.source,
|
||||
len(text),
|
||||
duration,
|
||||
time.time() - t0,
|
||||
)
|
||||
else:
|
||||
logger.info("抖音 MediaKit ASR 返回空文本(无旁白/BGM视频)")
|
||||
except MediaKitError as exc:
|
||||
logger.warning("MediaKit ASR 失败,回退本地 ASR: %s", exc)
|
||||
text = ""
|
||||
|
||||
# B2:回退下载 + 本地 ASR
|
||||
if not text:
|
||||
last_err_stage = "download"
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="douyin_extract_") as temp_dir:
|
||||
text, dl_duration = _direct_url_download_and_local_asr(direct_url, page_url, temp_dir)
|
||||
text = (text or "").strip()
|
||||
if dl_duration and not duration:
|
||||
duration = dl_duration
|
||||
if text:
|
||||
logger.info(
|
||||
"抖音本地 ASR 成功: source=%s text_len=%d total_time=%.1fs",
|
||||
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 Exception as exc: # noqa: BLE001
|
||||
logger.warning("本地 ASR 链路异常: %s", exc)
|
||||
text = ""
|
||||
|
||||
# ── Phase C:结果判定 & 兜底 ──
|
||||
|
||||
# ASR 空结果(无旁白视频)→ 使用解析源 desc 兜底
|
||||
if not text and feed_desc:
|
||||
text = feed_desc
|
||||
logger.info("抖音 ASR 空结果,使用解析源 desc 兜底: desc_len=%d", len(text))
|
||||
|
||||
if not text:
|
||||
stage_msg = {
|
||||
"parse": "抖音视频链接解析失败,请检查链接是否正确或稍后重试",
|
||||
"download": "抖音视频下载失败,请检查网络或稍后重试",
|
||||
"asr": "抖音语音识别失败,请稍后重试或手动输入文案",
|
||||
}
|
||||
user_msg = stage_msg.get(last_err_stage, "抖音链接解析暂时不可用,请稍后重试或手动输入文案")
|
||||
if _DOUYIN_DEBUG_ERRORS:
|
||||
user_msg = user_msg + f" [debug: stage={last_err_stage} source={result.source}]"
|
||||
logger.warning("抖音文案提取失败: url=%s stage=%s source=%s", page_url, last_err_stage, result.source)
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=user_msg)
|
||||
|
||||
return ExtractFromDouyinResponse(
|
||||
text=text,
|
||||
duration_seconds=duration,
|
||||
source_url=source_url,
|
||||
source_url=page_url,
|
||||
)
|
||||
|
||||
|
||||
# ── 2. AI 文案改写 ───────────────────────────────────────────────────────────
|
||||
# ── 2. AI 文案改写 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/ai-rewrite",
|
||||
response_model=AiRewriteResponse,
|
||||
)
|
||||
@router.post("/ai-rewrite", response_model=AiRewriteResponse)
|
||||
@points_gate("ai_rewrite")
|
||||
def ai_rewrite(
|
||||
request: AiRewriteRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> AiRewriteResponse:
|
||||
"""使用豆包大模型改写文案."""
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
content = (request.content or "").strip()
|
||||
if not content:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="文案内容不能为空",
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文案内容不能为空")
|
||||
style = request.style or "口语化"
|
||||
|
||||
client = get_doubao_client()
|
||||
if not client.is_available:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="AI 服务不可用,请联系管理员配置豆包大模型 API Key",
|
||||
)
|
||||
|
||||
system_prompt = (
|
||||
"你是一个专业的短视频文案改写专家。请对以下文案进行改写,"
|
||||
"要求:保留原意、口语化、适合短视频口播、调整语序避免查重。"
|
||||
)
|
||||
if style:
|
||||
system_prompt += f"\n风格要求:{style}"
|
||||
|
||||
user_prompt = f"请改写以下文案:\n\n{content}"
|
||||
|
||||
system_prompt = system_prompt + "\n风格要求:" + style
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
{"role": "user", "content": "请改写以下文案:\n\n" + content},
|
||||
]
|
||||
|
||||
try:
|
||||
rewritten = client.chat_completion(
|
||||
messages=messages,
|
||||
temperature=0.8,
|
||||
max_tokens=2048,
|
||||
)
|
||||
rewritten = client.chat_completion(messages=messages, temperature=0.8, max_tokens=2048)
|
||||
except Exception as exc:
|
||||
logger.error("AI 改写调用失败: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"AI 改写失败: {exc}",
|
||||
) from exc
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="AI 改写失败: " + str(exc)) from exc
|
||||
if not rewritten:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="AI 改写未返回有效结果",
|
||||
)
|
||||
|
||||
return AiRewriteResponse(
|
||||
original=content,
|
||||
rewritten=rewritten.strip(),
|
||||
style=style,
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="AI 改写未返回有效结果")
|
||||
return AiRewriteResponse(original=content, rewritten=rewritten.strip(), style=style)
|
||||
|
||||
|
||||
# ── 3. AI 标题生成 ───────────────────────────────────────────────────────────
|
||||
# ── 3. AI 标题生成 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/ai-generate-titles",
|
||||
response_model=AiGenerateTitlesResponse,
|
||||
)
|
||||
@router.post("/ai-generate-titles", response_model=AiGenerateTitlesResponse)
|
||||
@points_gate("ai_title")
|
||||
def ai_generate_titles(
|
||||
request: AiGenerateTitlesRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> AiGenerateTitlesResponse:
|
||||
"""使用现有 generate_smart_titles 生成标题."""
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
content = (request.content or "").strip()
|
||||
if not content:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="文案内容不能为空",
|
||||
)
|
||||
|
||||
# count 限制在 1-5(Pydantic ge=1 le=5 已校验),但为兼容直接调用场景截断
|
||||
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,
|
||||
)
|
||||
|
||||
result = generate_smart_titles(description=content, style="viral", count=count)
|
||||
titles = result.get("titles", [])[:count]
|
||||
|
||||
return AiGenerateTitlesResponse(titles=titles)
|
||||
|
||||
@@ -5,13 +5,16 @@ from __future__ import annotations
|
||||
import logging
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_user_repository
|
||||
from app.schemas.subscription import (
|
||||
BillingCycle,
|
||||
BillingRecord,
|
||||
ChangePlanRequest,
|
||||
ChangePlanResponse,
|
||||
MembershipType,
|
||||
SimpleResponse,
|
||||
SubscriptionInfo,
|
||||
ToggleAutoRenewRequest,
|
||||
@@ -25,43 +28,18 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============ 配额定义(硬编码,后续可迁移到配置中心) ============
|
||||
# ============ 会员展示名称(与 packages.domain.points_rules.MEMBERSHIP_PRICES 对应)============
|
||||
|
||||
PLAN_QUOTAS = {
|
||||
"free": {"max_projects": 3, "max_storage_gb": 10},
|
||||
"standard": {"max_projects": 10, "max_storage_gb": 50},
|
||||
"pro": {"max_projects": -1, "max_storage_gb": 100},
|
||||
"enterprise": {"max_projects": -1, "max_storage_gb": 1000},
|
||||
_PLAN_NAMES: dict[str, str] = {
|
||||
MembershipType.FREE: "免费用户",
|
||||
MembershipType.MONTHLY: "月卡会员",
|
||||
MembershipType.QUARTERLY: "季卡会员",
|
||||
MembershipType.YEARLY: "年卡会员",
|
||||
}
|
||||
|
||||
|
||||
# ============ Helper Functions ============
|
||||
|
||||
|
||||
def _get_plan_name(plan_id: str) -> str:
|
||||
"""获取套餐显示名称"""
|
||||
plan_names = {
|
||||
"free": "体验版",
|
||||
"standard": "标准版",
|
||||
"pro": "专业版",
|
||||
"enterprise": "企业版",
|
||||
}
|
||||
return plan_names.get(plan_id, "未知套餐")
|
||||
|
||||
|
||||
def _get_plan_price(plan_id: str, billing_cycle: str) -> float:
|
||||
"""获取套餐价格"""
|
||||
prices = {
|
||||
("free", "monthly"): 0,
|
||||
("free", "yearly"): 0,
|
||||
("standard", "monthly"): 99,
|
||||
("standard", "yearly"): 999,
|
||||
("pro", "monthly"): 299,
|
||||
("pro", "yearly"): 2999,
|
||||
("enterprise", "monthly"): 999,
|
||||
("enterprise", "yearly"): 9999,
|
||||
}
|
||||
return prices.get((plan_id, billing_cycle), 0)
|
||||
return _PLAN_NAMES.get(plan_id, "免费用户")
|
||||
|
||||
|
||||
def _build_subscription_info(user: AuthenticatedUser) -> SubscriptionInfo:
|
||||
@@ -74,15 +52,20 @@ def _build_subscription_info(user: AuthenticatedUser) -> SubscriptionInfo:
|
||||
period_start = now.isoformat()
|
||||
period_end = now.isoformat()
|
||||
|
||||
plan_id = user.user.subscription_plan or MembershipType.FREE
|
||||
# 旧档位(standard/pro/enterprise)统一降级为 monthly,避免前端炸掉
|
||||
if plan_id in {"standard", "pro", "enterprise"}:
|
||||
plan_id = MembershipType.MONTHLY
|
||||
|
||||
return SubscriptionInfo(
|
||||
id=f"sub-{user.user.id[:8]}",
|
||||
plan_id=user.user.subscription_plan or "free",
|
||||
plan_name=_get_plan_name(user.user.subscription_plan or "free"),
|
||||
plan_id=plan_id,
|
||||
plan_name=_get_plan_name(plan_id),
|
||||
status=user.user.subscription_status or "active",
|
||||
billing_cycle="monthly",
|
||||
billing_cycle=plan_id if plan_id != MembershipType.FREE else BillingCycle.MONTHLY,
|
||||
current_period_start=period_start,
|
||||
current_period_end=period_end,
|
||||
amount=_get_plan_price(user.user.subscription_plan or "free", "monthly"),
|
||||
amount=0 if plan_id == MembershipType.FREE else 0, # 金额由前端 /plans 接口展示
|
||||
auto_renew=True,
|
||||
created_at=user.user.created_at.isoformat() if user.user.created_at else now.isoformat(),
|
||||
)
|
||||
@@ -99,6 +82,39 @@ async def get_current_subscription(
|
||||
return _build_subscription_info(current_user)
|
||||
|
||||
|
||||
@router.get("/plans")
|
||||
def list_membership_plans(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""查询所有会员档位(供前端会员购买页展示)。
|
||||
|
||||
返回 points 积分体系下的会员档位(月卡/季卡/年卡),含价格、时长、积分折扣等信息。
|
||||
"""
|
||||
from packages.domain.points_rules import MEMBER_DISCOUNT, MEMBERSHIP_PRICES
|
||||
|
||||
plans: list[dict[str, Any]] = []
|
||||
for plan_id, info in MEMBERSHIP_PRICES.items():
|
||||
days = info["duration_days"]
|
||||
monthly_cents = round(info["price_cents"] * 30 / days)
|
||||
features: dict[str, Any] = {"max_resolution": "1080p"}
|
||||
if plan_id == MembershipType.MONTHLY:
|
||||
features.update({"free_clips_daily": 2})
|
||||
elif plan_id == MembershipType.QUARTERLY:
|
||||
features.update({"free_clips_daily": 5})
|
||||
elif plan_id == MembershipType.YEARLY:
|
||||
features.update({"free_clips_daily": "unlimited"})
|
||||
plans.append({
|
||||
"plan_id": plan_id,
|
||||
"name": info["name"],
|
||||
"price_cents": info["price_cents"],
|
||||
"monthly_price_cents": monthly_cents,
|
||||
"duration_days": days,
|
||||
"points_discount": MEMBER_DISCOUNT.get(plan_id, 1.0),
|
||||
"features": features,
|
||||
})
|
||||
return {"plans": plans}
|
||||
|
||||
|
||||
@router.get("/billing-records", response_model=list[BillingRecord])
|
||||
async def get_billing_records(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -117,7 +133,7 @@ async def get_billing_records(
|
||||
return [
|
||||
BillingRecord(
|
||||
id=r.id,
|
||||
plan_name=r.plan_name,
|
||||
plan_name=_get_plan_name(r.plan_name),
|
||||
amount=r.amount,
|
||||
billing_cycle=r.billing_cycle,
|
||||
status=r.status,
|
||||
@@ -131,6 +147,10 @@ async def get_billing_records(
|
||||
session.close()
|
||||
|
||||
|
||||
_VALID_PLANS = {MembershipType.MONTHLY, MembershipType.QUARTERLY, MembershipType.YEARLY}
|
||||
_VALID_CYCLES = {BillingCycle.MONTHLY, BillingCycle.QUARTERLY, BillingCycle.YEARLY}
|
||||
|
||||
|
||||
@router.post("/change-plan", response_model=ChangePlanResponse)
|
||||
async def change_plan(
|
||||
request: ChangePlanRequest,
|
||||
@@ -139,47 +159,45 @@ async def change_plan(
|
||||
) -> ChangePlanResponse:
|
||||
"""变更订阅套餐(升级/降级)"""
|
||||
# TODO: 接入支付验证(支付宝/微信支付)
|
||||
valid_plans = {"free", "standard", "pro", "enterprise"}
|
||||
if request.target_plan_id not in valid_plans:
|
||||
target_plan = request.target_plan_id
|
||||
if target_plan not in _VALID_PLANS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"无效的套餐ID。支持的套餐: {', '.join(valid_plans)}",
|
||||
detail=f"无效的会员类型。支持: {', '.join(sorted(_VALID_PLANS))}",
|
||||
)
|
||||
|
||||
valid_cycles = {"monthly", "yearly"}
|
||||
if request.billing_cycle not in valid_cycles:
|
||||
if request.billing_cycle not in _VALID_CYCLES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无效的计费周期。支持: monthly, yearly",
|
||||
detail=f"无效的计费周期。支持: {', '.join(sorted(_VALID_CYCLES))}",
|
||||
)
|
||||
|
||||
user = current_user.user
|
||||
current_plan = user.subscription_plan or "free"
|
||||
target_plan = request.target_plan_id
|
||||
current_plan = user.subscription_plan or MembershipType.FREE
|
||||
# 旧档位归一化,避免永远显示"您已经是xxx"
|
||||
if current_plan in {"standard", "pro", "enterprise"}:
|
||||
current_plan = MembershipType.MONTHLY
|
||||
|
||||
if current_plan == target_plan:
|
||||
return ChangePlanResponse(
|
||||
success=False,
|
||||
message=f"您已经是 {_get_plan_name(target_plan)}",
|
||||
message=f"您已经是{_get_plan_name(target_plan)}",
|
||||
)
|
||||
|
||||
# 通过 dataclasses.replace 创建新实例(不直接修改 dataclass)
|
||||
quotas = PLAN_QUOTAS.get(target_plan, PLAN_QUOTAS["free"])
|
||||
updated_user = replace(
|
||||
user,
|
||||
subscription_plan=target_plan,
|
||||
subscription_status="active",
|
||||
max_projects=quotas["max_projects"],
|
||||
max_storage_gb=quotas["max_storage_gb"],
|
||||
max_projects=-1, # 付费会员不限项目数
|
||||
max_storage_gb=100,
|
||||
)
|
||||
user_repository.save(updated_user)
|
||||
|
||||
# 用更新后的用户构造响应
|
||||
refreshed_auth_user = AuthenticatedUser(user=updated_user)
|
||||
|
||||
return ChangePlanResponse(
|
||||
success=True,
|
||||
message=f"套餐已成功变更为 {_get_plan_name(target_plan)}",
|
||||
message=f"套餐已成功变更为{_get_plan_name(target_plan)}",
|
||||
new_subscription=_build_subscription_info(refreshed_auth_user),
|
||||
)
|
||||
|
||||
@@ -191,10 +209,11 @@ async def cancel_subscription(
|
||||
) -> SimpleResponse:
|
||||
"""取消订阅"""
|
||||
user = current_user.user
|
||||
if user.subscription_plan == "free":
|
||||
plan_id = user.subscription_plan or MembershipType.FREE
|
||||
if plan_id == MembershipType.FREE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="体验版无需取消",
|
||||
detail="免费用户无需取消订阅",
|
||||
)
|
||||
|
||||
updated_user = replace(user, subscription_status="cancelled")
|
||||
@@ -202,7 +221,7 @@ async def cancel_subscription(
|
||||
|
||||
return SimpleResponse(
|
||||
success=True,
|
||||
message="订阅已取消,当前周期结束后停止服务",
|
||||
message="订阅已取消,当前周期结束后将降级为免费用户",
|
||||
)
|
||||
|
||||
|
||||
@@ -228,11 +247,14 @@ async def payment_callback(
|
||||
if SessionLocal is None:
|
||||
raise HTTPException(status_code=500, detail="Database not available")
|
||||
|
||||
# 仅接受当前会员体系的 plan 值
|
||||
if plan not in _VALID_PLANS:
|
||||
raise HTTPException(status_code=400, detail=f"未知的会员类型: {plan}")
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
repo = SQLAlchemyBillingRepository(session)
|
||||
|
||||
# 创建账单记录
|
||||
record_id = uuid.uuid4().hex
|
||||
repo.create(
|
||||
{
|
||||
@@ -245,19 +267,20 @@ async def payment_callback(
|
||||
}
|
||||
)
|
||||
|
||||
# 在事务中标记支付成功并更新订阅
|
||||
repo.mark_paid(record_id, payment_method, payment_id)
|
||||
|
||||
# 计算到期时间
|
||||
days = 365 if billing_cycle == "yearly" else 30
|
||||
days_map = {BillingCycle.MONTHLY: 30, BillingCycle.QUARTERLY: 90, BillingCycle.YEARLY: 365}
|
||||
days = days_map.get(billing_cycle, 30)
|
||||
expires_at = datetime.now(UTC) + timedelta(days=days)
|
||||
repo.update_subscription_on_payment(user_id, plan, expires_at)
|
||||
|
||||
return {"success": True, "message": "支付成功", "record_id": record_id}
|
||||
except HTTPException:
|
||||
session.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error(f"支付回调处理失败: user_id={user_id}, plan={plan}, error={e}")
|
||||
# 不返回原始异常信息,避免泄漏内部实现细节
|
||||
logger.error("支付回调处理失败: user_id=%s, plan=%s, error=%s", user_id, plan, e)
|
||||
raise HTTPException(status_code=500, detail="支付处理失败,请稍后重试") from e
|
||||
finally:
|
||||
session.close()
|
||||
@@ -269,10 +292,5 @@ async def toggle_auto_renew(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> SimpleResponse:
|
||||
"""切换自动续费"""
|
||||
# TODO: 实际需要在数据库中存储 auto_renew 字段
|
||||
status_text = "已开启自动续费" if request.enabled else "已关闭自动续费"
|
||||
|
||||
return SimpleResponse(
|
||||
success=True,
|
||||
message=status_text,
|
||||
)
|
||||
return SimpleResponse(success=True, message=status_text)
|
||||
|
||||
@@ -1,191 +1,35 @@
|
||||
"""Title library CRUD routes."""
|
||||
"""Title library routes — DEPRECATED (#1894).
|
||||
|
||||
独立标题库已废弃。前端应直接调用 GET /api/v1/scripts 获取文案列表,
|
||||
取每条文案的 `title` 字段作为标题候选。
|
||||
|
||||
所有 /api/v1/titles 端点统一返回 HTTP 410 Gone。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from app.api.routes._helpers import get_user_plan
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_user_repository
|
||||
from app.schemas.title_library import (
|
||||
CreateTitleLibraryRequest,
|
||||
ListTitleLibraryResponse,
|
||||
TitleLibraryItemResponse,
|
||||
UpdateTitleLibraryRequest,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.title_library_repository import SQLAlchemyTitleLibraryRepository
|
||||
from packages.application.title_library.commands import (
|
||||
CreateTitleLibraryCommand,
|
||||
PickTitleCommand,
|
||||
UpdateTitleLibraryCommand,
|
||||
)
|
||||
from packages.application.title_library.use_cases import (
|
||||
CreateTitleLibraryUseCase,
|
||||
DeleteTitleLibraryUseCase,
|
||||
GetTitleLibraryUseCase,
|
||||
ListTitleLibraryUseCase,
|
||||
NotFoundError,
|
||||
PickTitleUseCase,
|
||||
QuotaExceededError,
|
||||
UpdateTitleLibraryUseCase,
|
||||
)
|
||||
from packages.ports.user_repository import UserRepository
|
||||
from fastapi import APIRouter, Response, status
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_title_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyTitleLibraryRepository:
|
||||
return SQLAlchemyTitleLibraryRepository(session)
|
||||
_GONE_MESSAGE = (
|
||||
"标题库 API 已废弃(#1894):独立标题库已合并进文案库,"
|
||||
"请使用 GET /api/v1/scripts 获取文案列表并取 title 字段作为标题。"
|
||||
)
|
||||
|
||||
|
||||
def _to_response(item) -> TitleLibraryItemResponse:
|
||||
return TitleLibraryItemResponse(
|
||||
id=item.id,
|
||||
user_id=item.user_id,
|
||||
name=item.name,
|
||||
text=item.text,
|
||||
category=item.category,
|
||||
description=item.description,
|
||||
tags=item.tags,
|
||||
usage_count=item.usage_count,
|
||||
is_active=item.is_active,
|
||||
created_at=item.created_at,
|
||||
updated_at=item.updated_at,
|
||||
)
|
||||
def _gone(response: Response) -> dict:
|
||||
response.status_code = status.HTTP_410_GONE
|
||||
response.headers["Deprecation"] = "true"
|
||||
response.headers["Sunset"] = "Tue, 16 Sep 2026 00:00:00 GMT"
|
||||
return {"error": {"code": "GONE", "message": _GONE_MESSAGE}}
|
||||
|
||||
|
||||
@router.get("", response_model=ListTitleLibraryResponse)
|
||||
def list_titles(
|
||||
category: Optional[str] = Query(None),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
|
||||
) -> ListTitleLibraryResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
use_case = ListTitleLibraryUseCase(title_repository)
|
||||
items = use_case.execute(user_id, category=category, skip=skip, limit=limit)
|
||||
total = title_repository.count_by_user(user_id)
|
||||
return ListTitleLibraryResponse(
|
||||
items=[_to_response(i) for i in items],
|
||||
total=total,
|
||||
)
|
||||
@router.api_route("", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||
def titles_root_gone(response: Response) -> dict:
|
||||
return _gone(response)
|
||||
|
||||
|
||||
@router.post("/pick", response_model=TitleLibraryItemResponse)
|
||||
def pick_title(
|
||||
category: Optional[str] = Query(None, description="按分类筛选,不填则从全部标题中选"),
|
||||
exclude_ids: Optional[str] = Query(
|
||||
None,
|
||||
description="排除的标题ID(逗号分隔),用于批量生成时避免重复",
|
||||
),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
|
||||
) -> TitleLibraryItemResponse:
|
||||
"""智能选择一个标题。
|
||||
|
||||
策略:优先使用次数少的,从最少的前5个中随机选一个,兼顾公平和多样性。
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
exclude_list: list[str] = []
|
||||
if exclude_ids:
|
||||
exclude_list = [t.strip() for t in exclude_ids.split(",") if t.strip()]
|
||||
|
||||
use_case = PickTitleUseCase(title_repository)
|
||||
item = use_case.execute(
|
||||
PickTitleCommand(
|
||||
user_id=user_id,
|
||||
category=category,
|
||||
exclude_ids=exclude_list,
|
||||
)
|
||||
)
|
||||
if item is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="标题库为空,请先添加标题",
|
||||
)
|
||||
return _to_response(item)
|
||||
|
||||
|
||||
@router.get("/{title_id}", response_model=TitleLibraryItemResponse)
|
||||
def get_title(
|
||||
title_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
|
||||
) -> TitleLibraryItemResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
use_case = GetTitleLibraryUseCase(title_repository)
|
||||
item = use_case.execute(title_id, user_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
|
||||
return _to_response(item)
|
||||
|
||||
|
||||
@router.post("", response_model=TitleLibraryItemResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_title(
|
||||
request: CreateTitleLibraryRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> TitleLibraryItemResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
plan_name = get_user_plan(user_id, user_repository)
|
||||
command = CreateTitleLibraryCommand(
|
||||
user_id=user_id,
|
||||
name=request.name,
|
||||
text=request.text,
|
||||
category=request.category,
|
||||
description=request.description,
|
||||
tags=request.tags,
|
||||
)
|
||||
use_case = CreateTitleLibraryUseCase(title_repository)
|
||||
try:
|
||||
item = use_case.execute(command, plan_name=plan_name)
|
||||
except QuotaExceededError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"标题库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
||||
) from exc
|
||||
return _to_response(item)
|
||||
|
||||
|
||||
@router.put("/{title_id}", response_model=TitleLibraryItemResponse)
|
||||
def update_title(
|
||||
title_id: str,
|
||||
request: UpdateTitleLibraryRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
|
||||
) -> TitleLibraryItemResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
command = UpdateTitleLibraryCommand(
|
||||
title_id=title_id,
|
||||
user_id=user_id,
|
||||
name=request.name,
|
||||
text=request.text,
|
||||
category=request.category,
|
||||
description=request.description,
|
||||
tags=request.tags,
|
||||
)
|
||||
use_case = UpdateTitleLibraryUseCase(title_repository)
|
||||
try:
|
||||
item = use_case.execute(command)
|
||||
except NotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found") from _e
|
||||
return _to_response(item)
|
||||
|
||||
|
||||
@router.delete("/{title_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
def delete_title(
|
||||
title_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
|
||||
) -> Response:
|
||||
user_id = authenticated_user.user.id
|
||||
use_case = DeleteTitleLibraryUseCase(title_repository)
|
||||
deleted = use_case.execute(title_id, user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
|
||||
return
|
||||
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||
def titles_subpath_gone(response: Response, path: str) -> dict:
|
||||
return _gone(response)
|
||||
|
||||
@@ -4,12 +4,14 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.config import settings
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
@@ -51,6 +53,8 @@ from packages.application.tts_job.use_cases import (
|
||||
)
|
||||
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.domain.voice_presets import list_voices
|
||||
from packages.ports.asset_library_repository import AssetLibraryRepository
|
||||
from packages.ports.asset_repository import AssetRepository
|
||||
@@ -128,6 +132,7 @@ def _to_response(job, sign_url=None) -> TTSJobResponse:
|
||||
def synthesize(
|
||||
request: TTSSynthesizeRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
|
||||
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
voice_clone_repo=Depends(get_voice_clone_profile_repository),
|
||||
@@ -139,6 +144,31 @@ def synthesize(
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
|
||||
# ── 积分扣点(#1895 P2) ──
|
||||
_points_deducted = 0
|
||||
_points_scene = "ai_voice"
|
||||
_points_svc = PointsService() if settings.points_enabled else None
|
||||
if _points_svc is not None:
|
||||
# 中文按 ~240 字/分钟粗估时长,至少按 1 分钟扣 1 分
|
||||
est_minutes = max(1.0, math.ceil(len(request.text) / 240))
|
||||
_points_deducted = calculate_points_cost(
|
||||
_points_scene,
|
||||
is_member=getattr(authenticated_user.user, "is_member", False),
|
||||
duration_minutes=est_minutes,
|
||||
member_type=getattr(authenticated_user.user, "member_type", None),
|
||||
)
|
||||
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
|
||||
if not _deduct_res["success"]:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
|
||||
"required": _points_deducted,
|
||||
"balance": _deduct_res["balance"],
|
||||
},
|
||||
)
|
||||
|
||||
# 解析 voice_id:前端可能传克隆音色 profile UUID(而非 CosyVoice voice_id),
|
||||
# 与 /tts/preview 保持一致:命中 profile → 校验归属 → 取 CosyVoice voice_id
|
||||
actual_voice_id = request.voice_id
|
||||
@@ -177,6 +207,9 @@ 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_:
|
||||
@@ -198,6 +231,7 @@ def synthesize(
|
||||
cosyvoice_service=cosyvoice_service,
|
||||
)
|
||||
|
||||
synthesis_error: Exception | None = None
|
||||
try:
|
||||
job = workflow.start_synthesis(job.id)
|
||||
except Exception as e:
|
||||
@@ -205,10 +239,17 @@ def synthesize(
|
||||
# 但 DB 异常、网络异常等意外错误可能逃逸。
|
||||
# 与音色克隆接口保持一致:标记 failed,返回 201,不抛 500。
|
||||
logger.error(f"TTS 合成异常: job_id={job.id}, error={e}", exc_info=True)
|
||||
synthesis_error = e
|
||||
try:
|
||||
job = workflow.process_synthesis_failure(job.id, str(e))
|
||||
except Exception as inner_e:
|
||||
logger.error(f"标记 TTS job 失败时出错: job_id={job.id}, error={inner_e}")
|
||||
# 合成失败且已扣积分 → 退费
|
||||
if synthesis_error is not None and _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"TTS 合失败退积分异常: job_id={job.id}, err={refund_err}")
|
||||
|
||||
# 若任务处于 processing 状态(异步模式),触发 Celery 后台轮询
|
||||
if job.status.value == "processing":
|
||||
@@ -223,10 +264,17 @@ def synthesize(
|
||||
celery_app.send_task("worker.process_tts_synthesis", args=[job.id])
|
||||
except Exception as e:
|
||||
# Celery 调度失败,标记 job 为 failed
|
||||
# e used below for refund context
|
||||
try:
|
||||
workflow.process_synthesis_failure(job.id, f"Celery 任务调度失败: {e}")
|
||||
except Exception as inner_e:
|
||||
logger.error(f"Celery 调度后标记失败时出错: job_id={job.id}, error={inner_e}")
|
||||
# 调度失败退费
|
||||
if _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"Celery 调度失败退积分异常: job_id={job.id}, err={refund_err}")
|
||||
|
||||
return TTSSynthesizeResponse(
|
||||
job_id=job.id,
|
||||
@@ -553,6 +601,7 @@ def save_tts_job_to_library(
|
||||
def preview_tts(
|
||||
request: TTSPreviewRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
voice_clone_repo=Depends(get_voice_clone_profile_repository),
|
||||
) -> TTSPreviewResponse:
|
||||
@@ -561,6 +610,31 @@ def preview_tts(
|
||||
用于前端预览配音效果,限制文本长度 200 字以内。
|
||||
支持预设音色和克隆音色:克隆音色传的是 profile UUID,需解析为 CosyVoice voice_id。
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
# ── 积分扣点(#1895 P2) ──
|
||||
_points_deducted = 0
|
||||
_points_scene = "ai_voice"
|
||||
_points_svc = PointsService() if settings.points_enabled else None
|
||||
if _points_svc is not None:
|
||||
est_minutes = max(1.0, math.ceil(len(request.text) / 240))
|
||||
_points_deducted = calculate_points_cost(
|
||||
_points_scene,
|
||||
is_member=getattr(authenticated_user.user, "is_member", False),
|
||||
duration_minutes=est_minutes,
|
||||
member_type=getattr(authenticated_user.user, "member_type", None),
|
||||
)
|
||||
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
|
||||
if not _deduct_res["success"]:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
|
||||
"required": _points_deducted,
|
||||
"balance": _deduct_res["balance"],
|
||||
},
|
||||
)
|
||||
|
||||
# 解析 voice_id:前端可能传 VoiceCloneProfile UUID 或预设音色 ID
|
||||
actual_voice_id = request.voice_id
|
||||
profile = voice_clone_repo.get(request.voice_id)
|
||||
@@ -583,19 +657,22 @@ 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"),
|
||||
)
|
||||
except CosyVoiceError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"TTS 合成失败: {e}",
|
||||
) from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
except (CosyVoiceError, ValueError) as e:
|
||||
# 合成失败退费
|
||||
if _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"TTS 预览失败退积分异常: {refund_err}")
|
||||
if isinstance(e, CosyVoiceError):
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"TTS 合成失败: {e}") from e
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
return TTSPreviewResponse(
|
||||
audio_url=result.audio_url,
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.config import settings
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_repository,
|
||||
get_cosyvoice_service,
|
||||
get_db_session,
|
||||
get_project_repository,
|
||||
get_voice_clone_profile_repository,
|
||||
)
|
||||
@@ -22,6 +25,7 @@ from app.schemas.voice_clone import (
|
||||
VoiceCloneStatusResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import (
|
||||
SQLAlchemyVoiceCloneProfileRepository,
|
||||
@@ -38,6 +42,11 @@ from packages.application.voice_clone.use_cases import (
|
||||
from packages.application.voice_clone.workflow import (
|
||||
VoiceCloneWorkflowService,
|
||||
)
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
from packages.domain.points_service import PointsService
|
||||
|
||||
# remove duplicate
|
||||
_DUMMY_DELETED = ()
|
||||
from packages.ports.asset_repository import AssetRepository
|
||||
from packages.ports.project_repository import ProjectRepository
|
||||
from packages.shared.storage import SharedStorageService
|
||||
@@ -318,8 +327,12 @@ _ALLOWED_PREVIEW_EMOTIONS = {
|
||||
"悲伤",
|
||||
"愤怒",
|
||||
"惊奇",
|
||||
"吃惊",
|
||||
"害怕",
|
||||
"讨厌",
|
||||
# 灵应 P1 指定别名
|
||||
"中性",
|
||||
"伤心",
|
||||
"沉稳",
|
||||
"亲切",
|
||||
}
|
||||
@@ -335,6 +348,7 @@ def get_voice_clone_preview(
|
||||
description="情绪:neutral/happy/sad/angry/surprised/fearful/disgusted,兼容旧值 natural/excited/calm/friendly,空为默认自然",
|
||||
),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository),
|
||||
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
) -> VoiceClonePreviewResponse:
|
||||
@@ -346,10 +360,35 @@ def get_voice_clone_preview(
|
||||
"""
|
||||
import time
|
||||
|
||||
user_id = authenticated_user.user.id
|
||||
_points_deducted = 0
|
||||
_points_scene = "voice_clone_synth"
|
||||
_points_svc = PointsService() if settings.points_enabled else None
|
||||
_preview_text_for_points = text.strip() or CLONE_PREVIEW_TEMPLATE
|
||||
if _points_svc is not None:
|
||||
est_minutes = max(1.0, math.ceil(len(_preview_text_for_points) / 240))
|
||||
_points_deducted = calculate_points_cost(
|
||||
_points_scene,
|
||||
is_member=getattr(authenticated_user.user, "is_member", False),
|
||||
duration_minutes=est_minutes,
|
||||
member_type=getattr(authenticated_user.user, "member_type", None),
|
||||
)
|
||||
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
|
||||
if not _deduct_res["success"]:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
|
||||
"required": _points_deducted,
|
||||
"balance": _deduct_res["balance"],
|
||||
},
|
||||
)
|
||||
|
||||
if emotion not in _ALLOWED_PREVIEW_EMOTIONS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"不支持的 emotion 值: {emotion},可选: neutral/happy/sad/angry/surprised/fearful/disgusted 或中文 中立/开心/难过/生气/惊讶/恐惧/厌恶,或留空",
|
||||
detail=f"不支持的 emotion 值: {emotion},可选: neutral/happy/sad/angry/surprised/fearful/disgusted 或中文 中立/中性/开心/难过/伤心/生气/愤怒/惊讶/吃惊/恐惧/害怕/厌恶/讨厌 或留空",
|
||||
)
|
||||
|
||||
use_case = GetVoiceCloneUseCase(repository)
|
||||
@@ -389,9 +428,14 @@ def get_voice_clone_preview(
|
||||
speed=speed,
|
||||
emotion=emotion,
|
||||
)
|
||||
except CosyVoiceError as e:
|
||||
raise HTTPException(status_code=502, detail=f"TTS 合成失败: {e}") from e
|
||||
except ValueError as e:
|
||||
except (CosyVoiceError, ValueError) as e:
|
||||
if _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"克隆音色试听失败退积分异常: clone_id={clone_id}, err={refund_err}")
|
||||
if isinstance(e, CosyVoiceError):
|
||||
raise HTTPException(status_code=502, detail=f"TTS 合成失败: {e}") from e
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
# 缓存(仅默认参数组合)
|
||||
|
||||
@@ -53,9 +53,14 @@ 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)"
|
||||
default_factory=dict,
|
||||
description="标题配置(可含 title_image_dataurl:前端 Canvas 渲染的标题 PNG dataURL;含 line_overrides 逐行样式)",
|
||||
)
|
||||
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")
|
||||
@@ -83,6 +88,7 @@ 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
|
||||
|
||||
@@ -98,6 +98,24 @@ 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 音色 ID(preset 为 CosyVoice 音色 id;clone 为克隆档案 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 配音严格守卫。
|
||||
@@ -127,6 +145,26 @@ 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_id(TTS 音色 ID)")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
|
||||
has_project = bool(self.project_id.strip())
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""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="人物视频预签名下载 URL(GET)")
|
||||
audio_url: str = Field(..., description="驱动音频预签名下载 URL(GET)")
|
||||
lipsync_job_id: str = ""
|
||||
user_id: str = ""
|
||||
project_id: str = ""
|
||||
created_at: datetime
|
||||
upload_url: str = Field(..., description="结果视频预签名上传 URL(PUT, 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 = ""
|
||||
@@ -29,6 +29,7 @@ class LipsyncJobResponse(BaseModel):
|
||||
voice_id: str = ""
|
||||
script_text: str = ""
|
||||
speed: float = 1.0
|
||||
style: str = ""
|
||||
emotion: str = ""
|
||||
mediakit_task_id: str
|
||||
status: str
|
||||
@@ -67,9 +68,14 @@ 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="情绪(英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted,或中文 中立/开心/难过/生气/惊讶/恐惧/厌恶;空为默认自然)",
|
||||
description="[deprecated] 旧情绪参数,内部映射为 style",
|
||||
)
|
||||
|
||||
enable_video_loop: bool = Field(
|
||||
@@ -123,10 +129,15 @@ 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="情绪(英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted,或中文 中立/开心/难过/生气/惊讶/恐惧/厌恶;默认 neutral)",
|
||||
description="[deprecated] 旧情绪参数,内部映射为 style;默认 neutral",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ class PointRuleItem(BaseModel):
|
||||
base_points: int
|
||||
unit: str
|
||||
extra_per_30s: Optional[int] = None
|
||||
description: str = Field(default="", description="规则中文说明,例如 AI 配音每分钟消耗 X 积分")
|
||||
|
||||
|
||||
class PointsRulesResponse(BaseModel):
|
||||
@@ -139,7 +140,12 @@ class PointsOrderResponse(BaseModel):
|
||||
order_type: str
|
||||
product_code: str
|
||||
amount_cents: int
|
||||
points_amount: int = Field(0, description="本次充值/购买可获得的积分(仅 points 类型订单有意义)")
|
||||
status: str
|
||||
pay_params: dict[str, Any] = Field(
|
||||
default_factory=dict, description="拉起支付所需参数(payment_url/prepay_id 等),支付通道接入后填充"
|
||||
)
|
||||
expire_at: Optional[str] = Field(None, description="订单过期时间(ISO 8601),默认创建后 48 小时")
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
@@ -171,6 +177,27 @@ class MembershipStatusResponse(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
# ============ 订阅档位 ============
|
||||
|
||||
|
||||
class MembershipPlanItem(BaseModel):
|
||||
"""单个会员档位"""
|
||||
|
||||
plan_id: str = Field(..., description="档位标识: monthly/quarterly/yearly")
|
||||
name: str = Field(..., description="档位名称,例如 月卡")
|
||||
monthly_price_cents: int = Field(..., description="折算月价(分)")
|
||||
price_cents: int = Field(..., description="该档位总价(分)")
|
||||
duration_days: int = Field(..., description="时长(天)")
|
||||
points_discount: float = Field(..., description="该档位积分折扣,如 0.9 表示 9 折")
|
||||
features: dict[str, Any] = Field(default_factory=dict, description="档位权益(max_resolution 等)")
|
||||
|
||||
|
||||
class MembershipPlansResponse(BaseModel):
|
||||
"""所有会员档位列表"""
|
||||
|
||||
plans: list[MembershipPlanItem]
|
||||
|
||||
|
||||
# ============ 通用响应 ============
|
||||
|
||||
|
||||
|
||||
@@ -7,15 +7,21 @@ from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# ============ Enums / Types ============
|
||||
# 会员体系(#1951/#1955 实装):
|
||||
# free — 免费用户
|
||||
# monthly — 月卡
|
||||
# quarterly — 季卡
|
||||
# yearly — 年卡
|
||||
# 已废弃档位:standard / pro / enterprise(保留常量名便于识别旧字段,但不在 API 中暴露)
|
||||
|
||||
|
||||
class PlanType(str):
|
||||
"""套餐类型"""
|
||||
class MembershipType(str):
|
||||
"""会员类型(与 packages.domain.points_rules.MEMBERSHIP_PRICES 一致)"""
|
||||
|
||||
FREE = "free"
|
||||
STANDARD = "standard"
|
||||
PRO = "pro"
|
||||
ENTERPRISE = "enterprise"
|
||||
MONTHLY = "monthly"
|
||||
QUARTERLY = "quarterly"
|
||||
YEARLY = "yearly"
|
||||
|
||||
|
||||
class SubscriptionStatus(str):
|
||||
@@ -40,6 +46,7 @@ class BillingCycle(str):
|
||||
"""计费周期"""
|
||||
|
||||
MONTHLY = "monthly"
|
||||
QUARTERLY = "quarterly"
|
||||
YEARLY = "yearly"
|
||||
|
||||
|
||||
@@ -95,8 +102,8 @@ class SimpleResponse(BaseModel):
|
||||
class ChangePlanRequest(BaseModel):
|
||||
"""升级/降级请求"""
|
||||
|
||||
target_plan_id: str = Field(..., description="目标套餐ID")
|
||||
billing_cycle: str = Field(..., description="计费周期: monthly/yearly")
|
||||
target_plan_id: str = Field(..., description="目标会员类型: monthly/quarterly/yearly")
|
||||
billing_cycle: str = Field(..., description="计费周期: monthly/quarterly/yearly")
|
||||
|
||||
|
||||
class ToggleAutoRenewRequest(BaseModel):
|
||||
|
||||
@@ -16,9 +16,15 @@ 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="情绪(中文/英文:自然/兴奋/沉稳/亲切/开心/悲伤/愤怒/惊讶/恐惧/厌恶 等;通过 instruction 自然语言指令控制)",
|
||||
description="[deprecated] 旧情绪参数,内部映射为 style;新接入请使用 style",
|
||||
)
|
||||
voice_model: str = Field("", description="语音模型名称")
|
||||
voice_clone_profile_id: str = Field("", description="关联的音色克隆档案 ID")
|
||||
@@ -113,9 +119,14 @@ 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="语速")
|
||||
emotion: str = Field("", 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")
|
||||
language: str = Field("zh-CN", description="语言(zh-CN/en-US 等)")
|
||||
pitch: float = Field(1.0, ge=0.5, le=2.0, description="音调(预留,当前未使用)")
|
||||
pitch: float = Field(1.0, ge=0.5, le=2.0, description="音调(0.5-2.0),默认 1.0")
|
||||
|
||||
|
||||
class TTSPreviewResponse(BaseModel):
|
||||
|
||||
@@ -61,6 +61,7 @@ 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:
|
||||
"""创建渲染任务.
|
||||
@@ -112,6 +113,7 @@ 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)
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"""抖音视频解析多源轮询服务。
|
||||
|
||||
优先级(P0 最高):
|
||||
P0: App Feed API 直连(零成本,不用 API Key,当前最稳定)
|
||||
P1: TikHub API(付费 $0.001/次起,稳定)
|
||||
P2: apizero.cn 极数本源(按量付费,国内延迟低)
|
||||
|
||||
任一源成功即返回 MP4 直链 + 标题/文案;所有源均失败时返回 None。
|
||||
每个解析源独立超时(5-10s),总耗时不超过所有源超时之和(实际快速失败时远小于此)。
|
||||
未配置 API Key 的源自动跳过;无任何 Key 时 P0 仍可使用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── API Keys from env ──────────────────────────────────────────────────
|
||||
TIKHUB_API_KEY = os.environ.get("TIKHUB_API_KEY", "").strip()
|
||||
APIZERO_API_KEY = os.environ.get("APIZERO_API_KEY", "").strip()
|
||||
|
||||
# ── Timeouts (seconds) ────────────────────────────────────────────────
|
||||
_TIMEOUT_APP_FEED = 12
|
||||
_TIMEOUT_TIKHUB = 6
|
||||
_TIMEOUT_APIZERO = 6
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolveResult:
|
||||
video_url: str # MP4 直链;图文视频时为空字符串
|
||||
desc: str # 视频标题/描述文案
|
||||
source: str # 解析源名称,用于日志/metrics
|
||||
|
||||
|
||||
# ── URL preprocessing ─────────────────────────────────────────────────
|
||||
_AWEME_ID_RE = re.compile(
|
||||
r"(?:douyin\.com/(?:video|note)/|iesdouyin\.com/share/video/|aweme_id=)(\d{15,25})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _extract_url_from_text(text: str) -> str:
|
||||
"""从任意分享文本中提取首个 http(s) URL。"""
|
||||
if not text:
|
||||
return ""
|
||||
m = re.search(r"https?://\S+", text)
|
||||
return m.group(0).rstrip("。,!?!?,,;;\"'))】") if m else "" # noqa: B005
|
||||
|
||||
|
||||
def _canonicalize_url(url: str, timeout: int = 8) -> str:
|
||||
"""跟随 v.douyin.com 短链 302 重定向,返回完整 URL。失败时返回原 URL。"""
|
||||
if "v.douyin.com" not in url and "iesdouyin.com" not in url:
|
||||
return url
|
||||
try:
|
||||
with httpx.Client(
|
||||
timeout=timeout, follow_redirects=True, verify=False, headers={"User-Agent": "Mozilla/5.0"}
|
||||
) as c:
|
||||
resp = c.get(url)
|
||||
return str(resp.url)
|
||||
except Exception as exc:
|
||||
logger.debug("短链解析失败: %s (%s)", url, exc)
|
||||
return url
|
||||
|
||||
|
||||
# ── Provider P0: App Feed API (零成本直连) ────────────────────────────
|
||||
def _resolve_app_feed(url: str, timeout: int = _TIMEOUT_APP_FEED) -> Optional[ResolveResult]:
|
||||
"""抖音 Android App Feed API 直连 — 零依赖、无需 Key、目前最稳定。"""
|
||||
from packages.douyin_parser import fetch_douyin_video_url
|
||||
|
||||
video_url, desc = fetch_douyin_video_url(url, timeout=timeout, max_retries=2)
|
||||
if video_url:
|
||||
return ResolveResult(video_url=video_url, desc=desc or "", source="app_feed")
|
||||
if desc:
|
||||
# 图文视频:video_url 为 None 但 desc 可用
|
||||
return ResolveResult(video_url="", desc=desc, source="app_feed_image")
|
||||
return None
|
||||
|
||||
|
||||
# ── Provider P1: TikHub ───────────────────────────────────────────────
|
||||
def _resolve_tikhub(url: str, api_key: str, timeout: int = _TIMEOUT_TIKHUB) -> Optional[ResolveResult]:
|
||||
"""TikHub API: https://api.tikhub.io/
|
||||
两步:get_aweme_id → fetch_one_video
|
||||
"""
|
||||
if not api_key:
|
||||
return None
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
aweme_id = _AWEME_ID_RE.search(url or "")
|
||||
aweme_id = aweme_id.group(1) if aweme_id else None
|
||||
|
||||
if not aweme_id:
|
||||
try:
|
||||
with httpx.Client(timeout=timeout, verify=False) as c:
|
||||
r = c.get(
|
||||
"https://api.tikhub.io/api/v1/douyin/web/get_aweme_id",
|
||||
headers=headers,
|
||||
params={"url": url},
|
||||
)
|
||||
data = r.json()
|
||||
aweme_id = (data.get("data") or {}).get("aweme_id")
|
||||
except Exception as exc:
|
||||
logger.warning("TikHub get_aweme_id 失败: %s", exc)
|
||||
return None
|
||||
if not aweme_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=timeout, verify=False) as c:
|
||||
r = c.get(
|
||||
"https://api.tikhub.io/api/v1/douyin/app/v3/fetch_one_video",
|
||||
headers=headers,
|
||||
params={"aweme_id": aweme_id},
|
||||
)
|
||||
data = r.json()
|
||||
video = (data.get("data") or {}).get("video") or {}
|
||||
urls = []
|
||||
for k in ("download_addr", "play_addr_h264", "play_addr"):
|
||||
urls = (video.get(k) or {}).get("url_list") or []
|
||||
if urls:
|
||||
break
|
||||
if not urls:
|
||||
# bit_rate 兜底
|
||||
for br in video.get("bit_rate") or []:
|
||||
urls = (br.get("play_addr") or {}).get("url_list") or []
|
||||
if urls:
|
||||
break
|
||||
if not urls:
|
||||
return None
|
||||
# 优先 CDN 直链
|
||||
video_url = urls[0]
|
||||
for u in urls:
|
||||
if any(h in u for h in ("douyinvod.com", "bytecdn.com", "365yg.com")):
|
||||
video_url = u
|
||||
break
|
||||
desc = (data.get("data") or {}).get("desc", "")
|
||||
# 检测图文
|
||||
images = (data.get("data") or {}).get("images") or []
|
||||
if images and not any(h in video_url for h in ("douyinvod.com", "bytecdn.com", "amemv.com")):
|
||||
# 图文且无视频直链
|
||||
if desc:
|
||||
return ResolveResult(video_url="", desc=desc, source="tikhub_image")
|
||||
return None
|
||||
return ResolveResult(video_url=video_url, desc=desc or "", source="tikhub")
|
||||
except Exception as exc:
|
||||
logger.warning("TikHub fetch_one_video 失败: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
# ── Provider P2: apizero.cn ──────────────────────────────────────────
|
||||
def _resolve_apizero(url: str, api_key: str, timeout: int = _TIMEOUT_APIZERO) -> Optional[ResolveResult]:
|
||||
"""apizero.cn 极数本源: https://v1.apizero.cn/api/video-parse?url=...&flat=2"""
|
||||
if not api_key:
|
||||
return None
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
try:
|
||||
with httpx.Client(timeout=timeout, verify=False) as c:
|
||||
r = c.get(
|
||||
"https://v1.apizero.cn/api/video-parse",
|
||||
headers=headers,
|
||||
params={"url": url, "flat": 2},
|
||||
)
|
||||
data = r.json()
|
||||
d = data.get("data") or {}
|
||||
video_list = d.get("video_list") or []
|
||||
if not video_list:
|
||||
return None
|
||||
video_url = video_list[0].get("url", "")
|
||||
desc = d.get("title", "") or d.get("desc", "") or d.get("author", "")
|
||||
if not video_url:
|
||||
return None
|
||||
return ResolveResult(video_url=video_url, desc=desc, source="apizero")
|
||||
except Exception as exc:
|
||||
logger.warning("apizero 解析失败: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
# ── Main API ──────────────────────────────────────────────────────────
|
||||
def resolve_douyin_video(page_url: str) -> Optional[ResolveResult]:
|
||||
"""按 P0→P1→P2 顺序轮询解析抖音视频。
|
||||
|
||||
Args:
|
||||
page_url: 抖音 URL 或含 URL 的分享文本。
|
||||
|
||||
Returns:
|
||||
ResolveResult 或 None(所有源均失败)。
|
||||
图文视频时 video_url 为空字符串、desc 为文案。
|
||||
"""
|
||||
url = _extract_url_from_text(page_url) or page_url
|
||||
url = _canonicalize_url(url)
|
||||
|
||||
providers = [
|
||||
("app_feed", lambda: _resolve_app_feed(url)),
|
||||
("tikhub", lambda: _resolve_tikhub(url, TIKHUB_API_KEY)),
|
||||
("apizero", lambda: _resolve_apizero(url, APIZERO_API_KEY)),
|
||||
]
|
||||
|
||||
enabled_count = 0
|
||||
for name, fn in providers:
|
||||
if name == "tikhub" and not TIKHUB_API_KEY:
|
||||
continue
|
||||
if name == "apizero" and not APIZERO_API_KEY:
|
||||
continue
|
||||
enabled_count += 1
|
||||
t0 = time.time()
|
||||
try:
|
||||
result = fn()
|
||||
elapsed = time.time() - t0
|
||||
if result:
|
||||
domain = result.video_url.split("/")[2] if result.video_url and "/" in result.video_url else "(image)"
|
||||
logger.info(
|
||||
"抖音解析成功: source=%s url_domain=%s desc_len=%d time=%.2fs",
|
||||
result.source,
|
||||
domain,
|
||||
len(result.desc),
|
||||
elapsed,
|
||||
)
|
||||
return result
|
||||
logger.debug("解析源 %s 返回空 (%.2fs)", name, elapsed)
|
||||
except Exception as exc:
|
||||
logger.warning("解析源 %s 异常 (%.2fs): %s", name, time.time() - t0, exc)
|
||||
|
||||
if enabled_count == 0:
|
||||
logger.error("无任何抖音解析源可用:请检查 App Feed API 网络连通性")
|
||||
else:
|
||||
logger.warning("所有 %d 个抖音解析源均失败: url=%s", enabled_count, url)
|
||||
return None
|
||||
|
||||
|
||||
def available_providers() -> list[str]:
|
||||
"""返回当前可用的解析源列表(用于诊断)。"""
|
||||
provs = ["app_feed"]
|
||||
if TIKHUB_API_KEY:
|
||||
provs.append("tikhub")
|
||||
if APIZERO_API_KEY:
|
||||
provs.append("apizero")
|
||||
return provs
|
||||
@@ -423,6 +423,7 @@ 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,
|
||||
@@ -474,6 +475,7 @@ 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)。
|
||||
|
||||
@@ -608,18 +610,69 @@ 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 = 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,
|
||||
)
|
||||
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
|
||||
|
||||
# 片段区间写回素材 metadata(与落库同事务;replace_all_clips_transactional 内 commit)
|
||||
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)
|
||||
for item in clips_data:
|
||||
aid = item.get("asset_id", "")
|
||||
if aid:
|
||||
|
||||
@@ -61,10 +61,17 @@ 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.config:generation_task_id + title_config。
|
||||
|
||||
用 merge 方式更新,不整体覆盖 config,避免丢失其他字段。
|
||||
#1970:dedup_enabled 非 None 时一并写入,worker 据此决定 edge_crop/微变换;
|
||||
PR3 叙事模式再写 assembly_mode/script_id/video_ratio(可追溯,不影响渲染)。
|
||||
失败只记日志,不影响任务创建。
|
||||
"""
|
||||
if not plan_id:
|
||||
@@ -80,6 +87,16 @@ 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")
|
||||
@@ -157,6 +174,33 @@ 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,
|
||||
*,
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
"""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_seconds;Worker 推理期会通过 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=%s(status=%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_at:claim 时写入,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 心跳超时,回退 pending(attempt=%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:
|
||||
终态 task(status=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)
|
||||
@@ -29,6 +29,7 @@ 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
|
||||
@@ -36,6 +37,7 @@ 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,
|
||||
@@ -63,6 +65,7 @@ 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 预签名配置)."""
|
||||
@@ -108,6 +111,8 @@ class LipsyncService:
|
||||
script_text: str,
|
||||
speed: float,
|
||||
emotion: str,
|
||||
style: str = "",
|
||||
volume: int = 50,
|
||||
) -> str:
|
||||
"""TTS 直生:调 CosyVoice 合成音频并转存 OSS,返回可公网访问的音频 URL.
|
||||
|
||||
@@ -121,7 +126,9 @@ class LipsyncService:
|
||||
text=script_text,
|
||||
voice_id=actual_voice_id,
|
||||
speed=speed,
|
||||
emotion=emotion, # normalize 在 CosyVoiceService 内部完成
|
||||
style=style,
|
||||
volume=volume,
|
||||
emotion=emotion,
|
||||
language="zh",
|
||||
)
|
||||
except CosyVoiceError as exc:
|
||||
@@ -215,7 +222,57 @@ class LipsyncService:
|
||||
if timings:
|
||||
job.sentence_timings = timings
|
||||
|
||||
# 4. 签名 URL 并提交 MediaKit
|
||||
# 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(兜底路径)
|
||||
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
|
||||
@@ -244,6 +301,120 @@ 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(
|
||||
@@ -257,6 +428,8 @@ 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 = "",
|
||||
@@ -305,6 +478,7 @@ 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",
|
||||
@@ -326,6 +500,8 @@ class LipsyncService:
|
||||
voice_id,
|
||||
script_text,
|
||||
speed,
|
||||
style or "",
|
||||
volume,
|
||||
emotion or "",
|
||||
)
|
||||
)
|
||||
@@ -360,6 +536,8 @@ class LipsyncService:
|
||||
voice_id: str,
|
||||
script_text: str,
|
||||
speed: float = 1.0,
|
||||
style: str = "",
|
||||
volume: int = 50,
|
||||
emotion: str = "neutral",
|
||||
) -> dict:
|
||||
"""同步做 TTS 合成 + 下载 + ffprobe + 句子时间戳计算.
|
||||
@@ -478,6 +656,29 @@ 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
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
"""叙事剪辑前置服务 — #1970 PR3.
|
||||
|
||||
叙事模式(assembly_mode='narrative')在生成任务入队前同步完成:
|
||||
|
||||
1. 按 script_id 读取文案(归属校验);
|
||||
2. 按 tts_voice_source 解析音色(preset=CosyVoice 音色 id;clone=克隆档案 id,
|
||||
解析档案归属并取其 CosyVoice voice_id);
|
||||
3. 同步 TTS 合成(复用 tts_job 现有 workflow:提交即同步返回,未完成则轮询兜底),
|
||||
失败直接抛 NarrativeError(HTTP 层转 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
|
||||
# preset:tts_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),
|
||||
)
|
||||
@@ -22,6 +22,11 @@ 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
|
||||
@@ -52,10 +57,12 @@ class PlanGeneratorService:
|
||||
基于模板 + 素材,自动生成 EditPlan 及 EditPlanClip 列表。
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session, asset_repo=None) -> None:
|
||||
def __init__(self, db: Session, asset_repo=None, atom_clip_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
|
||||
|
||||
# ── 公开接口 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -121,18 +128,34 @@ class PlanGeneratorService:
|
||||
|
||||
# 4. 按 editing_mode 分配素材
|
||||
if asset_ids:
|
||||
# 获取素材时长信息,用于随机起始时间
|
||||
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,
|
||||
)
|
||||
# #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,
|
||||
)
|
||||
|
||||
# 5. 持久化所有 clips 并计算总时长
|
||||
created_clips: list[EditPlanClip] = []
|
||||
@@ -259,6 +282,103 @@ 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]] = {}
|
||||
|
||||
@@ -38,7 +38,12 @@ def transcribe_to_text(media_path: str | Path) -> str:
|
||||
ASRTranscriptionError: ASR 调用失败
|
||||
"""
|
||||
# 延迟导入,避免循环依赖和启动时副作用
|
||||
from apps.worker.services.asr_service_factory import get_asr_service
|
||||
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
|
||||
|
||||
asr = get_asr_service()
|
||||
if asr is None:
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""GPU MuseTalk 异步推理任务 — 将 GPU 推理等待从 HTTP 请求移至 Celery 后台执行.
|
||||
|
||||
优化目标:将 POST /lipsync/jobs 的 API 响应时间从 >200s 降到 <1s。
|
||||
任务流程:
|
||||
1. 加载 LipsyncJob,获取 gpu_task_id
|
||||
2. 调用 GpuLipsyncService.wait_for_result 轮询等待 GPU 完成
|
||||
3. 签名结果 URL(7 天),更新 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)
|
||||
@@ -82,7 +82,9 @@ def tts_synthesize_and_submit(
|
||||
voice_id: str,
|
||||
script_text: str,
|
||||
speed: float,
|
||||
emotion: str,
|
||||
style: str = "",
|
||||
volume: int = 50,
|
||||
emotion: str = "",
|
||||
):
|
||||
"""异步执行 TTS 合成 + OSS 转存 + MediaKit 提交.
|
||||
|
||||
@@ -159,6 +161,8 @@ def tts_synthesize_and_submit(
|
||||
text=script_text,
|
||||
voice_id=voice_id,
|
||||
speed=speed,
|
||||
style=style,
|
||||
volume=volume,
|
||||
emotion=emotion,
|
||||
language="zh",
|
||||
)
|
||||
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
@@ -8,7 +8,8 @@ const PASSWORD = "SmokePass123!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
|
||||
/** 将浏览器侧 /api/v1 请求路由到 Playwright request 源(支持跨域) */
|
||||
async function routeBrowserApiToTestApi(page: Page) {
|
||||
if (!apiOrigin) return
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
@@ -24,292 +25,358 @@ async function loginWithRetry(
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
): Promise<string> {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
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`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
throw new Error("Login failed after retries")
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册新用户 + 建项目/视频库/上传 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}` }
|
||||
|
||||
type ProjectResponse = { id: string }
|
||||
type LibraryResponse = { id: string }
|
||||
type AssetListResponse = {
|
||||
items: Array<{
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
}>
|
||||
}
|
||||
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
|
||||
|
||||
test.describe("Core generation flow", () => {
|
||||
test.describe.configure({ timeout: 360_000 })
|
||||
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
|
||||
|
||||
test("walks through 6-step wizard and starts generation", async ({ page, request }) => {
|
||||
test.setTimeout(360_000)
|
||||
|
||||
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}`
|
||||
|
||||
// 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,
|
||||
},
|
||||
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(upload.status()).toBe(200)
|
||||
},
|
||||
})
|
||||
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 }
|
||||
}
|
||||
|
||||
// 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] },
|
||||
/**
|
||||
* #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}` }
|
||||
|
||||
// 确保默认模板存在(智能剪辑页依赖模板)
|
||||
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)
|
||||
|
||||
// 注入登录态 + 路由 API
|
||||
await page.addInitScript((t: string) => {
|
||||
window.localStorage.setItem("access_token", t)
|
||||
window.localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({ state: { token: t, user: null } }),
|
||||
)
|
||||
.toBe("ready")
|
||||
}, token)
|
||||
await routeBrowserApiToTestApi(page)
|
||||
|
||||
// #1926 P0 fix: POST /templates CRUD endpoint removed; GET /templates
|
||||
// now auto-creates a default template for new users. Use the first one.
|
||||
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,
|
||||
// ── 提前 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,
|
||||
}),
|
||||
)
|
||||
},
|
||||
{
|
||||
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: 20_000,
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// Step 1: template - default selected, click next
|
||||
await expect(page.locator(".xx-choice-item.selected")).toBeVisible()
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
// ── Step 1:默认随机混剪选中,点下一步 ──────────────────────────
|
||||
await expect(page.getByText("选择模式", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("随机混剪")).toBeVisible()
|
||||
await page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
// Step1 下一步弹出数量选择弹窗(Issue #1677 固定6步:模板→素材→配音→标题→确认生成→封面)
|
||||
// 单视频流程:默认 1 个,点击「生成 1 个视频」进入步骤2
|
||||
await expect(page.getByRole("heading", { name: "要生成几个视频?" })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
// ── 配音选择弹窗:选第一个配音 → 确认 ─────────────────────────
|
||||
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 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 })
|
||||
await page.getByRole("button", { name: "生成 1 个视频" }).click()
|
||||
|
||||
// Step 2: select material (card grid UI)
|
||||
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible()
|
||||
const librarySelect = page.locator("select").first()
|
||||
await librarySelect.selectOption({ label: libraryName })
|
||||
// 新 UI: 素材以 9:16 竖屏卡片展示,点击卡片选中
|
||||
// 注意:卡片中心是播放按钮(stopPropagation 会阻止选中),所以点击左上角避开
|
||||
const materialCard = page.getByTestId("material-card").filter({ hasText: sourceFileName })
|
||||
await expect(materialCard).toBeVisible({ timeout: 10_000 })
|
||||
await materialCard.click({ position: { x: 15, y: 15 } })
|
||||
// 验证选中:卡片应出现勾选标记(用 testid 定位,避免 ✓ 字符文本匹配不稳定)
|
||||
await expect(materialCard.getByTestId("material-card-check")).toBeVisible({ timeout: 5_000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 3: voice (可选步骤,新注册用户无配音素材,直接跳过)
|
||||
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible({ timeout: 15000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 4: title(新顺序:标题在预览之前)
|
||||
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible({ timeout: 15000 })
|
||||
// 等待组件完全渲染
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Antd AutoComplete 的 placeholder 渲染在 span 上,input 无 placeholder 属性
|
||||
// 使用 Antd AutoComplete 特有的 class 定位输入框
|
||||
const titleInput = page.locator(".ant-select-auto-complete input")
|
||||
// ── Step 3:填写标题 ──────────────────────────────────────────
|
||||
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
|
||||
const titleInput = page.getByPlaceholder("输入或从标题库选择")
|
||||
await expect(titleInput).toBeVisible({ timeout: 5000 })
|
||||
await titleInput.fill(`测试随机剪辑 ${suffix}`)
|
||||
await page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
const titleText = `E2E Test ${suffix}`
|
||||
await titleInput.fill(titleText)
|
||||
// ── 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 4(标题+实时预览):确认生成按钮已移到标题页,点击直接创建最终渲染任务
|
||||
// 等待前端实时预览就绪:未就绪时右侧 FrontendPreviewPlayer 显示「准备预览素材...」占位,
|
||||
// 就绪(previewReady:素材已解析 + 模板已选中)后占位消失;否则按钮会被校验拦截弹 warning
|
||||
await page
|
||||
.getByText("准备预览素材")
|
||||
.waitFor({ state: "detached", timeout: 30_000 })
|
||||
.catch(() => {})
|
||||
const createTask = page.waitForResponse(
|
||||
(r) => r.url().includes("/generation/tasks") && r.request().method() === "POST",
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
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 ✓")
|
||||
})
|
||||
|
||||
// Wait for generation API to be called
|
||||
// 前端直接创建生成任务:POST /generation/tasks
|
||||
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 },
|
||||
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")
|
||||
|
||||
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,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
// 预设音色(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,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
// 点击「确认生成视频」
|
||||
await page.locator(".xx-btn-primary").filter({ hasText: "确认生成视频" }).first().click()
|
||||
// 克隆音色:空列表
|
||||
await page.route(
|
||||
(url) => url.pathname.endsWith("/voice-clones"),
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [] }),
|
||||
}),
|
||||
)
|
||||
|
||||
// Verify generation was triggered
|
||||
const genResp = await generatePromise
|
||||
if (!genResp.ok()) {
|
||||
const body = await genResp.text()
|
||||
console.error(
|
||||
`[E2E DEBUG] 触发生成接口失败: status=${genResp.status()} url=${genResp.url()} body=${body.slice(0, 500)}`,
|
||||
)
|
||||
}
|
||||
// Generate API may return 400 in test env if template has no ready segments
|
||||
// That is OK for a wizard flow smoke test
|
||||
if (genResp.ok()) {
|
||||
const genData = (await genResp.json()) as {
|
||||
items: Array<{ id: string; status: string }>
|
||||
total: number
|
||||
}
|
||||
expect(genData.items.length).toBeGreaterThan(0)
|
||||
expect(genData.items[0].id).toBeTruthy()
|
||||
// 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合成配音" }),
|
||||
}),
|
||||
)
|
||||
|
||||
// 单视频(N=1):点击「确认生成视频」后跳 Step 5「确认生成」,展示实时渲染进度
|
||||
await expect(page.getByRole("heading", { name: "🎬 确认生成" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
// 等待渲染完成:进度卡变为「视频生成完成」(最长等待 3 分钟)
|
||||
await expect(page.getByText("视频生成完成")).toBeVisible({ timeout: 180_000 })
|
||||
|
||||
// 全部完成后「下一步:选择封面」解锁,点击进入 Step 6
|
||||
await page.getByRole("button", { name: /下一步:选择封面/ }).click()
|
||||
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
} else {
|
||||
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
|
||||
// 创建失败时停留在标题页并展示错误提示
|
||||
await page
|
||||
.getByText(/生成失败|重新生成/)
|
||||
.isVisible({ timeout: 15_000 })
|
||||
.catch(() => false)
|
||||
}
|
||||
|
||||
// Verify product library page loads (smoke: just verify page renders)
|
||||
await page.goto("/app/products")
|
||||
await expect(page).toHaveURL(/\/app\/products/)
|
||||
// Verify page container exists = page rendered correctly
|
||||
// (works in all states: loading/error/success - more reliable than checking search input)
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
await page.goto("/app/generate")
|
||||
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// 清理所有路由,避免页面关闭时飞地API请求导致测试报错
|
||||
await page.unrouteAll({ behavior: "ignoreErrors" })
|
||||
})
|
||||
// ── Step 1:切到叙事剪辑 → 下一步 ────────────────────────────
|
||||
await expect(page.getByText("选择模式", { exact: true })).toBeVisible()
|
||||
await page.getByText("叙事剪辑").click()
|
||||
await page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
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 expect(page.getByText("📝 选择文案")).toBeVisible({ timeout: 5000 })
|
||||
await page.getByText("测试带货文案").first().click()
|
||||
await page.getByRole("button", { name: "确认选择" }).click()
|
||||
await expect(page.getByText("📝 选择文案")).not.toBeVisible()
|
||||
|
||||
const register = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, username, password: PASSWORD, display_name: username },
|
||||
})
|
||||
expect(register.status()).toBe(201)
|
||||
// ── 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 })
|
||||
|
||||
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}` }
|
||||
// ── 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()
|
||||
|
||||
const project = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `E2E API Proj ${suffix}` },
|
||||
})
|
||||
expect(project.status()).toBe(200)
|
||||
// ── 数量弹窗 ─────────────────────────────────────────────────
|
||||
await expect(page.getByText("要生成几个视频?")).toBeVisible({ timeout: 5000 })
|
||||
await page.getByRole("button", { name: "生成 1 个视频" }).click()
|
||||
|
||||
// List generation tasks via task center API
|
||||
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)
|
||||
// ── 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 ✓")
|
||||
})
|
||||
})
|
||||
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
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 ✓`)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -7,8 +7,19 @@ export interface GenerateCoverTitleConfig {
|
||||
font_color?: string
|
||||
position?: string
|
||||
bold?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: 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
|
||||
}
|
||||
|
||||
export interface GenerateCoverRequest {
|
||||
|
||||
+290
-422
@@ -1,477 +1,345 @@
|
||||
/**
|
||||
* 积分 & 会员 API 封装(v2 两档会员制)
|
||||
* 后端接口未就绪阶段使用 mock 数据;接口就绪后把 MOCK 开关关掉即可。
|
||||
* 积分系统 API 封装
|
||||
* 对齐后端 staging 实测最终契约(2026-09-16)
|
||||
*
|
||||
* 当前 POINTS_API_MOCK=true:使用 MOCK_* 常量 + setTimeout 模拟延迟,
|
||||
* 等后端 P0(支付通道接入、change-plan 校验)稳定后切 false 联调。
|
||||
*
|
||||
* 会员/订阅 API 在 @/api/subscription 中定义,避免重复封装。
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
PointsBalance,
|
||||
PointsTransaction,
|
||||
PointsTransactionsParams,
|
||||
PointsTransactionsResponse,
|
||||
PointsPackagesResponse,
|
||||
PointsRechargeRequest,
|
||||
PointsOrder,
|
||||
PointsRulesResponse,
|
||||
PointsPackagesResponse,
|
||||
PointsTransaction,
|
||||
PointsTransactionsResponse,
|
||||
PointsCheckRequest,
|
||||
PointsCheckResponse,
|
||||
SubscriptionCurrent,
|
||||
SubscribeRequest,
|
||||
SubscriptionPlan,
|
||||
CreateRechargeOrderRequest,
|
||||
CreateRechargeOrderResponse,
|
||||
DailyUsage,
|
||||
MembershipResponse,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* 是否启用 mock 数据(后端 PR 合入前为 true;对接真实接口后改为 false)
|
||||
*/
|
||||
export const POINTS_API_MOCK = true
|
||||
/** 模拟网络延迟(ms) */
|
||||
const MOCK_DELAY = 500
|
||||
|
||||
// ==================== Mock 数据 ====================
|
||||
/* ================================================================
|
||||
* Mock 数据
|
||||
* ================================================================ */
|
||||
|
||||
/** mock 余额(无 free_clips_* 字段,已拆分到 dailyUsage) */
|
||||
const MOCK_BALANCE: PointsBalance = {
|
||||
balance: 580,
|
||||
total_earned: 1200,
|
||||
total_spent: 620,
|
||||
balance: 258,
|
||||
total_earned: 500,
|
||||
total_spent: 242,
|
||||
is_member: false,
|
||||
member_type: null,
|
||||
member_expires_at: null,
|
||||
free_clips_used: 1,
|
||||
free_clips_limit: 2,
|
||||
free_clips_remaining: 1,
|
||||
}
|
||||
|
||||
const MOCK_PACKAGES: PointsPackagesResponse = {
|
||||
packages: [
|
||||
{
|
||||
id: "starter_pack",
|
||||
name: "体验包",
|
||||
points: 100,
|
||||
price: 990,
|
||||
discounted_price_for_free: 990,
|
||||
discounted_price_for_monthly: 891,
|
||||
discounted_price_for_quarterly: 861,
|
||||
discounted_price_for_yearly: 792,
|
||||
},
|
||||
{
|
||||
id: "basic_pack",
|
||||
name: "基础包",
|
||||
points: 500,
|
||||
price: 3900,
|
||||
discounted_price_for_free: 3900,
|
||||
discounted_price_for_monthly: 3510,
|
||||
discounted_price_for_quarterly: 3393,
|
||||
discounted_price_for_yearly: 3120,
|
||||
},
|
||||
{
|
||||
id: "pro_pack",
|
||||
name: "专业包",
|
||||
points: 2000,
|
||||
price: 12900,
|
||||
discounted_price_for_free: 12900,
|
||||
discounted_price_for_monthly: 11610,
|
||||
discounted_price_for_quarterly: 11223,
|
||||
discounted_price_for_yearly: 10320,
|
||||
},
|
||||
],
|
||||
user_member_type: "free",
|
||||
unit_price_yuan: 0.1,
|
||||
}
|
||||
|
||||
const MOCK_RULES: PointsRulesResponse = {
|
||||
rules: [
|
||||
{
|
||||
scene_key: "ai_voice",
|
||||
scene_name: "AI 配音",
|
||||
points_per_use: 1,
|
||||
unit: "分钟",
|
||||
description: "每生成 1 分钟配音",
|
||||
name: "AI 配音",
|
||||
base_points: 2,
|
||||
unit: "次",
|
||||
description: "单次配音消耗 2 积分,超 30 秒每 30 秒 +1 积分",
|
||||
extra_per_30s: 1,
|
||||
},
|
||||
{
|
||||
scene_key: "ai_video",
|
||||
scene_name: "智能混剪",
|
||||
points_per_use: 3,
|
||||
name: "AI 视频生成",
|
||||
base_points: 8,
|
||||
unit: "条",
|
||||
extra_per_30s: 1,
|
||||
description: "每条 ≤30s 3 积分,每加 30s +1",
|
||||
description: "单条视频 8 积分起,按视频时长加收",
|
||||
extra_per_30s: 3,
|
||||
},
|
||||
{
|
||||
scene_key: "ai_digital_human",
|
||||
scene_name: "AI 数字人",
|
||||
points_per_use: 15,
|
||||
unit: "分钟",
|
||||
description: "每生成 1 分钟口播",
|
||||
name: "AI 数字人",
|
||||
base_points: 15,
|
||||
unit: "次",
|
||||
description: "数字人生成 15 积分起",
|
||||
extra_per_30s: 5,
|
||||
},
|
||||
{
|
||||
scene_key: "voice_clone_train",
|
||||
scene_name: "声音克隆训练",
|
||||
points_per_use: 0,
|
||||
name: "声音克隆训练",
|
||||
base_points: 20,
|
||||
unit: "次",
|
||||
description: "训练免费",
|
||||
description: "声音模型训练一次性消耗 20 积分",
|
||||
},
|
||||
{
|
||||
scene_key: "voice_clone_synth",
|
||||
name: "声音克隆合成",
|
||||
base_points: 3,
|
||||
unit: "次",
|
||||
description: "使用克隆声音合成音频每次 3 积分",
|
||||
},
|
||||
{
|
||||
scene_key: "douyin_extract",
|
||||
name: "抖音文案提取",
|
||||
base_points: 1,
|
||||
unit: "次",
|
||||
description: "提取抖音视频文案每次 1 积分",
|
||||
},
|
||||
{
|
||||
scene_key: "ai_rewrite",
|
||||
name: "AI 文案改写",
|
||||
base_points: 2,
|
||||
unit: "次",
|
||||
description: "AI 改写文案每次 2 积分",
|
||||
},
|
||||
{
|
||||
scene_key: "ai_title",
|
||||
name: "AI 标题生成",
|
||||
base_points: 1,
|
||||
unit: "次",
|
||||
description: "AI 生成标题每次 1 积分,一次生成多条",
|
||||
},
|
||||
{
|
||||
scene_key: "ai_cover",
|
||||
name: "AI 封面生成",
|
||||
base_points: 3,
|
||||
unit: "次",
|
||||
description: "AI 生成封面每次 3 积分",
|
||||
},
|
||||
{ scene_key: "voice_clone_synth", scene_name: "声音克隆合成", points_per_use: 1, unit: "分钟" },
|
||||
{ scene_key: "douyin_extract", scene_name: "抖音链接提取", points_per_use: 1, unit: "次" },
|
||||
{ scene_key: "ai_rewrite", scene_name: "AI 改写文案", points_per_use: 1, unit: "次" },
|
||||
{ scene_key: "ai_title", scene_name: "AI 标题生成", points_per_use: 1, unit: "次" },
|
||||
{ scene_key: "ai_cover", scene_name: "AI 封面生成", points_per_use: 1, unit: "张" },
|
||||
],
|
||||
free_user_multiplier: 1.15,
|
||||
note: "免费用户消耗 = 会员消耗 × 1.15,向上取整",
|
||||
}
|
||||
|
||||
function genMockTransactions(): PointsTransactionsResponse {
|
||||
const now = new Date()
|
||||
const list = [
|
||||
const MOCK_PACKAGES: PointsPackagesResponse = {
|
||||
packages: [
|
||||
{ code: "points_100", name: "100 积分", points: 100, price_cents: 990, unit_price: 0.099 },
|
||||
{ code: "points_500", name: "500 积分", points: 500, price_cents: 4490, unit_price: 0.0898 },
|
||||
{ code: "points_1000", name: "1000 积分", points: 1000, price_cents: 7990, unit_price: 0.0799 },
|
||||
{
|
||||
src: "ai_voice",
|
||||
name: "AI 配音",
|
||||
type: "spend" as const,
|
||||
amt: 1,
|
||||
desc: "生成配音 1 分钟",
|
||||
days: 0,
|
||||
hours: 0,
|
||||
mins: 30,
|
||||
code: "points_3000",
|
||||
name: "3000 积分",
|
||||
points: 3000,
|
||||
price_cents: 19900,
|
||||
unit_price: 0.0663,
|
||||
},
|
||||
{
|
||||
src: "ai_video",
|
||||
name: "智能混剪",
|
||||
type: "spend" as const,
|
||||
amt: 5,
|
||||
desc: "生成 1 分钟视频(基础3+30s*2)",
|
||||
days: 0,
|
||||
hours: 1,
|
||||
mins: 15,
|
||||
},
|
||||
{
|
||||
src: "task_reward",
|
||||
name: "任务奖励",
|
||||
type: "earn" as const,
|
||||
amt: 20,
|
||||
desc: "首次生成视频奖励",
|
||||
days: 1,
|
||||
hours: 0,
|
||||
mins: 0,
|
||||
},
|
||||
{
|
||||
src: "recharge",
|
||||
name: "充值",
|
||||
type: "earn" as const,
|
||||
amt: 500,
|
||||
desc: "基础包充值",
|
||||
days: 15,
|
||||
hours: 0,
|
||||
mins: 0,
|
||||
},
|
||||
{
|
||||
src: "ai_rewrite",
|
||||
name: "AI 改写文案",
|
||||
type: "spend" as const,
|
||||
amt: 2,
|
||||
desc: "免费用户价(1×1.15 向上取整)",
|
||||
days: 16,
|
||||
hours: 2,
|
||||
mins: 10,
|
||||
},
|
||||
{
|
||||
src: "ai_title",
|
||||
name: "AI 标题生成",
|
||||
type: "spend" as const,
|
||||
amt: 2,
|
||||
desc: "免费用户价",
|
||||
days: 16,
|
||||
hours: 3,
|
||||
mins: 0,
|
||||
},
|
||||
{
|
||||
src: "douyin_extract",
|
||||
name: "抖音链接提取",
|
||||
type: "spend" as const,
|
||||
amt: 2,
|
||||
desc: "提取 3 分钟文案",
|
||||
days: 18,
|
||||
hours: 0,
|
||||
mins: 0,
|
||||
},
|
||||
{
|
||||
src: "ai_digital_human",
|
||||
name: "AI 数字人",
|
||||
type: "spend" as const,
|
||||
amt: 18,
|
||||
desc: "数字人口播 1 分钟(免费用户价)",
|
||||
days: 20,
|
||||
hours: 0,
|
||||
mins: 0,
|
||||
},
|
||||
{
|
||||
src: "task_reward",
|
||||
name: "任务奖励",
|
||||
type: "earn" as const,
|
||||
amt: 50,
|
||||
desc: "注册赠送",
|
||||
days: 30,
|
||||
hours: 0,
|
||||
mins: 0,
|
||||
},
|
||||
]
|
||||
let bal = MOCK_BALANCE.balance
|
||||
const items = list
|
||||
.map((t, i) => {
|
||||
const signed = t.type === "earn" ? t.amt : -t.amt
|
||||
const balance_after = bal // 按时间倒序:earliest 先算
|
||||
// adjust running bal
|
||||
bal = t.type === "earn" ? bal - t.amt : bal + t.amt
|
||||
const d = new Date(now)
|
||||
d.setDate(d.getDate() - t.days)
|
||||
d.setHours(d.getHours() - t.hours)
|
||||
d.setMinutes(d.getMinutes() - t.mins)
|
||||
return {
|
||||
id: `tx_${i + 1}`,
|
||||
type: t.type,
|
||||
source: t.src as PointsBalance extends never ? never : string,
|
||||
source_name: t.name,
|
||||
amount: t.amt,
|
||||
signed_amount: signed,
|
||||
balance_after,
|
||||
description: t.desc,
|
||||
ref_id: null,
|
||||
created_at: d.toISOString(),
|
||||
}
|
||||
})
|
||||
.reverse()
|
||||
// Rebuild balance_after going forward
|
||||
let running = 50 + 0 // after registration gift
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
const it = items[i] as PointsTransaction & { balance_after?: number }
|
||||
if (it.source === "task_reward" && it.description.includes("注册")) running = 50
|
||||
}
|
||||
running = 50
|
||||
const fwd = [...items].reverse() as Array<PointsTransaction & { balance_after?: number }>
|
||||
for (const it of fwd) {
|
||||
running += it.signed_amount
|
||||
it.balance_after = running
|
||||
}
|
||||
return { items: fwd, total: fwd.length, page: 1, page_size: 20 } as PointsTransactionsResponse
|
||||
],
|
||||
user_discount: null,
|
||||
}
|
||||
|
||||
// ==================== 真实 API ====================
|
||||
|
||||
/** 查询积分余额 + 会员状态 */
|
||||
export async function getPointsBalance(): Promise<PointsBalance> {
|
||||
if (POINTS_API_MOCK) {
|
||||
return new Promise((r) => setTimeout(() => r({ ...MOCK_BALANCE }), 180))
|
||||
}
|
||||
const res = await apiClient.get("/points/balance")
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 查询积分流水(分页) */
|
||||
export async function getPointsTransactions(
|
||||
params: PointsTransactionsParams = {},
|
||||
): Promise<PointsTransactionsResponse> {
|
||||
if (POINTS_API_MOCK) {
|
||||
return new Promise((r) => setTimeout(() => r(genMockTransactions()), 200))
|
||||
}
|
||||
const res = await apiClient.get("/points/transactions", { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 查询积分包列表 */
|
||||
export async function getPointsPackages(): Promise<PointsPackagesResponse> {
|
||||
if (POINTS_API_MOCK) {
|
||||
return new Promise((r) => setTimeout(() => r({ ...MOCK_PACKAGES }), 150))
|
||||
}
|
||||
const res = await apiClient.get("/points/packages")
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 创建积分充值订单(mock 阶段返回 "pending" 订单,前端弹"支付开发中") */
|
||||
export async function createPointsOrder(req: PointsRechargeRequest): Promise<PointsOrder> {
|
||||
if (POINTS_API_MOCK) {
|
||||
const pkg = MOCK_PACKAGES.packages.find((p) => p.id === req.package_id)
|
||||
const mt = MOCK_PACKAGES.user_member_type
|
||||
type DiscountKey =
|
||||
| "discounted_price_for_free"
|
||||
| "discounted_price_for_monthly"
|
||||
| "discounted_price_for_quarterly"
|
||||
| "discounted_price_for_yearly"
|
||||
const discountKey = `discounted_price_for_${mt}` as DiscountKey
|
||||
const price = pkg?.[discountKey] ?? pkg?.price ?? 0
|
||||
return new Promise((r) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
r({
|
||||
id: `mock_order_${Date.now()}`,
|
||||
package_id: req.package_id,
|
||||
package_name: pkg?.name ?? "",
|
||||
points_amount: pkg?.points ?? 0,
|
||||
price_cents: price,
|
||||
original_price_cents: pkg?.price ?? 0,
|
||||
discount: price / (pkg?.price || 1),
|
||||
currency: "CNY",
|
||||
status: "pending",
|
||||
payment_method: null,
|
||||
payment_id: null,
|
||||
paid_at: null,
|
||||
expire_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
}),
|
||||
300,
|
||||
),
|
||||
)
|
||||
}
|
||||
const res = await apiClient.post("/points/recharge", req)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 查询积分消耗规则 */
|
||||
export async function getPointsRules(): Promise<PointsRulesResponse> {
|
||||
if (POINTS_API_MOCK) {
|
||||
return new Promise((r) => setTimeout(() => r({ ...MOCK_RULES }), 120))
|
||||
}
|
||||
const res = await apiClient.get("/points/rules")
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 消费前余额预检查 */
|
||||
export async function checkPoints(req: PointsCheckRequest): Promise<PointsCheckResponse> {
|
||||
if (POINTS_API_MOCK) {
|
||||
const rule = MOCK_RULES.rules.find((r) => r.scene_key === req.scene_key)
|
||||
if (!rule) {
|
||||
return {
|
||||
allowed: false,
|
||||
required_points: 0,
|
||||
current_balance: MOCK_BALANCE.balance,
|
||||
remaining_after: MOCK_BALANCE.balance,
|
||||
is_free_quota: false,
|
||||
code: "SCENE_NOT_FOUND",
|
||||
message: "未知场景",
|
||||
recharge_url: "/app/points",
|
||||
}
|
||||
}
|
||||
const units = req.units ?? 1
|
||||
let base = rule.points_per_use * units
|
||||
if (rule.extra_per_30s && units > 1) {
|
||||
// ai_video extra_per_30s: base already covers first 30s, subtract
|
||||
base = rule.points_per_use + rule.extra_per_30s * (units - 1)
|
||||
}
|
||||
const isFree =
|
||||
MOCK_BALANCE.is_member === false &&
|
||||
req.scene_key === "ai_video" &&
|
||||
(MOCK_BALANCE.free_clips_remaining ?? 0) > 0
|
||||
const needed = isFree
|
||||
? 0
|
||||
: MOCK_BALANCE.is_member
|
||||
? base
|
||||
: Math.ceil(base * MOCK_RULES.free_user_multiplier)
|
||||
const allowed = isFree || MOCK_BALANCE.balance >= needed
|
||||
return {
|
||||
allowed,
|
||||
required_points: needed,
|
||||
current_balance: MOCK_BALANCE.balance,
|
||||
remaining_after: MOCK_BALANCE.balance - needed,
|
||||
is_free_quota: isFree,
|
||||
code: allowed ? undefined : "INSUFFICIENT_POINTS",
|
||||
message: allowed
|
||||
? undefined
|
||||
: `积分不足,需要 ${needed} 积分,当前余额 ${MOCK_BALANCE.balance}`,
|
||||
recharge_url: "/app/points",
|
||||
}
|
||||
}
|
||||
const res = await apiClient.post("/points/check", req)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ==================== 订阅相关 ====================
|
||||
|
||||
/** 订阅套餐(定价常量,前端硬编码;折扣由后端会员类型决定) */
|
||||
export const SUBSCRIPTION_PLANS: SubscriptionPlan[] = [
|
||||
const MOCK_TRANSACTIONS: PointsTransaction[] = [
|
||||
{
|
||||
id: "monthly",
|
||||
name: "月卡",
|
||||
price_cents: 1990,
|
||||
price_yuan: 19.9,
|
||||
per_month_yuan: 19.9,
|
||||
billing_label: "/月",
|
||||
id: 1,
|
||||
type: "deduct",
|
||||
source: "ai_video",
|
||||
amount: 10,
|
||||
balance_after: 248,
|
||||
description: "AI 视频生成 ×1(非会员倍率)",
|
||||
ref_id: "task_abc123",
|
||||
created_at: "2026-09-16T08:30:00Z",
|
||||
},
|
||||
{
|
||||
id: "quarterly",
|
||||
name: "季卡",
|
||||
price_cents: 3990,
|
||||
price_yuan: 39.9,
|
||||
per_month_yuan: 13.3,
|
||||
savings_percent: 33,
|
||||
recommended: true,
|
||||
billing_label: "/季",
|
||||
id: 2,
|
||||
type: "add",
|
||||
source: "recharge",
|
||||
amount: 100,
|
||||
balance_after: 258,
|
||||
description: "充值 100 积分",
|
||||
ref_id: "order_xyz789",
|
||||
created_at: "2026-09-15T14:20:00Z",
|
||||
},
|
||||
{
|
||||
id: "yearly",
|
||||
name: "年卡",
|
||||
price_cents: 15900,
|
||||
price_yuan: 159,
|
||||
per_month_yuan: 13.25,
|
||||
savings_percent: 33,
|
||||
billing_label: "/年",
|
||||
id: 3,
|
||||
type: "deduct",
|
||||
source: "ai_voice",
|
||||
amount: 3,
|
||||
balance_after: 158,
|
||||
description: "AI 配音 ×1(45s 加收)",
|
||||
ref_id: "",
|
||||
created_at: "2026-09-15T10:15:00Z",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: "add",
|
||||
source: "sign_up",
|
||||
amount: 60,
|
||||
balance_after: 161,
|
||||
description: "新用户注册赠送",
|
||||
ref_id: "",
|
||||
created_at: "2026-09-10T09:00:00Z",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: "deduct",
|
||||
source: "ai_title",
|
||||
amount: 1,
|
||||
balance_after: 101,
|
||||
description: "AI 标题生成 ×1",
|
||||
ref_id: "",
|
||||
created_at: "2026-09-14T16:45:00Z",
|
||||
},
|
||||
]
|
||||
|
||||
/** 查询当前订阅 */
|
||||
export async function getCurrentSubscription(): Promise<SubscriptionCurrent> {
|
||||
if (POINTS_API_MOCK) {
|
||||
return new Promise((r) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
r({
|
||||
is_member: MOCK_BALANCE.is_member,
|
||||
member_type: MOCK_BALANCE.member_type,
|
||||
member_type_name: MOCK_BALANCE.is_member ? "付费会员" : "免费会员",
|
||||
status: MOCK_BALANCE.is_member ? "active" : "none",
|
||||
current_period_start: null,
|
||||
current_period_end: MOCK_BALANCE.member_expires_at,
|
||||
auto_renew: false,
|
||||
points_discount: MOCK_BALANCE.is_member ? 0.9 : 1.0,
|
||||
}),
|
||||
150,
|
||||
),
|
||||
)
|
||||
}
|
||||
const res = await apiClient.get("/subscription/current")
|
||||
return res.data
|
||||
const MOCK_DAILY_USAGE: DailyUsage = {
|
||||
free_clips_used: 1,
|
||||
free_clips_limit: 3,
|
||||
free_clips_remaining: 2,
|
||||
reset_at: new Date(Date.now() + 8 * 3600_000).toISOString(),
|
||||
}
|
||||
|
||||
/** 开通/续费会员 */
|
||||
export async function subscribe(req: SubscribeRequest): Promise<PointsOrder> {
|
||||
if (POINTS_API_MOCK) {
|
||||
const plan = SUBSCRIPTION_PLANS.find((p) => p.id === req.member_type)!
|
||||
return new Promise((r) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
r({
|
||||
id: `mock_sub_${Date.now()}`,
|
||||
package_id: plan.id,
|
||||
package_name: plan.name,
|
||||
points_amount: 0,
|
||||
price_cents: plan.price_cents,
|
||||
original_price_cents: plan.price_cents,
|
||||
discount: 1,
|
||||
currency: "CNY",
|
||||
status: "pending",
|
||||
payment_method: null,
|
||||
payment_id: null,
|
||||
paid_at: null,
|
||||
expire_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
}),
|
||||
300,
|
||||
),
|
||||
)
|
||||
}
|
||||
const res = await apiClient.post("/subscription/subscribe", req)
|
||||
return res.data
|
||||
const MOCK_MEMBERSHIP: MembershipResponse = {
|
||||
is_member: false,
|
||||
member_type: null,
|
||||
member_expires_at: null,
|
||||
points_balance: 258,
|
||||
max_resolution: "720p",
|
||||
}
|
||||
|
||||
/** 取消自动续费 */
|
||||
export async function cancelAutoRenew(): Promise<{ success: boolean; message: string }> {
|
||||
if (POINTS_API_MOCK) {
|
||||
return new Promise((r) =>
|
||||
setTimeout(() => r({ success: true, message: "已取消自动续费" }), 200),
|
||||
)
|
||||
/* ================================================================
|
||||
* 积分 API
|
||||
* ================================================================ */
|
||||
|
||||
/** 获取积分余额 */
|
||||
export async function getPointsBalance(): Promise<PointsBalance> {
|
||||
if (process.env.POINTS_API_MOCK === "true") {
|
||||
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
|
||||
return { ...MOCK_BALANCE }
|
||||
}
|
||||
const res = await apiClient.post("/subscription/cancel")
|
||||
return res.data
|
||||
const { data } = await apiClient.get(`/points/balance`)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取积分消耗规则 */
|
||||
export async function getPointsRules(): Promise<PointsRulesResponse> {
|
||||
if (process.env.POINTS_API_MOCK === "true") {
|
||||
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
|
||||
return { rules: [...MOCK_RULES.rules], free_user_multiplier: MOCK_RULES.free_user_multiplier }
|
||||
}
|
||||
const { data } = await apiClient.get(`/points/rules`)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取充值包列表 */
|
||||
export async function getPointsPackages(): Promise<PointsPackagesResponse> {
|
||||
if (process.env.POINTS_API_MOCK === "true") {
|
||||
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
|
||||
return { packages: MOCK_PACKAGES.packages.map((p) => ({ ...p })), user_discount: null }
|
||||
}
|
||||
const { data } = await apiClient.get(`/points/packages`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取积分流水(分页)
|
||||
*/
|
||||
export async function getPointsTransactions(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
): Promise<PointsTransactionsResponse> {
|
||||
if (process.env.POINTS_API_MOCK === "true") {
|
||||
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
|
||||
const start = (page - 1) * pageSize
|
||||
const items = MOCK_TRANSACTIONS.slice(start, start + pageSize)
|
||||
return {
|
||||
items: items.map((t) => ({ ...t })),
|
||||
total: MOCK_TRANSACTIONS.length,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
}
|
||||
}
|
||||
const { data } = await apiClient.get(`/points/transactions`, {
|
||||
params: { page, page_size: pageSize },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建充值订单
|
||||
* 注意:当前 pay_params 返回空对象 {}(支付通道未接入),
|
||||
* 前端可以完成订单创建 UI,但无法发起真实支付,待后续支付通道接入后联调。
|
||||
*/
|
||||
export async function createPointsOrder(
|
||||
data: CreateRechargeOrderRequest,
|
||||
): Promise<CreateRechargeOrderResponse> {
|
||||
if (process.env.POINTS_API_MOCK === "true") {
|
||||
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY * 2))
|
||||
const pkg = MOCK_PACKAGES.packages.find((p) => p.code === data.package_id)
|
||||
if (!pkg) throw new Error("充值包不存在")
|
||||
return {
|
||||
id: `mock_order_${Date.now()}`,
|
||||
order_type: "points_recharge",
|
||||
product_code: pkg.code,
|
||||
amount_cents: pkg.price_cents,
|
||||
points_amount: pkg.points,
|
||||
status: "pending",
|
||||
pay_params: {},
|
||||
expire_at: new Date(Date.now() + 30 * 60_000).toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
const { data: d } = await apiClient.post(`/points/recharge`, data)
|
||||
return d
|
||||
}
|
||||
|
||||
/**
|
||||
* 积分预检查(消耗前调用)
|
||||
*/
|
||||
export async function checkPoints(data: PointsCheckRequest): Promise<PointsCheckResponse> {
|
||||
if (process.env.POINTS_API_MOCK === "true") {
|
||||
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
|
||||
const rule = MOCK_RULES.rules.find((r) => r.scene_key === data.scene_key)
|
||||
if (!rule) {
|
||||
throw {
|
||||
error: {
|
||||
code: 400,
|
||||
message: `未知场景:${data.scene_key}`,
|
||||
valid_scenes: MOCK_RULES.rules.map((r) => r.scene_key),
|
||||
},
|
||||
}
|
||||
}
|
||||
const durationExtra =
|
||||
data.duration_minutes && data.duration_minutes > 0.5 && rule.extra_per_30s
|
||||
? Math.ceil((data.duration_minutes * 60 - 30) / 30) * rule.extra_per_30s
|
||||
: 0
|
||||
const base = (rule.base_points + durationExtra) * data.quantity
|
||||
const balance = MOCK_BALANCE.balance
|
||||
const multiplier = MOCK_BALANCE.is_member ? 1 : MOCK_RULES.free_user_multiplier
|
||||
const required = Math.ceil(base * multiplier)
|
||||
// 免费额度抵扣
|
||||
const isFreeQuota = !MOCK_BALANCE.is_member && MOCK_DAILY_USAGE.free_clips_remaining > 0
|
||||
const finalRequired = isFreeQuota ? 0 : required
|
||||
return {
|
||||
allowed: balance >= finalRequired,
|
||||
required_points: finalRequired,
|
||||
current_balance: balance,
|
||||
remaining_after: balance - finalRequired,
|
||||
is_free_quota: isFreeQuota,
|
||||
}
|
||||
}
|
||||
const { data: d2 } = await apiClient.post(`/points/check`, data)
|
||||
return d2
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* 每日免费额度 + 会员聚合信息(新接口)
|
||||
* ================================================================ */
|
||||
|
||||
/** 获取每日免费额度使用情况 */
|
||||
export async function getDailyUsage(): Promise<DailyUsage> {
|
||||
if (process.env.POINTS_API_MOCK === "true") {
|
||||
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
|
||||
return { ...MOCK_DAILY_USAGE }
|
||||
}
|
||||
const { data } = await apiClient.get(`/usage/daily`)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取会员聚合信息(创作页可用来判断 max_resolution) */
|
||||
export async function getMembership(): Promise<MembershipResponse> {
|
||||
if (process.env.POINTS_API_MOCK === "true") {
|
||||
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
|
||||
return { ...MOCK_MEMBERSHIP }
|
||||
}
|
||||
const { data } = await apiClient.get(`/points/subscription/membership`)
|
||||
return data
|
||||
}
|
||||
|
||||
+175
-165
@@ -1,76 +1,134 @@
|
||||
/**
|
||||
* 积分 & 会员系统 API 类型定义(v2 两档会员制)
|
||||
* 接口契约对齐后端设计文档 membership-points-design-v2.md
|
||||
* 积分系统类型定义
|
||||
* 对齐后端 staging 实测最终契约(2026-09-16)
|
||||
*
|
||||
* Base path: /api/v1/
|
||||
* 会员/订阅相关类型请从 @/api/subscription/types 引入,本文件仅保留积分核心类型。
|
||||
*/
|
||||
|
||||
/** 会员类型 */
|
||||
export type MemberType = "free" | "monthly" | "quarterly" | "yearly"
|
||||
|
||||
/** 积分流水类型 */
|
||||
export type PointsTxType = "earn" | "spend" | "refund"
|
||||
|
||||
/** 积分来源/消耗场景 */
|
||||
/* ================================================================
|
||||
* 场景键
|
||||
* ================================================================ */
|
||||
/**
|
||||
* 积分消耗场景键(9 个)
|
||||
* - ai_script 已拆分为 douyin_extract / ai_rewrite / ai_title,前端禁止再传 ai_script
|
||||
*/
|
||||
export type PointsSource =
|
||||
| "recharge" // 充值
|
||||
| "task_reward" // 任务奖励
|
||||
| "ai_voice" // AI 配音
|
||||
| "ai_video" // AI 视频生成
|
||||
| "ai_digital_human" // AI 数字人
|
||||
| "ai_video" // 智能混剪
|
||||
| "voice_clone_train" // 声音克隆训练
|
||||
| "voice_clone_synth" // 声音克隆合成
|
||||
| "douyin_extract" // 抖音链接提取
|
||||
| "ai_rewrite" // AI 改写文案
|
||||
| "douyin_extract" // 抖音提取文案
|
||||
| "ai_rewrite" // AI 文案改写
|
||||
| "ai_title" // AI 标题生成
|
||||
| "ai_cover" // AI 封面生成
|
||||
| "subscription_bonus" // 会员赠送
|
||||
| "admin_adjust" // 管理员调整
|
||||
| "refund" // 失败退还
|
||||
|
||||
/** 会员 & 积分余额响应 */
|
||||
/** 非消耗场景 source 前缀(用于流水 source 字段) */
|
||||
export type PointsSourceExtra =
|
||||
PointsSource | `refund:${string}` | "recharge" | "sign_up" | "bind_phone" | "gift" | "admin"
|
||||
|
||||
/* ================================================================
|
||||
* 通用
|
||||
* ================================================================ */
|
||||
/** ISO 8601 时间字符串 */
|
||||
export type ISODate = string
|
||||
|
||||
/* ================================================================
|
||||
* 积分余额(GET /points/balance)
|
||||
* ================================================================ */
|
||||
export interface PointsBalance {
|
||||
/** 当前可用积分 */
|
||||
balance: number
|
||||
/** 累计获得 */
|
||||
/** 累计获得积分 */
|
||||
total_earned: number
|
||||
/** 累计消耗 */
|
||||
/** 累计消耗积分 */
|
||||
total_spent: number
|
||||
/** 是否付费会员(free 用户为 false) */
|
||||
/** 是否为付费会员 */
|
||||
is_member: boolean
|
||||
/** 会员类型:monthly / quarterly / yearly;free 用户为 null */
|
||||
member_type: Extract<MemberType, "monthly" | "quarterly" | "yearly"> | null
|
||||
/** 会员到期时间 ISO 字符串 */
|
||||
member_expires_at: string | null
|
||||
/** 今日免费混剪已用次数 */
|
||||
free_clips_used?: number
|
||||
/** 今日免费混剪额度上限 */
|
||||
free_clips_limit?: number
|
||||
/** 今日免费混剪剩余 */
|
||||
free_clips_remaining?: number
|
||||
/** 会员类型(monthly/quarterly/yearly,非会员 null)。推荐使用 /subscription/current 的 plan_id+billing_cycle 做判断 */
|
||||
member_type: "monthly" | "quarterly" | "yearly" | null
|
||||
/** 会员到期时间 */
|
||||
member_expires_at: ISODate | null
|
||||
}
|
||||
|
||||
/** 积分流水记录 */
|
||||
/* ================================================================
|
||||
* 积分规则(GET /points/rules)
|
||||
* ================================================================ */
|
||||
export interface PointsRule {
|
||||
scene_key: PointsSource
|
||||
/** 场景中文名 */
|
||||
name: string
|
||||
/** 基准消耗积分(points_per_use 改名) */
|
||||
base_points: number
|
||||
/** 单位描述,如「次」「分钟」「个」 */
|
||||
unit: string
|
||||
/** 超过30秒后每30秒额外积分(视频/语音类) */
|
||||
extra_per_30s?: number
|
||||
/** 场景说明(后端已补回) */
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface PointsRulesResponse {
|
||||
rules: PointsRule[]
|
||||
/** 非会员消耗倍率(如 1.15) */
|
||||
free_user_multiplier: number
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* 充值包(GET /points/packages)
|
||||
* ================================================================ */
|
||||
export interface PointsPackage {
|
||||
/** 包编码(id 改名) */
|
||||
code: string
|
||||
name: string
|
||||
points: number
|
||||
/** 原价,单位分 */
|
||||
price_cents: number
|
||||
/** 每积分单价(元),展示用 */
|
||||
unit_price: number
|
||||
}
|
||||
|
||||
export interface PointsPackagesResponse {
|
||||
packages: PointsPackage[]
|
||||
/** 当前用户折扣(会员折扣或活动折扣),null 表示无折扣 */
|
||||
user_discount: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 充值包前端展示辅助:折后价(分)
|
||||
* 后端废弃 4 档 discounted_price_for_*,前端按 price_cents * (user_discount ?? 1) 计算。
|
||||
*/
|
||||
export function getDiscountPriceCents(pkg: PointsPackage, userDiscount: number | null): number {
|
||||
return Math.round(pkg.price_cents * (userDiscount ?? 1))
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* 积分流水(GET /points/transactions)
|
||||
* ================================================================ */
|
||||
export type PointsTxType = "add" | "deduct"
|
||||
|
||||
export interface PointsTransaction {
|
||||
id: string
|
||||
/** earn / spend / refund */
|
||||
id: number
|
||||
/** 流水类型:add=获得/退款,deduct=消耗 */
|
||||
type: PointsTxType
|
||||
/** 来源场景 */
|
||||
source: PointsSource
|
||||
/** 场景中文名称 */
|
||||
source_name: string
|
||||
/** 变动数量(正数) */
|
||||
/**
|
||||
* 消耗/获得来源:
|
||||
* - 消耗场景直接用 PointsSource 值
|
||||
* - 充值/退款/赠送使用 recharge / refund:<source> / sign_up / bind_phone / gift / admin
|
||||
*/
|
||||
source: string
|
||||
/** 变动数量(绝对值,正负由 type 决定) */
|
||||
amount: number
|
||||
/** 带符号的变动数(收入+,支出-) */
|
||||
signed_amount: number
|
||||
/** 变动后余额 */
|
||||
balance_after: number
|
||||
/** 备注描述 */
|
||||
/** 中文描述 */
|
||||
description: string
|
||||
/** 关联业务 ID */
|
||||
ref_id: string | null
|
||||
created_at: string
|
||||
/** 关联订单/任务 ID,空字符串 "" 表示无关联(不是 null) */
|
||||
ref_id: string
|
||||
created_at: ISODate
|
||||
}
|
||||
|
||||
/** 积分流水分页响应 */
|
||||
export interface PointsTransactionsResponse {
|
||||
items: PointsTransaction[]
|
||||
total: number
|
||||
@@ -78,140 +136,92 @@ export interface PointsTransactionsResponse {
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** 积分流水查询参数 */
|
||||
export interface PointsTransactionsParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
type?: PointsTxType
|
||||
source?: PointsSource
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}
|
||||
|
||||
/** 积分包 */
|
||||
export interface PointsPackage {
|
||||
id: "starter_pack" | "basic_pack" | "pro_pack" | string
|
||||
/** 中文名称 */
|
||||
name: string
|
||||
/** 积分数量 */
|
||||
points: number
|
||||
/** 原价(分) */
|
||||
price: number
|
||||
/** 各会员类型折扣价(分) */
|
||||
discounted_price_for_free: number
|
||||
discounted_price_for_monthly: number
|
||||
discounted_price_for_quarterly: number
|
||||
discounted_price_for_yearly: number
|
||||
}
|
||||
|
||||
/** 积分包列表响应 */
|
||||
export interface PointsPackagesResponse {
|
||||
packages: PointsPackage[]
|
||||
/** 当前用户会员类型,用于前端计算折后价 */
|
||||
user_member_type: MemberType
|
||||
/** 积分单价(元/积分,按会员价计) */
|
||||
unit_price_yuan: number
|
||||
}
|
||||
|
||||
/** 创建充值订单请求 */
|
||||
export interface PointsRechargeRequest {
|
||||
/* ================================================================
|
||||
* 创建充值订单(POST /points/recharge)
|
||||
* ================================================================ */
|
||||
export interface CreateRechargeOrderRequest {
|
||||
/** 充值包 code(字段名保留 package_id 与后端一致) */
|
||||
package_id: string
|
||||
payment_method?: "wechat_pay" | "alipay"
|
||||
}
|
||||
|
||||
/** 订单状态 */
|
||||
export type OrderStatus = "pending" | "paid" | "failed" | "refunded" | "expired"
|
||||
|
||||
/** 充值订单响应 */
|
||||
export interface PointsOrder {
|
||||
export interface CreateRechargeOrderResponse {
|
||||
id: string
|
||||
package_id: string
|
||||
package_name: string
|
||||
order_type: string
|
||||
product_code: string
|
||||
/** 订单金额(分) */
|
||||
amount_cents: number
|
||||
/** 充值积分数量 */
|
||||
points_amount: number
|
||||
price_cents: number
|
||||
original_price_cents: number
|
||||
discount: number
|
||||
currency: "CNY"
|
||||
status: OrderStatus
|
||||
payment_method: string | null
|
||||
payment_id: string | null
|
||||
paid_at: string | null
|
||||
expire_at: string | null
|
||||
created_at: string
|
||||
/** 微信/支付宝支付参数(mock 阶段前端自行处理) */
|
||||
pay_params?: Record<string, string>
|
||||
status: string
|
||||
/**
|
||||
* 支付参数(支付通道未接入时返回空对象 {},前端可透传)
|
||||
*/
|
||||
pay_params: Record<string, unknown>
|
||||
/** 订单过期时间 */
|
||||
expire_at: ISODate
|
||||
created_at: ISODate
|
||||
}
|
||||
|
||||
/** 订阅套餐(月/季/年) */
|
||||
export interface SubscriptionPlan {
|
||||
id: "monthly" | "quarterly" | "yearly"
|
||||
name: string
|
||||
price_cents: number
|
||||
price_yuan: number
|
||||
per_month_yuan: number
|
||||
savings_percent?: number
|
||||
recommended?: boolean
|
||||
billing_label: string
|
||||
}
|
||||
|
||||
/** 当前订阅详情 */
|
||||
export interface SubscriptionCurrent {
|
||||
is_member: boolean
|
||||
member_type: Extract<MemberType, "monthly" | "quarterly" | "yearly"> | null
|
||||
member_type_name: string
|
||||
status: "active" | "expired" | "cancelled" | "none"
|
||||
current_period_start: string | null
|
||||
current_period_end: string | null
|
||||
auto_renew: boolean
|
||||
/** 订阅会员对应的积分折扣 */
|
||||
points_discount: number
|
||||
}
|
||||
|
||||
/** 开通/续费订阅请求 */
|
||||
export interface SubscribeRequest {
|
||||
member_type: "monthly" | "quarterly" | "yearly"
|
||||
payment_method?: "wechat_pay" | "alipay"
|
||||
}
|
||||
|
||||
/** 积分消耗规则 */
|
||||
export interface PointsRule {
|
||||
scene_key: PointsSource
|
||||
scene_name: string
|
||||
/** 每次消耗基础积分(会员价) */
|
||||
points_per_use: number
|
||||
/** 计量单位:条/分钟/次/张 */
|
||||
unit: string
|
||||
/** 额外每 30s 加积分(ai_video 用) */
|
||||
extra_per_30s?: number
|
||||
/** 说明文案 */
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface PointsRulesResponse {
|
||||
rules: PointsRule[]
|
||||
/** 免费用户消耗倍率 */
|
||||
free_user_multiplier: number
|
||||
note: string
|
||||
}
|
||||
|
||||
/** 消费前余额检查请求 */
|
||||
/* ================================================================
|
||||
* 积分预检查(POST /points/check)
|
||||
* ================================================================ */
|
||||
export interface PointsCheckRequest {
|
||||
scene_key: PointsSource
|
||||
/** 单位数量(时长/条数),默认 1 */
|
||||
units?: number
|
||||
/** 数量(units 改名) */
|
||||
quantity: number
|
||||
/** 预计时长(分钟),可选 */
|
||||
duration_minutes?: number
|
||||
}
|
||||
|
||||
/** 消费前余额检查响应 */
|
||||
export interface PointsCheckResponse {
|
||||
/** 是否可以执行 */
|
||||
allowed: boolean
|
||||
/** 需要消耗积分 */
|
||||
required_points: number
|
||||
/** 当前余额 */
|
||||
current_balance: number
|
||||
/** 扣除后剩余 */
|
||||
remaining_after: number
|
||||
/** 是否走免费额度(混剪场景) */
|
||||
/** 是否走免费额度 */
|
||||
is_free_quota: boolean
|
||||
/** 拒绝原因代码 */
|
||||
code?: "INSUFFICIENT_POINTS" | "FREE_QUOTA_EXCEEDED" | "SCENE_NOT_FOUND"
|
||||
message?: string
|
||||
/** 充值页跳转 URL */
|
||||
recharge_url?: string
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* 每日使用情况(GET /usage/daily,新接口)
|
||||
* ================================================================ */
|
||||
export interface DailyUsage {
|
||||
/** 今日已用免费次数 */
|
||||
free_clips_used: number
|
||||
/** 每日免费次数上限 */
|
||||
free_clips_limit: number
|
||||
/** 今日剩余免费次数 */
|
||||
free_clips_remaining: number
|
||||
/** 额度重置时间 */
|
||||
reset_at: ISODate
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* 会员聚合信息(GET /points/subscription/membership,新接口)
|
||||
* ================================================================ */
|
||||
export interface MembershipResponse {
|
||||
is_member: boolean
|
||||
/** 会员类型(monthly/quarterly/yearly,非会员 null) */
|
||||
member_type: "monthly" | "quarterly" | "yearly" | null
|
||||
member_expires_at: ISODate | null
|
||||
/** 当前积分余额(冗余,可与 balance 互校) */
|
||||
points_balance: number
|
||||
/** 最大分辨率,如 "720p" / "1080p" / "4k" */
|
||||
max_resolution: string
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* 错误响应(统一格式 {error:{code,message}})
|
||||
* ================================================================ */
|
||||
export interface ApiError {
|
||||
error: {
|
||||
code: number
|
||||
message: string
|
||||
/** 部分场景会返回,如 unknown scene_key */
|
||||
valid_scenes?: PointsSource[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./scripts"
|
||||
export * from "./types"
|
||||
export * from "./scripts-ai"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 文案库 AI 能力 API(#1893)
|
||||
* 三个端点均走真实后端,不参与 SCRIPTS_API_MOCK 开关。
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
|
||||
/** ── 1. 从抖音视频提取文案(下载 + ASR) */
|
||||
export interface ExtractFromDouyinRequest {
|
||||
url: string
|
||||
}
|
||||
export interface ExtractFromDouyinResponse {
|
||||
text: string
|
||||
duration_seconds?: number
|
||||
source_url?: string
|
||||
}
|
||||
|
||||
export async function extractScriptFromDouyin(
|
||||
body: ExtractFromDouyinRequest,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<ExtractFromDouyinResponse> {
|
||||
const res = await apiClient.post<ExtractFromDouyinResponse>(
|
||||
"/scripts/extract-from-douyin",
|
||||
body,
|
||||
{
|
||||
// ASR 可能较慢,给足超时
|
||||
timeout: 60_000,
|
||||
signal: opts?.signal,
|
||||
},
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** ── 2. AI 改写文案 */
|
||||
export type RewriteStyle = "口语化" | "正式" | "活泼" | "治愈" | "励志"
|
||||
|
||||
export const REWRITE_STYLE_OPTIONS: { value: RewriteStyle; label: string }[] = [
|
||||
{ value: "口语化", label: "口语化" },
|
||||
{ value: "正式", label: "正式" },
|
||||
{ value: "活泼", label: "活泼" },
|
||||
{ value: "治愈", label: "治愈" },
|
||||
{ value: "励志", label: "励志" },
|
||||
]
|
||||
|
||||
export interface AiRewriteRequest {
|
||||
content: string
|
||||
style?: RewriteStyle
|
||||
}
|
||||
export interface AiRewriteResponse {
|
||||
original: string
|
||||
rewritten: string
|
||||
style: RewriteStyle
|
||||
}
|
||||
|
||||
export async function aiRewriteScript(
|
||||
body: AiRewriteRequest,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<AiRewriteResponse> {
|
||||
const res = await apiClient.post<AiRewriteResponse>("/scripts/ai-rewrite", body, {
|
||||
timeout: 60_000,
|
||||
signal: opts?.signal,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** ── 3. AI 生成标题 */
|
||||
export interface AiGenerateTitlesRequest {
|
||||
content: string
|
||||
count?: number
|
||||
}
|
||||
export interface AiGenerateTitlesResponse {
|
||||
titles: string[]
|
||||
}
|
||||
|
||||
export async function aiGenerateTitles(
|
||||
body: AiGenerateTitlesRequest,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<AiGenerateTitlesResponse> {
|
||||
const res = await apiClient.post<AiGenerateTitlesResponse>(
|
||||
"/scripts/ai-generate-titles",
|
||||
{ content: body.content, count: body.count ?? 3 },
|
||||
{
|
||||
timeout: 30_000,
|
||||
signal: opts?.signal,
|
||||
},
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
@@ -11,8 +11,12 @@ import type {
|
||||
ScriptCategory,
|
||||
} from "./types"
|
||||
|
||||
/** 是否启用 mock(后端合入后改为 false) */
|
||||
export const SCRIPTS_API_MOCK = true
|
||||
/**
|
||||
* 是否启用 mock。
|
||||
* #1894:文案库接口已上线,默认 false 走真实 API;
|
||||
* 通过 SCRIPTS_API_MOCK=true 环境变量可本地开启 mock 调试(行为同 POINTS_API_MOCK)。
|
||||
*/
|
||||
export const SCRIPTS_API_MOCK = (process.env.SCRIPTS_API_MOCK as string | undefined) === "true"
|
||||
|
||||
// ==================== Mock 数据 ====================
|
||||
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
/**
|
||||
* 订阅 API — 目录化入口
|
||||
* 保持与原 subscription.ts 相同导出,向后兼容
|
||||
* 对齐后端 staging 最终契约(2026-09-16)
|
||||
*/
|
||||
|
||||
// 类型
|
||||
export type {
|
||||
PlanId,
|
||||
PlanType,
|
||||
SubscriptionStatus,
|
||||
BillingStatus,
|
||||
BillingCycle,
|
||||
Plan,
|
||||
SubscriptionInfo,
|
||||
SubscriptionPlan,
|
||||
SubscriptionPlansResponse,
|
||||
BillingRecord,
|
||||
ChangePlanRequest,
|
||||
ChangePlanResponse,
|
||||
ToggleAutoRenewRequest,
|
||||
} from "./types"
|
||||
|
||||
export { PLAN_LABEL, BILLING_CYCLE_LABEL } from "./types"
|
||||
|
||||
// API 函数
|
||||
export {
|
||||
getCurrentSubscription,
|
||||
getSubscriptionPlans,
|
||||
getBillingRecords,
|
||||
changePlan,
|
||||
cancelSubscription,
|
||||
|
||||
@@ -1,47 +1,154 @@
|
||||
/**
|
||||
* 订阅相关 API 函数
|
||||
* 订阅/会员 API 封装
|
||||
* 对齐后端 staging 实测最终契约(2026-09-16)
|
||||
*
|
||||
* Base path: /api/v1/
|
||||
* 所有请求走 apiClient(已配置 baseURL=/api/v1 和 token 拦截器)。
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
SubscriptionInfo,
|
||||
SubscriptionPlan,
|
||||
SubscriptionPlansResponse,
|
||||
BillingRecord,
|
||||
ChangePlanRequest,
|
||||
ChangePlanResponse,
|
||||
SubscriptionInfo,
|
||||
ToggleAutoRenewRequest,
|
||||
} from "./types"
|
||||
|
||||
/** 获取当前订阅信息 */
|
||||
export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => {
|
||||
const response = await apiClient.get("/subscription/current")
|
||||
return response.data
|
||||
const MOCK_DELAY = 500
|
||||
|
||||
const MOCK_SUBSCRIPTION: SubscriptionInfo = {
|
||||
id: "sub_mock_001",
|
||||
plan_id: "free",
|
||||
plan_name: "免费版",
|
||||
status: "active",
|
||||
billing_cycle: "monthly",
|
||||
current_period_start: new Date(Date.now() - 30 * 86400_000).toISOString(),
|
||||
current_period_end: new Date(Date.now() + 30 * 86400_000).toISOString(),
|
||||
amount: 0,
|
||||
auto_renew: false,
|
||||
created_at: new Date(Date.now() - 30 * 86400_000).toISOString(),
|
||||
}
|
||||
|
||||
/** 获取账单记录列表 */
|
||||
const MOCK_PLANS: SubscriptionPlan[] = [
|
||||
{
|
||||
plan_id: "free",
|
||||
name: "免费版",
|
||||
price_cents: 0,
|
||||
monthly_price_cents: 0,
|
||||
duration_days: 0,
|
||||
points_discount: 1,
|
||||
features: { max_resolution: "720p", free_clips_daily: 3 },
|
||||
},
|
||||
{
|
||||
plan_id: "monthly",
|
||||
name: "月度会员",
|
||||
price_cents: 1990,
|
||||
monthly_price_cents: 1990,
|
||||
duration_days: 30,
|
||||
points_discount: 0.9,
|
||||
features: { max_resolution: "1080p", free_clips_daily: 10 },
|
||||
},
|
||||
{
|
||||
plan_id: "quarterly",
|
||||
name: "季度会员",
|
||||
price_cents: 3990,
|
||||
monthly_price_cents: 1330,
|
||||
duration_days: 90,
|
||||
points_discount: 0.85,
|
||||
features: { max_resolution: "1080p", free_clips_daily: 15 },
|
||||
},
|
||||
{
|
||||
plan_id: "yearly",
|
||||
name: "年度会员",
|
||||
price_cents: 15900,
|
||||
monthly_price_cents: 1325,
|
||||
duration_days: 365,
|
||||
points_discount: 0.8,
|
||||
features: { max_resolution: "4k", free_clips_daily: 30 },
|
||||
},
|
||||
]
|
||||
|
||||
const MOCK_BILLING: BillingRecord[] = []
|
||||
|
||||
const isMock = () => (process.env.POINTS_API_MOCK as string | undefined) === "true"
|
||||
|
||||
/** 获取当前订阅 */
|
||||
export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => {
|
||||
if (isMock()) {
|
||||
await new Promise((r) => setTimeout(r, MOCK_DELAY))
|
||||
return { ...MOCK_SUBSCRIPTION }
|
||||
}
|
||||
const { data } = await apiClient.get("/subscription/current")
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取所有订阅档位 */
|
||||
export const getSubscriptionPlans = async (): Promise<SubscriptionPlansResponse> => {
|
||||
if (isMock()) {
|
||||
await new Promise((r) => setTimeout(r, MOCK_DELAY))
|
||||
return { plans: MOCK_PLANS.map((p) => ({ ...p, features: { ...p.features } })) }
|
||||
}
|
||||
const { data } = await apiClient.get("/subscription/plans")
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取账单记录 */
|
||||
export const getBillingRecords = async (): Promise<BillingRecord[]> => {
|
||||
const response = await apiClient.get("/subscription/billing-records")
|
||||
return response.data
|
||||
if (isMock()) {
|
||||
await new Promise((r) => setTimeout(r, MOCK_DELAY))
|
||||
return MOCK_BILLING.map((r) => ({ ...r }))
|
||||
}
|
||||
const { data } = await apiClient.get("/subscription/billing-records")
|
||||
return data
|
||||
}
|
||||
|
||||
/** 升级/降级套餐 */
|
||||
export const changePlan = async (request: ChangePlanRequest): Promise<ChangePlanResponse> => {
|
||||
const response = await apiClient.post("/subscription/change-plan", request)
|
||||
return response.data
|
||||
if (isMock()) {
|
||||
await new Promise((r) => setTimeout(r, MOCK_DELAY * 2))
|
||||
const plan = MOCK_PLANS.find((p) => p.plan_id === request.target_plan_id)
|
||||
if (!plan) return { success: false, message: "套餐不存在" }
|
||||
const newSub: SubscriptionInfo = {
|
||||
...MOCK_SUBSCRIPTION,
|
||||
plan_id: plan.plan_id,
|
||||
plan_name: plan.name,
|
||||
billing_cycle: request.billing_cycle,
|
||||
amount: plan.price_cents,
|
||||
status: "pending",
|
||||
current_period_start: new Date().toISOString(),
|
||||
current_period_end: new Date(Date.now() + plan.duration_days * 86400_000).toISOString(),
|
||||
auto_renew: true,
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: "订阅变更成功(mock,支付通道待接入)",
|
||||
new_subscription: newSub,
|
||||
}
|
||||
}
|
||||
const { data } = await apiClient.post("/subscription/change-plan", request)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 取消订阅 */
|
||||
export const cancelSubscription = async (): Promise<{
|
||||
success: boolean
|
||||
message: string
|
||||
}> => {
|
||||
const response = await apiClient.post("/subscription/cancel")
|
||||
return response.data
|
||||
/** 取消订阅(到期后失效) */
|
||||
export const cancelSubscription = async (): Promise<{ success: boolean; message: string }> => {
|
||||
if (isMock()) {
|
||||
await new Promise((r) => setTimeout(r, MOCK_DELAY))
|
||||
return { success: true, message: "已取消订阅,到期后将不再续费" }
|
||||
}
|
||||
const { data } = await apiClient.post("/subscription/cancel")
|
||||
return data
|
||||
}
|
||||
|
||||
/** 切换自动续费 */
|
||||
export const toggleAutoRenew = async (
|
||||
enabled: boolean,
|
||||
req: ToggleAutoRenewRequest,
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.post("/subscription/toggle-auto-renew", {
|
||||
enabled,
|
||||
})
|
||||
return response.data
|
||||
if (isMock()) {
|
||||
await new Promise((r) => setTimeout(r, MOCK_DELAY))
|
||||
return { success: true, message: req.enabled ? "已开启自动续费" : "已关闭自动续费" }
|
||||
}
|
||||
const { data } = await apiClient.post("/subscription/toggle-auto-renew", req)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -1,65 +1,115 @@
|
||||
/**
|
||||
* 订阅相关类型定义
|
||||
* 订阅/会员类型定义
|
||||
* 对齐后端 staging 实测最终契约(2026-09-16)
|
||||
*
|
||||
* Base path: /api/v1/
|
||||
*/
|
||||
|
||||
/** 套餐类型 */
|
||||
export type PlanType = "free" | "standard" | "pro" | "enterprise"
|
||||
|
||||
/** 订阅状态 */
|
||||
export type SubscriptionStatus = "active" | "expired" | "cancelled" | "trial"
|
||||
|
||||
/** 账单状态 */
|
||||
export type BillingStatus = "paid" | "pending" | "failed" | "refunded"
|
||||
/** 订阅计划 ID */
|
||||
export type PlanId = "free" | "monthly" | "quarterly" | "yearly"
|
||||
|
||||
/** 计费周期 */
|
||||
export type BillingCycle = "monthly" | "yearly"
|
||||
|
||||
/** 套餐信息 */
|
||||
export interface Plan {
|
||||
id: PlanType
|
||||
name: string
|
||||
price: number | null
|
||||
yearly_price?: number | null
|
||||
description: string
|
||||
recommended: boolean
|
||||
features: string[]
|
||||
}
|
||||
/** 订阅状态 */
|
||||
export type SubscriptionStatus = "active" | "expired" | "cancelled" | "pending"
|
||||
|
||||
/** 当前订阅信息 */
|
||||
/** 账单状态 */
|
||||
export type BillingStatus = "paid" | "pending" | "failed" | "refunded"
|
||||
|
||||
/* ================================================================
|
||||
* 当前订阅(GET /subscription/current)
|
||||
* ================================================================ */
|
||||
export interface SubscriptionInfo {
|
||||
id: string
|
||||
plan_id: PlanType
|
||||
plan_id: PlanId
|
||||
plan_name: string
|
||||
status: SubscriptionStatus
|
||||
/** 当前计费周期:monthly 对月卡/季卡按自然月续费;yearly 对年卡 */
|
||||
billing_cycle: BillingCycle
|
||||
current_period_start: string
|
||||
current_period_end: string
|
||||
/** 本期金额(分) */
|
||||
amount: number
|
||||
auto_renew: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 账单记录 */
|
||||
/* ================================================================
|
||||
* 订阅计划(GET /subscription/plans)
|
||||
* ================================================================ */
|
||||
export interface SubscriptionPlan {
|
||||
plan_id: PlanId
|
||||
/** 中文名 */
|
||||
name: string
|
||||
/** 价格(分),年卡/季卡为总价 */
|
||||
price_cents: number
|
||||
/** 折算月价(分),对比用 */
|
||||
monthly_price_cents: number
|
||||
/** 时长(天) */
|
||||
duration_days: number
|
||||
/** 积分折扣(0.9 = 9折,1 = 无折扣) */
|
||||
points_discount: number
|
||||
features: {
|
||||
max_resolution: string
|
||||
free_clips_daily: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface SubscriptionPlansResponse {
|
||||
plans: SubscriptionPlan[]
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* 账单(GET /subscription/billing-records)
|
||||
* ================================================================ */
|
||||
export interface BillingRecord {
|
||||
id: string
|
||||
plan_name: string
|
||||
amount: number
|
||||
billing_cycle: BillingCycle
|
||||
/** 订单类型:subscribe/renew/upgrade/refund */
|
||||
order_type: string
|
||||
plan_id: PlanId
|
||||
/** 金额(分) */
|
||||
amount_cents: number
|
||||
status: BillingStatus
|
||||
payment_method: string
|
||||
created_at: string
|
||||
invoice_url?: string
|
||||
paid_at?: string
|
||||
}
|
||||
|
||||
/** 升级/降级请求 */
|
||||
/* ================================================================
|
||||
* 变更/取消/开关自动续费
|
||||
* ================================================================ */
|
||||
export interface ChangePlanRequest {
|
||||
target_plan_id: PlanType
|
||||
target_plan_id: PlanId
|
||||
billing_cycle: BillingCycle
|
||||
}
|
||||
|
||||
/** 升级/降级响应 */
|
||||
export interface ChangePlanResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
new_subscription?: SubscriptionInfo
|
||||
}
|
||||
|
||||
export interface ToggleAutoRenewRequest {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* 中文标签映射
|
||||
* ================================================================ */
|
||||
export const PLAN_LABEL: Record<PlanId, string> = {
|
||||
free: "免费版",
|
||||
monthly: "月度会员",
|
||||
quarterly: "季度会员",
|
||||
yearly: "年度会员",
|
||||
}
|
||||
|
||||
export const BILLING_CYCLE_LABEL: Record<BillingCycle, string> = {
|
||||
monthly: "月付",
|
||||
yearly: "年付",
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 旧命名保留别名,新代码请直接用 PlanId
|
||||
*/
|
||||
export type PlanType = PlanId
|
||||
|
||||
@@ -71,7 +71,17 @@ export interface CreateGenerationTaskRequest {
|
||||
duration?: number
|
||||
/** 视频宽高比,如 "9:16" */
|
||||
video_ratio?: string
|
||||
/** 标题烧录配置 */
|
||||
/** #1970:剪辑模式 random/narrative */
|
||||
assembly_mode?: "random" | "narrative"
|
||||
/** #1970:叙事模式下的文案 ID */
|
||||
script_id?: string
|
||||
/** #1970:TTS 音色 ID */
|
||||
tts_voice_id?: string
|
||||
/** #1970:TTS 音色来源 preset/clone */
|
||||
tts_voice_source?: "preset" | "clone"
|
||||
/** #1970:智能降重开关(默认 true) */
|
||||
dedup_enabled?: boolean
|
||||
/** 标题烧录配置(#2001 扩展:描边/阴影参数/行距/自动换行/背景/逐行/封面) */
|
||||
title_config?: {
|
||||
text?: string
|
||||
font?: string
|
||||
@@ -79,8 +89,34 @@ export interface CreateGenerationTaskRequest {
|
||||
font_color?: string
|
||||
position?: string
|
||||
bold?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: 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
|
||||
}
|
||||
/** 关联的草稿 ID(编辑流程数据链路用) */
|
||||
source_edit_plan_id?: string
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface SegmentTtsConfig {
|
||||
pitch: number
|
||||
volume: number
|
||||
subtitle_sync: boolean
|
||||
/** 配音风格预设(natural/excited/professional/sweet/news/livestream) */
|
||||
style?: string
|
||||
}
|
||||
|
||||
/** 片段裁剪配置 */
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* 标题相关 API — 目录化入口
|
||||
* 保持与原 titles.ts 相同导出,向后兼容
|
||||
*/
|
||||
|
||||
// 类型
|
||||
export type {
|
||||
TitleItem,
|
||||
BackendTitleResponse,
|
||||
BackendCreateTitleRequest,
|
||||
BackendUpdateTitleRequest,
|
||||
CreateTitleRequest,
|
||||
} from "./types"
|
||||
|
||||
// 工具函数
|
||||
export { toTitleItem } from "./utils"
|
||||
|
||||
// API 函数
|
||||
export { getTitles, createTitle, updateTitle, deleteTitle, batchImportTitles } from "./titles"
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* 标题相关 API 函数
|
||||
* Phase 1 新增:全局标题库
|
||||
* 注意:后端 schema 使用 name + text 字段,前端 UI 用 content 展示
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
BackendCreateTitleRequest,
|
||||
BackendTitleResponse,
|
||||
BackendUpdateTitleRequest,
|
||||
CreateTitleRequest,
|
||||
TitleItem,
|
||||
} from "./types"
|
||||
import { toTitleItem } from "./utils"
|
||||
|
||||
/** 获取当前用户的所有标题 */
|
||||
export const getTitles = async (): Promise<TitleItem[]> => {
|
||||
const response = await apiClient.get<{ items: BackendTitleResponse[] } | BackendTitleResponse[]>(
|
||||
"/titles",
|
||||
)
|
||||
// 兼容两种后端返回格式:{ items: [...] } 或直接 [...]
|
||||
const items = Array.isArray(response.data) ? response.data : response.data.items || []
|
||||
return items.map(toTitleItem)
|
||||
}
|
||||
|
||||
/** 创建标题 */
|
||||
export const createTitle = async (data: CreateTitleRequest): Promise<TitleItem> => {
|
||||
// 后端要求 name(≤255)和 text(≤500),name 从 content 截取
|
||||
const payload: BackendCreateTitleRequest = {
|
||||
name: data.content.slice(0, 255),
|
||||
text: data.content.slice(0, 500),
|
||||
category: data.category || "default",
|
||||
}
|
||||
const response = await apiClient.post<BackendTitleResponse>("/titles", payload)
|
||||
return toTitleItem(response.data)
|
||||
}
|
||||
|
||||
/** 更新标题 */
|
||||
export const updateTitle = async (
|
||||
titleId: string,
|
||||
data: Partial<CreateTitleRequest>,
|
||||
): Promise<TitleItem> => {
|
||||
const payload: BackendUpdateTitleRequest = {}
|
||||
if (data.content !== undefined) {
|
||||
payload.name = data.content.slice(0, 255)
|
||||
payload.text = data.content.slice(0, 500)
|
||||
}
|
||||
if (data.category !== undefined) {
|
||||
payload.category = data.category
|
||||
}
|
||||
// 后端用 PUT,非 PATCH
|
||||
const response = await apiClient.put<BackendTitleResponse>(`/titles/${titleId}`, payload)
|
||||
return toTitleItem(response.data)
|
||||
}
|
||||
|
||||
/** 删除标题 */
|
||||
export const deleteTitle = async (titleId: string): Promise<void> => {
|
||||
await apiClient.delete(`/titles/${titleId}`)
|
||||
}
|
||||
|
||||
/** 批量导入标题 */
|
||||
export const batchImportTitles = async (titles: string[]): Promise<{ imported_count: number }> => {
|
||||
const response = await apiClient.post("/titles/batch-import", { titles })
|
||||
return response.data
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* 标题相关类型定义
|
||||
*/
|
||||
|
||||
/** 标题条目(前端展示用) */
|
||||
export interface TitleItem {
|
||||
id: string
|
||||
content: string
|
||||
category?: string
|
||||
source?: string
|
||||
word_count?: number
|
||||
is_favorite?: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 后端标题响应格式 */
|
||||
export interface BackendTitleResponse {
|
||||
id: string
|
||||
user_id: string
|
||||
name: string
|
||||
text: string
|
||||
category: string
|
||||
description: string
|
||||
tags: string[]
|
||||
usage_count: number
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 后端创建标题请求格式 */
|
||||
export interface BackendCreateTitleRequest {
|
||||
name: string
|
||||
text: string
|
||||
category: string
|
||||
description?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
/** 后端更新标题请求格式 */
|
||||
export interface BackendUpdateTitleRequest {
|
||||
name?: string
|
||||
text?: string
|
||||
category?: string
|
||||
description?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
/** 创建标题请求(前端接口,保持向后兼容) */
|
||||
export interface CreateTitleRequest {
|
||||
content: string
|
||||
category?: string
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* 标题数据转换工具函数
|
||||
*/
|
||||
import type { BackendTitleResponse, TitleItem } from "./types"
|
||||
|
||||
/** 将后端响应映射为前端 TitleItem */
|
||||
export const toTitleItem = (item: BackendTitleResponse): TitleItem => ({
|
||||
id: item.id,
|
||||
content: item.text,
|
||||
category: item.category,
|
||||
word_count: item.text?.length || 0,
|
||||
created_at: item.created_at,
|
||||
updated_at: item.updated_at,
|
||||
})
|
||||
@@ -18,6 +18,9 @@ export type {
|
||||
TTSPreviewResponse,
|
||||
} from "./types"
|
||||
|
||||
export type { TtsStyle, TtsStyleOption } from "./styles"
|
||||
export { TTS_STYLE_OPTIONS, DEFAULT_TTS_STYLE, getTtsStyle } from "./styles"
|
||||
|
||||
// API 函数
|
||||
export {
|
||||
synthesizeSpeech,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* TTS 配音风格预设(情感/语气风格)
|
||||
* - key:传给后端的 style 标识,便于后端按策略合成
|
||||
* - 未传 style 时后端默认自然亲切
|
||||
*
|
||||
* 注:与原 emotion(CosyVoice 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]
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,8 @@ export interface TTSSynthesizeRequest {
|
||||
output_name?: string
|
||||
language?: string
|
||||
emotion?: string
|
||||
/** 配音风格预设(自然亲切/激动兴奋/沉稳专业/温柔甜美/新闻播报/直播带货),不传默认 natural */
|
||||
style?: string
|
||||
speed?: number
|
||||
voice_model?: string
|
||||
voice_clone_profile_id?: string
|
||||
@@ -106,6 +108,8 @@ 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 试听响应 */
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/**
|
||||
* Header 右上角的积分徽章(💎 580)
|
||||
* Header 右上角积分徽章
|
||||
* - 余额 <10 时橙色告警
|
||||
* - 点击弹出 Popover:余额、会员信息、充值入口、积分明细入口
|
||||
*
|
||||
* 字段对齐新契约:
|
||||
* - balance.is_member / balance.member_type 保留但降级;推荐用 membership.member_type
|
||||
* - 免费额度、会员 max_resolution 在 popover 展示
|
||||
*/
|
||||
import React, { useEffect } from "react"
|
||||
import { Popover, Button, Tag, Space, Typography, Badge } from "antd"
|
||||
@@ -13,31 +17,41 @@ 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
|
||||
|
||||
const MEMBER_LABEL: Record<string, string> = {
|
||||
monthly: "月卡会员",
|
||||
quarterly: "季卡会员",
|
||||
yearly: "年卡会员",
|
||||
}
|
||||
|
||||
const PointsBadge: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { balance, init, loading } = usePointsStore()
|
||||
const { balance, membership, subscription, dailyUsage, init, loading } = usePointsStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!ENABLE_CREDIT_SYSTEM) return
|
||||
if (!balance) init()
|
||||
}, [balance, init])
|
||||
|
||||
const bal = balance?.balance ?? 0
|
||||
// 功能开关:积分系统关闭时直接隐藏徽章
|
||||
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
|
||||
const zero = bal === 0
|
||||
const isMember = !!balance?.is_member
|
||||
const memberLabel = isMember
|
||||
? balance?.member_type === "yearly"
|
||||
? "年卡会员"
|
||||
: balance?.member_type === "quarterly"
|
||||
? "季卡会员"
|
||||
: balance?.member_type === "monthly"
|
||||
? "月卡会员"
|
||||
: "付费会员"
|
||||
: "免费会员"
|
||||
const isMember = membership?.is_member ?? balance?.is_member ?? false
|
||||
const memberKey =
|
||||
membership?.member_type ??
|
||||
(subscription?.plan_id && subscription.plan_id !== "free" ? subscription.plan_id : null)
|
||||
const memberLabel = memberKey ? MEMBER_LABEL[memberKey] || "付费会员" : "免费会员"
|
||||
const maxRes = membership?.max_resolution
|
||||
|
||||
const freeRemain = dailyUsage?.free_clips_remaining ?? 0
|
||||
|
||||
const popContent = (
|
||||
<div className="xx-points-popover">
|
||||
@@ -58,9 +72,16 @@ const PointsBadge: React.FC = () => {
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
{balance?.member_expires_at && (
|
||||
{!isMember && dailyUsage && freeRemain > 0 && (
|
||||
<Text type="secondary" className="xx-points-expire">
|
||||
今日剩余免费次数:{freeRemain}/{dailyUsage.free_clips_limit}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{balance?.member_expires_at && isMember && (
|
||||
<Text type="secondary" className="xx-points-expire">
|
||||
会员到期:{new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}
|
||||
{maxRes ? ` · ${maxRes}` : ""}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -80,16 +101,16 @@ const PointsBadge: React.FC = () => {
|
||||
type="primary"
|
||||
icon={<ThunderboltOutlined />}
|
||||
block
|
||||
onClick={() => navigate("/app/points")}
|
||||
onClick={() => navigate("/points/recharge")}
|
||||
>
|
||||
充值积分
|
||||
</Button>
|
||||
<Button block onClick={() => navigate("/app/points/transactions")}>
|
||||
<Button block onClick={() => navigate("/points/transactions")}>
|
||||
积分明细
|
||||
<RightOutlined />
|
||||
</Button>
|
||||
{!isMember && (
|
||||
<Button block type="link" onClick={() => navigate("/app/subscription")}>
|
||||
<Button block type="link" onClick={() => navigate("/subscription")}>
|
||||
<CrownOutlined /> 升级会员享折扣
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,31 +1,41 @@
|
||||
/**
|
||||
* 功能操作按钮旁的"消耗积分"提示
|
||||
* 例:[生成配音] 💎 -1 积分
|
||||
* - 根据 scene_key 自动读取规则
|
||||
* - 免费用户自动计算 ×1.15 向上取整
|
||||
* - 根据 scene_key 自动读取规则(来自 store.rules)
|
||||
* - 免费用户自动计算 ×free_user_multiplier 向上取整
|
||||
* - 会员 floor(base × points_discount)
|
||||
* - 余额不足时显示红色告警 + 充值提示
|
||||
*
|
||||
* 使用:<PointsCost scene="ai_voice" units={1} />
|
||||
* 字段对齐新契约:
|
||||
* - rule.points_per_use → base_points
|
||||
* - balance.free_clips_remaining → dailyUsage.free_clips_remaining
|
||||
* - props.units → 保留兼容,新代码优先用 quantity
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
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 { getPointsRules } from "@/api/points"
|
||||
import "./PointsCost.css"
|
||||
|
||||
interface Props {
|
||||
/** 消耗场景 key */
|
||||
scene: PointsSource
|
||||
/** 单位数(分钟数/条数/张数),默认 1 */
|
||||
units?: number
|
||||
/** 数量(新字段),默认 1 */
|
||||
quantity?: number
|
||||
/** 预计时长(分钟),可选 */
|
||||
durationMinutes?: number
|
||||
/** 是否显示为紧凑模式(仅图标+数字,不显示单位文字) */
|
||||
compact?: boolean
|
||||
/** 余额不足时,是否显示充值提示 */
|
||||
showRechargeHint?: boolean
|
||||
/** 自定义 class */
|
||||
className?: string
|
||||
/**
|
||||
* @deprecated 旧字段保留兼容,内部映射为 quantity
|
||||
*/
|
||||
units?: number
|
||||
}
|
||||
|
||||
/** 单位中文 */
|
||||
@@ -38,31 +48,26 @@ const UNIT_LABEL: Record<string, string> = {
|
||||
|
||||
const PointsCost: React.FC<Props> = ({
|
||||
scene,
|
||||
units = 1,
|
||||
quantity,
|
||||
units,
|
||||
durationMinutes,
|
||||
compact = false,
|
||||
showRechargeHint = true,
|
||||
className = "",
|
||||
}) => {
|
||||
const { balance, init } = usePointsStore()
|
||||
const [rules, setRules] = useState<Awaited<ReturnType<typeof getPointsRules>> | null>(null)
|
||||
}: Props) => {
|
||||
const { balance, dailyUsage, rules, membership } = usePointsStore()
|
||||
const qty = quantity ?? units ?? 1
|
||||
|
||||
useEffect(() => {
|
||||
if (!balance) init()
|
||||
if (!rules) {
|
||||
getPointsRules()
|
||||
.then(setRules)
|
||||
.catch(() => {})
|
||||
}
|
||||
}, [balance, init, rules])
|
||||
|
||||
const { cost, isFreeQuota, rule, isFreeUser, insufficient } = useMemo(() => {
|
||||
const { cost, isFreeQuota, rule, isFreeUser, insufficient, freeRemain } = useMemo(() => {
|
||||
const isMem = membership?.is_member ?? balance?.is_member ?? false
|
||||
if (!rules || !balance) {
|
||||
return {
|
||||
cost: 0,
|
||||
isFreeQuota: false,
|
||||
rule: null,
|
||||
isFreeUser: !balance?.is_member,
|
||||
isFreeUser: !isMem,
|
||||
insufficient: false,
|
||||
freeRemain: 0,
|
||||
}
|
||||
}
|
||||
const rule = rules.rules.find((r) => r.scene_key === scene)
|
||||
@@ -71,49 +76,57 @@ const PointsCost: React.FC<Props> = ({
|
||||
cost: 0,
|
||||
isFreeQuota: false,
|
||||
rule: null,
|
||||
isFreeUser: !balance.is_member,
|
||||
isFreeUser: !isMem,
|
||||
insufficient: false,
|
||||
freeRemain: 0,
|
||||
}
|
||||
// 免费训练不扣费
|
||||
if (rule.points_per_use === 0) {
|
||||
if (rule.base_points === 0) {
|
||||
return {
|
||||
cost: 0,
|
||||
isFreeQuota: false,
|
||||
rule,
|
||||
isFreeUser: !balance.is_member,
|
||||
isFreeUser: !isMem,
|
||||
insufficient: false,
|
||||
freeRemain: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// 智能混剪:首条30s=3分,每+30s +1
|
||||
// 计算 base
|
||||
let baseCost: number
|
||||
if (scene === "ai_video" && rule.extra_per_30s) {
|
||||
// units 当作"30s 段数"更简单;按分钟算:minutes 个 30s 段 - 1
|
||||
const segments = Math.max(1, Math.ceil(units * 2)) // 1min = 2 segments
|
||||
baseCost = rule.points_per_use + rule.extra_per_30s * (segments - 1)
|
||||
const minutes = durationMinutes ?? qty
|
||||
const segments = Math.max(1, Math.ceil(minutes * 2))
|
||||
baseCost = rule.base_points + rule.extra_per_30s * (segments - 1)
|
||||
} else {
|
||||
baseCost = rule.points_per_use * Math.max(1, units)
|
||||
baseCost = rule.base_points * Math.max(1, qty)
|
||||
}
|
||||
|
||||
// 混剪 + 免费用户 + 今日有免费额度 → 免费
|
||||
const isFree =
|
||||
scene === "ai_video" && !balance.is_member && (balance.free_clips_remaining ?? 0) > 0
|
||||
// 非会员 + 今日免费额度 → 免费
|
||||
const freeRemain = dailyUsage?.free_clips_remaining ?? 0
|
||||
const isFree = scene === "ai_video" && !isMem && freeRemain > 0
|
||||
|
||||
const isFreeUser = !balance.is_member
|
||||
const cost = isFree
|
||||
? 0
|
||||
: isFreeUser
|
||||
? Math.ceil(baseCost * rules.free_user_multiplier)
|
||||
: baseCost
|
||||
const multiplier = rules.free_user_multiplier ?? 1.15
|
||||
const cost = isFree ? 0 : isMem ? Math.floor(baseCost) : Math.ceil(baseCost * multiplier)
|
||||
const insufficient = !isFree && balance.balance < cost
|
||||
return { cost, isFreeQuota: isFree, rule, isFreeUser, insufficient }
|
||||
}, [rules, balance, scene, units])
|
||||
return {
|
||||
cost,
|
||||
isFreeQuota: isFree,
|
||||
rule,
|
||||
isFreeUser: !isMem,
|
||||
insufficient,
|
||||
freeRemain,
|
||||
}
|
||||
}, [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}`} />
|
||||
}
|
||||
|
||||
if (rule.points_per_use === 0) {
|
||||
if (rule.base_points === 0) {
|
||||
return (
|
||||
<span className={`xx-points-cost free ${className}`}>
|
||||
<span className="xx-points-tag-free">免费</span>
|
||||
@@ -123,12 +136,10 @@ const PointsCost: React.FC<Props> = ({
|
||||
|
||||
if (isFreeQuota) {
|
||||
return (
|
||||
<Tooltip title={`今日免费额度剩余 ${balance.free_clips_remaining} 条,不扣积分`}>
|
||||
<Tooltip title={`今日免费额度剩余 ${freeRemain} 条,不扣积分`}>
|
||||
<span className={`xx-points-cost free-quota ${className}`}>
|
||||
<span className="xx-points-tag-free">免费</span>
|
||||
{!compact && (
|
||||
<span className="xx-points-desc">(今日剩余 {balance.free_clips_remaining} 条)</span>
|
||||
)}
|
||||
{!compact && <span className="xx-points-desc">(今日剩余 {freeRemain} 条)</span>}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
@@ -136,7 +147,7 @@ const PointsCost: React.FC<Props> = ({
|
||||
|
||||
const unitLabel = compact
|
||||
? ""
|
||||
: ` /${units > 1 ? `${units}${UNIT_LABEL[rule.unit] ?? rule.unit}` : rule.unit}`
|
||||
: `/${qty > 1 ? `${qty}${UNIT_LABEL[rule.unit] ?? rule.unit}` : rule.unit}`
|
||||
|
||||
return (
|
||||
<span
|
||||
|
||||
@@ -21,6 +21,7 @@ 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"
|
||||
|
||||
@@ -57,30 +58,36 @@ const Header: React.FC = () => {
|
||||
label: "订阅管理",
|
||||
onClick: () => navigate("/app/subscription"),
|
||||
},
|
||||
// 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"),
|
||||
},
|
||||
// 积分系统开关关闭时隐藏积分相关菜单项(代码保留不删除)
|
||||
...(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"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ type: "divider" },
|
||||
{
|
||||
key: "logout",
|
||||
@@ -130,7 +137,13 @@ const Header: React.FC = () => {
|
||||
|
||||
{/* v2: 升级会员入口(仅免费用户显示) */}
|
||||
{!isMember && (
|
||||
<Tooltip title="升级会员解锁无限混剪、批量导出,积分 8 折起">
|
||||
<Tooltip
|
||||
title={
|
||||
ENABLE_CREDIT_SYSTEM
|
||||
? "升级会员解锁无限混剪、批量导出,积分 8 折起"
|
||||
: "升级会员解锁无限混剪、批量导出"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
|
||||
@@ -4,7 +4,6 @@ export const ROUTE_TITLE_MAP: Record<string, string> = {
|
||||
"/app/generate": "智能剪辑",
|
||||
"/app/assets": "视频库",
|
||||
"/app/voices": "配音库",
|
||||
"/app/titles": "标题库",
|
||||
"/app/products": "成片库",
|
||||
"/app/templates": "模板库",
|
||||
"/app/history": "任务历史",
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* 标题样式相关常量(#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)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 共享标题样式配置类型(#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,
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 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
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 功能开关配置
|
||||
* 集中管理前端特性的启用/隐藏,便于灰度与回滚。
|
||||
* 注意:仅控制 UI 展示与前端校验,后端扣减逻辑由后端对应开关控制。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 积分系统 UI 开关(默认 false = 隐藏)
|
||||
* - false:隐藏所有积分相关入口/余额/消耗提示/不足弹窗/充值入口;会员标识保留;
|
||||
* 功能流程不做积分预校验,直接走生成。
|
||||
* - true:展示完整积分系统 UI。
|
||||
*/
|
||||
export const ENABLE_CREDIT_SYSTEM = false
|
||||
@@ -3,10 +3,10 @@
|
||||
* Header.tsx 和 Sidebar.tsx 共享此数据源,避免路由配置重复
|
||||
*/
|
||||
import React from "react"
|
||||
import { ENABLE_CREDIT_SYSTEM } from "./features"
|
||||
import {
|
||||
DashboardOutlined,
|
||||
FileOutlined,
|
||||
FileTextOutlined,
|
||||
AudioOutlined,
|
||||
EditOutlined,
|
||||
VideoCameraOutlined,
|
||||
@@ -51,12 +51,6 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
path: "/app/assets",
|
||||
icon: React.createElement(FileOutlined),
|
||||
},
|
||||
{
|
||||
key: "titles",
|
||||
label: "标题库",
|
||||
path: "/app/titles",
|
||||
icon: React.createElement(FileTextOutlined),
|
||||
},
|
||||
{
|
||||
key: "scripts",
|
||||
label: "文案库",
|
||||
@@ -112,12 +106,17 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
path: "/app/subscription",
|
||||
icon: React.createElement(CrownOutlined),
|
||||
},
|
||||
{
|
||||
key: "points",
|
||||
label: "积分中心",
|
||||
path: "/app/points",
|
||||
icon: React.createElement(ThunderboltOutlined),
|
||||
},
|
||||
// 积分系统开关关闭时隐藏积分中心入口(代码保留不删除)
|
||||
...(ENABLE_CREDIT_SYSTEM
|
||||
? [
|
||||
{
|
||||
key: "points",
|
||||
label: "积分中心",
|
||||
path: "/app/points",
|
||||
icon: React.createElement(ThunderboltOutlined),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
/** 侧边栏导航分组(Sidebar 分组列表使用) */
|
||||
@@ -160,12 +159,6 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
path: "/app/voices",
|
||||
icon: React.createElement(AudioOutlined),
|
||||
},
|
||||
{
|
||||
key: "titles",
|
||||
label: "标题库",
|
||||
path: "/app/titles",
|
||||
icon: React.createElement(FileTextOutlined),
|
||||
},
|
||||
{
|
||||
key: "scripts",
|
||||
label: "文案库",
|
||||
@@ -213,12 +206,17 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
path: "/app/subscription",
|
||||
icon: React.createElement(CrownOutlined),
|
||||
},
|
||||
{
|
||||
key: "points",
|
||||
label: "积分中心",
|
||||
path: "/app/points",
|
||||
icon: React.createElement(ThunderboltOutlined),
|
||||
},
|
||||
// 积分系统开关关闭时隐藏积分中心入口(代码保留不删除)
|
||||
...(ENABLE_CREDIT_SYSTEM
|
||||
? [
|
||||
{
|
||||
key: "points",
|
||||
label: "积分中心",
|
||||
path: "/app/points",
|
||||
icon: React.createElement(ThunderboltOutlined),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -30,11 +30,7 @@ import {
|
||||
} from "./api/aiAvatar"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import type { RenderJob, SentenceTiming } from "./types"
|
||||
import {
|
||||
normalizeEmotion,
|
||||
buildTitleConfigPayload,
|
||||
buildCoverConfigPayload,
|
||||
} from "./utils/contract"
|
||||
import { buildTitleConfigPayload, buildCoverConfigPayload } from "./utils/contract"
|
||||
import { renderTitleToPngDataUrl, getVideoResolution } from "./utils/titleCanvas"
|
||||
|
||||
/** 面板折叠状态 */
|
||||
@@ -94,7 +90,7 @@ const AiAvatarPage: React.FC = () => {
|
||||
state.resetTtsPreview()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.scriptText, state.selectedVoice?.voice_id, state.speed, state.emotion])
|
||||
}, [state.scriptText, state.selectedVoice?.voice_id, state.speed, state.style])
|
||||
|
||||
const _clearTtsProgressTimer = useCallback(() => {
|
||||
if (ttsProgressTimerRef.current) {
|
||||
@@ -148,7 +144,7 @@ const AiAvatarPage: React.FC = () => {
|
||||
voice_id: state.selectedVoice!.voice_id,
|
||||
script_text: state.scriptText,
|
||||
speed: state.speed,
|
||||
emotion: normalizeEmotion(state.emotion),
|
||||
style: state.style,
|
||||
})
|
||||
_clearTtsProgressTimer()
|
||||
setTtsProgress(100)
|
||||
@@ -175,7 +171,7 @@ const AiAvatarPage: React.FC = () => {
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.emotion])
|
||||
}, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.style])
|
||||
|
||||
const handleRetryTts = useCallback(() => {
|
||||
handleGenerateTts()
|
||||
@@ -254,7 +250,7 @@ const AiAvatarPage: React.FC = () => {
|
||||
script_text: state.scriptText,
|
||||
video_url: videoUrl,
|
||||
speed: state.speed,
|
||||
emotion: normalizeEmotion(state.emotion),
|
||||
style: state.style,
|
||||
}
|
||||
}
|
||||
const job = await createLipsyncJob(payload)
|
||||
@@ -298,7 +294,8 @@ const AiAvatarPage: React.FC = () => {
|
||||
state.selectedVoice,
|
||||
state.scriptText,
|
||||
state.speed,
|
||||
state.emotion,
|
||||
|
||||
state.style,
|
||||
state.ttsPreview,
|
||||
])
|
||||
|
||||
@@ -598,8 +595,8 @@ const AiAvatarPage: React.FC = () => {
|
||||
onVoiceSourceChange={state.setVoiceSource}
|
||||
selectedVoice={state.selectedVoice}
|
||||
onSelectVoice={state.setSelectedVoice}
|
||||
emotion={state.emotion}
|
||||
onEmotionChange={state.setEmotion}
|
||||
style={state.style}
|
||||
onStyleChange={state.setStyle}
|
||||
speed={state.speed}
|
||||
onSpeedChange={state.setSpeed}
|
||||
language={state.language}
|
||||
@@ -619,6 +616,7 @@ const AiAvatarPage: React.FC = () => {
|
||||
scriptText={state.scriptText}
|
||||
onScriptTextChange={state.setScriptText}
|
||||
onOpenScriptModal={() => state.setShowScriptModal(true)}
|
||||
onScriptCreated={(s) => state.selectScript(s as import("./types").Script)}
|
||||
/>
|
||||
<div className="aa-step-btn-row">
|
||||
<button
|
||||
@@ -1146,8 +1144,9 @@ const ScriptSelectModalLazy: React.FC<{
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setLoading(true)
|
||||
getScripts()
|
||||
.then((items) => setScripts(Array.isArray(items) ? items : []))
|
||||
// #1894: getScripts 返回 { items, total } 分页结构,取 items 即可
|
||||
getScripts({ page_size: 200 })
|
||||
.then((res) => setScripts(Array.isArray(res) ? res : (res.items ?? [])))
|
||||
.catch(() => setScripts([]))
|
||||
.finally(() => setLoading(false))
|
||||
}, [open])
|
||||
|
||||
@@ -2,31 +2,15 @@
|
||||
* AI数字人 — API 封装(#1822 契约对齐)
|
||||
*/
|
||||
import apiClient from "@/api/client"
|
||||
import type { Script, LipsyncJob, RenderJob, BRollSegment, SentenceTiming } from "../types"
|
||||
// #1894: Script 类型统一从 @/api/scripts 取(ai-avatar 本地 Script 仅保留渲染/对口型等自有类型)
|
||||
import type { LipsyncJob, RenderJob, BRollSegment, SentenceTiming } from "../types"
|
||||
|
||||
/* ── 文案库 ── */
|
||||
export const getScripts = async (): Promise<Script[]> => {
|
||||
const response = await apiClient.get<{ items?: Script[] } | Script[]>("/scripts")
|
||||
// 后端列表返回 { items, total } 分页对象,做兼容解包 + 数组防御(#1809 白屏修复)
|
||||
const data = response.data as unknown
|
||||
if (Array.isArray(data)) return data
|
||||
const items = (data as { items?: Script[] })?.items
|
||||
return Array.isArray(items) ? items : []
|
||||
}
|
||||
|
||||
export const getScriptById = async (id: string): Promise<Script> => {
|
||||
const response = await apiClient.get<Script>(`/scripts/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const createScript = async (data: { title: string; content: string }): Promise<Script> => {
|
||||
const response = await apiClient.post<Script>("/scripts", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const deleteScript = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/scripts/${id}`)
|
||||
}
|
||||
/* ── 文案库 ──
|
||||
* #1894: 统一走 @/api/scripts 的 getScripts,不再各自封装;
|
||||
* 这样 mock 开关、分页/搜索参数、字段对齐都和文案库页面保持一致。
|
||||
*/
|
||||
// #1894: 统一复用文案库 API,不再在 ai-avatar 里重复实现
|
||||
export { getScripts, getScript as getScriptById, createScript, deleteScript } from "@/api/scripts"
|
||||
|
||||
/* ── 素材单查(拿到 file_url 作为对口型的 video_url) ── */
|
||||
export const getAssetById = async (id: string): Promise<{ file_url?: string; id: string }> => {
|
||||
@@ -57,10 +41,13 @@ export const createLipsyncJob = async (data: {
|
||||
speed?: number
|
||||
/** 情绪英文枚举:neutral/happy/sad/angry/surprised/fearful/disgusted(TTS 直生模式用;前端经 normalizeEmotion 归一化) */
|
||||
emotion?: string
|
||||
/** 配音风格预设(natural/excited/professional/sweet/news/livestream) */
|
||||
style?: string
|
||||
enable_video_loop?: boolean
|
||||
project_id?: string
|
||||
}): Promise<LipsyncJob> => {
|
||||
const response = await apiClient.post<LipsyncJob>("/lipsync/jobs", data)
|
||||
// GPU 口型同步推理约 20s,留足余量到 120s 防止 10s 默认超时
|
||||
const response = await apiClient.post<LipsyncJob>("/lipsync/jobs", data, { timeout: 120_000 })
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -70,6 +57,7 @@ export const previewTts = async (data: {
|
||||
script_text: string
|
||||
speed?: number
|
||||
emotion?: string
|
||||
style?: string
|
||||
}): Promise<{
|
||||
audio_url: string
|
||||
duration: number
|
||||
|
||||
@@ -29,15 +29,7 @@ function formatTime(seconds: number): string {
|
||||
return `${m}:${s.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** 字体名 → 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["思源黑体"]
|
||||
import { getFontFamily as getFontFamilyByKey } from "@/components/title/constants"
|
||||
|
||||
export function PanelLipsyncPreview({
|
||||
lipsyncJob,
|
||||
@@ -83,63 +75,105 @@ export function PanelLipsyncPreview({
|
||||
const previewScale = containerWidth > 0 ? containerWidth / 720 : 0.35
|
||||
const ps = useCallback((v: number) => Math.round(v * previewScale * 100) / 100, [previewScale])
|
||||
|
||||
/** 标题叠加样式(字号/padding/描边/阴影均按 previewScale 缩放,保持与成片视觉一致) */
|
||||
const titleOverlayStyle: React.CSSProperties | null =
|
||||
/** 标题叠加样式(新字段全支持:描边宽色/阴影参数化/背景块/行距/顶部边距/自动换行) */
|
||||
const titleOverlayData =
|
||||
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)
|
||||
// 描边宽度基准 ≈ 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 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
|
||||
const style: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
color: titleConfig.color || "#ffffff",
|
||||
fontFamily: getFontFamily(titleConfig.font || "思源黑体"),
|
||||
fontFamily: getFontFamilyByKey(titleConfig.font || "source_sans_sc"),
|
||||
fontSize: `${fontSize}px`,
|
||||
fontWeight: titleConfig.bold ? 700 : 400,
|
||||
fontStyle: titleConfig.italic ? "italic" : "normal",
|
||||
textAlign: "center",
|
||||
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",
|
||||
wordBreak: "break-word",
|
||||
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,
|
||||
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",
|
||||
}
|
||||
|
||||
if (
|
||||
titleConfig.position === "custom" &&
|
||||
titleConfig.pos_x != null &&
|
||||
titleConfig.pos_y != null
|
||||
) {
|
||||
style.left = `${titleConfig.pos_x}%`
|
||||
style.top = `${titleConfig.pos_y}%`
|
||||
style.transform = "translateX(-50%) translateY(-50%)"
|
||||
wrap.left = `${titleConfig.pos_x}%`
|
||||
wrap.top = `${titleConfig.pos_y}%`
|
||||
wrap.transform = "translate(-50%, -50%)"
|
||||
} else if (titleConfig.position === "top") {
|
||||
style.left = "50%"
|
||||
style.top = padV
|
||||
style.transform = "translateX(-50%)"
|
||||
wrap.top = `${padV + mTop}px`
|
||||
wrap.transform = "translateX(-50%)"
|
||||
} else if (titleConfig.position === "bottom") {
|
||||
style.left = "50%"
|
||||
style.bottom = padV
|
||||
style.transform = "translateX(-50%)"
|
||||
wrap.bottom = `${padV}px`
|
||||
wrap.transform = "translateX(-50%)"
|
||||
} else {
|
||||
style.left = "50%"
|
||||
style.top = "50%"
|
||||
style.transform = "translateX(-50%) translateY(-50%)"
|
||||
wrap.top = "50%"
|
||||
wrap.transform = "translate(-50%, -50%)"
|
||||
}
|
||||
return style
|
||||
return { style, wrap, lines }
|
||||
})()
|
||||
: null
|
||||
|
||||
@@ -252,25 +286,23 @@ export function PanelLipsyncPreview({
|
||||
{isDone && lipsyncJob?.output_video_url ? (
|
||||
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||
<video src={lipsyncJob.output_video_url} controls />
|
||||
{titleOverlayStyle && (
|
||||
{titleOverlayData && (
|
||||
<div
|
||||
ref={titleDragRef}
|
||||
style={{
|
||||
...titleOverlayStyle,
|
||||
...titleOverlayData.wrap,
|
||||
cursor: onTitlePositionChange ? "grab" : "default",
|
||||
pointerEvents: onTitlePositionChange ? "auto" : "none",
|
||||
}}
|
||||
onPointerDown={handleTitlePointerDown}
|
||||
onPointerMove={handleTitlePointerMove}
|
||||
onPointerUp={handleTitlePointerUp}
|
||||
onPointerCancel={handleTitlePointerUp}
|
||||
>
|
||||
{titleConfig!.title.split(/[//]/).map((part, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <br />}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
<div style={titleOverlayData.style}>
|
||||
{titleOverlayData.lines.map((part: string, i: number) => (
|
||||
<div key={i}>{part}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
/**
|
||||
* AI数字人 — 文案面板(步骤1用)
|
||||
* 文案库选择 / 手动输入 + 字数统计
|
||||
* #1894: 文案库选择走 @/api/scripts;手动输入支持一键「保存到文案库」
|
||||
*/
|
||||
import { useState } from "react"
|
||||
import { message } from "antd"
|
||||
import { createScript } from "../api/aiAvatar"
|
||||
|
||||
interface PanelScriptProps {
|
||||
scriptText: string
|
||||
onScriptTextChange: (text: string) => void
|
||||
onOpenScriptModal: () => void
|
||||
/** 手动保存到文案库后回调(把新脚本传入,父组件可更新 selectedScript) */
|
||||
onScriptCreated?: (script: { id: string; title: string; content: string }) => void
|
||||
}
|
||||
|
||||
type ScriptTab = "library" | "manual"
|
||||
@@ -16,8 +20,30 @@ export function PanelScript({
|
||||
scriptText,
|
||||
onScriptTextChange,
|
||||
onOpenScriptModal,
|
||||
onScriptCreated,
|
||||
}: PanelScriptProps) {
|
||||
const [scriptTab, setScriptTab] = useState<ScriptTab>("library")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const handleSaveToLibrary = async () => {
|
||||
const text = scriptText.trim()
|
||||
if (!text) {
|
||||
message.warning("请先输入文案内容")
|
||||
return
|
||||
}
|
||||
// 用正文前 20 字作为默认标题
|
||||
const autoTitle = text.slice(0, 20).replace(/\n+/g, " ").trim() || "手动输入文案"
|
||||
setSaving(true)
|
||||
try {
|
||||
const created = await createScript({ title: autoTitle, content: text, tags: [] })
|
||||
message.success({ content: "已保存到文案库", duration: 1 })
|
||||
onScriptCreated?.(created)
|
||||
} catch {
|
||||
message.error("保存到文案库失败,请稍后重试")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="aa-script-lipsync">
|
||||
@@ -59,7 +85,20 @@ export function PanelScript({
|
||||
}
|
||||
onChange={(e) => onScriptTextChange(e.target.value)}
|
||||
/>
|
||||
<div className="aa-char-count">{scriptText.length} 字</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div className="aa-char-count">{scriptText.length} 字</div>
|
||||
{scriptTab === "manual" && scriptText.trim().length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--text"
|
||||
disabled={saving}
|
||||
onClick={handleSaveToLibrary}
|
||||
style={{ fontSize: 12, padding: "2px 8px" }}
|
||||
>
|
||||
{saving ? "保存中..." : "💾 保存到文案库"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@ 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, FONT_OPTIONS, TITLE_PRESETS } from "@/pages/generate/constants"
|
||||
import { POSITION_OPTIONS } from "@/pages/generate/constants"
|
||||
import { FONT_OPTIONS, TITLE_PRESETS } from "@/components/title/constants"
|
||||
import type { AiAvatarTitleConfig } from "../types"
|
||||
import { getTitles } from "@/api/titles"
|
||||
// #1894: 标题数据源切换到文案库,取 script.title 作为候选
|
||||
import { getScripts } from "@/api/scripts"
|
||||
|
||||
const { TextArea } = Input
|
||||
|
||||
@@ -28,17 +30,80 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
|
||||
/** TitleStylePanel 内部高亮的预设 key(面板本地状态) */
|
||||
const [activePreset, setActivePreset] = useState<string | null>(null)
|
||||
|
||||
/** 标题库选项(复用智能剪辑的标题库) */
|
||||
/** 标题库选项(#1894:从文案库 scripts[].title 取候选) */
|
||||
const [titleOptions, setTitleOptions] = useState<TitleOption[]>([])
|
||||
useEffect(() => {
|
||||
getTitles()
|
||||
.then((items) => setTitleOptions(items.map((t) => ({ label: t.content, value: t.content }))))
|
||||
getScripts({ page_size: 200 })
|
||||
.then((res) => {
|
||||
const items = Array.isArray(res) ? res : (res.items ?? [])
|
||||
// 去重 + 过滤空标题
|
||||
const seen = new Set<string>()
|
||||
const opts: TitleOption[] = []
|
||||
for (const s of items) {
|
||||
const t = (s.title || "").trim()
|
||||
if (!t || seen.has(t)) continue
|
||||
seen.add(t)
|
||||
opts.push({ label: t, value: t })
|
||||
}
|
||||
setTitleOptions(opts)
|
||||
})
|
||||
.catch(() => setTitleOptions([]))
|
||||
}, [])
|
||||
|
||||
/** AiAvatarTitleConfig → TitleSettings(补齐 aiAutoSelect / 自由坐标字段) */
|
||||
const titleSettings: TitleSettings = useMemo(
|
||||
() => ({
|
||||
/** 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 {
|
||||
aiAutoSelect: false,
|
||||
title: titleConfig.title,
|
||||
position: titleConfig.position,
|
||||
@@ -51,24 +116,219 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
|
||||
color: titleConfig.color,
|
||||
posX: null,
|
||||
posY: null,
|
||||
}),
|
||||
[titleConfig],
|
||||
)
|
||||
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])
|
||||
|
||||
/** 应用预设:与智能剪辑一致,只覆盖 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({
|
||||
color: preset.style.color,
|
||||
bold: preset.style.bold,
|
||||
italic: preset.style.italic,
|
||||
stroke: preset.style.stroke,
|
||||
shadow: preset.style.shadow,
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
/** 字段 patch 透传:TitleStylePanel 的 onUpdateStyle(camelCase → 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 多行 + 标题库选择 */}
|
||||
@@ -84,10 +344,12 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
|
||||
style={{ fontSize: 15 }}
|
||||
/>
|
||||
<div style={{ marginTop: 8, display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontSize: 12, color: "#8c8ca1", whiteSpace: "nowrap" }}>📚 标题库</span>
|
||||
<span style={{ fontSize: 12, color: "#8c8ca1", whiteSpace: "nowrap" }}>
|
||||
📚 文案库标题
|
||||
</span>
|
||||
<TitleLibraryAutoComplete
|
||||
key={titleConfig.title}
|
||||
placeholder="选择标题填入上方"
|
||||
placeholder="从文案库选择标题"
|
||||
value=""
|
||||
onChange={(val) => {
|
||||
if (val) onUpdate({ title: val })
|
||||
@@ -110,8 +372,13 @@ 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}
|
||||
titlePresets={
|
||||
TITLE_PRESETS as unknown as React.ComponentProps<typeof TitleStylePanel>["titlePresets"]
|
||||
}
|
||||
POSITION_OPTIONS={POSITION_OPTIONS}
|
||||
FONT_OPTIONS={FONT_OPTIONS}
|
||||
/>
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
/**
|
||||
* AI数字人 — 配音库面板(面板3)
|
||||
* 音色来源切换(系统预设 / 我的音色)、音色选择与试听、情绪/语速/语言参数
|
||||
* 音色来源切换(系统预设 / 我的音色)、音色选择与试听、风格/语速/语言参数
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { message } from "antd"
|
||||
import { fetchVoices } from "@/api/voices/voices"
|
||||
import { previewTts } from "@/api/tts"
|
||||
import { normalizeEmotion } from "../utils/contract"
|
||||
import TtsStyleSelector from "@/components/voice/TtsStyleSelector"
|
||||
import type { TtsStyle } from "@/api/tts/styles"
|
||||
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"
|
||||
@@ -22,8 +21,8 @@ interface PanelVoiceSelectorProps {
|
||||
onVoiceSourceChange: (source: VoiceSource) => void
|
||||
selectedVoice: UnifiedVoiceItem | null
|
||||
onSelectVoice: (voice: UnifiedVoiceItem) => void
|
||||
emotion: VoiceEmotion
|
||||
onEmotionChange: (e: VoiceEmotion) => void
|
||||
style: TtsStyle
|
||||
onStyleChange: (s: TtsStyle) => void
|
||||
speed: number
|
||||
onSpeedChange: (s: number) => void
|
||||
language: VoiceLanguage
|
||||
@@ -35,8 +34,8 @@ export function PanelVoiceSelector({
|
||||
onVoiceSourceChange,
|
||||
selectedVoice,
|
||||
onSelectVoice,
|
||||
emotion,
|
||||
onEmotionChange,
|
||||
style,
|
||||
onStyleChange,
|
||||
speed,
|
||||
onSpeedChange,
|
||||
language,
|
||||
@@ -139,31 +138,30 @@ export function PanelVoiceSelector({
|
||||
/* 克隆音色:preview_url/audio_url 通常为空,需走 POST /tts/preview
|
||||
* 现合成示例文案再播放,对齐配音库 useAudioPlayer 行为 */
|
||||
if (voice.type === "clone") {
|
||||
const cached = previewCacheRef.current.get(voice.voice_clone_profile_id || voice.id)
|
||||
const cacheKey = `${voice.voice_clone_profile_id || voice.id}::${style}`
|
||||
const cached = previewCacheRef.current.get(cacheKey)
|
||||
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)
|
||||
emotion: normalizeEmotion(emotion), // 情绪中文→英文枚举
|
||||
style,
|
||||
})
|
||||
if (!res.audio_url) {
|
||||
setPreviewingId(null)
|
||||
message.error("合成试听失败:未返回音频")
|
||||
return
|
||||
}
|
||||
previewCacheRef.current.set(targetId, res.audio_url)
|
||||
previewCacheRef.current.set(cacheKey, 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,
|
||||
@@ -272,23 +270,6 @@ 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">
|
||||
语言
|
||||
@@ -324,6 +305,9 @@ 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>
|
||||
)
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* AI数字人 — 标题库选择弹窗
|
||||
* 复用智能剪辑的标题库 API,选择标题后填入输入框
|
||||
*/
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { getTitles } from "@/api/titles"
|
||||
import type { TitleItem } from "@/api/titles/types"
|
||||
|
||||
interface TitleLibraryModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSelect: (title: string) => void
|
||||
}
|
||||
|
||||
const TitleLibraryModal: React.FC<TitleLibraryModalProps> = ({ open, onClose, onSelect }) => {
|
||||
const [titles, setTitles] = useState<TitleItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setLoading(true)
|
||||
getTitles()
|
||||
.then((items) => setTitles(items))
|
||||
.catch(() => setTitles([]))
|
||||
.finally(() => setLoading(false))
|
||||
}, [open])
|
||||
|
||||
const filtered = titles.filter(
|
||||
(t) => !search || t.content.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="aa-modal-overlay" onClick={onClose}>
|
||||
<div className="aa-modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 600 }}>
|
||||
<div className="aa-modal__header">
|
||||
<span className="aa-modal__title">从标题库选择</span>
|
||||
<button className="aa-modal__close" onClick={onClose}></button>
|
||||
</div>
|
||||
<div className="aa-modal__body">
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<input
|
||||
className="aa-input"
|
||||
placeholder="搜索标题..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: "center", padding: 40, color: "#8c8ca1" }}>加载中...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div style={{ textAlign: "center", padding: 40, color: "#8c8ca1" }}>
|
||||
暂无标题,请先在标题库创建
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ maxHeight: 400, overflowY: "auto" }}>
|
||||
{filtered.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
marginBottom: 8,
|
||||
background: "#f8f8fc",
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
transition: "background 0.2s",
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = "#eef0ff")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = "#f8f8fc")}
|
||||
onClick={() => {
|
||||
onSelect(t.content)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 14, color: "#1a1a2e", marginBottom: 4 }}>{t.content}</div>
|
||||
<div style={{ fontSize: 12, color: "#8c8ca1" }}>
|
||||
{t.word_count ?? t.content.length}字 ·{" "}
|
||||
{t.created_at ? new Date(t.created_at).toLocaleDateString() : ""}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-modal__footer">
|
||||
<button className="aa-btn" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitleLibraryModal
|
||||
@@ -6,7 +6,6 @@ import type { AssetItem } from "@/api/assets"
|
||||
import type { UnifiedVoiceItem } from "@/api/voices/types"
|
||||
import {
|
||||
type VoiceSource,
|
||||
type VoiceEmotion,
|
||||
type VoiceLanguage,
|
||||
type Script,
|
||||
type LipsyncJob,
|
||||
@@ -17,6 +16,7 @@ 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 [emotion, setEmotion] = useState<VoiceEmotion>("neutral")
|
||||
const [style, setStyle] = useState<TtsStyle>(DEFAULT_TTS_STYLE)
|
||||
const [speed, setSpeed] = useState(1.0)
|
||||
const [language, setLanguage] = useState<VoiceLanguage>("zh")
|
||||
|
||||
@@ -113,8 +113,8 @@ export function useAiAvatar() {
|
||||
setVoiceSource,
|
||||
selectedVoice,
|
||||
setSelectedVoice,
|
||||
emotion,
|
||||
setEmotion,
|
||||
style,
|
||||
setStyle,
|
||||
speed,
|
||||
setSpeed,
|
||||
language,
|
||||
|
||||
@@ -56,15 +56,11 @@ export interface TtsPreviewResult {
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/* ── 文案 ── */
|
||||
export interface Script {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
char_count: number
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
}
|
||||
/* ── 文案 ──
|
||||
* #1894: 直接复用文案库的 ScriptItem 类型,保证字段(title/content/tags/...)一致;
|
||||
* 个别 ai-avatar 专属属性如有需要再在此处扩展。
|
||||
*/
|
||||
export type Script = import("@/api/scripts").ScriptItem
|
||||
|
||||
/* ── 对口型任务 ── */
|
||||
export interface LipsyncJob {
|
||||
@@ -104,7 +100,7 @@ export interface BRollSegment {
|
||||
pip_scale: number
|
||||
}
|
||||
|
||||
/* ── 标题配置 ── */
|
||||
/* ── 标题配置(#2001 升级:细粒度描边/阴影/背景/排版/逐行/封面独立标题) ── */
|
||||
export interface AiAvatarTitleConfig {
|
||||
title: string
|
||||
position: string
|
||||
@@ -119,6 +115,42 @@ 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>
|
||||
}
|
||||
|
||||
/* ── 封面配置 ── */
|
||||
@@ -153,12 +185,27 @@ export const DEFAULT_TITLE_CONFIG: AiAvatarTitleConfig = {
|
||||
size: 48,
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
stroke: true,
|
||||
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 = {
|
||||
|
||||
@@ -67,6 +67,37 @@ 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,
|
||||
@@ -75,16 +106,68 @@ export function buildTitleConfigPayload(
|
||||
font_color: cfg.color || "#ffffff",
|
||||
position,
|
||||
bold: !!cfg.bold,
|
||||
stroke: cfg.stroke ? { enabled: true, width: 2, color: "#000000" } : { enabled: false },
|
||||
shadow: cfg.shadow
|
||||
? { enabled: true, color: "#000000", offset_x: 2, offset_y: 2 }
|
||||
italic: !!cfg.italic,
|
||||
stroke: cfg.stroke
|
||||
? { enabled: true, width: strokeWidth, color: strokeColor }
|
||||
: { enabled: false },
|
||||
shadow: cfg.shadow
|
||||
? {
|
||||
enabled: true,
|
||||
color: shadowColor,
|
||||
offset_x: shadowOffsetX,
|
||||
offset_y: shadowOffsetY,
|
||||
blur: shadowBlur,
|
||||
}
|
||||
: { 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
|
||||
|
||||
@@ -9,37 +9,78 @@
|
||||
* 按 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 将标题渲染为透明背景 PNG 的 dataURL(data: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
|
||||
function autoWrapLines(rawTitle: string, maxCharsPerLine: number): string[] {
|
||||
const manual = 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)
|
||||
|
||||
@@ -49,86 +90,183 @@ export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | n
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return null
|
||||
|
||||
const baseSize = Math.max(12, Math.round(titleConfig.size || 48))
|
||||
const baseSize = Math.max(12, Math.round(cfg.size || 48))
|
||||
const size = r(baseSize)
|
||||
const bold = !!titleConfig.bold
|
||||
const italic = !!titleConfig.italic
|
||||
const color = titleConfig.color || "#ffffff"
|
||||
const stroke = !!titleConfig.stroke
|
||||
const shadow = !!titleConfig.shadow
|
||||
const bold = !!cfg.bold
|
||||
const italic = !!cfg.italic
|
||||
const color = cfg.color || "#ffffff"
|
||||
const stroke = !!cfg.stroke
|
||||
const shadow = !!cfg.shadow
|
||||
|
||||
// 字体族 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(" ")
|
||||
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)
|
||||
ctx.fillStyle = color
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
|
||||
// 阴影(shadow=true 时开启)——按 scale 缩放
|
||||
if (shadow) {
|
||||
ctx.shadowColor = "rgba(0,0,0,0.8)"
|
||||
ctx.shadowBlur = r(4)
|
||||
ctx.shadowOffsetX = 0
|
||||
ctx.shadowOffsetY = r(2)
|
||||
}
|
||||
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
|
||||
})
|
||||
|
||||
// 位置计算:与 PanelLipsyncPreview 的 CSS 对齐(按 scale 缩放 PAD)
|
||||
const PAD = r(16)
|
||||
let centerX = videoWidth / 2
|
||||
const position = titleConfig.position || "bottom"
|
||||
const lineGap = size * 1.2
|
||||
const totalTextH = lines.length * lineGap - (lineGap - size) // 所有行的总高度
|
||||
// 文本块顶部 y(textBaseline=middle 时首行基线)
|
||||
const position = cfg.position || "bottom"
|
||||
let firstLineY: number
|
||||
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
|
||||
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
|
||||
firstLineY = centerY - totalTextH / 2 + size / 2
|
||||
} else if (position === "top") {
|
||||
// 顶部:y = size/2 + PAD
|
||||
firstLineY = size / 2 + PAD
|
||||
firstLineY = size / 2 + PAD + r(marginTopBase)
|
||||
} else if (position === "center") {
|
||||
firstLineY = videoHeight / 2 - totalTextH / 2 + size / 2
|
||||
} else {
|
||||
// bottom(默认)
|
||||
firstLineY = videoHeight - totalTextH - PAD + size / 2
|
||||
}
|
||||
|
||||
// 描边参数:描边 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) => {
|
||||
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) => {
|
||||
const y = firstLineY + idx * lineGap
|
||||
if (doStroke) {
|
||||
const prevShadowColor = ctx.shadowColor
|
||||
const prevShadowBlur = ctx.shadowBlur
|
||||
// 描边不要带阴影(避免黑色描边发虚)
|
||||
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 {
|
||||
ctx.shadowColor = "rgba(0,0,0,0)"
|
||||
ctx.shadowBlur = 0
|
||||
ctx.lineWidth = strokeWidth
|
||||
ctx.strokeStyle = "#000000"
|
||||
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.lineJoin = "round"
|
||||
ctx.strokeText(line, centerX, y)
|
||||
// 恢复阴影
|
||||
if (shadow) {
|
||||
ctx.shadowColor = "rgba(0,0,0,0.8)"
|
||||
ctx.shadowBlur = r(4)
|
||||
} else {
|
||||
ctx.shadowColor = prevShadowColor
|
||||
ctx.shadowBlur = prevShadowBlur
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -138,9 +276,6 @@ export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | n
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频真实分辨率(HTMLVideoElement + loadedmetadata,超时 3 秒兜底 720×1280)。
|
||||
*/
|
||||
export function getVideoResolution(
|
||||
videoUrl: string,
|
||||
timeoutMs = 3000,
|
||||
|
||||
@@ -11,6 +11,9 @@ import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useCloneProgress } from "@/hooks/useCloneProgress"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import VoiceSelectModal from "./components/VoiceSelectModal"
|
||||
import ScriptSelectModal from "./components/ScriptSelectModal"
|
||||
import TtsVoiceModal from "./components/TtsVoiceModal"
|
||||
import GenerateHeader from "./components/GenerateHeader"
|
||||
import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer"
|
||||
import CanvasPreviewGrid from "./components/CanvasPreviewGrid"
|
||||
@@ -30,6 +33,7 @@ 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"
|
||||
|
||||
@@ -39,7 +43,7 @@ const GeneratePage: React.FC = () => {
|
||||
/* ── 表单状态 ── */
|
||||
const formState = useGenerateFormState()
|
||||
/* ── 积分状态 ── */
|
||||
const { balance, init: initPoints } = usePointsStore()
|
||||
const { balance, dailyUsage, rules, init: initPoints } = usePointsStore()
|
||||
useEffect(() => {
|
||||
initPoints()
|
||||
}, [initPoints])
|
||||
@@ -62,11 +66,28 @@ const GeneratePage: React.FC = () => {
|
||||
selectedVoice,
|
||||
setSelectedVoice,
|
||||
voiceMode,
|
||||
setVoiceMode,
|
||||
selectedClonedVoice,
|
||||
setSelectedClonedVoice,
|
||||
editMode,
|
||||
setEditMode,
|
||||
selectedScript,
|
||||
setSelectedScript,
|
||||
ttsVoiceId,
|
||||
setTtsVoiceId,
|
||||
ttsVoiceSource,
|
||||
setTtsVoiceSource,
|
||||
ttsStyle,
|
||||
setTtsStyle,
|
||||
ttsVoiceAssetId,
|
||||
setTtsVoiceAssetId,
|
||||
dedupEnabled,
|
||||
setDedupEnabled,
|
||||
|
||||
cloneModalOpen,
|
||||
setCloneModalOpen,
|
||||
videoRatio,
|
||||
setVideoRatio,
|
||||
duration,
|
||||
style,
|
||||
autoSubtitles,
|
||||
@@ -124,6 +145,11 @@ const GeneratePage: React.FC = () => {
|
||||
/* ── 数量选择弹窗 ── */
|
||||
const [countModalOpen, setCountModalOpen] = useState(false)
|
||||
|
||||
/* ── #1970 流程重构:分支弹窗 ── */
|
||||
const [voiceModalOpen, setVoiceModalOpen] = useState(false)
|
||||
const [scriptModalOpen, setScriptModalOpen] = useState(false)
|
||||
const [ttsModalOpen, setTtsModalOpen] = useState(false)
|
||||
|
||||
/* ── 标题样式回调 ── */
|
||||
const styleUpdaters = useTitleStyleUpdaters({
|
||||
titleSettings,
|
||||
@@ -301,6 +327,13 @@ const GeneratePage: React.FC = () => {
|
||||
selectedClonedVoice,
|
||||
coverSettings,
|
||||
videoRatio,
|
||||
editMode,
|
||||
selectedScript,
|
||||
ttsVoiceId,
|
||||
ttsVoiceSource,
|
||||
ttsStyle,
|
||||
ttsVoiceAssetId,
|
||||
dedupEnabled,
|
||||
style,
|
||||
duration,
|
||||
autoSubtitles,
|
||||
@@ -340,7 +373,7 @@ const GeneratePage: React.FC = () => {
|
||||
return Array.from({ length: count }, (_, i) => list[i] ?? "")
|
||||
})
|
||||
setSelectedVariantIds(Array.from({ length: count }, (_, i) => i))
|
||||
setCurrentStep(2)
|
||||
setCurrentStep(3)
|
||||
},
|
||||
[
|
||||
setPreviewCount,
|
||||
@@ -354,14 +387,83 @@ const GeneratePage: React.FC = () => {
|
||||
],
|
||||
)
|
||||
|
||||
/* ── #1970:Step1 弹窗回调 ── */
|
||||
const handleVoiceModalConfirm = useCallback(
|
||||
(voiceAssetId: string) => {
|
||||
setSelectedVoice(voiceAssetId)
|
||||
setVoiceMode("custom")
|
||||
setVoiceModalOpen(false)
|
||||
setCurrentStep(2)
|
||||
},
|
||||
[setSelectedVoice, setVoiceMode, setCurrentStep],
|
||||
)
|
||||
|
||||
const handleScriptModalConfirm = useCallback(
|
||||
(script: import("@/api/scripts").ScriptItem) => {
|
||||
setSelectedScript(script)
|
||||
// 自动带入标题(若标题为空则预填)
|
||||
if (!titleSettings.title?.trim() && script.title) {
|
||||
setTitleSettings((prev) => ({ ...prev, title: script.title, aiAutoSelect: false }))
|
||||
}
|
||||
setScriptModalOpen(false)
|
||||
// 自动打开 TTS 弹窗
|
||||
setTtsModalOpen(true)
|
||||
},
|
||||
[setSelectedScript, setTitleSettings, titleSettings.title],
|
||||
)
|
||||
|
||||
const handleTtsSynthesized = useCallback(
|
||||
(payload: {
|
||||
voiceAssetId: string
|
||||
ttsVoiceId: string
|
||||
ttsVoiceSource: "preset" | "clone"
|
||||
ttsStyle?: string
|
||||
}) => {
|
||||
setTtsVoiceId(payload.ttsVoiceId)
|
||||
setTtsVoiceSource(payload.ttsVoiceSource)
|
||||
if (payload.ttsStyle) setTtsStyle(payload.ttsStyle)
|
||||
setTtsVoiceAssetId(payload.voiceAssetId)
|
||||
if (payload.ttsVoiceSource === "clone") {
|
||||
setSelectedClonedVoice(payload.ttsVoiceId)
|
||||
setVoiceMode("clone")
|
||||
} else {
|
||||
setSelectedVoice(payload.ttsVoiceId)
|
||||
setVoiceMode("preset")
|
||||
}
|
||||
setTtsModalOpen(false)
|
||||
message.success("配音合成成功")
|
||||
setCurrentStep(2)
|
||||
},
|
||||
[
|
||||
setTtsVoiceId,
|
||||
setTtsVoiceSource,
|
||||
setTtsStyle,
|
||||
setTtsVoiceAssetId,
|
||||
setSelectedVoice,
|
||||
setSelectedClonedVoice,
|
||||
setVoiceMode,
|
||||
setCurrentStep,
|
||||
],
|
||||
)
|
||||
|
||||
/* ── 步骤3「确认生成视频」:校验通过 → 创建正式生成任务 → 跳步骤4看实时进展 ── */
|
||||
const handleConfirmGenerate = useCallback(async () => {
|
||||
// 积分预检查
|
||||
const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
|
||||
const check = hasEnoughPoints(balance ?? null, units)
|
||||
if (!check.sufficient) {
|
||||
message.error(check.reason ?? "积分不足,请充值")
|
||||
return
|
||||
// 积分预检查(积分系统关闭时跳过,直接走生成流程)
|
||||
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
|
||||
}
|
||||
}
|
||||
if (isBatch) {
|
||||
if (selectedVariantIds.length === 0) {
|
||||
@@ -397,18 +499,28 @@ const GeneratePage: React.FC = () => {
|
||||
handleGenerate,
|
||||
setCurrentStep,
|
||||
balance,
|
||||
dailyUsage,
|
||||
rules,
|
||||
])
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
const { goNext, goPrev } = useStepNavigation({
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
editMode,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
generated,
|
||||
onOpenCountModal: () => setCountModalOpen(true),
|
||||
onOpenStep1Modal: () => {
|
||||
if (editMode === "random") {
|
||||
setVoiceModalOpen(true)
|
||||
} else {
|
||||
setScriptModalOpen(true)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/* ── 最终成片 ── */
|
||||
@@ -422,11 +534,18 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
/* ── 积分消耗估算(步骤3确认生成展示用) ── */
|
||||
const unitsForCost = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
|
||||
const pointsEstimate = useMemo(
|
||||
() => hasEnoughPoints(balance ?? null, unitsForCost),
|
||||
[unitsForCost, balance],
|
||||
)
|
||||
const insufficientPoints = !pointsEstimate.sufficient
|
||||
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
|
||||
|
||||
/* ================================================================
|
||||
渲染
|
||||
@@ -445,7 +564,7 @@ const GeneratePage: React.FC = () => {
|
||||
{!isBatch ? (
|
||||
<FrontendPreviewPlayer
|
||||
assets={previewAssets}
|
||||
videoRatio={videoRatio}
|
||||
videoRatio={videoRatio as "9:16" | "16:9"}
|
||||
ready={previewAssets.length > 0}
|
||||
serverClips={serverClips}
|
||||
voiceAudioUrl={previewVoiceAudioUrl || undefined}
|
||||
@@ -480,7 +599,7 @@ const GeneratePage: React.FC = () => {
|
||||
<CanvasPreviewGrid
|
||||
count={previewCount}
|
||||
assets={previewAssets}
|
||||
videoRatio={videoRatio}
|
||||
videoRatio={videoRatio as "9:16" | "16:9"}
|
||||
titles={previewTitles}
|
||||
titleSettings={titleSettings}
|
||||
voiceAudioUrls={variantVoiceAudioUrls}
|
||||
@@ -521,6 +640,7 @@ const GeneratePage: React.FC = () => {
|
||||
onToggleStroke={styleUpdaters.toggleStroke}
|
||||
onToggleShadow={styleUpdaters.toggleShadow}
|
||||
onApplyPreset={styleUpdaters.applyPreset}
|
||||
onUpdateStyle={styleUpdaters.updateStyle}
|
||||
activePreset={styleUpdaters.activePreset}
|
||||
titlePresets={styleUpdaters.titlePresets}
|
||||
bgm={bgm}
|
||||
@@ -528,6 +648,16 @@ const GeneratePage: React.FC = () => {
|
||||
coverSettings={coverSettings}
|
||||
onCoverSettingsChange={setCoverSettings}
|
||||
selectedVoice={selectedVoice}
|
||||
editMode={editMode}
|
||||
onEditModeChange={setEditMode}
|
||||
dedupEnabled={dedupEnabled}
|
||||
onDedupEnabledChange={setDedupEnabled}
|
||||
onPreviewCountChange={setPreviewCount}
|
||||
videoRatio={videoRatio as "9:16" | "16:9"}
|
||||
onVideoRatioChange={(r) => setVideoRatio(r)}
|
||||
selectedScript={selectedScript}
|
||||
ttsVoiceId={ttsVoiceId}
|
||||
ttsVoiceSource={ttsVoiceSource}
|
||||
onSelectedVoiceChange={setSelectedVoice}
|
||||
onServerClipsChange={setServerClips}
|
||||
generating={generating}
|
||||
@@ -654,6 +784,29 @@ const GeneratePage: React.FC = () => {
|
||||
onClose={() => setCloneModalOpen(false)}
|
||||
onSuccess={handleCloneSuccess}
|
||||
/>
|
||||
|
||||
{/* #1970 流程弹窗 */}
|
||||
<VoiceSelectModal
|
||||
open={voiceModalOpen}
|
||||
selectedVoice={selectedVoice}
|
||||
onCancel={() => setVoiceModalOpen(false)}
|
||||
onConfirm={handleVoiceModalConfirm}
|
||||
/>
|
||||
<ScriptSelectModal
|
||||
open={scriptModalOpen}
|
||||
selectedScriptId={selectedScript?.id ?? null}
|
||||
onCancel={() => setScriptModalOpen(false)}
|
||||
onConfirm={handleScriptModalConfirm}
|
||||
/>
|
||||
<TtsVoiceModal
|
||||
open={ttsModalOpen}
|
||||
scriptText={selectedScript?.content ?? ""}
|
||||
scriptTitle={selectedScript?.title ?? ""}
|
||||
style={ttsStyle}
|
||||
onStyleChange={setTtsStyle}
|
||||
onCancel={() => setTtsModalOpen(false)}
|
||||
onSynthesized={handleTtsSynthesized}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
/**
|
||||
* 第5步「确认生成」— 批量渲染进度网格(Issue #1677)
|
||||
* 第5步「确认生成」— 批量渲染进度网格(Issue #1677 / #1800 布局修复)
|
||||
*
|
||||
* N 个正式生成任务各自独立卡片:进度条 / 成功成片播放 / 失败原因 + 单独重试。
|
||||
* 数据来自 useGenerateVideo 的 batchTasks(useGenerationPolling 实时回传)。
|
||||
*
|
||||
* #1800 修复点:
|
||||
* - 不再用 inline style 硬编码 grid 列宽 / 卡片 maxWidth,改由 CSS 统一控制
|
||||
* (便于响应式 + 避免 inline 覆盖类规则)。
|
||||
* - 标题图标 + 文本拆分为独立 span,文本 span 加 flex:1/min-width:0/ellipsis,
|
||||
* 防止长标题在窄列里溢出导致与相邻卡片进度条视觉重叠。
|
||||
*/
|
||||
import React from "react"
|
||||
import { LoadingOutlined, CheckCircleFilled, CloseCircleOutlined } from "@ant-design/icons"
|
||||
@@ -34,36 +40,34 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
|
||||
完成 {tasks.filter((t) => t.status === "completed").length} / {tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="xx-batch-gen-grid"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 320px))",
|
||||
justifyContent: "center",
|
||||
justifyItems: "center",
|
||||
gap: 14,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{/* #1800: grid 列宽 / gap / justify 全部交由 .xx-batch-gen-grid CSS 控制 */}
|
||||
<div className="xx-batch-gen-grid">
|
||||
{sorted.map((task) => {
|
||||
const title = titles[task.variantIndex] || `视频 ${task.variantIndex + 1}`
|
||||
const video = (task.videos?.[0] || null) as GeneratedVideo | null
|
||||
return (
|
||||
<div
|
||||
key={task.taskId}
|
||||
className={`xx-batch-gen-card status-${task.status}`}
|
||||
style={{ maxWidth: 320 }}
|
||||
>
|
||||
<div key={task.taskId} className={`xx-batch-gen-card status-${task.status}`}>
|
||||
<div className="xx-batch-gen-card-head">
|
||||
<span className="xx-batch-gen-card-title" title={title}>
|
||||
{task.status === "completed" ? (
|
||||
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 6 }} />
|
||||
<CheckCircleFilled
|
||||
className="xx-batch-gen-card-icon"
|
||||
style={{ color: "#52c41a" }}
|
||||
/>
|
||||
) : task.status === "failed" ? (
|
||||
<CloseCircleOutlined style={{ color: "#ef4444", marginRight: 6 }} />
|
||||
<CloseCircleOutlined
|
||||
className="xx-batch-gen-card-icon"
|
||||
style={{ color: "#ef4444" }}
|
||||
/>
|
||||
) : (
|
||||
<LoadingOutlined style={{ color: "#1677ff", marginRight: 6 }} />
|
||||
<LoadingOutlined
|
||||
className="xx-batch-gen-card-icon"
|
||||
style={{ color: "#1677ff" }}
|
||||
/>
|
||||
)}
|
||||
视频 {task.variantIndex + 1}:{title}
|
||||
<span className="xx-batch-gen-card-title-text">
|
||||
视频 {task.variantIndex + 1}:{title}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* GeneratePage 步骤底部操作按钮(Issue #1677 修正:固定 6 步)
|
||||
* v2: 步骤4 按钮前显示本次积分消耗;积分不足时禁用按钮并提示充值
|
||||
* GeneratePage 步骤底部操作按钮(5 步向导:素材→配音→标题→确认生成→封面)
|
||||
* v3: 修复 #1954 off-by-one —— 步骤4 单视频/批量生成完成后正确显示「下一步:选择封面」
|
||||
*
|
||||
* 步骤 1~3:上一步 / 下一步
|
||||
* 步骤 4(选择标题):「✨ 确认生成视频 / 确认生成 N 个视频」→ 创建正式生成任务,成功后跳步骤5
|
||||
* 步骤 5(确认生成):渲染进度页,全部完成后「下一步:选择封面」;仅上一步
|
||||
* 步骤 6(选择封面):仅上一步
|
||||
* 步骤 4(确认生成/进度):未开始 →「✨ 确认生成视频」;生成中 →「⏳ 视频渲染中…」;
|
||||
* 失败 →「🔄 重新生成」;全部完成 →「下一步:选择封面 →」
|
||||
* 步骤 5(选择封面):仅上一步,无主按钮
|
||||
*/
|
||||
import React from "react"
|
||||
import { Tooltip } from "antd"
|
||||
@@ -22,14 +22,13 @@ export interface GenerateStepActionsProps {
|
||||
generateError: string | null
|
||||
/** 批量模式下勾选的视频数量(N=1 时为1) */
|
||||
selectedCount?: number
|
||||
// v2: 积分相关
|
||||
/** 本次预估消耗积分 */
|
||||
estimatedCost?: number
|
||||
/** 是否积分不足 */
|
||||
pointsInsufficient?: boolean
|
||||
/** 积分不足原因 */
|
||||
insufficientReason?: string
|
||||
/** 剩余免费混剪次数 */
|
||||
/** 本次使用的免费混剪次数 */
|
||||
freeClipsUsedThisTime?: number
|
||||
/** 前往充值 */
|
||||
onRecharge?: () => void
|
||||
@@ -60,8 +59,17 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
/* 步骤 4:选择标题 — 确认生成 */
|
||||
/* 步骤 4:确认生成 / 进度 / 完成进封面 */
|
||||
if (currentStep === 4) {
|
||||
// #1954 修复:单视频/批量 全部生成完成后显示「下一步:选择封面」
|
||||
if (generated && !generating && !generateError) {
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onNext}>
|
||||
下一步:选择封面 →
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const costTag =
|
||||
typeof estimatedCost === "number" && estimatedCost > 0 ? (
|
||||
<span className="xx-step-cost-tag">
|
||||
@@ -118,23 +126,7 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
/* 步骤 5:确认生成进度页 — 全部完成后下一步进封面 */
|
||||
if (currentStep === 5) {
|
||||
if (generated) {
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onNext}>
|
||||
下一步:选择封面 →
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" disabled>
|
||||
⏳ 视频渲染中…
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* 步骤 6(封面,最后一步):无主按钮 */
|
||||
/* 步骤 5(封面,最后一步):无主按钮 */
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
/**
|
||||
* GeneratePage 步骤内容渲染(#1899 简化为 5 步,#1913 传递 selectedTemplate)
|
||||
* 步骤顺序:素材(1) → 配音(2) → 标题(3) → 确认生成(4) → 封面(5)
|
||||
* 步骤3预览(Canvas 网格)与步骤4进度(批量渲染网格)由 GeneratePage 直接渲染在左侧大区域。
|
||||
* GeneratePage 步骤内容渲染(#1970 流程重构)
|
||||
* 步骤顺序:选择模式(1) → 选择素材(2) → 选择标题(3) → 确认生成(4) → 选择封面(5)
|
||||
* 原步骤"选择配音"已从主流程移除,改为 Step1 下一步分支弹窗(VoiceSelectModal / ScriptSelectModal → TtsVoiceModal)。
|
||||
*/
|
||||
import React from "react"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { TitleSettings } from "../types"
|
||||
import type { ScriptItem } from "@/api/scripts"
|
||||
import Step1EditMode from "./Step1EditMode"
|
||||
import type { EditMode } from "./Step1EditMode"
|
||||
import Step2MaterialSelect from "../components/Step2MaterialSelect"
|
||||
import Step3VoiceWithMode from "./Step3VoiceWithMode"
|
||||
import Step4TitleSettings from "../components/Step4TitleSettings"
|
||||
import Step6CoverSettings from "../components/Step6CoverSettings"
|
||||
import BatchGenerationGrid from "./BatchGenerationGrid"
|
||||
@@ -17,19 +19,28 @@ import type { GeneratedVideo } from "@/api/template-editor"
|
||||
|
||||
export interface GenerateStepContentProps {
|
||||
currentStep: number
|
||||
/* 片段数量(#1899) */
|
||||
/* Step1:剪辑模式 + 生成设置 */
|
||||
editMode: EditMode
|
||||
onEditModeChange: (m: EditMode) => void
|
||||
dedupEnabled: boolean
|
||||
onDedupEnabledChange: (v: boolean) => void
|
||||
/* ── 片段数量(#1899) ── */
|
||||
clipCount: number
|
||||
onClipCountChange: (n: number) => void
|
||||
/* 素材 */
|
||||
/* ── 生成数量/比例(Step1 设置) ── */
|
||||
previewCount: number
|
||||
onPreviewCountChange: (n: number) => void
|
||||
videoRatio: "9:16" | "16:9"
|
||||
onVideoRatioChange: (r: "9:16" | "16:9") => void
|
||||
/* ── 素材 ── */
|
||||
materialMode: "manual" | "auto"
|
||||
onMaterialModeChange: (mode: "manual" | "auto") => void
|
||||
selectedMaterials: string[]
|
||||
onSelectedMaterialsChange: (ids: string[]) => void
|
||||
smartSelectedIds: string[]
|
||||
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||
/* 当前选中的模板/草稿 ID;空串时由后端自动兜底(#1913) */
|
||||
selectedTemplate?: string
|
||||
/* 标题 */
|
||||
/* ── 标题 ── */
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
onUpdatePosition: (position: string) => void
|
||||
@@ -40,16 +51,22 @@ export interface GenerateStepContentProps {
|
||||
onToggleStroke: () => void
|
||||
onToggleShadow: () => void
|
||||
onApplyPreset: (presetKey: string) => void
|
||||
onUpdateStyle?: (patch: Partial<TitleSettings>) => void
|
||||
activePreset: string | null
|
||||
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
|
||||
/* 封面 */
|
||||
titlePresets: Array<{
|
||||
key: string
|
||||
label: string
|
||||
emoji?: string
|
||||
style: Record<string, unknown>
|
||||
}>
|
||||
/* ── 封面 ── */
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
/* 配音 */
|
||||
/* ── 配音 ── */
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (id: string) => void
|
||||
onServerClipsChange: (clips: EditPlanClip[]) => void
|
||||
/* 生成 */
|
||||
/* ── 生成 ── */
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
@@ -58,14 +75,10 @@ export interface GenerateStepContentProps {
|
||||
onRetry: () => void
|
||||
onRetryBatchTask: (taskId: string) => void
|
||||
onDismissError: () => void
|
||||
/** 批量:每个正式生成任务的独立状态(步骤4进度网格) */
|
||||
batchTasks: BatchTaskState[]
|
||||
/** BGM 开关 */
|
||||
bgm: boolean
|
||||
/** BGM 配置 */
|
||||
bgmConfig?: { enabled: boolean; music_id?: string }
|
||||
/* ── 批量生成(#1677)── */
|
||||
previewCount: number
|
||||
/* ── 批量生成 ── */
|
||||
previewTitles: string[]
|
||||
onPreviewTitlesChange: (titles: string[]) => void
|
||||
voiceModePerVideo: boolean
|
||||
@@ -74,15 +87,27 @@ export interface GenerateStepContentProps {
|
||||
onVoiceLibraryIdsChange: (ids: string[]) => void
|
||||
previewCovers: string[]
|
||||
onPreviewCoversChange: (urls: string[]) => void
|
||||
/** 批量模式勾选的变体索引 */
|
||||
selectedVariantIds?: number[]
|
||||
/* ── 摘要信息(#1970 Step4 展示用) ── */
|
||||
selectedScript: ScriptItem | null
|
||||
ttsVoiceId: string
|
||||
ttsVoiceSource: "preset" | "clone"
|
||||
}
|
||||
|
||||
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
|
||||
// Only destructure props actually referenced in JSX below
|
||||
const {
|
||||
currentStep,
|
||||
editMode,
|
||||
onEditModeChange,
|
||||
dedupEnabled,
|
||||
onDedupEnabledChange,
|
||||
clipCount,
|
||||
onClipCountChange,
|
||||
previewCount,
|
||||
onPreviewCountChange,
|
||||
videoRatio,
|
||||
onVideoRatioChange,
|
||||
materialMode,
|
||||
onMaterialModeChange,
|
||||
selectedMaterials,
|
||||
@@ -100,35 +125,29 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onToggleStroke,
|
||||
onToggleShadow,
|
||||
onApplyPreset,
|
||||
onUpdateStyle,
|
||||
activePreset,
|
||||
titlePresets,
|
||||
coverSettings,
|
||||
onCoverSettingsChange,
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
onServerClipsChange,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
progress,
|
||||
onRetry,
|
||||
generatedVideos,
|
||||
batchTasks,
|
||||
onRetryBatchTask,
|
||||
previewCount,
|
||||
previewTitles,
|
||||
onPreviewTitlesChange,
|
||||
voiceModePerVideo,
|
||||
onVoiceModePerVideoChange,
|
||||
voiceLibraryIds,
|
||||
onVoiceLibraryIdsChange,
|
||||
previewCovers,
|
||||
onPreviewCoversChange,
|
||||
selectedVariantIds,
|
||||
selectedScript,
|
||||
ttsVoiceId,
|
||||
ttsVoiceSource,
|
||||
} = props
|
||||
|
||||
// #1913:包装 onServerClipsChange,适配 hook 的 (clips, templateId?) 签名
|
||||
// 如果 hook 传回了后端兜底创建的 templateId,同时通知外层更新 selectedTemplate
|
||||
const handleClipsChange = React.useCallback(
|
||||
(clips: EditPlanClip[], _templateId?: string) => {
|
||||
onServerClipsChange(clips)
|
||||
@@ -138,8 +157,22 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<Step1EditMode
|
||||
editMode={editMode}
|
||||
onEditModeChange={onEditModeChange}
|
||||
previewCount={previewCount}
|
||||
onPreviewCountChange={onPreviewCountChange}
|
||||
videoRatio={videoRatio}
|
||||
onVideoRatioChange={onVideoRatioChange}
|
||||
dedupEnabled={dedupEnabled}
|
||||
onDedupEnabledChange={onDedupEnabledChange}
|
||||
/>
|
||||
)
|
||||
case 2:
|
||||
return (
|
||||
<Step2MaterialSelect
|
||||
editMode={editMode}
|
||||
materialMode={materialMode}
|
||||
onMaterialModeChange={onMaterialModeChange}
|
||||
selectedMaterials={selectedMaterials}
|
||||
@@ -152,18 +185,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onServerClipsChange={handleClipsChange}
|
||||
/>
|
||||
)
|
||||
case 2:
|
||||
return (
|
||||
<Step3VoiceWithMode
|
||||
previewCount={previewCount}
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={onSelectedVoiceChange}
|
||||
voiceModePerVideo={voiceModePerVideo}
|
||||
onVoiceModePerVideoChange={onVoiceModePerVideoChange}
|
||||
voiceLibraryIds={voiceLibraryIds}
|
||||
onVoiceLibraryIdsChange={onVoiceLibraryIdsChange}
|
||||
/>
|
||||
)
|
||||
case 3:
|
||||
return (
|
||||
<Step4TitleSettings
|
||||
@@ -177,6 +198,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onToggleStroke={onToggleStroke}
|
||||
onToggleShadow={onToggleShadow}
|
||||
onApplyPreset={onApplyPreset}
|
||||
onUpdateStyle={onUpdateStyle}
|
||||
activePreset={activePreset}
|
||||
titlePresets={titlePresets}
|
||||
previewCount={previewCount}
|
||||
@@ -185,20 +207,49 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
/>
|
||||
)
|
||||
case 4:
|
||||
/* 确认生成页:批量=逐任务进度网格;单视频=仅渲染进度/失败状态 */
|
||||
if (previewCount > 1) {
|
||||
return (
|
||||
<BatchGenerationGrid
|
||||
tasks={batchTasks}
|
||||
titles={previewTitles}
|
||||
onRetryTask={onRetryBatchTask}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (generated && !generating && !generateError) return null
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
{generating && (
|
||||
{/* 配置摘要(#1970) */}
|
||||
<div
|
||||
style={{
|
||||
padding: 14,
|
||||
background: "#f9fafb",
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.8,
|
||||
color: "#374151",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 6, color: "#111" }}>
|
||||
📋 生成配置
|
||||
</div>
|
||||
<div>🎬 剪辑模式:{editMode === "random" ? "🎲 随机混剪" : "📖 叙事剪辑"}</div>
|
||||
{editMode === "random" ? (
|
||||
<div>🎙️ 配音来源:配音库音频</div>
|
||||
) : (
|
||||
<>
|
||||
<div>📝 文案:{selectedScript?.title ?? "未选择"}</div>
|
||||
<div>
|
||||
🎙️ 合成配音音色:
|
||||
{ttsVoiceId
|
||||
? `${ttsVoiceSource === "clone" ? "克隆音色" : "系统音色"}(${ttsVoiceId.slice(0, 8)}...)`
|
||||
: "未选择"}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div>📱 视频比例:{videoRatio}</div>
|
||||
<div>🎯 智能降重:{dedupEnabled ? "已开启" : "已关闭"}</div>
|
||||
{previewCount > 1 && <div>📦 生成数量:{previewCount} 个</div>}
|
||||
</div>
|
||||
|
||||
{previewCount > 1 ? (
|
||||
<BatchGenerationGrid
|
||||
tasks={batchTasks}
|
||||
titles={previewTitles}
|
||||
onRetryTask={onRetryBatchTask}
|
||||
/>
|
||||
) : generating ? (
|
||||
<div className="xx-gen-progress-card">
|
||||
<div className="xx-gen-progress-header">
|
||||
<div className="xx-gen-progress-info">
|
||||
@@ -217,8 +268,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generateError && !generating && (
|
||||
) : generateError ? (
|
||||
<div className="xx-gen-error-card">
|
||||
<div className="xx-gen-error-info">
|
||||
<div className="xx-gen-error-title">生成失败</div>
|
||||
@@ -228,7 +278,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
🔄 重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
case 5:
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* 叙事剪辑 — 文案选择弹窗(#1970)
|
||||
* - 搜索框:防抖 300ms,命中文字黄色高亮
|
||||
* - 标签筛选行:全部/带货/工厂/测评/教程/口播/种草
|
||||
* - 数量统计 + 卡片列表(可滚动,max-height 420px)
|
||||
* - 调用 GET /api/v1/scripts?keyword=&tag=&page_size=200
|
||||
*/
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from "react"
|
||||
import { Modal, Input, Tag, Spin } from "antd"
|
||||
import { SearchOutlined, CheckCircleFilled } from "@ant-design/icons"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getScripts } from "@/api/scripts"
|
||||
import type { ScriptItem } from "@/api/scripts"
|
||||
|
||||
interface ScriptSelectModalProps {
|
||||
open: boolean
|
||||
selectedScriptId: string | null
|
||||
onCancel: () => void
|
||||
onConfirm: (script: ScriptItem) => void
|
||||
}
|
||||
|
||||
const SCRIPT_TABS = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "带货", label: "带货" },
|
||||
{ key: "工厂", label: "工厂" },
|
||||
{ key: "测评", label: "测评" },
|
||||
{ key: "教程", label: "教程" },
|
||||
{ key: "口播", label: "口播" },
|
||||
{ key: "种草", label: "种草" },
|
||||
]
|
||||
|
||||
/** 在文本中用 <mark> 高亮关键词(黄色背景) */
|
||||
function highlight(text: string, keyword: string): React.ReactNode {
|
||||
if (!keyword) return text
|
||||
const idx = text.toLowerCase().indexOf(keyword.toLowerCase())
|
||||
if (idx < 0) return text
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<mark style={{ background: "#fef08a", color: "#713f12", padding: "0 2px", borderRadius: 2 }}>
|
||||
{text.slice(idx, idx + keyword.length)}
|
||||
</mark>
|
||||
{text.slice(idx + keyword.length)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ScriptSelectModal: React.FC<ScriptSelectModalProps> = ({
|
||||
open,
|
||||
selectedScriptId,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const [innerSelected, setInnerSelected] = useState<string | null>(selectedScriptId)
|
||||
const [activeTag, setActiveTag] = useState<string>("all")
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [debouncedKw, setDebouncedKw] = useState("")
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setInnerSelected(selectedScriptId)
|
||||
setActiveTag("all")
|
||||
setSearchInput("")
|
||||
setDebouncedKw("")
|
||||
}
|
||||
}, [open, selectedScriptId])
|
||||
|
||||
// 300ms 防抖
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => setDebouncedKw(searchInput.trim()), 300)
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
}
|
||||
}, [searchInput])
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["scripts", "select-modal", debouncedKw, activeTag],
|
||||
queryFn: () =>
|
||||
getScripts({
|
||||
page: 1,
|
||||
page_size: 200,
|
||||
keyword: debouncedKw || undefined,
|
||||
tag: activeTag === "all" ? undefined : activeTag,
|
||||
}),
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const scripts: ScriptItem[] = useMemo(() => data?.items ?? [], [data])
|
||||
const selected = useMemo(
|
||||
() => scripts.find((s) => s.id === innerSelected) ?? null,
|
||||
[scripts, innerSelected],
|
||||
)
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (selected) onConfirm(selected)
|
||||
}, [selected, onConfirm])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="📝 选择文案"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={handleConfirm}
|
||||
okText="确认选择"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ disabled: !selected, style: { background: "#7c3aed" } }}
|
||||
width={680}
|
||||
destroyOnClose
|
||||
>
|
||||
{/* 搜索 */}
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined style={{ color: "#9ca3af" }} />}
|
||||
placeholder="搜索标题、内容或标签"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
|
||||
{/* 标签筛选 */}
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 12 }}>
|
||||
{SCRIPT_TABS.map((t) => {
|
||||
const active = activeTag === t.key
|
||||
return (
|
||||
<Tag
|
||||
key={t.key}
|
||||
onClick={() => setActiveTag(t.key)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
padding: "4px 14px",
|
||||
borderRadius: 16,
|
||||
border: active ? "1px solid #7c3aed" : "1px solid #e5e7eb",
|
||||
background: active ? "#ede9fe" : "#fff",
|
||||
color: active ? "#7c3aed" : "#4b5563",
|
||||
margin: 0,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</Tag>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 数量统计 */}
|
||||
<div style={{ fontSize: 12, color: "#6b7280", marginBottom: 8 }}>
|
||||
共 {data?.total ?? scripts.length} 条文案
|
||||
</div>
|
||||
|
||||
{/* 卡片列表 */}
|
||||
<div style={{ maxHeight: 420, overflowY: "auto", paddingRight: 4 }}>
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: "center", padding: "40px 0" }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : scripts.length === 0 ? (
|
||||
<div style={{ textAlign: "center", padding: "40px 0", color: "#9ca3af" }}>
|
||||
暂无匹配文案
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{scripts.map((s) => {
|
||||
const isSel = innerSelected === s.id
|
||||
const preview = (s.content || "").replace(/\s+/g, " ").slice(0, 80)
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
onClick={() => setInnerSelected(s.id)}
|
||||
style={{
|
||||
padding: 14,
|
||||
borderRadius: 8,
|
||||
border: isSel ? "2px solid #7c3aed" : "1px solid #e5e7eb",
|
||||
background: isSel ? "#faf5ff" : "#fff",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{isSel && (
|
||||
<CheckCircleFilled
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
right: 12,
|
||||
color: "#7c3aed",
|
||||
fontSize: 18,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: isSel ? "#6d28d9" : "#111",
|
||||
marginBottom: 4,
|
||||
paddingRight: 24,
|
||||
}}
|
||||
>
|
||||
{highlight(s.title || "未命名", debouncedKw)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#6b7280",
|
||||
lineHeight: 1.6,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{highlight(preview + ((s.content || "").length > 80 ? "..." : ""), debouncedKw)}
|
||||
</div>
|
||||
{s.tags && s.tags.length > 0 && (
|
||||
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
|
||||
{s.tags.slice(0, 5).map((tg) => (
|
||||
<Tag
|
||||
key={tg}
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 11,
|
||||
padding: "1px 8px",
|
||||
borderRadius: 10,
|
||||
background: "#f3f4f6",
|
||||
border: "none",
|
||||
color: "#6b7280",
|
||||
}}
|
||||
>
|
||||
{tg}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ScriptSelectModal
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Step 1 选择剪辑模式 + 生成设置(#1970 新流程第一步)
|
||||
* - 剪辑模式:🎲随机混剪 / 📖叙事剪辑,二选一,选中紫底紫框
|
||||
* - 生成设置:生成数量(-/+ 1-10 默认1)、视频比例(9:16/16:9 默认9:16)、智能降重开关(默认开)
|
||||
*/
|
||||
import React from "react"
|
||||
import { MinusOutlined, PlusOutlined } from "@ant-design/icons"
|
||||
|
||||
export type EditMode = "random" | "narrative"
|
||||
|
||||
interface Step1EditModeProps {
|
||||
editMode: EditMode
|
||||
onEditModeChange: (mode: EditMode) => void
|
||||
/** 生成数量(1-10,默认1) */
|
||||
previewCount: number
|
||||
onPreviewCountChange: (n: number) => void
|
||||
/** 视频比例 */
|
||||
videoRatio: "9:16" | "16:9"
|
||||
onVideoRatioChange: (ratio: "9:16" | "16:9") => void
|
||||
/** 智能降重开关(默认 true) */
|
||||
dedupEnabled: boolean
|
||||
onDedupEnabledChange: (v: boolean) => void
|
||||
}
|
||||
|
||||
const PURPLE = "#7c3aed"
|
||||
const PURPLE_BG = "linear-gradient(135deg, #ede9fe, #ddd6fe)"
|
||||
const PURPLE_BORDER = "2px solid #7c3aed"
|
||||
|
||||
const MODE_CARDS: Array<{
|
||||
key: EditMode
|
||||
emoji: string
|
||||
title: string
|
||||
desc: string
|
||||
features: string[]
|
||||
}> = [
|
||||
{
|
||||
key: "random",
|
||||
emoji: "🎲",
|
||||
title: "随机混剪",
|
||||
desc: "根据配音时长随机抽取素材片段,灵活组合",
|
||||
features: ["随机抽帧组合", "每次画面不同", "适合批量生成"],
|
||||
},
|
||||
{
|
||||
key: "narrative",
|
||||
emoji: "📖",
|
||||
title: "叙事剪辑",
|
||||
desc: "按文案内容匹配相关画面,有逻辑组织镜头",
|
||||
features: ["画面匹配文案", "叙事感更强", "需要素材标签"],
|
||||
},
|
||||
]
|
||||
|
||||
const Step1EditMode: React.FC<Step1EditModeProps> = ({
|
||||
editMode,
|
||||
onEditModeChange,
|
||||
previewCount,
|
||||
onPreviewCountChange,
|
||||
videoRatio,
|
||||
onVideoRatioChange,
|
||||
dedupEnabled,
|
||||
onDedupEnabledChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎬 选择剪辑模式</h3>
|
||||
<p style={{ color: "#666", fontSize: 14, marginBottom: 16 }}>
|
||||
选择适合您的剪辑方式,后续流程会根据模式自动调整
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
|
||||
gap: 16,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{MODE_CARDS.map((card) => {
|
||||
const selected = editMode === card.key
|
||||
return (
|
||||
<div
|
||||
key={card.key}
|
||||
onClick={() => onEditModeChange(card.key)}
|
||||
style={{
|
||||
padding: 20,
|
||||
borderRadius: 12,
|
||||
border: selected ? PURPLE_BORDER : "1px solid #e5e7eb",
|
||||
background: selected ? PURPLE_BG : "#fff",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 36, marginBottom: 8 }}>{card.emoji}</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: selected ? PURPLE : "#111",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
{card.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#666", marginBottom: 12 }}>{card.desc}</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{card.features.map((f) => (
|
||||
<div key={f} style={{ fontSize: 12, color: selected ? "#6d28d9" : "#6b7280" }}>
|
||||
✅ {f}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginTop: 8 }}>⚙️ 生成设置</h3>
|
||||
|
||||
<div className="xx-form-field" style={{ marginTop: 12 }}>
|
||||
<label>生成数量</label>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPreviewCountChange(Math.max(1, previewCount - 1))}
|
||||
disabled={previewCount <= 1}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: previewCount <= 1 ? "not-allowed" : "pointer",
|
||||
color: previewCount <= 1 ? "#d1d5db" : "#374151",
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
<MinusOutlined />
|
||||
</button>
|
||||
<span
|
||||
style={{
|
||||
minWidth: 40,
|
||||
textAlign: "center",
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: "#111",
|
||||
}}
|
||||
>
|
||||
{previewCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPreviewCountChange(Math.min(10, previewCount + 1))}
|
||||
disabled={previewCount >= 10}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: previewCount >= 10 ? "not-allowed" : "pointer",
|
||||
color: previewCount >= 10 ? "#d1d5db" : "#374151",
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
<PlusOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: "#6b7280" }}>最多一次生成 10 个</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xx-form-field" style={{ marginTop: 16 }}>
|
||||
<label>视频比例</label>
|
||||
<div style={{ display: "flex", gap: 12, marginTop: 4 }}>
|
||||
{[
|
||||
{ key: "9:16" as const, emoji: "📱", label: "竖屏 9:16" },
|
||||
{ key: "16:9" as const, emoji: "🖥️", label: "横屏 16:9" },
|
||||
].map((opt) => {
|
||||
const selected = videoRatio === opt.key
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
onClick={() => onVideoRatioChange(opt.key)}
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
borderRadius: 8,
|
||||
border: selected ? PURPLE_BORDER : "1px solid #e5e7eb",
|
||||
background: selected ? PURPLE_BG : "#fff",
|
||||
color: selected ? PURPLE : "#374151",
|
||||
cursor: "pointer",
|
||||
fontSize: 14,
|
||||
fontWeight: selected ? 600 : 400,
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
{opt.emoji} {opt.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="xx-form-field"
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: "12px 16px",
|
||||
background: "#f9fafb",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 500, color: "#111" }}>
|
||||
🎯 智能降重 {dedupEnabled ? "已开启" : "已关闭"}
|
||||
</span>
|
||||
<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,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step1EditMode
|
||||
@@ -11,6 +11,8 @@ import SmartMatchInput from "./material/SmartMatchInput"
|
||||
import SmartMatchResults from "./material/SmartMatchResults"
|
||||
|
||||
interface Step2MaterialSelectProps {
|
||||
/** 剪辑模式:random 随机混剪 / narrative 叙事剪辑(#1970) */
|
||||
editMode?: "random" | "narrative"
|
||||
materialMode: "manual" | "auto"
|
||||
onMaterialModeChange: (mode: "manual" | "auto") => void
|
||||
selectedMaterials: string[]
|
||||
@@ -43,6 +45,27 @@ const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
|
||||
<div className="xx-form-section">
|
||||
<h3>📦 选择素材</h3>
|
||||
|
||||
{/* 叙事剪辑:AI 智能匹配提示卡(#1970) */}
|
||||
{props.editMode === "narrative" && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: "12px 16px",
|
||||
background: "linear-gradient(135deg,#ede9fe,#f5f3ff)",
|
||||
border: "1px solid #c4b5fd",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
color: "#5b21b6",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 18 }}>🤖</span>
|
||||
<span>AI智能匹配:系统将根据您的文案内容,从素材库自动匹配合适的视频片段</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 片段数量(#1899) */}
|
||||
<div className="xx-form-field" style={{ marginTop: 12 }}>
|
||||
<label>片段数量</label>
|
||||
|
||||
@@ -12,7 +12,8 @@ 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, FONT_OPTIONS } from "../constants"
|
||||
import { POSITION_OPTIONS } from "../constants"
|
||||
import { FONT_OPTIONS } from "@/components/title/constants"
|
||||
import { useStep4Title } from "../hooks/useStep4Title"
|
||||
import AiTitleGenerator from "./title/AiTitleGenerator"
|
||||
import TitleLibraryAutoComplete from "./title/TitleLibraryAutoComplete"
|
||||
@@ -33,8 +34,15 @@ interface Step4TitleSettingsProps {
|
||||
onToggleStroke: () => void
|
||||
onToggleShadow: () => void
|
||||
onApplyPreset: (presetKey: string) => void
|
||||
onUpdateStyle?: (patch: Partial<TitleSettings>) => void
|
||||
activePreset: string | null
|
||||
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
|
||||
titlePresets: Array<{
|
||||
key: string
|
||||
label: string
|
||||
emoji?: string
|
||||
style?: Record<string, unknown>
|
||||
previewStyle?: React.CSSProperties
|
||||
}>
|
||||
/* ── 批量生成(#1677)── */
|
||||
/** 生成数量 */
|
||||
previewCount?: number
|
||||
@@ -84,6 +92,7 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
onToggleStroke,
|
||||
onToggleShadow,
|
||||
onApplyPreset,
|
||||
onUpdateStyle,
|
||||
activePreset,
|
||||
titlePresets,
|
||||
previewCount = 1,
|
||||
@@ -285,6 +294,8 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
onToggleStroke={onToggleStroke}
|
||||
onToggleShadow={onToggleShadow}
|
||||
onApplyPreset={onApplyPreset}
|
||||
onUpdateStyle={onUpdateStyle}
|
||||
showCoverToggle
|
||||
activePreset={activePreset}
|
||||
titlePresets={titlePresets}
|
||||
POSITION_OPTIONS={POSITION_OPTIONS}
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
/**
|
||||
* 叙事剪辑 — TTS 音色选择 + 合成配音弹窗(#1970)
|
||||
* - Tabs:✨系统音色 / 🎙️我的克隆音色
|
||||
* - 2列音色卡片(头像emoji+名称+描述+标签+▶试听+选中✓)
|
||||
* - 底部:取消 / 🎧 合成配音(主按钮,必须选音色才能点)
|
||||
* - 合成中:紫色 spinner + "正在合成配音..." + "请稍候,通常需要10-30秒"
|
||||
* - 合成成功:保存到配音库并回调(voiceAssetId + ttsVoiceId + ttsVoiceSource)
|
||||
*
|
||||
* 复用现有 /api/tts 的 synthesizeSpeech + 轮询 getTTSJobStatus 逻辑;
|
||||
* 不直接复用 TtsModal(它是页面配音弹窗,含文本输入/语速/情感等字段,叙事模式文本来自文案)。
|
||||
*/
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from "react"
|
||||
import { Modal, Tabs, Spin, message } from "antd"
|
||||
import { CheckCircleFilled, SoundOutlined } from "@ant-design/icons"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { fetchPresetVoices } from "@/api/voices"
|
||||
import { getVoiceClones } from "@/api/voice-clone"
|
||||
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
|
||||
/** 需要合成的文本(来自选中的文案 content) */
|
||||
scriptText: string
|
||||
scriptTitle: string
|
||||
onCancel: () => void
|
||||
/** 合成成功回调:asset_id 为保存到配音库后的素材ID */
|
||||
onSynthesized: (payload: {
|
||||
voiceAssetId: string
|
||||
ttsVoiceId: string
|
||||
ttsVoiceSource: "preset" | "clone"
|
||||
ttsStyle: TtsStyle
|
||||
}) => void
|
||||
/** 当前风格 */
|
||||
style?: TtsStyle
|
||||
onStyleChange?: (s: TtsStyle) => void
|
||||
}
|
||||
|
||||
type TtsSynthStatus = "idle" | "synthesizing" | "saving" | "done" | "error"
|
||||
|
||||
const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
|
||||
open,
|
||||
scriptText,
|
||||
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")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
/* 系统音色 */
|
||||
const { data: presetData } = useQuery({
|
||||
queryKey: ["preset-voices", "modal"],
|
||||
queryFn: fetchPresetVoices,
|
||||
enabled: open,
|
||||
})
|
||||
const presetVoices: PresetVoiceItem[] = useMemo(() => presetData?.items ?? [], [presetData])
|
||||
|
||||
/* 克隆音色(仅 ready 状态可用) */
|
||||
const { data: cloneListRaw = [] } = useQuery({
|
||||
queryKey: ["voice-clones", "ready"],
|
||||
queryFn: () => getVoiceClones({ status: "ready" }),
|
||||
enabled: open,
|
||||
})
|
||||
const cloneVoices: VoiceClone[] = useMemo(
|
||||
() => cloneListRaw.filter((v: VoiceClone) => v.status === "ready"),
|
||||
[cloneListRaw],
|
||||
)
|
||||
|
||||
/* 打开时重置状态 */
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedVoiceId("")
|
||||
setStatus("idle")
|
||||
setError(null)
|
||||
setActiveTab("preset")
|
||||
setInternalStyle(externalStyle ?? DEFAULT_TTS_STYLE)
|
||||
} else {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
setPreviewingId(null)
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
const handlePreview = useCallback(
|
||||
(voiceId: string, previewUrl: string | null | undefined) => {
|
||||
if (!previewUrl) {
|
||||
message.info("该音色暂无试听音频")
|
||||
return
|
||||
}
|
||||
if (previewingId === voiceId && audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
setPreviewingId(null)
|
||||
return
|
||||
}
|
||||
if (audioRef.current) audioRef.current.pause()
|
||||
const a = new Audio(previewUrl)
|
||||
audioRef.current = a
|
||||
setPreviewingId(voiceId)
|
||||
a.onended = () => {
|
||||
setPreviewingId(null)
|
||||
audioRef.current = null
|
||||
}
|
||||
a.play().catch(() => {
|
||||
setPreviewingId(null)
|
||||
audioRef.current = null
|
||||
})
|
||||
},
|
||||
[previewingId],
|
||||
)
|
||||
|
||||
const textToSynth = useMemo(() => {
|
||||
// 文案内容取首段(过长会被 TTS 截断,保持和用户感知一致)
|
||||
const t = (scriptText || "").trim()
|
||||
return t.length > 500 ? t.slice(0, 500) : t
|
||||
}, [scriptText])
|
||||
|
||||
const handleSynthesize = useCallback(async () => {
|
||||
if (!selectedVoiceId) {
|
||||
message.warning("请先选择一个音色")
|
||||
return
|
||||
}
|
||||
if (!textToSynth) {
|
||||
message.warning("文案内容为空,无法合成")
|
||||
return
|
||||
}
|
||||
setStatus("synthesizing")
|
||||
setError(null)
|
||||
try {
|
||||
const isClone = activeTab === "clone"
|
||||
const payload: Record<string, unknown> = {
|
||||
text: textToSynth,
|
||||
speed: 1.0,
|
||||
language: "zh-CN",
|
||||
style: currentStyle,
|
||||
}
|
||||
if (isClone) {
|
||||
payload.voice_clone_profile_id = selectedVoiceId
|
||||
} else {
|
||||
payload.voice_id = selectedVoiceId
|
||||
}
|
||||
const resp = await synthesizeSpeech(
|
||||
payload as unknown as Parameters<typeof synthesizeSpeech>[0],
|
||||
)
|
||||
const jobId = resp.job_id
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
timerRef.current = setInterval(async () => {
|
||||
try {
|
||||
const job = await getTTSJobStatus(jobId)
|
||||
if (job.status === "completed") {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
resolve()
|
||||
} else if (job.status === "failed") {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
reject(new Error(job.error_message || "合成失败"))
|
||||
}
|
||||
} catch (e) {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
reject(e)
|
||||
}
|
||||
}, 2000)
|
||||
})
|
||||
|
||||
// 保存到配音库
|
||||
setStatus("saving")
|
||||
await saveTtsToLibrary(jobId, { name: scriptTitle?.slice(0, 30) || "AI合成配音" })
|
||||
setStatus("done")
|
||||
|
||||
// 合成成功后回调;voiceAssetId 由后端在保存时产出,这里用 ttsVoiceId 占位,
|
||||
// 父流程会在下一次 asset 列表刷新后重新选取;前端直接以 ttsVoiceId 为 key 传给后端
|
||||
// (叙事模式后端通过 script_id + tts_voice_id 自行再合成,不依赖 asset_id)。
|
||||
onSynthesized({
|
||||
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])
|
||||
|
||||
const renderVoiceCard = (v: {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
gender?: string
|
||||
tags?: string[]
|
||||
preview_url?: string | null
|
||||
}) => {
|
||||
const isSel = selectedVoiceId === v.id
|
||||
const isPlaying = previewingId === v.id
|
||||
const emoji = v.gender ? (VOICE_GENDER_ICON[v.gender] ?? "🎤") : "🎤"
|
||||
return (
|
||||
<div
|
||||
key={v.id}
|
||||
onClick={() => setSelectedVoiceId(v.id)}
|
||||
style={{
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
border: isSel ? "2px solid #7c3aed" : "1px solid #e5e7eb",
|
||||
background: isSel ? "#faf5ff" : "#fff",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{isSel && (
|
||||
<CheckCircleFilled
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
right: 10,
|
||||
color: "#7c3aed",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: "50%",
|
||||
background: isSel ? "linear-gradient(135deg,#7c3aed,#a78bfa)" : "#f3f4f6",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
{emoji}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: isSel ? "#6d28d9" : "#111",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{v.name}
|
||||
</div>
|
||||
{v.description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "#6b7280",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{v.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{v.preview_url && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handlePreview(v.id, v.preview_url)
|
||||
}}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
border: "none",
|
||||
background: isPlaying ? "#ef4444" : "#7c3aed",
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: 11,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<SoundOutlined />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{v.tags && v.tags.length > 0 && (
|
||||
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
|
||||
{v.tags.slice(0, 3).map((tg) => (
|
||||
<span
|
||||
key={tg}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
padding: "1px 6px",
|
||||
borderRadius: 8,
|
||||
background: "#f3f4f6",
|
||||
color: "#6b7280",
|
||||
}}
|
||||
>
|
||||
{tg}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* 合成中 loading 覆盖层 */
|
||||
const renderSynthOverlay = () => {
|
||||
if (status !== "synthesizing" && status !== "saving") return null
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: "rgba(255,255,255,0.92)",
|
||||
zIndex: 10,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 12,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Spin size="large" style={{ color: "#7c3aed" }} />
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: "#6d28d9" }}>
|
||||
{status === "synthesizing" ? "正在合成配音..." : "正在保存到配音库..."}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#6b7280" }}>请稍候,通常需要 10-30 秒</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="🎙️ 合成配音"
|
||||
open={open}
|
||||
onCancel={status === "synthesizing" || status === "saving" ? undefined : onCancel}
|
||||
cancelText="取消"
|
||||
okText="🎧 合成配音"
|
||||
okButtonProps={{
|
||||
disabled: !selectedVoiceId || status === "synthesizing" || status === "saving",
|
||||
style: { background: "#7c3aed" },
|
||||
}}
|
||||
onOk={handleSynthesize}
|
||||
width={680}
|
||||
destroyOnClose
|
||||
confirmLoading={status === "synthesizing" || status === "saving"}
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
background: "#fef2f2",
|
||||
border: "1px solid #fecaca",
|
||||
color: "#b91c1c",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#6b7280",
|
||||
marginBottom: 12,
|
||||
padding: "8px 12px",
|
||||
background: "#f9fafb",
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
将根据文案《{scriptTitle?.slice(0, 30) || "所选文案"}》合成配音,文本长度:
|
||||
{textToSynth.length} 字
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<TtsStyleSelector value={currentStyle} onChange={handleStyleChange} compact />
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(k) => {
|
||||
setActiveTab(k as "preset" | "clone")
|
||||
setSelectedVoiceId("")
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
key: "preset",
|
||||
label: "✨ 系统音色",
|
||||
children: (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 10,
|
||||
maxHeight: 420,
|
||||
overflowY: "auto",
|
||||
paddingRight: 4,
|
||||
}}
|
||||
>
|
||||
{presetVoices.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
gridColumn: "1/-1",
|
||||
textAlign: "center",
|
||||
padding: 30,
|
||||
color: "#9ca3af",
|
||||
}}
|
||||
>
|
||||
正在加载系统音色...
|
||||
</div>
|
||||
) : (
|
||||
presetVoices.map((v) =>
|
||||
renderVoiceCard({
|
||||
id: v.voice_id,
|
||||
name: v.name,
|
||||
description: v.description,
|
||||
gender: v.gender,
|
||||
tags: v.tags,
|
||||
preview_url: v.preview_url,
|
||||
}),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "clone",
|
||||
label: "🎙️ 我的克隆音色",
|
||||
children: (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 10,
|
||||
maxHeight: 420,
|
||||
overflowY: "auto",
|
||||
paddingRight: 4,
|
||||
}}
|
||||
>
|
||||
{cloneVoices.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
gridColumn: "1/-1",
|
||||
textAlign: "center",
|
||||
padding: 30,
|
||||
color: "#9ca3af",
|
||||
}}
|
||||
>
|
||||
暂无就绪的克隆音色,请先在配音库完成音色克隆
|
||||
</div>
|
||||
) : (
|
||||
cloneVoices.map((v) =>
|
||||
renderVoiceCard({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
description: v.description,
|
||||
gender: "neutral",
|
||||
tags: ["克隆"],
|
||||
preview_url: v.sample_url || null,
|
||||
}),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{renderSynthOverlay()}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default TtsVoiceModal
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* 随机混剪 — 配音选择弹窗(#1970)
|
||||
* 内容复用 Step5VoiceSelect 的配音库音频卡片(图标+文件名+时长/大小+▶试听),
|
||||
* 无 TTS / 克隆音色入口;确认后进入 Step2。
|
||||
*/
|
||||
import React from "react"
|
||||
import { Modal } from "antd"
|
||||
import { AudioOutlined } from "@ant-design/icons"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useState, useRef, useCallback } from "react"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
interface VoiceSelectModalProps {
|
||||
open: boolean
|
||||
selectedVoice: string
|
||||
onCancel: () => void
|
||||
onConfirm: (voiceAssetId: string) => void
|
||||
}
|
||||
|
||||
const getDuration = (item: AssetItem): number =>
|
||||
item.duration ?? (item.metadata?.duration as number) ?? 0
|
||||
const getFileSize = (item: AssetItem): number =>
|
||||
item.file_size ?? (item.metadata?.file_size as number) ?? 0
|
||||
const isAiVoice = (item: AssetItem): boolean => {
|
||||
const d = getDuration(item)
|
||||
const s = getFileSize(item)
|
||||
return (!d || d <= 0) && (!s || s <= 0)
|
||||
}
|
||||
const fmtDur = (s?: number): string => {
|
||||
if (!s || s <= 0) return "时长未知"
|
||||
return `${s.toFixed(1)}秒`
|
||||
}
|
||||
const fmtSize = (b?: number): string => {
|
||||
if (!b || b <= 0) return "未知"
|
||||
if (b < 1024) return `${b} B`
|
||||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`
|
||||
if (b < 1024 * 1024 * 1024) return `${(b / (1024 * 1024)).toFixed(1)} MB`
|
||||
return `${(b / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
||||
}
|
||||
|
||||
const VoiceSelectModal: React.FC<VoiceSelectModalProps> = ({
|
||||
open,
|
||||
selectedVoice,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const [innerSelected, setInnerSelected] = React.useState(selectedVoice)
|
||||
const [playingId, setPlayingId] = useState<string | null>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) setInnerSelected(selectedVoice)
|
||||
}, [open, selectedVoice])
|
||||
|
||||
const { data: materials = [], isLoading } = useQuery({
|
||||
queryKey: ["assets", "voice", "modal"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const togglePlay = useCallback(
|
||||
(item: AssetItem) => {
|
||||
if (playingId === item.id && audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
setPlayingId(null)
|
||||
return
|
||||
}
|
||||
if (audioRef.current) audioRef.current.pause()
|
||||
if (!item.file_url) return
|
||||
const audio = new Audio(item.file_url)
|
||||
audioRef.current = audio
|
||||
setPlayingId(item.id)
|
||||
audio.onended = () => {
|
||||
setPlayingId(null)
|
||||
audioRef.current = null
|
||||
}
|
||||
audio.play().catch(() => {
|
||||
setPlayingId(null)
|
||||
audioRef.current = null
|
||||
})
|
||||
},
|
||||
[playingId],
|
||||
)
|
||||
|
||||
const handleGoUpload = () => navigate("/app/voices?tab=material&upload=1")
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!innerSelected) return
|
||||
onConfirm(innerSelected)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="🎙️ 选择配音"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={handleConfirm}
|
||||
okText="确认选择"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ disabled: !innerSelected, style: { background: "#7c3aed" } }}
|
||||
width={720}
|
||||
destroyOnClose
|
||||
>
|
||||
<p style={{ color: "#666", fontSize: 13, marginBottom: 12 }}>
|
||||
从配音库中选择已上传的音频素材,点击 ▶ 可试听
|
||||
</p>
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: "center", padding: "40px 0", color: "#999" }}>加载中...</div>
|
||||
) : materials.length === 0 ? (
|
||||
<div style={{ textAlign: "center", padding: "40px 0", color: "#999" }}>
|
||||
<AudioOutlined style={{ fontSize: 48, color: "#d9d9d9", marginBottom: 12 }} />
|
||||
<p style={{ marginBottom: 12 }}>暂无配音素材</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoUpload}
|
||||
style={{
|
||||
padding: "8px 20px",
|
||||
background: "#7c3aed",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
去配音库上传
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
|
||||
gap: 12,
|
||||
maxHeight: 460,
|
||||
overflowY: "auto",
|
||||
paddingRight: 4,
|
||||
}}
|
||||
>
|
||||
{materials.map((item) => {
|
||||
const isSel = innerSelected === item.id
|
||||
const isPlaying = playingId === item.id
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => setInnerSelected(item.id)}
|
||||
style={{
|
||||
padding: 14,
|
||||
borderRadius: 8,
|
||||
border: isSel ? "2px solid #7c3aed" : "1px solid #e8e8e8",
|
||||
background: isSel ? "#ede9fe" : "#fff",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 8,
|
||||
background: isSel
|
||||
? "linear-gradient(135deg,#7c3aed,#a78bfa)"
|
||||
: "linear-gradient(135deg,#f0f0f0,#e8e8e8)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<AudioOutlined style={{ color: isSel ? "#fff" : "#666" }} />
|
||||
</div>
|
||||
{item.file_url && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
togglePlay(item)
|
||||
}}
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: "50%",
|
||||
border: "none",
|
||||
background: isPlaying ? "#ef4444" : "#7c3aed",
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginTop: 8,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
color: isSel ? "#6d28d9" : "#333",
|
||||
}}
|
||||
title={item.name}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: 11,
|
||||
color: "#999",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{isAiVoice(item) ? (
|
||||
<span style={{ color: "#7c3aed", fontWeight: 500 }}>AI 音色</span>
|
||||
) : (
|
||||
<span>{fmtDur(getDuration(item))}</span>
|
||||
)}
|
||||
<span>{isAiVoice(item) ? "按文本合成" : fmtSize(getFileSize(item))}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoiceSelectModal
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 标题迷你 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,3 +190,255 @@
|
||||
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,12 +1,26 @@
|
||||
/**
|
||||
* 标题样式设置区
|
||||
* 位置/字体/字号/样式按钮/预设
|
||||
* 标题样式设置面板(#2001 升级)
|
||||
*
|
||||
* P0:描边宽度滑块 / 描边颜色选择器 / 每行最大字符数 / 行距+顶部边距 /
|
||||
* 4款爆款字体 / 抖音爆款黄预设
|
||||
* P1:阴影参数化 / 背景色块 / Canvas 实时迷你预览 /
|
||||
* 封面独立标题配置入口
|
||||
*
|
||||
* 向后兼容:旧的 onToggleBold/Italic/Stroke/Shadow/onUpdatePosition/onUpdateFont/
|
||||
* onUpdateSize/onApplyPreset props 全部保留;新增字段通过 onUpdateStyle 统一回写。
|
||||
*/
|
||||
import React from "react"
|
||||
import React, { useState } from "react"
|
||||
import { Tabs } from "antd"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import TitlePresetsGrid from "./TitlePresetsGrid"
|
||||
// 标题样式面板共用样式(#1809 ⑦):智能剪辑与 AI数字人复用同一组件,
|
||||
// 由组件自带样式,避免 AI数字人页面重复引入整个 generate.css
|
||||
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 "./TitleStylePanel.css"
|
||||
|
||||
interface PositionOption {
|
||||
@@ -14,14 +28,17 @@ interface PositionOption {
|
||||
label: string
|
||||
}
|
||||
|
||||
interface TitlePresetItem {
|
||||
interface LegacyPreset {
|
||||
key: string
|
||||
label: string
|
||||
previewStyle: React.CSSProperties
|
||||
emoji?: string
|
||||
style?: Record<string, unknown>
|
||||
previewStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
interface TitleStylePanelProps {
|
||||
settings: TitleSettings
|
||||
/* 旧 props(兼容) */
|
||||
onUpdatePosition: (position: string) => void
|
||||
onUpdateFont: (font: string) => void
|
||||
onUpdateSize: (size: number) => void
|
||||
@@ -31,9 +48,130 @@ interface TitleStylePanelProps {
|
||||
onToggleShadow: () => void
|
||||
onApplyPreset: (presetKey: string) => void
|
||||
activePreset: string | null
|
||||
titlePresets: TitlePresetItem[]
|
||||
titlePresets: LegacyPreset[]
|
||||
POSITION_OPTIONS: PositionOption[]
|
||||
FONT_OPTIONS: string[]
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
|
||||
@@ -47,107 +185,352 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
|
||||
onToggleShadow,
|
||||
onApplyPreset,
|
||||
activePreset,
|
||||
titlePresets,
|
||||
titlePresets: _titlePresets,
|
||||
POSITION_OPTIONS,
|
||||
FONT_OPTIONS,
|
||||
showCoverToggle = false,
|
||||
previewWidth = 220,
|
||||
onUpdateStyle,
|
||||
}) => {
|
||||
const upd = (patch: Partial<TitleSettings>) => {
|
||||
onUpdateStyle?.(patch)
|
||||
}
|
||||
|
||||
/* 封面独立标题切换 */
|
||||
const [coverOpen, setCoverOpen] = useState(!!settings.coverTitle)
|
||||
|
||||
return (
|
||||
<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 className="xx-title-style-section ts-panel">
|
||||
{/* 实时迷你预览 */}
|
||||
<div className="ts-preview-wrap">
|
||||
<TitleMiniPreview
|
||||
settings={settings}
|
||||
width={previewWidth}
|
||||
sampleText={settings.title || "预览标题文字"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 预设样式 */}
|
||||
<div className="xx-form-field">
|
||||
<label>预设样式</label>
|
||||
<TitlePresetsGrid
|
||||
presets={titlePresets}
|
||||
activePreset={activePreset}
|
||||
onApply={onApplyPreset}
|
||||
fontFamily={settings.font}
|
||||
/>
|
||||
{/* 预设样式(10个,含抖音爆款黄) */}
|
||||
<div className="ts-form-field">
|
||||
<label>爆款预设</label>
|
||||
<PresetGrid activePreset={activePreset} onApply={onApplyPreset} settings={settings} />
|
||||
</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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,10 +27,10 @@ export const VOICE_GENDER_ICON: Record<string, string> = {
|
||||
neutral: "✨",
|
||||
}
|
||||
|
||||
/* ── 步骤定义(5步,#1899 简化:删除选模板步骤) ── */
|
||||
/* ── 步骤定义(5步,#1970 流程重构:选择模式 → 素材 → 标题 → 确认 → 封面) ── */
|
||||
export const STEPS = [
|
||||
{ key: 1, label: "选择素材" },
|
||||
{ key: 2, label: "选择配音" },
|
||||
{ key: 1, label: "选择模式" },
|
||||
{ key: 2, label: "选择素材" },
|
||||
{ key: 3, label: "选择标题" },
|
||||
{ key: 4, label: "确认生成" },
|
||||
{ key: 5, label: "选择封面" },
|
||||
@@ -54,11 +54,28 @@ export const POSITION_OPTIONS = [
|
||||
{ value: "custom", label: "自定义" },
|
||||
]
|
||||
|
||||
/* ── 标题字体选项 ── */
|
||||
export const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "微软雅黑", "楷体"]
|
||||
/* ── 标题字体选项(#2001:新增 4 款爆款字体) ── */
|
||||
export const FONT_OPTIONS = [
|
||||
"优设标题黑",
|
||||
"阿里普惠体Bold",
|
||||
"抖音美好体",
|
||||
"思源黑体Heavy",
|
||||
"思源黑体",
|
||||
"思源宋体",
|
||||
"苹方",
|
||||
"微软雅黑",
|
||||
"楷体",
|
||||
]
|
||||
|
||||
/* ── 标题字体 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',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user