fix(viral-video): #2122 三阶段 pipeline 补齐 voice_id/voice_source/video_ratio 字段
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m15s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 34s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m13s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 55s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m16s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 4m17s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m33s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 3m21s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 5m16s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m14s
AI Code Review / AI Code Review (pull_request) Successful in 6m46s
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m35s

- domain/model/schema 新增 voice_id/voice_source/video_ratio 三个字段
- 幂等 ALTER TABLE 迁移补列(voice_id VARCHAR(200) / voice_source VARCHAR(20) / video_ratio VARCHAR(10))
- POST /analyze-images 接收音色/比例参数,创建 job 时持久化
- POST /{id}/generate-copy 接收 voice_id/voice_source/video_ratio/style_guide,写回 job
- _step_tts 优先使用 job.voice_id,空则回退 job.persona_id,再空用默认 longxiaochun_v3
- _run_render_pipeline 调用 call_video_generation 时使用 job.video_ratio(默认 9:16)
- GET /{id} 返回 voice_id/voice_source/video_ratio
- 前端 TS types 同步补字段(AnalyzeImagesRequest/GenerateCopyRequest/ViralVideoJob)
- 新增 2 个单测验证字段持久化;viral 全量 95 个单测通过
This commit is contained in:
xiaoxia
2026-10-01 12:31:06 +08:00
parent 033c4a2eab
commit 0615075d5d
9 changed files with 136 additions and 6 deletions
+11
View File
@@ -104,6 +104,9 @@ def _to_response(job) -> ViralVideoJobResponse:
storyboard=getattr(job, "storyboard", None),
generated_copy_text=getattr(job, "generated_copy_text", "") or "",
copy_result=_build_copy_result(job),
voice_id=getattr(job, "voice_id", "") or "",
voice_source=getattr(job, "voice_source", "") or "",
video_ratio=getattr(job, "video_ratio", "9:16") or "9:16",
intent_result=job.intent_result,
result_video_url=job.result_video_url,
credits_cost=job.credits_cost,
@@ -192,6 +195,9 @@ def analyze_images(
reference_video_url=request.reference_video_url or "",
style_template_id=request.style_template_id or "",
style_strength=request.style_strength or "medium",
voice_id=request.voice_id or "",
voice_source=request.voice_source or "",
video_ratio=request.video_ratio or "9:16",
)
repo.save(job)
@@ -247,6 +253,11 @@ def generate_copy(
job.reference_video_url = request.reference_video_url or job.reference_video_url
job.style_strength = request.style_strength or job.style_strength
job.style_template_id = request.style_template_id or job.style_template_id
if request.style_guide is not None:
job.style_guide = request.style_guide
job.voice_id = request.voice_id or job.voice_id
job.voice_source = request.voice_source or job.voice_source
job.video_ratio = request.video_ratio or job.video_ratio or "9:16"
job.resume_from_image_analyzed()
repo.update(job)
+15 -2
View File
@@ -64,12 +64,16 @@ class CreateViralVideoRequest(BaseModel):
class AnalyzeImagesRequest(BaseModel):
"""v1.5 阶段1:创建任务并仅做图片/视频分析。images 必填,其他参数可选(阶段2再传)。"""
"""v1.5 阶段1:创建任务并仅做图片/视频分析。images 必填,其他参数可选(阶段2再传/覆盖)。"""
images: list[str] = Field(..., min_length=1, max_length=20)
reference_video_url: str = Field(default="", description="参考爆款视频 URL(可选,有则同步做风格分析)")
style_template_id: str = Field(default="", description="风格模板 ID(可选)")
style_strength: str = Field(default="medium")
# v1.5.1 音色/视频参数(STEP1 已选定的音色可先传,阶段2可覆盖)
voice_id: str = Field(default="", description="TTS 音色 ID;空则用 persona_id 兜底")
voice_source: str = Field(default="", description="音色来源:preset/library/clone/upload")
video_ratio: str = Field(default="9:16", description="Seedance 视频比例:9:16/16:9/1:1 等")
class GenerateCopyRequest(BaseModel):
@@ -77,7 +81,7 @@ class GenerateCopyRequest(BaseModel):
industry: str = Field(default="")
target_customer: str = Field(default="")
persona_id: str = Field(default="")
persona_id: str = Field(default="", description="人设 ID;voice_id 为空时也作为音色 ID 兜底")
viral_structure: str = Field(default="")
marketing_purpose: str = Field(default="")
bgm_preference: str = Field(default="")
@@ -88,6 +92,11 @@ class GenerateCopyRequest(BaseModel):
reference_video_url: str = Field(default="")
style_strength: str = Field(default="medium")
style_template_id: str = Field(default="")
style_guide: dict | None = Field(default=None)
# v1.5.1 音色/视频参数
voice_id: str = Field(default="", description="TTS 音色 ID(优先级高于 persona_id)")
voice_source: str = Field(default="", description="音色来源:preset/library/clone/upload")
video_ratio: str = Field(default="9:16", description="Seedance 视频比例")
@field_validator("fusion_level")
@classmethod
@@ -157,6 +166,10 @@ class ViralVideoJobResponse(BaseModel):
generated_copy_text: str = ""
# v1.5 前端 CopyResult 结构(final_copy/suggested_copy/title/scenes)
copy_result: dict | None = None
# v1.5.1 音色/视频参数
voice_id: str = ""
voice_source: str = ""
video_ratio: str = "9:16"
intent_result: dict | None = None
result_video_url: str = ""
credits_cost: int = 0
+18
View File
@@ -133,6 +133,12 @@ export interface ViralVideoJob {
intent_text?: string
copy_result?: CopyResult
image_analysis?: ImageAnalysisResult
/** TTS 音色 ID */
voice_id?: string
/** 音色来源 */
voice_source?: string
/** 视频比例 */
video_ratio?: string
progress_stage?: ViralVideoStage
progress_percent?: number
progress_message?: string
@@ -182,6 +188,12 @@ export interface AnalyzeImagesRequest {
reference_video_url?: string
style_template_id?: string
style_strength?: StyleStrength
/** TTS 音色 ID(STEP1 已选音色时传;阶段2 generate-copy 可覆盖) */
voice_id?: string
/** 音色来源:preset | library | clone | upload */
voice_source?: "preset" | "library" | "clone" | "upload"
/** Seedance 视频比例:9:16 | 16:9 | 1:1 等 */
video_ratio?: string
}
/** v1.5 阶段2请求:填完营销参数后生成文案+分镜(POST /viral-video/{id}/generate-copy) */
@@ -200,6 +212,12 @@ export interface GenerateCopyRequest {
style_strength?: StyleStrength
style_template_id?: string
style_guide?: string | Record<string, unknown>
/** TTS 音色 ID(优先级高于 persona_id) */
voice_id?: string
/** 音色来源:preset | library | clone | upload */
voice_source?: "preset" | "library" | "clone" | "upload"
/** Seedance 视频比例(9:16/16:9/1:1 等) */
video_ratio?: string
}
/** v1.5 阶段3请求:用户确认/编辑文案后开始渲染(POST /viral-video/{id}/confirm-copy) */
+4 -4
View File
@@ -484,9 +484,9 @@ def _step_tts(job: ViralVideoJob, copy_text: str):
from apps.worker.services.tts_service_factory import get_tts_service
tts_service = get_tts_service()
# Bug #2110: persona_id 透传给 voice_id(空则用 CosyVoice 默认 longxiaochun_v3),
# 统一输出 mp3 给后续 ffmpeg 混音(之前默认 wav 导致部分 provider/后处理不兼容)。
voice_id = (job.persona_id or "").strip()
# v1.5.1: 优先使用 voice_id;voice_id 为空时回退 persona_id(旧字段兼容);
# 再空则用 CosyVoice 默认 longxiaochun_v3。统一输出 mp3 给后续 ffmpeg 混音。
voice_id = (getattr(job, "voice_id", "") or job.persona_id or "").strip()
try:
result = tts_service.synthesize(
text=copy_text,
@@ -585,7 +585,7 @@ def _step_render(job, storyboard, tts_path, bgm):
prompt=prompt,
image_url=first_image if i == 0 else None,
duration=dur,
ratio="9:16",
ratio=(getattr(job, "video_ratio", None) or "9:16"),
resolution="720p",
output_dir=str(tmpdir),
)
@@ -943,6 +943,10 @@ class ViralVideoJobModel(Base):
style_strength = Column(String(20), nullable=False, default="medium")
style_guide = Column(JSON, nullable=True)
style_template_id = Column(String(36), nullable=False, default="", index=True)
# v1.5 音频/视频参数
voice_id = Column(String(200), nullable=False, default="")
voice_source = Column(String(20), nullable=False, default="")
video_ratio = Column(String(10), nullable=False, default="9:16")
# 结果与状态
status = Column(String(30), nullable=False, default="pending", index=True)
intent_result = Column(JSON, nullable=True)
@@ -84,6 +84,9 @@ def ensure_database_exists(database_url: str) -> None:
_VIRAL_VIDEO_BACKFILL_COLS = [
("storyboard", "JSON"),
("generated_copy_text", "TEXT NOT NULL DEFAULT ''"),
("voice_id", "VARCHAR(200) NOT NULL DEFAULT ''"),
("voice_source", "VARCHAR(20) NOT NULL DEFAULT ''"),
("video_ratio", "VARCHAR(10) NOT NULL DEFAULT '9:16'"),
]
@@ -32,6 +32,9 @@ def _to_domain(model: ViralVideoJobModel) -> ViralVideoJob:
style_strength=getattr(model, "style_strength", "medium") or "medium",
style_guide=dict(model.style_guide) if model.style_guide else None,
style_template_id=getattr(model, "style_template_id", "") or "",
voice_id=getattr(model, "voice_id", "") or "",
voice_source=getattr(model, "voice_source", "") or "",
video_ratio=getattr(model, "video_ratio", "9:16") or "9:16",
status=ViralVideoStatus(model.status) if model.status else ViralVideoStatus.PENDING,
intent_result=dict(model.intent_result) if model.intent_result else None,
image_analysis=dict(model.image_analysis) if getattr(model, "image_analysis", None) else None,
@@ -73,6 +76,9 @@ class SQLAlchemyViralVideoJobRepository:
style_strength=job.style_strength,
style_guide=job.style_guide,
style_template_id=job.style_template_id,
voice_id=job.voice_id,
voice_source=job.voice_source,
video_ratio=job.video_ratio,
status=job.status,
intent_result=job.intent_result,
image_analysis=job.image_analysis,
@@ -121,6 +127,9 @@ class SQLAlchemyViralVideoJobRepository:
model.reference_video_url = job.reference_video_url
model.style_strength = job.style_strength
model.style_template_id = job.style_template_id
model.voice_id = job.voice_id or ""
model.voice_source = job.voice_source or ""
model.video_ratio = job.video_ratio or "9:16"
model.updated_at = datetime.now(timezone.utc)
self.session.commit()
+4
View File
@@ -120,6 +120,10 @@ class ViralVideoJob:
style_strength: str = StyleStrength.MEDIUM
style_guide: dict | None = None
style_template_id: str = ""
# v1.5 音频/视频参数
voice_id: str = "" # TTS 音色 ID(CosyVoice voice_id);为空则用 persona_id 兜底
voice_source: str = "" # preset/library/clone/upload
video_ratio: str = "9:16" # Seedance 视频比例:9:16 / 16:9 / 1:1 / etc.
# v1.4 图片分析结果(run_pipeline 持久化,resume 时读取给文案/分镜)
image_analysis: dict | None = None
# v1.5 三步分步流水线产物(持久化,供前端 GET 读取 + resume 消费)
+68
View File
@@ -54,6 +54,9 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending
"image_analysis": None,
"storyboard": None,
"generated_copy_text": "",
"voice_id": "",
"voice_source": "",
"video_ratio": "9:16",
"credits_cost": 0,
"updated_at": None,
}.items():
@@ -277,6 +280,71 @@ class TestGenerateCopy:
vv_mod.generate_copy("job-gc2", GenerateCopyRequest(), authenticated_user=user, session=session)
assert exc.value.status_code == 409
def test_generate_copy_persists_voice_and_ratio(self):
"""generate-copy 应把 voice_id/voice_source/video_ratio 写入 job。"""
from unittest.mock import patch
from app.api.routes import viral_video as vv_mod
from app.schemas.viral_video import GenerateCopyRequest
from packages.domain.viral_video import ViralVideoStatus
user = _auth_user("u1")
session = MagicMock()
job = _make_job(job_id="job-gc3", user_id="u1", status=ViralVideoStatus.IMAGE_ANALYZED)
repo = MagicMock()
repo.get.return_value = job
req = GenerateCopyRequest(
voice_id="cosy_voice_001",
voice_source="library",
video_ratio="16:9",
)
with (
patch.object(vv_mod, "_get_job_repo", return_value=repo),
patch.object(vv_mod.celery_app, "send_task"),
):
vv_mod.generate_copy("job-gc3", req, authenticated_user=user, session=session)
assert job.voice_id == "cosy_voice_001"
assert job.voice_source == "library"
assert job.video_ratio == "16:9"
class TestAnalyzeImagesPersist:
def test_analyze_images_persists_voice_and_ratio(self):
"""analyze-images 创建任务时应带上 voice/video_ratio 字段。"""
from unittest.mock import patch
from app.api.routes import viral_video as vv_mod
from app.schemas.viral_video import AnalyzeImagesRequest
user = _auth_user("u1")
session = MagicMock()
saved = {}
class FakeRepo:
def save(self, job):
saved["job"] = job
def get(self, jid):
return None
req = AnalyzeImagesRequest(
images=["img-1"],
voice_id="preset_v1",
voice_source="preset",
video_ratio="1:1",
)
with (
patch.object(vv_mod, "_get_job_repo", return_value=FakeRepo()),
patch.object(vv_mod.celery_app, "send_task"),
):
resp = vv_mod.analyze_images(req, authenticated_user=user, session=session)
job = saved["job"]
assert job.voice_id == "preset_v1"
assert job.voice_source == "preset"
assert job.video_ratio == "1:1"
assert resp.images == ["img-1"]
class TestConfirmCopy:
def test_confirm_copy_dispatches_render(self):