fix(viral-video): 三阶段 pipeline 补齐 voice_id/voice_source/video_ratio 字段 #2123

Merged
auto-approve-bot merged 1 commits from fix/2122-three-stage-missing-fields into develop 2026-10-01 13:05:40 +08:00
10 changed files with 164 additions and 7 deletions
+18
View File
@@ -104,6 +104,10 @@ 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",
video_model=getattr(job, "video_model", "") or "",
intent_result=job.intent_result,
result_video_url=job.result_video_url,
credits_cost=job.credits_cost,
@@ -155,6 +159,10 @@ def create_viral_video(
reference_video_url=request.reference_video_url,
style_strength=request.style_strength,
style_template_id=request.style_template_id,
voice_id=getattr(request, "voice_id", "") or "",
voice_source=getattr(request, "voice_source", "") or "",
video_ratio=getattr(request, "video_ratio", "9:16") or "9:16",
video_model=getattr(request, "video_model", "") or "",
)
# 持久化
@@ -192,6 +200,10 @@ 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",
video_model=request.video_model or "",
)
repo.save(job)
@@ -247,6 +259,12 @@ 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.video_model = request.video_model or job.video_model or ""
job.resume_from_image_analyzed()
repo.update(job)
+23 -2
View File
@@ -45,6 +45,11 @@ class CreateViralVideoRequest(BaseModel):
reference_video_url: str = Field(default="", description="参考爆款视频 URL")
style_strength: str = Field(default="medium", description="风格强度: light/medium/strict")
style_template_id: str = Field(default="", description="风格模板 ID")
# v1.5.1 音色/视频参数(旧接口兼容:前端兜底走 /generate 时也能传)
voice_id: str = Field(default="", description="TTS 音色 ID")
voice_source: str = Field(default="", description="音色来源")
video_ratio: str = Field(default="9:16", description="Seedance 视频比例")
video_model: str = Field(default="", description="Seedance 模型 ID")
@field_validator("fusion_level")
@classmethod
@@ -64,12 +69,17 @@ 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 等")
video_model: str = Field(default="", description="Seedance 模型 ID;空则使用服务端默认")
class GenerateCopyRequest(BaseModel):
@@ -77,7 +87,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 +98,12 @@ 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 视频比例")
video_model: str = Field(default="", description="Seedance 模型 ID;空则使用服务端默认")
@field_validator("fusion_level")
@classmethod
@@ -157,6 +173,11 @@ 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"
video_model: str = ""
intent_result: dict | None = None
result_video_url: str = ""
credits_cost: int = 0
+20
View File
@@ -133,6 +133,10 @@ export interface ViralVideoJob {
intent_text?: string
copy_result?: CopyResult
image_analysis?: ImageAnalysisResult
/** 视频比例 */
video_ratio?: string
/** Seedance 模型 ID */
video_model?: string
progress_stage?: ViralVideoStage
progress_percent?: number
progress_message?: string
@@ -182,6 +186,14 @@ 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
/** Seedance 模型 ID(空则使用服务端默认) */
video_model?: string
}
/** v1.5 阶段2请求:填完营销参数后生成文案+分镜(POST /viral-video/{id}/generate-copy) */
@@ -200,6 +212,14 @@ 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
/** Seedance 模型 ID(空则使用服务端默认) */
video_model?: string
}
/** v1.5 阶段3请求:用户确认/编辑文案后开始渲染(POST /viral-video/{id}/confirm-copy) */
+5 -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,9 +585,10 @@ 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),
model=getattr(job, "video_model", "") or None,
)
if not seg_path or not Path(seg_path).exists():
logger.warning("[爆款视频] 分镜 %d 生成失败,使用占位片段", i + 1)
@@ -943,6 +943,11 @@ 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")
video_model = Column(String(100), nullable=False, default="")
# 结果与状态
status = Column(String(30), nullable=False, default="pending", index=True)
intent_result = Column(JSON, nullable=True)
@@ -84,6 +84,10 @@ 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'"),
("video_model", "VARCHAR(100) NOT NULL DEFAULT ''"),
]
@@ -32,6 +32,10 @@ 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",
video_model=getattr(model, "video_model", "") or "",
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 +77,10 @@ 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,
video_model=job.video_model,
status=job.status,
intent_result=job.intent_result,
image_analysis=job.image_analysis,
@@ -121,6 +129,10 @@ 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.video_model = job.video_model or ""
model.updated_at = datetime.now(timezone.utc)
self.session.commit()
+5
View File
@@ -120,6 +120,11 @@ 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.
video_model: str = "" # Seedance 模型 ID;空则用 settings.doubao_video_model 默认值
# v1.4 图片分析结果(run_pipeline 持久化,resume 时读取给文案/分镜)
image_analysis: dict | None = None
# v1.5 三步分步流水线产物(持久化,供前端 GET 读取 + resume 消费)
+3 -1
View File
@@ -253,6 +253,7 @@ class DoubaoClient:
generate_audio: bool = False,
watermark: bool = False,
output_dir: str | None = None,
model: str | None = None,
) -> str | None:
"""调用 Seedance 2.5 文生/图生视频(异步任务→轮询→下载),返回本地 MP4 路径;失败返回 None。
@@ -277,7 +278,8 @@ class DoubaoClient:
settings = get_shared_settings()
poll_interval = getattr(settings, "doubao_video_poll_interval", 10) or 10
total_timeout = getattr(settings, "doubao_video_timeout", 600) or 600
video_model = getattr(settings, "doubao_video_model", None) or "doubao-seedance-2-5-260628"
default_video_model = getattr(settings, "doubao_video_model", None) or "doubao-seedance-2-5-260628"
video_model = model or default_video_model
content: list[dict[str, Any]] = [{"type": "text", "text": prompt.strip()}]
if image_url:
+69
View File
@@ -54,6 +54,10 @@ 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",
"video_model": "",
"credits_cost": 0,
"updated_at": None,
}.items():
@@ -277,6 +281,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):