fix(viral-video): 三阶段 pipeline 补齐 voice_id/voice_source/video_ratio 字段 #2123
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 消费)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user