diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index 901edf659..0f0a2daac 100755 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -7,7 +7,6 @@ from app.auth import AuthenticatedUser, get_current_user from app.core.storage import OSSStorageService, get_storage_service from app.core.task_enqueue import ( GLOBAL_PENDING_LIMIT, - USER_PENDING_LIMIT, GlobalQueueFull, UserPendingLimitExceeded, build_rate_limit_detail, @@ -50,12 +49,98 @@ from packages.domain.smart_match import smart_select_assets # #2035:文案关键词 → 素材分类 映射表(用于 smart_match category_match 维度) # AssetClassification 枚举: scenic / product / person / animal / food / tech / sport / music / other _CATEGORY_KEYWORDS: dict[str, set[str]] = { - "scenic": {"风景", "自然", "山水", "大海", "天空", "日落", "日出", "森林", "城市", "建筑", "夜景", "街道", "公园", "景区", "旅行", "旅游", "户外"}, - "product": {"产品", "商品", "展示", "演示", "开箱", "评测", "好物", "推荐", "种草", "购物", "电商", "带货", "品牌", "广告", "包装"}, - "person": {"人物", "人物采访", "对话", "说话", "讲解", "演讲", "采访", "聊天", "开会", "工作", "办公室", "团队", "员工", "老板", "女性", "男性", "美女", "帅哥"}, + "scenic": { + "风景", + "自然", + "山水", + "大海", + "天空", + "日落", + "日出", + "森林", + "城市", + "建筑", + "夜景", + "街道", + "公园", + "景区", + "旅行", + "旅游", + "户外", + }, + "product": { + "产品", + "商品", + "展示", + "演示", + "开箱", + "评测", + "好物", + "推荐", + "种草", + "购物", + "电商", + "带货", + "品牌", + "广告", + "包装", + }, + "person": { + "人物", + "人物采访", + "对话", + "说话", + "讲解", + "演讲", + "采访", + "聊天", + "开会", + "工作", + "办公室", + "团队", + "员工", + "老板", + "女性", + "男性", + "美女", + "帅哥", + }, "animal": {"动物", "宠物", "狗", "猫", "鸟", "鱼", "马", "牛", "羊", "野生动物", "动物园"}, - "food": {"美食", "食物", "餐饮", "餐厅", "做饭", "烹饪", "厨房", "菜品", "饮料", "水果", "甜点", "蛋糕", "咖啡", "茶", "零食", "吃"}, - "tech": {"科技", "数码", "电脑", "手机", "屏幕", "软件", "APP", "互联网", "AI", "人工智能", "机器人", "办公", "程序员", "代码", "屏幕录制"}, + "food": { + "美食", + "食物", + "餐饮", + "餐厅", + "做饭", + "烹饪", + "厨房", + "菜品", + "饮料", + "水果", + "甜点", + "蛋糕", + "咖啡", + "茶", + "零食", + "吃", + }, + "tech": { + "科技", + "数码", + "电脑", + "手机", + "屏幕", + "软件", + "APP", + "互联网", + "AI", + "人工智能", + "机器人", + "办公", + "程序员", + "代码", + "屏幕录制", + }, "sport": {"运动", "健身", "跑步", "篮球", "足球", "游泳", "瑜伽", "户外", "锻炼", "体育", "比赛", "球场"}, "music": {"音乐", "歌曲", "演唱会", "乐器", "唱歌", "跳舞", "舞蹈", "MV", "演出", "乐队", "钢琴", "吉他", "节奏"}, } @@ -77,6 +162,7 @@ def _infer_expected_categories(script_tags: set[str] | None) -> set[str] | None: break return matched or None + from packages.middleware.points_gate import points_gate logger = logging.getLogger(__name__) @@ -193,10 +279,13 @@ def _select_assets_from_library( # #2035:加载片段级 AI 标签,供叙事模式 AI 加权和 smart 模式语义匹配使用。 # 失败降级为空(不影响选片主流程)。 clip_ai_tags_by_asset: dict[str, list[dict]] = {} - ai_tags_by_asset: dict[str, dict] = {} # asset_id → 聚合后的 ai_tags dict(取首个有 has_text 的片段;合并 scene/objects/action 去重) + ai_tags_by_asset: dict[ + str, dict + ] = {} # asset_id → 聚合后的 ai_tags dict(取首个有 has_text 的片段;合并 scene/objects/action 去重) try: if db is not None: from packages.adapters.sqlalchemy_impl.models import AssetAtomClipModel + ready_ids = [a.id for a in ready_video_assets] clip_rows = ( db.query(AssetAtomClipModel.asset_id, AssetAtomClipModel.ai_tags) @@ -594,21 +683,13 @@ def create_generation_task( # 同批次任务共享 batch_id,用于视频查重时批次内比对 batch_id = uuid.uuid4().hex if count > 1 else "" - # 预检查:批量提交前先看会不会超限,避免建一半才拒 + # 预检查(Bug B #2098):只保留全局 503 保护,用户级不再硬拒 429; + # 超额任务直接入队等待 worker 自然消费,前端展示排队位置而非阻止提交。 + # USER_PENDING_LIMIT 作为软上限(safe_enqueue 兜底),提高到 20 支持批量提交。 try: - user_pending = generation_task_repository.count_pending_by_user(user_id) global_pending = generation_task_repository.count_pending_total() - if user_pending + count > USER_PENDING_LIMIT: - raise UserPendingLimitExceeded( - user_id=user_id, pending_count=user_pending + count, limit=USER_PENDING_LIMIT - ) if global_pending + count > GLOBAL_PENDING_LIMIT: raise GlobalQueueFull(pending_count=global_pending + count, limit=GLOBAL_PENDING_LIMIT) - except UserPendingLimitExceeded as e: - raise HTTPException( - status_code=429, - detail=build_rate_limit_detail(e, generation_task_repository, scope="user"), - ) from e except GlobalQueueFull as e: raise HTTPException( status_code=503, @@ -923,14 +1004,10 @@ def create_generation_task( else: failed_tasks.append(task) except UserPendingLimitExceeded as _e: - # 兜底:如果预检查后又并发提交了,在这里也拦住 - failed_tasks.append(task) - if not created_tasks: - raise HTTPException( - status_code=429, - detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"), - ) from _e - break + # Bug B #2098: 用户级限流已改为软限制,此分支理论上不再触发; + # 极端并发兜底仍入队(safe_enqueue 内部会打 warning 日志),不 429 拒绝 + logger.warning("[生成任务] 用户 pending 超软限制,仍允许入队: task_id=%s", task.id) + created_tasks.append(task) except GlobalQueueFull as _e: failed_tasks.append(task) if not created_tasks: @@ -1081,10 +1158,8 @@ def confirm_generation( ): logger.warning("[确认生成] 入队失败: task_id=%s", new_task.id) except UserPendingLimitExceeded as _e: - raise HTTPException( - status_code=429, - detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"), - ) from None + # Bug B #2098: 用户级限流已软处理,理论上不再触发;作为防御仍放行 + logger.warning("[任务] 用户 pending 超软限制,任务已入队") except GlobalQueueFull as _e: raise HTTPException( status_code=503, @@ -1263,22 +1338,8 @@ def retry_generation_task( raise HTTPException(status_code=409, detail="Only failed tasks can be retried") user_id = authenticated_user.user.id - # 预检查:创建前判断,>= 上限就拒绝 - user_pending = generation_task_repository.count_pending_by_user(user_id) + # 预检查(Bug B #2098):只保留全局 503,用户级不再硬拒 global_pending = generation_task_repository.count_pending_total() - if user_pending >= USER_PENDING_LIMIT: - raise HTTPException( - status_code=429, - detail=build_rate_limit_detail( - UserPendingLimitExceeded( - user_id=user_id, - pending_count=user_pending, - limit=USER_PENDING_LIMIT, - ), - generation_task_repository, - scope="user", - ), - ) if global_pending >= GLOBAL_PENDING_LIMIT: raise HTTPException( status_code=503, @@ -1321,10 +1382,8 @@ def retry_generation_task( ): logger.warning("[生成任务] 重试入队失败: task_id=%s", retried.id) except UserPendingLimitExceeded as _e: - raise HTTPException( - status_code=429, - detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"), - ) from None + # Bug B #2098: 用户级限流已软处理,理论上不再触发;作为防御仍放行 + logger.warning("[任务] 用户 pending 超软限制,任务已入队") except GlobalQueueFull as _e: raise HTTPException( status_code=503, diff --git a/apps/api/app/core/task_enqueue.py b/apps/api/app/core/task_enqueue.py index c3b11329c..bea98657b 100755 --- a/apps/api/app/core/task_enqueue.py +++ b/apps/api/app/core/task_enqueue.py @@ -6,7 +6,7 @@ from app.core.celery_app import celery_app logger = logging.getLogger(__name__) # ── 限流阈值常量(全系统统一管理,不要在业务代码里硬编码) ── -USER_PENDING_LIMIT = 3 # 单用户 pending 上限 +USER_PENDING_LIMIT = 20 # 单用户 pending 上限(#2098: 从 3 提到 20,支持批量任务自动排队) GLOBAL_PENDING_LIMIT = 20 # 全局 pending 上限 WORKER_CONCURRENCY = 4 # worker 渲染并发数(infra/docker/compose.yml WORKER_CONCURRENCY 默认值) @@ -263,19 +263,18 @@ def safe_enqueue_generation_task( _mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc)) raise exc - # 用户级限流检查(传了 user_id 才做) + # Bug B #2098: 用户级限流改为软提示,不再硬拒;所有任务都入队等待 worker 自然消费。 + # user_pending_limit 作为兜底阈值保留(默认 20),达到时打 warning 日志但仍入队, + # 避免极端情况下恶意用户无限堆积任务。真正的系统保护由全局 GLOBAL_PENDING_LIMIT 承担。 if user_id: user_pending = generation_task_repository.count_pending_by_user(user_id) if user_pending > user_pending_limit: logger.warning( - "[队列限流] 用户 pending 任务数超限(入队前): user_id=%s, count=%d/%d", + "[队列限流] 用户 pending 任务数超过软上限(入队): user_id=%s, count=%d/%d, 仍允许入队排队", user_id, user_pending, user_pending_limit, ) - exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending, limit=user_pending_limit) - _mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc)) - raise exc # ── 发送 Celery 任务 ── try: @@ -317,16 +316,18 @@ def safe_enqueue_generation_task( user_after = generation_task_repository.count_pending_by_user(user_id) if user_id else 0 global_over = global_after > global_pending_limit - user_over = bool(user_id and user_after > user_pending_limit) - - if global_over or user_over: - if global_over: - reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}" - exc = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit) - else: - reason = f"用户 pending 超限(入队后): {user_after}/{user_pending_limit}" - exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_after, limit=user_pending_limit) + # Bug B #2098: 用户超限仅日志警告,不回滚任务 + if user_id and user_after > user_pending_limit: + logger.warning( + "[队列限流] 用户 pending 超软上限(入队后): user_id=%s, count=%d/%d", + user_id, + user_after, + user_pending_limit, + ) + if global_over: + reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}" + exc = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit) logger.warning( "[队列限流] %s, task_id=%s, user_id=%s — 回滚状态为 failed", reason, diff --git a/apps/worker/video_processing/render_adapter.py b/apps/worker/video_processing/render_adapter.py index ccf3b5222..b64e756a8 100755 --- a/apps/worker/video_processing/render_adapter.py +++ b/apps/worker/video_processing/render_adapter.py @@ -132,6 +132,7 @@ class RenderAdapter: work_dir: Path | None = None, progress_cb: ProgressCallback | None = None, voiceover_audio_path: str | None = None, + task_config_override: dict | None = None, # Bug A: task 级 config 覆盖,防并发竞态 ) -> RenderAdapterResult: """渲染一个 EditPlan。 @@ -217,6 +218,7 @@ class RenderAdapter: rendered_clip_ids=rendered_clip_ids, failed_clip_ids=failed_clip_ids, voiceover_audio_path=voiceover_audio_path, + task_config_override=task_config_override, ) # 成功时将临时目录所有权转移给调用方,阻止 finally 清理 if result.success and temp_dir: @@ -392,7 +394,7 @@ class RenderAdapter: return asset_path_map, rendered_clip_ids, failed_clip_ids, asset_storage_map - def _prepare_bgm(self, plan, work_dir: Path, plan_id: str) -> str | None: + def _prepare_bgm(self, plan, work_dir: Path, plan_id: str, *, bgm_override: dict | None = None) -> str | None: """准备 BGM 音频文件(从 plan.config.bgm 读取配置)。 支持 3 种来源(按优先级): @@ -405,7 +407,9 @@ class RenderAdapter: from urllib.parse import urlparse plan_config = plan.config or {} - bgm_config = plan_config.get("bgm", {}) or {} + bgm_config = dict(plan_config.get("bgm", {}) or {}) + if isinstance(bgm_override, dict) and bgm_override: + bgm_config.update(bgm_override) # Bug A: 任务级 BGM 覆盖,防并发竞态 if not bgm_config.get("enabled", False): return None @@ -567,6 +571,7 @@ class RenderAdapter: failed_clip_ids: list[str] | None = None, voiceover_audio_path: str | None = None, asset_storage_map: dict[str, str] | None = None, + task_config_override: dict | None = None, # Bug A: task 级 config 覆盖,防并发竞态 ) -> RenderAdapterResult: """执行统一渲染核心流程(BGM + ASR + 渲染 + 缩略图 + 上传)。 @@ -580,8 +585,9 @@ class RenderAdapter: Returns: RenderAdapterResult """ - # 1. 准备 BGM - bgm_path = self._prepare_bgm(plan, work_dir, plan_id) + # 1. 准备 BGM(Bug A: 传 task 级 bgm override) + _bgm_override = (task_config_override or {}).get("bgm") if isinstance(task_config_override, dict) else None + bgm_path = self._prepare_bgm(plan, work_dir, plan_id, bgm_override=_bgm_override) self._report_progress(progress_cb, 40.0, "执行视频渲染") @@ -589,8 +595,10 @@ class RenderAdapter: plan_config = plan.config or {} asr_service = self._get_asr_service() - # 3. 读取输出分辨率 - export_config = plan_config.get("export", {}) or {} + # 3. 读取输出分辨率(Bug A: task override 优先) + export_config = dict(plan_config.get("export", {}) or {}) + if isinstance(task_config_override, dict) and isinstance(task_config_override.get("export"), dict): + export_config.update(task_config_override["export"]) if not isinstance(export_config, dict): export_config = {} output_width, output_height = _parse_resolution(export_config.get("resolution")) @@ -615,6 +623,7 @@ class RenderAdapter: asr_service=asr_service, voiceover_audio_path=voiceover_audio_path, clip_has_text=clip_has_text, + override_config=task_config_override, ) # 注入每个视频段对应素材的 storage_key,供全 GPU 直连管线直接签名下载 _storage_map = asset_storage_map or {} diff --git a/apps/worker/video_processing/unified_render_service.py b/apps/worker/video_processing/unified_render_service.py index 6c39cd569..18a9f8197 100755 --- a/apps/worker/video_processing/unified_render_service.py +++ b/apps/worker/video_processing/unified_render_service.py @@ -158,6 +158,7 @@ class UnifiedRenderService: bgm_path: str | None = None, # BGM 本地文件路径 voiceover_audio_path: str | None = None, # 配音素材库音频本地路径 clip_has_text: list[bool] | None = None, # 源视频片段是否有文字(来自 atom_clip.ai_tags.has_text) + override_config: dict | None = None, # Bug A: task 级 config 覆盖(title/bgm/export/subtitle),防并发竞态 ): self.plan = plan self.clips = clips @@ -170,6 +171,9 @@ class UnifiedRenderService: self.asr_service = asr_service self.bgm_path = bgm_path self.voiceover_audio_path = voiceover_audio_path + # Bug A: task 级 config override(深拷贝),优先级高于 plan.config; + # 避免同 plan 多任务并发渲染时 _sync_task_config_to_plan 写 plan.config["title"] 互相覆盖。 + self._override_config = dict(override_config) if isinstance(override_config, dict) else {} # #1970:片段级文字检测(顺序与非 audio 的源视频片段一致);None 表示无可靠检测,保守不翻转 self._clip_has_text = clip_has_text self._transition_engine = TransitionEngine(default_duration=transition_duration) @@ -180,6 +184,28 @@ class UnifiedRenderService: self._micro_plan_cache: Any = None self._micro_plan_loaded = False + def _cfg_section(self, section: str) -> dict: + """读取单个配置段:override_config 优先于 plan.config(Bug A 防并发竞态)。""" + base = dict((self.plan.config or {}).get(section, {}) or {}) + override = self._override_config.get(section) + if isinstance(override, dict) and override: + base.update(override) # 浅合并,保留 base 中未被覆盖字段 + return base + + def _effective_config(self) -> dict: + """读取完整 config:override_config 顶层段覆盖 plan.config(Bug A 防并发竞态)。""" + import copy + + full = copy.deepcopy(self.plan.config or {}) + for k, v in self._override_config.items(): + if isinstance(v, dict): + sec = dict(full.get(k, {}) or {}) + sec.update(v) + full[k] = sec + else: + full[k] = v + return full + # ── #1970 PR2 智能降重:片段级微变换 ─────────────────────────────────── def _dedup_enabled(self) -> bool: """读取 plan.config.dedup_enabled,缺省视为 True(向后兼容)。""" @@ -431,7 +457,7 @@ class UnifiedRenderService: has_audio = pass_through_has_audio # 直通模式下也支持 BGM 混音:提取音频 → 混 BGM → 合并回视频 if self.bgm_path and pass_through_has_audio: - config = self.plan.config or {} + config = self._effective_config() bgm_config = config.get("bgm", {}) or {} if bgm_config.get("enabled", False): ctx = RenderContext(work_dir=self.work_dir, plan_id=self.plan.id) @@ -471,7 +497,7 @@ class UnifiedRenderService: "[unified-render] pass-through BGM mix failed, skipping: plan_id=%s", self.plan.id ) else: - config = self.plan.config or {} + config = self._effective_config() bgm_config = config.get("bgm", {}) or {} if not isinstance(bgm_config, dict): bgm_config = {} @@ -696,7 +722,7 @@ class UnifiedRenderService: Returns: ASS 文件路径,没有字幕时返回 None """ - config = self.plan.config or {} + config = self._effective_config() # #1901 统一读 "title",兼容老数据 "title_config" title_cfg = config.get("title", {}) or {} if not isinstance(title_cfg, dict) or not (title_cfg.get("text") or "").strip(): @@ -881,7 +907,7 @@ class UnifiedRenderService: Returns: 是否成功添加了配音音轨 """ - config = self.plan.config or {} + config = self._effective_config() tts_cfg = config.get("tts", {}) or {} if not isinstance(tts_cfg, dict): tts_cfg = {} @@ -2275,7 +2301,7 @@ class UnifiedRenderService: try: from video_processing import gpu_direct_pipeline as gdp - cfg = self.plan.config or {} + cfg = self._effective_config() video_layer = next(_lyr for _lyr in layers if _lyr.role not in ("audio",)) video_clips = [c for c in video_layer.clips if c.clip_type != "audio"] diff --git a/apps/worker/worker_app/tasks/generation.py b/apps/worker/worker_app/tasks/generation.py index 5e2d8978d..9a227c67f 100644 --- a/apps/worker/worker_app/tasks/generation.py +++ b/apps/worker/worker_app/tasks/generation.py @@ -616,69 +616,49 @@ def _precompute_render_metadata( # ── Celery Task ────────────────────────────────────────────────────────────── -def _sync_task_config_to_plan(source_edit_plan_id: str, task_info: dict, db) -> str | None: - """将 GenerationTask 的配置同步到 EditPlan.config,返回配音本地路径(如果有)。 +def _build_task_config_override(task_info: dict) -> dict: + """Bug A: 从 task_info 构建任务级 config override 深拷贝,供渲染时覆盖 plan.config。 - 包括:title_config、BGM、输出分辨率。配音单独处理(需下载到本地)。 + 所有渲染相关配置(title/bgm/export)从任务自身读取,不再依赖共享 plan.config, + 彻底消除同 plan 多任务并发渲染时的竞态覆盖问题。 """ - from packages.adapters.sqlalchemy_impl.edit_plan_repository import ( - SQLAlchemyEditPlanRepository, - ) + import copy - plan_repo = SQLAlchemyEditPlanRepository(db) - plan = plan_repo.get(source_edit_plan_id) - if plan is None: - logger.error("[task] EditPlan not found: %s", source_edit_plan_id) - return None + override: dict = {} - plan_config = dict(plan.config or {}) - changed = False - - # 标题配置 + # 标题配置(字段名归一化) title_config = task_info.get("title_config") or {} - if title_config and isinstance(title_config, dict) and title_config.get("text", "").strip(): - cfg = dict(title_config) - # 字段名归一化 + if isinstance(title_config, dict) and title_config: + cfg = copy.deepcopy(title_config) if "font_size" in cfg and "size" not in cfg: cfg["size"] = cfg["font_size"] if "font_color" in cfg and "color" not in cfg: cfg["color"] = cfg["font_color"] - plan_config["title"] = cfg - changed = True - logger.info("[task] title_config synced to plan: %s", cfg.get("text", "")[:30]) + override["title"] = cfg # BGM 配置 bgm_config = task_info.get("bgm_config") or {} - if bgm_config: - from packages.domain.bgm_utils import merge_bgm_config - - existing_bgm = plan_config.get("bgm", {}) or {} - plan_config["bgm"] = merge_bgm_config(existing_bgm, bgm_config) - changed = True + if isinstance(bgm_config, dict) and bgm_config: + override["bgm"] = copy.deepcopy(bgm_config) # 输出分辨率 ow = task_info.get("output_width") or OUTPUT_WIDTH oh = task_info.get("output_height") or OUTPUT_HEIGHT if ow >= 100 and oh >= 100: - export_cfg = dict(plan_config.get("export", {}) or {}) - export_cfg["resolution"] = f"{ow}x{oh}" - plan_config["export"] = export_cfg - changed = True + override["export"] = {"resolution": f"{ow}x{oh}"} - if changed: - plan.config = plan_config - plan_repo.update(plan) - logger.info("[task] plan.config synced: plan_id=%s", source_edit_plan_id) + return override + + +def _download_voice_for_task(task_info: dict, source_edit_plan_id: str) -> str | None: + """下载任务配音到本地临时文件,返回路径(不读写 plan.config)。""" + import tempfile - # 配音下载 voiceover_path: str | None = None voice_library_id = task_info.get("voice_library_id", "") - # #1749:voice_ids 冗余字段已移除;配音一律以 voice_library_id 为准(独立配音每变体各自绑定) effective_voice_id = voice_library_id or "" if effective_voice_id: - import tempfile - voice_tmp = Path(tempfile.gettempdir()) / f"voice_{source_edit_plan_id}_{id(task_info)}.mp3" try: if _download_voice_asset(effective_voice_id, voice_tmp): @@ -697,6 +677,9 @@ def _render_from_edit_plan( ) -> tuple[Path, float, list[dict] | None, str | None, str | None, str, bool]: """从 EditPlan 数据库记录直接渲染(不再内存重建clips)。 + Bug A 修复:不再通过 _sync_task_config_to_plan 写共享 plan.config; + 渲染配置通过 task_config_override 参数直接传入渲染层,彻底消除并发竞态。 + Returns: (output_path, render_duration, cover_candidates, voiceover_path, temp_dir, thumbnail_url, edge_crop_applied) """ @@ -705,8 +688,10 @@ def _render_from_edit_plan( db = SessionLocal() try: - # 同步配置到 plan.config + 下载配音 - voiceover_path = _sync_task_config_to_plan(source_edit_plan_id, task_info, db) + # Bug A: 构建任务级 config override(深拷贝自 task_info),不写 plan.config,避免并发竞态 + task_override = _build_task_config_override(task_info) + # 下载配音到本地临时文件(不依赖 plan.config) + voiceover_path = _download_voice_for_task(task_info, source_edit_plan_id) # 进度回调 def _progress_cb(progress: float, stage: str): @@ -722,6 +707,7 @@ def _render_from_edit_plan( job_id=task_id, progress_cb=_progress_cb, voiceover_audio_path=voiceover_path, + task_config_override=task_override, ) if not result.success: