diff --git a/apps/worker/worker_app/tasks/viral_video.py b/apps/worker/worker_app/tasks/viral_video.py index bdbf9050c..8adc448c2 100644 --- a/apps/worker/worker_app/tasks/viral_video.py +++ b/apps/worker/worker_app/tasks/viral_video.py @@ -1163,7 +1163,9 @@ def _step_render(job: ViralVideoJob, copy_result: dict, tts_audio_url: str | Non usage = result.get("usage") if not video_path or not Path(video_path).exists() or Path(video_path).stat().st_size == 0: raise RuntimeError("Seedance 视频生成失败:返回空文件或路径不存在") - logger.info("[爆款视频] Seedance 单次生成完成: %s size=%d usage=%s", video_path, Path(video_path).stat().st_size, usage) + logger.info( + "[爆款视频] Seedance 单次生成完成: %s size=%d usage=%s", video_path, Path(video_path).stat().st_size, usage + ) return str(video_path), (usage if isinstance(usage, dict) else None) @@ -1566,6 +1568,7 @@ def _try_refund_viral_video(job: ViralVideoJob) -> None: """爆款视频生成失败:若已预扣积分则全额退款。""" try: from packages.shared import get_shared_settings + _s = get_shared_settings() if not _s.points_enabled: return @@ -1573,12 +1576,16 @@ def _try_refund_viral_video(job: ViralVideoJob) -> None: if prepaid <= 0: return from packages.domain.points_service import PointsService + svc = PointsService() # 使用独立 session(避免污染外层事务) ssn = SessionLocal() try: svc.refund_viral_video( - job.user_id, prepaid, getattr(job, "credits_transaction_id", "") or "", ssn, + job.user_id, + prepaid, + getattr(job, "credits_transaction_id", "") or "", + ssn, ) job.credits_prepaid = 0.0 finally: @@ -1591,6 +1598,7 @@ def _settle_viral_video(job: ViralVideoJob, usage: dict | None) -> None: """爆款视频生成成功:按实际 usage 结算,多退少补,写 credits_cost。""" try: from packages.shared import get_shared_settings + _s = get_shared_settings() if not _s.points_enabled: job.credits_cost = 0.0 @@ -1602,6 +1610,7 @@ def _settle_viral_video(job: ViralVideoJob, usage: dict | None) -> None: return from packages.domain.points_rules import calculate_viral_video_credits, resolve_video_dimensions from packages.domain.points_service import PointsService + w, h = resolve_video_dimensions( getattr(job, "video_resolution", "720p") or "720p", getattr(job, "video_ratio", "9:16") or "9:16", @@ -1613,7 +1622,8 @@ def _settle_viral_video(job: ViralVideoJob, usage: dict | None) -> None: actual_tokens = int(at) actual_credits = calculate_viral_video_credits( int(getattr(job, "duration", 15) or 15), - w, h, + w, + h, getattr(job, "video_model", "") or "seedance-2.5", actual_tokens=actual_tokens, ) @@ -1683,11 +1693,9 @@ def _run_render_pipeline(job_id: str, session, repo, job) -> dict: _set_stage(job, repo, session, ViralVideoStage.RENDERING, "正在生成视频(约1-3分钟)...") video_path = None usage = None - render_err = None try: video_path, usage = _step_render(job, copy_result, tts_url) except Exception as e: - render_err = e logger.error("[爆款视频][阶段3] Seedance 生成失败,触发退款: %s", e, exc_info=True) # 退款 _try_refund_viral_video(job) diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index 4ecaf77f1..993c21c49 100755 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -962,9 +962,9 @@ class ViralVideoJobModel(Base): ) # v1.6: 编导脚本结构{overview,scene_and_lighting,shots,hard_constraints,negative_prompts,voiceover_script} result_video_url = Column(String(1000), nullable=False, default="") credits_cost = Column(Float, nullable=False, default=0) - video_resolution = Column(String(20), nullable=False, default='720p') + video_resolution = Column(String(20), nullable=False, default="720p") credits_prepaid = Column(Float, nullable=False, default=0.0) - credits_transaction_id = Column(String(36), nullable=False, default='') + credits_transaction_id = Column(String(36), nullable=False, default="") error_msg = Column(Text, nullable=False, default="") retry_count = Column(Integer, nullable=False, default=0) started_at = Column(DateTime(timezone=True), nullable=True) diff --git a/packages/domain/points_rules.py b/packages/domain/points_rules.py index 21a142ee2..f62fda66e 100644 --- a/packages/domain/points_rules.py +++ b/packages/domain/points_rules.py @@ -32,9 +32,21 @@ VIRAL_VIDEO_FPS = 24 # 分辨率别名映射 -> 标准 key _RESOLUTION_ALIASES: dict[str, str] = { - "480p": "480p", "普清": "480p", "default": "480p", "low": "480p", "sd": "480p", - "720p": "720p", "高清": "720p", "medium": "720p", "hd": "720p", - "1080p": "1080p", "超清": "1080p", "high": "1080p", "ultra": "1080p", "全能": "1080p", "fhd": "1080p", + "480p": "480p", + "普清": "480p", + "default": "480p", + "low": "480p", + "sd": "480p", + "720p": "720p", + "高清": "720p", + "medium": "720p", + "hd": "720p", + "1080p": "1080p", + "超清": "1080p", + "high": "1080p", + "ultra": "1080p", + "全能": "1080p", + "fhd": "1080p", } # 分辨率 -> 高度 _RESOLUTION_HEIGHT: dict[str, int] = {"480p": 480, "720p": 720, "1080p": 1080} diff --git a/packages/domain/points_service.py b/packages/domain/points_service.py index a5f1ba28a..935df002d 100644 --- a/packages/domain/points_service.py +++ b/packages/domain/points_service.py @@ -328,7 +328,9 @@ class PointsService: if not res.get("success"): logger.warning( "[viral_video] 结算补扣余额不足 user_id=%s extra=%.2f balance=%s (不阻塞任务完成)", - user_id, extra, res.get("balance"), + user_id, + extra, + res.get("balance"), ) return {"success": bool(res.get("success")), "action": "deduct", "diff": extra, "amount": extra} except Exception: diff --git a/tests/unit/test_viral_video.py b/tests/unit/test_viral_video.py index 6938bc6a6..a7d363cf7 100755 --- a/tests/unit/test_viral_video.py +++ b/tests/unit/test_viral_video.py @@ -128,6 +128,7 @@ class TestViralVideoJobDefaults: assert job.result_video_url == "" assert job.error_msg == "" + class TestViralVideoStage: """阶段枚举测试。""" diff --git a/tests/unit/test_viral_video_p0.py b/tests/unit/test_viral_video_p0.py index 30c70622c..2ab4de7c7 100644 --- a/tests/unit/test_viral_video_p0.py +++ b/tests/unit/test_viral_video_p0.py @@ -212,7 +212,10 @@ class TestCallVideoGeneration: with patch("packages.shared.ai_service.get_doubao_client") as mock_get: mock_client = MagicMock() mock_client.is_available = True - mock_client.video_generation.return_value = {"video_path": str(out), "usage": {"completion_tokens": 1000000}} + mock_client.video_generation.return_value = { + "video_path": str(out), + "usage": {"completion_tokens": 1000000}, + } mock_get.return_value = mock_client result = call_video_generation(prompt="测试", image_url="https://img/x.jpg", duration=5, ratio="9:16") assert result is not None and result["video_path"] == str(out) @@ -238,7 +241,10 @@ class TestCallVideoGenerationV16: with patch("packages.shared.ai_service.get_doubao_client") as mock_get: mock_client = MagicMock() mock_client.is_available = True - mock_client.video_generation.return_value = {"video_path": str(out), "usage": {"completion_tokens": 1500000}} + mock_client.video_generation.return_value = { + "video_path": str(out), + "usage": {"completion_tokens": 1500000}, + } mock_get.return_value = mock_client result = call_video_generation( prompt="测试",