From 4d09bd630e8a21bdee575b516c799e946fecdcf7 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 9 Sep 2026 19:55:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(ai-avatar):=20=E5=A5=91=E7=BA=A6=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E6=A0=87=E9=A2=98=E5=AD=97=E6=AE=B5=E7=BA=A0=E6=AD=A3?= =?UTF-8?q?=20+=20script=5Fid=20=E6=94=B9=E4=B8=BA=E5=8F=AF=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 文档 §5/§6:标题是单个 title_config dict(非 titles[] 数组),字段以 build_title_drawtext_filter() 为准:text/content、font_size/size、 font_color/color、position(top/center/bottom/custom)、pos_x/pos_y、bold、 stroke、shadow、enabled;删除不存在的 titles[]/fontSize/frame/start/end 描述 - script_id 改为可选:手动输入文案/TTS 直生场景不关联文案库条目,留空不校验, 避免手动文案用户被 ScriptNotFound 卡住;选了文案库时仍做归属校验 - 迁移 074:ai_avatar_render_jobs.script_id 默认空串 - 新增 schema 单测(script_id 空/空白/title_config 单 dict),94 测试全绿 Refs #1797 #1826 --- ...074_ai_avatar_render_script_id_optional.py | 36 +++++++++ apps/api/app/schemas/ai_avatar_render.py | 10 +-- .../app/services/ai_avatar_render_service.py | 28 +++---- docs/ai-avatar-api-contract-1822.md | 77 ++++++++++++------- packages/adapters/sqlalchemy_impl/models.py | 3 +- .../test_ai_avatar_emotion_tts_lipsync.py | 23 ++++++ 6 files changed, 130 insertions(+), 47 deletions(-) create mode 100644 alembic/versions/074_ai_avatar_render_script_id_optional.py diff --git a/alembic/versions/074_ai_avatar_render_script_id_optional.py b/alembic/versions/074_ai_avatar_render_script_id_optional.py new file mode 100644 index 000000000..ffd2156a0 --- /dev/null +++ b/alembic/versions/074_ai_avatar_render_script_id_optional.py @@ -0,0 +1,36 @@ +"""ai_avatar_render_jobs.script_id 放宽为可空串(手动文案直生场景不关联文案库) + +Revision ID: 074_render_script_id_optional +Revises: 073_add_lipsync_tts_fields +Create Date: 2026-09-09 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "074_render_script_id_optional" +down_revision = "073_add_lipsync_tts_fields" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 列保持 NOT NULL(空串占位),仅应用层允许不传;这里显式补 server_default 防止历史约束歧义 + with op.batch_alter_table("ai_avatar_render_jobs") as batch: + batch.alter_column( + "script_id", + existing_type=sa.String(length=36), + nullable=False, + server_default="", + ) + + +def downgrade() -> None: + with op.batch_alter_table("ai_avatar_render_jobs") as batch: + batch.alter_column( + "script_id", + existing_type=sa.String(length=36), + nullable=False, + server_default=None, + ) diff --git a/apps/api/app/schemas/ai_avatar_render.py b/apps/api/app/schemas/ai_avatar_render.py index b240a9d9d..88832017d 100644 --- a/apps/api/app/schemas/ai_avatar_render.py +++ b/apps/api/app/schemas/ai_avatar_render.py @@ -50,7 +50,7 @@ class CreateAiAvatarRenderRequest(BaseModel): """创建渲染任务请求.""" lipsync_job_id: str = Field(..., description="对口型任务 ID") - script_id: str = Field(..., description="文案 ID") + 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="标题配置") cover_config: dict[str, Any] = Field(default_factory=dict, description="封面配置") @@ -67,10 +67,8 @@ class CreateAiAvatarRenderRequest(BaseModel): @field_validator("script_id") @classmethod def validate_script_id(cls, v: str) -> str: - v = v.strip() - if not v: - raise ValueError("script_id 不能为空") - return v + # script_id 可选:手动输入文案(TTS 直生)场景不关联文案库条目 + return (v or "").strip() class AiAvatarRenderJobResponse(BaseModel): @@ -80,7 +78,7 @@ class AiAvatarRenderJobResponse(BaseModel): user_id: str project_id: str lipsync_job_id: str - script_id: str + script_id: str = "" b_roll_segments: list[dict[str, Any]] title_config: dict[str, Any] cover_config: dict[str, Any] diff --git a/apps/api/app/services/ai_avatar_render_service.py b/apps/api/app/services/ai_avatar_render_service.py index b24bbc203..2baa5cac2 100644 --- a/apps/api/app/services/ai_avatar_render_service.py +++ b/apps/api/app/services/ai_avatar_render_service.py @@ -53,8 +53,8 @@ class AiAvatarRenderService: *, user_id: str, lipsync_job_id: str, - script_id: str, - b_roll_segments: list[dict[str, Any]], + script_id: str = "", + b_roll_segments: list[dict[str, Any]] | None = None, title_config: dict[str, Any], cover_config: dict[str, Any], project_id: str = "", @@ -83,17 +83,19 @@ class AiAvatarRenderService: if not lipsync_job.output_video_url: raise AiAvatarRenderError("对口型任务输出视频 URL 为空", code="LipsyncJobNoOutput") - # 2. 验证文案归属 - script = ( - self.db.query(ScriptModel) - .filter( - ScriptModel.id == script_id, - ScriptModel.user_id == user_id, + # 2. 验证文案归属(仅当选了文案库条目时;手动输入文案直生场景 script_id 可空) + script_id = (script_id or "").strip() + if script_id: + script = ( + self.db.query(ScriptModel) + .filter( + ScriptModel.id == script_id, + ScriptModel.user_id == user_id, + ) + .first() ) - .first() - ) - if script is None: - raise AiAvatarRenderError("文案不存在或无权访问", code="ScriptNotFound") + if script is None: + raise AiAvatarRenderError("文案不存在或无权访问", code="ScriptNotFound") # 3. 创建渲染任务 job_id = str(uuid.uuid4()) @@ -103,7 +105,7 @@ class AiAvatarRenderService: project_id=project_id, lipsync_job_id=lipsync_job_id, script_id=script_id, - b_roll_segments=[s if isinstance(s, dict) else s.model_dump() for s in b_roll_segments], + 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, status="pending", diff --git a/docs/ai-avatar-api-contract-1822.md b/docs/ai-avatar-api-contract-1822.md index aac212af2..960efc061 100644 --- a/docs/ai-avatar-api-contract-1822.md +++ b/docs/ai-avatar-api-contract-1822.md @@ -107,40 +107,63 @@ --- -## 5. 标题配置 `title_config` 字段清单(build_title_drawtext_filter) +## 5. 渲染接口 `POST /api/v1/ai-avatar/render` -AI 数字人渲染请求 `titles[]` 每项结构,字段名与类型如下: +```jsonc +{ + "lipsync_job_id": "7c29a3b2-...", // 必填,已 completed 的对口型任务 + "script_id": "", // 可选!见下方说明 + "b_roll_segments": [], // 可选,B-roll 片段 + "title_config": { ... }, // 可选,单个标题配置 dict(见 §6) + "cover_config": { ... }, // 可选,封面配置(建议改用 smart-cover) + "project_id": "" +} +``` -| 字段 | 类型 | 必填 | 默认 | 说明 | -|------|------|------|------|------| -| `text` | string | ✅ | — | 标题文本。**多行用 `\n` 分隔**;内部统一把 `/` 替换为 `/`(drawtext 转义保护) | -| `start` | float | ✅ | — | 出现时间(秒) | -| `end` | float | ✅ | — | 消失时间(秒),须 > start | -| `fontSize` | number | ❌ | 48 | 字号(像素),范围建议 12~200 | -| `color` | string | ❌ | `white` | 文字颜色,CSS/FFmpeg 颜色名或 `0xRRGGBB`(如 `red`、`0xFF5733`) | -| `fontPath` | string | ❌ | 内置思源黑体 | 字体文件绝对路径(通常不传,用默认中文字体) | -| `backgroundColor` | string | ❌ | 黑色 | 背景框颜色 | -| `borderColor` | string | ❌ | 白色 | 边框颜色 | -| `borderWidth` | int | ❌ | 2 | 边框宽度(像素) | -| `backgroundOpacity` | float | ❌ | 0.5 | 背景框不透明度 0~1 | -| `frame` | object | ❌ | 居中底部 | 画面内位置,见下 | -| `frame.x` | float | ❌ | `null` | 框中心 X 比例 0~1;null=水平居中 | -| `frame.y` | float | ❌ | `null` | 框中心 Y 比例 0~1;null=底部(0.75) | -| `frame.widthRatio` | float | ❌ | 0.9 | 框宽占画面比例 0~1 | -| `frame.heightRatio` | float | ❌ | 0.2 | 框高占画面比例 0~1 | -| `z_index` | int | ❌ | 0 | 层级(预留,当前标题在 B-roll 之后) | - -**前端注意事项** -- 多行标题直接在 `text` 里写 `\n`,后端按行处理 drawtext 的 `textfile` 换行,无需手工拆滤镜。 -- 颜色建议固定传英文颜色名(`white`/`black`/`red`…)或 `0xRRGGBB`,不要传 `#RRGGBB`(`#` 会被滤镜解析干扰)。 -- 位置不传 frame 时标题在画面居中偏下(y≈0.75),这是常见口播标题位置。 +**`script_id` 是否必填:可选。** +- 从文案库选了文案时传对应文案 ID(后端做归属校验)。 +- **手动输入文案、走 TTS 直生模式时不传(留空)即可**——渲染管线不依赖文案内容,`script_id` 仅用于归属校验。留空不会卡手动文案用户。 --- -## 6. 前端对接清单 +## 6. 标题配置 `title_config` 字段清单(以 build_title_drawtext_filter 为准) + +渲染请求收的是**单个 `title_config` dict**(不是 `titles[]` 数组),字段与 `packages/domain/video_filter_builder.py` 的 `build_title_drawtext_filter()` 完全对齐: + +| 字段 | 别名 | 类型 | 必填 | 默认 | 说明 | +|------|------|------|------|------|------| +| `text` | `content` | string | ✅ | — | 标题文字;为空或 `enabled=false` 时不渲染标题 | +| `enabled` | — | bool | ❌ | `true` | 是否启用标题;false 跳过 | +| `font` | `font_preset` | string | ❌ | 思源黑体 | 字体名(后端按名字解析字体文件) | +| `font_size` | `size` | int | ❌ | 36 | 字号(像素) | +| `font_color` | `color` | string | ❌ | `#ffffff` | 文字颜色,`#RRGGBB`;后端自动去掉 `#`,也可传 `RRGGBB` 或颜色名 | +| `position` | — | string | ❌ | `top` | 预设位置:`top`(y=50) / `center`(垂直居中) / `bottom`(底部上移50px) / `custom` | +| `pos_x` | — | int/float | ❌ | — | 自定义 X 坐标(像素),仅 `position=custom` 生效 | +| `pos_y` | — | int/float | ❌ | — | 自定义 Y 坐标(像素),仅 `position=custom` 生效 | +| `bold` | — | bool | ❌ | `true` | 粗体(Bold 字体变体,回退 borderw 模拟) | +| `stroke` | — | bool/object | ❌ | — | 描边。`true`=黑描边宽2;object 见下 | +| `stroke.enabled` | — | bool | ❌ | true | 是否描边 | +| `stroke.width` | — | int | ❌ | 2 | 描边宽度 | +| `stroke.color` | — | string | ❌ | `#000000` | 描边颜色 | +| `shadow` | — | bool/object | ❌ | — | 阴影。`true`=黑色阴影偏移2px;object 见下 | +| `shadow.enabled` | — | bool | ❌ | true | 是否阴影 | +| `shadow.color` | — | string | ❌ | `#000000` | 阴影颜色 | +| `shadow.offset_x` | — | int | ❌ | 2 | 阴影 X 偏移 | +| `shadow.offset_y` | — | int | ❌ | 2 | 阴影 Y 偏移 | + +**前端注意事项** +- 标题是**整条成片一个标题**(单个 dict),不是按时间段的标题数组;没有 `start`/`end`/`frame`/`fontSize` 这些字段。 +- 位置用 `position` 四档枚举;自由摆放用 `position="custom"` + `pos_x`/`pos_y`(像素坐标,非比例)。 +- 颜色统一传 `#RRGGBB` 即可,后端会处理 `#`;三档预设位置下标题始终水平居中。 +- `stroke`/`shadow` 传 `true` 用默认样式,或传 object 精细控制颜色/宽度/偏移。 + +--- + +## 7. 前端对接清单 1. 对口型:改用**模式 A**(voice_id + script_text + speed + emotion),不要再先调 TTS 拿 audio_url。 2. 音色 ID:`voice_id` 可直接传克隆音色的 profile UUID,后端会解析为 CosyVoice voice_id(与 /tts 一致)。 3. 情绪下拉:自然/兴奋/沉稳/亲切 → natural/excited/calm/friendly。 4. 封面:点「智能获取封面」→ POST `/ai-avatar/render/smart-cover`,用返回的 `cover_url`。 -5. 轮询:识别 `running` 等中间态,不要只认 `submitted`。 +5. 渲染:手动文案直生场景 `script_id` 留空;标题传**单个** `title_config` dict(字段见 §6)。 +6. 轮询:识别 `running` 等中间态,不要只认 `submitted`。 diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index fb3cfe25c..135c54ad9 100755 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -721,7 +721,8 @@ class AiAvatarRenderJob(Base): # 输入参数 lipsync_job_id = Column(String(36), nullable=False) - script_id = Column(String(36), nullable=False) + # 文案 ID 可选:手动输入文案(TTS 直生)场景不关联文案库条目 + script_id = Column(String(36), nullable=False, default="") b_roll_segments = Column(JSON, nullable=False, default=list) # b_roll_segments 格式: [{"script_segment_index": 0, "asset_url": "...", "mode": "fullscreen|pip", "start_time": 5.0, "end_time": 10.0}, ...] title_config = Column(JSON, nullable=False, default=dict) diff --git a/tests/unit/test_ai_avatar_emotion_tts_lipsync.py b/tests/unit/test_ai_avatar_emotion_tts_lipsync.py index 26c400961..f81041d5c 100644 --- a/tests/unit/test_ai_avatar_emotion_tts_lipsync.py +++ b/tests/unit/test_ai_avatar_emotion_tts_lipsync.py @@ -259,3 +259,26 @@ def test_smart_cover_returns_empty_when_mediakit_unavailable(): mk_patch.return_value = mk url = cov.generate_smart_cover("https://oss/avatar.mp4") assert url == "" + + +# ── 渲染 script_id 可选(手动文案直生场景)────────────────────────────── + +def test_render_request_script_id_optional(): + import sys, os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api")) + from app.schemas.ai_avatar_render import CreateAiAvatarRenderRequest + + # 手动文案直生:不传 script_id 也合法 + req = CreateAiAvatarRenderRequest(lipsync_job_id="j-1") + assert req.script_id == "" + + # 空白被 strip + req2 = CreateAiAvatarRenderRequest(lipsync_job_id="j-1", script_id=" ") + assert req2.script_id == "" + + # title_config 是单个 dict + req3 = CreateAiAvatarRenderRequest( + lipsync_job_id="j-1", + title_config={"text": "标题", "position": "top", "font_size": 40}, + ) + assert req3.title_config["position"] == "top"