diff --git a/apps/api/app/api/routes/viral_video.py b/apps/api/app/api/routes/viral_video.py index e6c16053f..2353f3740 100644 --- a/apps/api/app/api/routes/viral_video.py +++ b/apps/api/app/api/routes/viral_video.py @@ -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) diff --git a/apps/api/app/schemas/viral_video.py b/apps/api/app/schemas/viral_video.py index 2789796f6..baf26eef8 100755 --- a/apps/api/app/schemas/viral_video.py +++ b/apps/api/app/schemas/viral_video.py @@ -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 diff --git a/apps/web/src/api/viral-video/types.ts b/apps/web/src/api/viral-video/types.ts index 0fd27db7b..137f66d48 100644 --- a/apps/web/src/api/viral-video/types.ts +++ b/apps/web/src/api/viral-video/types.ts @@ -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 + /** 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) */ diff --git a/apps/worker/worker_app/tasks/viral_video.py b/apps/worker/worker_app/tasks/viral_video.py index 20cea372a..f49cd24a3 100644 --- a/apps/worker/worker_app/tasks/viral_video.py +++ b/apps/worker/worker_app/tasks/viral_video.py @@ -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), ) diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index 06d423720..2e00d0da1 100755 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -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) diff --git a/packages/adapters/sqlalchemy_impl/session.py b/packages/adapters/sqlalchemy_impl/session.py index 77642159f..b5020332b 100644 --- a/packages/adapters/sqlalchemy_impl/session.py +++ b/packages/adapters/sqlalchemy_impl/session.py @@ -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'"), ] diff --git a/packages/adapters/sqlalchemy_impl/viral_video_repository.py b/packages/adapters/sqlalchemy_impl/viral_video_repository.py index 0e0ba53b8..50013696b 100755 --- a/packages/adapters/sqlalchemy_impl/viral_video_repository.py +++ b/packages/adapters/sqlalchemy_impl/viral_video_repository.py @@ -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() diff --git a/packages/domain/viral_video.py b/packages/domain/viral_video.py index 3ce7ed09a..ca008c866 100755 --- a/packages/domain/viral_video.py +++ b/packages/domain/viral_video.py @@ -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 消费) diff --git a/tests/unit/test_viral_video_routes.py b/tests/unit/test_viral_video_routes.py index eaf231f2d..c1eff5295 100644 --- a/tests/unit/test_viral_video_routes.py +++ b/tests/unit/test_viral_video_routes.py @@ -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):