diff --git a/apps/api/app/services/plan_generator_service.py b/apps/api/app/services/plan_generator_service.py index 2edf565d6..57d986f7d 100755 --- a/apps/api/app/services/plan_generator_service.py +++ b/apps/api/app/services/plan_generator_service.py @@ -151,8 +151,8 @@ class PlanGeneratorService: plan_config: dict[str, Any] = { "editing_mode": editing_mode, } - # 继承模板的 cover/title/subtitle/bgm 配置 - for key in ("cover", "title", "subtitle", "bgm"): + # 继承模板的 cover/title/subtitle/bgm/export/filter 配置 + for key in ("cover", "title", "subtitle", "bgm", "export", "filter"): if key in template_config: plan_config[key] = template_config[key] @@ -187,6 +187,10 @@ class PlanGeneratorService: cfg.transition_effect.value if hasattr(cfg.transition_effect, "value") else cfg.transition_effect ) + # 从 clip config 中解析 playback_speed(兼容 speed_ratio 字段名) + clip_cfg = cfg.config or {} + playback_speed = clip_cfg.get("playback_speed", clip_cfg.get("speed_ratio", 1.0)) or 1.0 + clip = EditPlanClip.create( plan_id=plan_id, clip_type=clip_type, @@ -195,6 +199,8 @@ class PlanGeneratorService: text_content=getattr(cfg, "text_template", "") or "", duration=duration, transition_effect=transition or "cut", + playback_speed=playback_speed, + config=clip_cfg, ) clips.append(clip) diff --git a/tests/unit/test_plan_generator.py b/tests/unit/test_plan_generator.py index 263dd1aaa..a7202e079 100755 --- a/tests/unit/test_plan_generator.py +++ b/tests/unit/test_plan_generator.py @@ -682,3 +682,207 @@ class TestTotalDuration: # 3 个 clips × 5 秒 = 15 秒 assert result["plan"].total_duration == 15.0 + + +# --------------------------------------------------------------------------- +# 测试:模板配置传递(export / filter / 片段级 speed_ratio) +# --------------------------------------------------------------------------- + + +class TestTemplateConfigPropagation: + """模板配置到剪辑计划的传递验证""" + + def test_export_config_propagated(self): + """模板 export 配置(watermark/resolution)应传递到 plan.config.export""" + svc, _, _ = _make_generator() + template = _make_template( + editing_mode=EditingMode.ONE_TAKE.value, + config={ + "export": { + "resolution": "1080x1920", + "fps": 30, + "watermark_enabled": True, + "watermark_text": "小虾剪辑", + }, + }, + ) + clip_configs = _make_clip_configs( + specs=[ + {"clip_type": ClipType.MAIN, "order": 0, "min_duration": 3.0, "max_duration": 5.0}, + ] + ) + + result = svc.generate_from_template( + template=template, + clip_configs=clip_configs, + asset_ids=["a1"], + ) + + export_cfg = result["plan"].config.get("export", {}) + assert export_cfg.get("watermark_enabled") is True + assert export_cfg.get("watermark_text") == "小虾剪辑" + assert export_cfg.get("resolution") == "1080x1920" + assert export_cfg.get("fps") == 30 + + def test_filter_config_propagated(self): + """模板 filter 配置应传递到 plan.config.filter""" + svc, _, _ = _make_generator() + template = _make_template( + editing_mode=EditingMode.ONE_TAKE.value, + config={ + "filter": { + "enabled": True, + "preset_id": "filter_vivid", + "intensity": 80, + }, + }, + ) + clip_configs = _make_clip_configs( + specs=[ + {"clip_type": ClipType.MAIN, "order": 0, "min_duration": 3.0, "max_duration": 5.0}, + ] + ) + + result = svc.generate_from_template( + template=template, + clip_configs=clip_configs, + asset_ids=["a1"], + ) + + filter_cfg = result["plan"].config.get("filter", {}) + assert filter_cfg.get("enabled") is True + assert filter_cfg.get("preset_id") == "filter_vivid" + assert filter_cfg.get("intensity") == 80 + + def test_subtitle_asr_config_propagated(self): + """模板 subtitle ASR 配置应传递到 plan.config.subtitle""" + svc, _, _ = _make_generator() + template = _make_template( + editing_mode=EditingMode.ONE_TAKE.value, + config={ + "subtitle": { + "enabled": True, + "auto_generated": True, + "position": "bottom", + "source": "asr", + }, + }, + ) + clip_configs = _make_clip_configs( + specs=[ + {"clip_type": ClipType.MAIN, "order": 0, "min_duration": 3.0, "max_duration": 5.0}, + ] + ) + + result = svc.generate_from_template( + template=template, + clip_configs=clip_configs, + asset_ids=["a1"], + ) + + subtitle_cfg = result["plan"].config.get("subtitle", {}) + assert subtitle_cfg.get("enabled") is True + assert subtitle_cfg.get("auto_generated") is True + assert subtitle_cfg.get("position") == "bottom" + + def test_speed_ratio_mapped_to_playback_speed(self): + """clip_config.config.speed_ratio 应映射到 clip.playback_speed""" + svc, _, _ = _make_generator() + template = _make_template(editing_mode=EditingMode.ONE_TAKE.value) + clip_configs = _make_clip_configs( + specs=[ + { + "clip_type": ClipType.MAIN, + "order": 0, + "min_duration": 3.0, + "max_duration": 5.0, + "config": {"speed_ratio": 1.2, "name": "开场"}, + }, + { + "clip_type": ClipType.MAIN, + "order": 1, + "min_duration": 3.0, + "max_duration": 5.0, + "config": {"speed_ratio": 0.8, "name": "结尾"}, + }, + ] + ) + + result = svc.generate_from_template( + template=template, + clip_configs=clip_configs, + asset_ids=["a1", "a2"], + ) + + clips = result["clips"] + assert clips[0].playback_speed == 1.2 + assert clips[1].playback_speed == 0.8 + + def test_playback_speed_takes_priority_over_speed_ratio(self): + """clip_config.config.playback_speed 优先于 speed_ratio""" + svc, _, _ = _make_generator() + template = _make_template(editing_mode=EditingMode.ONE_TAKE.value) + clip_configs = _make_clip_configs( + specs=[ + { + "clip_type": ClipType.MAIN, + "order": 0, + "min_duration": 3.0, + "max_duration": 5.0, + "config": {"speed_ratio": 1.2, "playback_speed": 1.5}, + }, + ] + ) + + result = svc.generate_from_template( + template=template, + clip_configs=clip_configs, + asset_ids=["a1"], + ) + + assert result["clips"][0].playback_speed == 1.5 + + def test_default_playback_speed_is_1_0(self): + """无 speed_ratio / playback_speed 时,默认 1.0""" + svc, _, _ = _make_generator() + template = _make_template(editing_mode=EditingMode.ONE_TAKE.value) + clip_configs = _make_clip_configs( + specs=[ + {"clip_type": ClipType.MAIN, "order": 0, "min_duration": 3.0, "max_duration": 5.0}, + ] + ) + + result = svc.generate_from_template( + template=template, + clip_configs=clip_configs, + asset_ids=["a1"], + ) + + assert result["clips"][0].playback_speed == 1.0 + + def test_clip_config_dict_propagated(self): + """clip_config.config 整体应传递到 clip.config""" + svc, _, _ = _make_generator() + template = _make_template(editing_mode=EditingMode.ONE_TAKE.value) + clip_configs = _make_clip_configs( + specs=[ + { + "clip_type": ClipType.MAIN, + "order": 0, + "min_duration": 3.0, + "max_duration": 5.0, + "config": {"speed_ratio": 1.2, "name": "开场", "custom_field": "value"}, + }, + ] + ) + + result = svc.generate_from_template( + template=template, + clip_configs=clip_configs, + asset_ids=["a1"], + ) + + clip_cfg = result["clips"][0].config + assert clip_cfg.get("speed_ratio") == 1.2 + assert clip_cfg.get("name") == "开场" + assert clip_cfg.get("custom_field") == "value"