From cdce1b2e105ab1e34e4d2a63e1b9745033021f91 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 1 Oct 2026 04:00:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(viral-video):=20fix=206=20E2E=20bugs=20?= =?UTF-8?q?=E2=80=94=20fusion=5Flevel=20alias,=20concat=20silent=20segment?= =?UTF-8?q?s,=20ingest=20lookup,=20duplicated=20URL,=20TTS=20voice/format,?= =?UTF-8?q?=20Seedance=20first-frame=20ratio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug1 (P0): schema accepts 'full_ai' as alias for 'ai_full' (Pydantic field_validator normalizes) Bug2 (P0): concat_video_files probes each segment audio stream; Seedance gen_audio=False segments now marked has_audio=False so concat filter uses aevalsrc silence instead of failing with ffmpeg exit 234 Bug3 (P1): find_by_storage_key now queries (storage_key OR file_url) to cover historical data where the legacy file_url column held assets///... paths Bug4 (P1): duplicated-hit response no longer accesses non-existent domain Asset.file_url; new helper _get_existing_asset_url uses storage_key (fallback file_url) through storage_service.get_url() Bug5 (P1): _step_tts passes job.persona_id as voice_id (default longxiaochun_v3) and forces format='mp3' so downstream ffmpeg -map 1:a:0 works regardless of provider Bug6 (P1): call_video_generation omits ratio param in first-frame (image_url) mode; ai_client.video_generation ratio becomes Optional[str] and is omitted from payload when None, fixing 400 InvalidParameter from Seedance --- apps/api/app/api/routes/upload.py | 19 ++++++++++++++++++- apps/api/app/schemas/viral_video.py | 5 ++++- apps/worker/video_processing/concat_engine.py | 15 ++++++++++++++- apps/worker/worker_app/tasks/viral_video.py | 17 ++++++++++++++--- .../sqlalchemy_impl/asset_repository.py | 15 +++++++++++++-- packages/shared/ai_client.py | 11 +++++++---- packages/shared/ai_service.py | 12 +++++++++--- 7 files changed, 79 insertions(+), 15 deletions(-) diff --git a/apps/api/app/api/routes/upload.py b/apps/api/app/api/routes/upload.py index eb7e5d9f7..135bf8d87 100644 --- a/apps/api/app/api/routes/upload.py +++ b/apps/api/app/api/routes/upload.py @@ -191,6 +191,23 @@ def _find_duplicate_asset( return None + +def _get_existing_asset_url(existing: Any, storage_service: Any) -> str: + """安全获取已存在素材的公网 URL,兼容 domain Asset(无 file_url 字段)和 ORM model。""" + # Domain Asset 只有 storage_key 字段;ORM model 有 file_url 但存的也是 storage_key + key = "" + for attr in ("storage_key", "file_url"): + v = getattr(existing, attr, None) + if v: + key = v + break + if not key: + return "" + try: + return storage_service.get_url(key) or "" + except Exception: + return "" + def _create_pending_asset( asset_repository, project_id, @@ -390,7 +407,7 @@ async def prepare_direct_upload( duplicated=True, skip_transfer=True, asset_id=existing.id, - url=existing.file_url or storage_service.get_url(existing.storage_key) or "", + url=_get_existing_asset_url(existing, storage_service), ) file_id = uuid4().hex[:8] diff --git a/apps/api/app/schemas/viral_video.py b/apps/api/app/schemas/viral_video.py index 1d16bf1fb..359aeae18 100755 --- a/apps/api/app/schemas/viral_video.py +++ b/apps/api/app/schemas/viral_video.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, Field, field_validator # ── 枚举常量 ───────────────────────────────────────────────────────────── -VALID_FUSION_LEVELS = ("ai_full", "ai_polish", "user_primary") +VALID_FUSION_LEVELS = ("ai_full", "full_ai", "ai_polish", "user_primary") VALID_STYLE_STRENGTHS = ("light", "medium", "strict") VALID_STAGES = ( "image_analysis", @@ -50,6 +50,9 @@ class CreateViralVideoRequest(BaseModel): @field_validator("fusion_level") @classmethod def _validate_fusion_level(cls, v: str) -> str: + # 兼容前端历史写法 full_ai(等价 ai_full) + if v == "full_ai": + return "ai_full" if v not in VALID_FUSION_LEVELS: raise ValueError(f"fusion_level 必须是 {VALID_FUSION_LEVELS} 之一") return v diff --git a/apps/worker/video_processing/concat_engine.py b/apps/worker/video_processing/concat_engine.py index 3bf049412..47a21f5df 100755 --- a/apps/worker/video_processing/concat_engine.py +++ b/apps/worker/video_processing/concat_engine.py @@ -553,7 +553,20 @@ def concat_video_files( if work_dir is None: work_dir = output_path.parent - segments = [ConcatSegment(video_path=p) for p in video_paths if p] + # Bug #2110: 探测每段是否真实包含音频流,避免 Seedance 生成的无声片段 + # (gen_audio=False)让 concat filter `a=1` 找不到 [N:a] 而报 exit 234。 + from video_processing.ffmpeg_utils import probe_has_audio as _probe_has_audio + + segments: list[ConcatSegment] = [] + for p in video_paths: + if not p: + continue + try: + has_audio = _probe_has_audio(p) + except Exception: + has_audio = True # 探测失败保守认为有音频 + segments.append(ConcatSegment(video_path=p, has_audio=has_audio)) + config = ConcatConfig(segments=segments, force_reencode=force_reencode) engine = ConcatEngine(work_dir) diff --git a/apps/worker/worker_app/tasks/viral_video.py b/apps/worker/worker_app/tasks/viral_video.py index 9054ead24..cacdd029a 100644 --- a/apps/worker/worker_app/tasks/viral_video.py +++ b/apps/worker/worker_app/tasks/viral_video.py @@ -385,15 +385,26 @@ def _step_tts(job: ViralVideoJob, copy_text: str): from apps.worker.services.tts_service_factory import get_tts_service tts_service = get_tts_service() - # 兼容老接口:部分 provider 只接收 text 参数 + # Bug #2110: persona_id 透传给 voice_id(空则用 CosyVoice 默认 longxiaochun_v3), + # 统一输出 mp3 给后续 ffmpeg 混音(之前默认 wav 导致部分 provider/后处理不兼容)。 + voice_id = (job.persona_id or "").strip() try: - result = tts_service.synthesize(text=copy_text, voice_id=job.persona_id or "default") + result = tts_service.synthesize( + text=copy_text, + voice_id=voice_id or "longxiaochun_v3", + format="mp3", + ) except TypeError: - result = tts_service.synthesize(text=copy_text) + # 老 provider 只支持 text 参数 + try: + result = tts_service.synthesize(text=copy_text, voice_id=voice_id or "longxiaochun_v3") + except TypeError: + result = tts_service.synthesize(text=copy_text) if result is None: return None p = _Path(result) if not isinstance(result, _Path) else result if p.exists(): + logger.info("[爆款视频] TTS 合成完成: voice=%s path=%s size=%d", voice_id or "longxiaochun_v3", p, p.stat().st_size) return p logger.warning("[爆款视频] TTS 返回路径不存在: %s", p) return None diff --git a/packages/adapters/sqlalchemy_impl/asset_repository.py b/packages/adapters/sqlalchemy_impl/asset_repository.py index d1cab4535..c59fe2dd4 100755 --- a/packages/adapters/sqlalchemy_impl/asset_repository.py +++ b/packages/adapters/sqlalchemy_impl/asset_repository.py @@ -502,8 +502,19 @@ class SQLAlchemyAssetRepository: return [self._to_domain(m) for m in models] def find_by_storage_key(self, storage_key: str) -> Asset | None: - """按 storage_key(对应 DB 中的 file_url)查找素材。""" - model = self.session.query(AssetModel).filter(AssetModel.file_url == storage_key).first() + """按 storage_key 查找素材。 + + Bug #2110: 历史数据 file_url 列可能是旧路径(assets/...),新代码统一写入 + storage_key 列。双列 OR 查询,避免占位 asset 因路径错配导致 ingest 兜底新建 + 第二条 READY 记录,原占位卡 PROCESSING → 前端缩略图出现后消失。 + """ + if not storage_key: + return None + model = ( + self.session.query(AssetModel) + .filter((AssetModel.storage_key == storage_key) | (AssetModel.file_url == storage_key)) + .first() + ) if model is None: return None return self._to_domain(model) diff --git a/packages/shared/ai_client.py b/packages/shared/ai_client.py index fe12327e6..936c6f732 100755 --- a/packages/shared/ai_client.py +++ b/packages/shared/ai_client.py @@ -248,7 +248,7 @@ class DoubaoClient: *, image_url: str | None = None, duration: int = 5, - ratio: str = "9:16", + ratio: str | None = "9:16", resolution: str = "720p", generate_audio: bool = False, watermark: bool = False, @@ -287,11 +287,13 @@ class DoubaoClient: "model": video_model, "content": content, "generate_audio": generate_audio, - "ratio": ratio, "duration": int(duration), "resolution": resolution, "watermark": watermark, } + # Bug #2110: ratio=None 时不传(首帧图生视频跟随原图比例,传 ratio 会 400 InvalidParameter) + if ratio: + create_payload["ratio"] = ratio headers = { "Authorization": f"Bearer {self.api_key}", @@ -299,12 +301,13 @@ class DoubaoClient: } create_url = f"{self.base_url}/contents/generations/tasks" logger.info( - "Seedance 创建任务请求: url=%s model=%s duration=%ds ratio=%s gen_audio=%s", + "Seedance 创建任务请求: url=%s model=%s duration=%ds ratio=%s gen_audio=%s image_url=%s", create_url, video_model, duration, - ratio, + ratio or "(follow-image)", generate_audio, + bool(image_url), ) # 1) 创建任务(带重试) diff --git a/packages/shared/ai_service.py b/packages/shared/ai_service.py index d9af4e394..d527dd06b 100755 --- a/packages/shared/ai_service.py +++ b/packages/shared/ai_service.py @@ -543,29 +543,35 @@ def call_video_generation( *, image_url: str | None = None, duration: int = 5, - ratio: str = "9:16", + ratio: str | None = "9:16", resolution: str = "720p", output_dir: str | None = None, ) -> str | None: """调用 Seedance 2.5 生成视频段,返回本地 MP4 路径;失败返回 None。 封装 ai_client.video_generation:提交异步任务→轮询→下载到本地。 + Bug #2110: 首帧参考图模式下不传 ratio(API 要求跟随首帧图比例,传 ratio=9:16 + 会返回 400 InvalidParameter)。 """ client = get_doubao_client() if not client.is_available: logger.warning("[ai_service] 豆包客户端未配置,跳过视频生成") return None + # 首帧模式:不强制 ratio,让模型跟随首帧图比例 + effective_ratio = None if image_url else ratio try: - return client.video_generation( + kwargs: dict = dict( prompt=prompt, image_url=image_url, duration=duration, - ratio=ratio, resolution=resolution, generate_audio=False, # 我们自己混 TTS watermark=False, output_dir=output_dir, ) + if effective_ratio: + kwargs["ratio"] = effective_ratio + return client.video_generation(**kwargs) except Exception as e: logger.error("[ai_service] call_video_generation 异常: %s", e, exc_info=True) return None