diff --git a/apps/api/app/services/plan_generator_service.py b/apps/api/app/services/plan_generator_service.py index de0b9b938..758510c0f 100755 --- a/apps/api/app/services/plan_generator_service.py +++ b/apps/api/app/services/plan_generator_service.py @@ -234,6 +234,11 @@ class PlanGeneratorService: # 有缓存的素材片段起点从随机镜头段选取,无缓存走随机起点兜底 asset_scene_points = self._fetch_asset_scene_points(asset_ids) + # 正式生成也随机重排片段顺序(降重,默认开启无开关) + # smart_match 决定选哪些素材,shuffle 只改变分配到 clips 的顺序 + asset_ids = list(asset_ids) # 复制避免修改调用方原列表 + random.shuffle(asset_ids) + distribute_assets( clips, asset_ids, diff --git a/tests/unit/test_plan_generator.py b/tests/unit/test_plan_generator.py index 37544c067..2d328cbc3 100755 --- a/tests/unit/test_plan_generator.py +++ b/tests/unit/test_plan_generator.py @@ -1007,3 +1007,162 @@ class TestAssetDurationsAlwaysFetched: call_kwargs = mock_distribute.call_args asset_durations = call_kwargs.kwargs.get("asset_durations", call_kwargs[1].get("asset_durations")) assert asset_durations is None + + +# --------------------------------------------------------------------------- +# 测试:正式生成片段随机重排(Issue #1663) +# --------------------------------------------------------------------------- + + +class TestFormalGenerationShuffle: + """验证正式生成时片段顺序随机化。 + + Issue #1663: 正式生成时 smart_match 排序后对 asset_ids 做 random.shuffle, + 使得同一批素材每次生成的视频片段顺序不同,有利于查重降重。 + """ + + def _make_service_with_asset_repo(self): + """创建带 mock asset_repo 的 PlanGeneratorService(复用 TestAssetDurationsAlwaysFetched 模式)""" + from apps.api.app.services.plan_generator_service import PlanGeneratorService + + plan_repo = StubEditPlanRepository() + clip_repo = StubEditPlanClipRepository() + + asset_repo = MagicMock() + + def fake_get(asset_id): + mock_asset = MagicMock() + mock_asset.duration = 30.0 + mock_asset.quality_score = None + mock_asset.created_at = None + mock_asset.metadata = {} + return mock_asset + + asset_repo.get = MagicMock(side_effect=fake_get) + + with ( + patch( + "apps.api.app.services.plan_generator_service.SQLAlchemyEditPlanRepository", + return_value=plan_repo, + ), + patch( + "apps.api.app.services.plan_generator_service.SQLAlchemyEditPlanClipRepository", + return_value=clip_repo, + ), + ): + db = MagicMock() + svc = PlanGeneratorService(db, asset_repo=asset_repo) + svc._plan_repo = plan_repo + svc._clip_repo = clip_repo + + return svc, asset_repo + + def test_formal_generation_shuffles_asset_ids(self): + """正式生成路径下 asset_ids 应被打乱,多次调用顺序应不同""" + svc, _ = self._make_service_with_asset_repo() + + template = _make_template("one_take") + # 6 个 clip 容纳 6 个素材 + clip_configs = _make_clip_configs( + template_id=template.id, + specs=[ + {"clip_type": ClipType.MAIN, "order": i, "min_duration": 3.0, "max_duration": 5.0} for i in range(6) + ], + ) + + asset_ids = ["a1", "a2", "a3", "a4", "a5", "a6"] + + # 收集多次调用中 distribute_assets 收到的 asset_ids 顺序 + captured_orders = [] + with patch( + "apps.api.app.services.plan_generator_service.distribute_assets", + side_effect=lambda clips, asset_ids, *a, **kw: captured_orders.append(list(asset_ids)), + ): + # mock _sort_assets_by_smart_score 返回固定顺序,验证 shuffle 会打乱 + with patch.object( + svc, + "_sort_assets_by_smart_score", + side_effect=lambda ids: list(ids), # 原样返回 + ): + with patch.object( + svc, + "_fetch_asset_scene_points", + return_value={}, + ): + for _ in range(10): + svc.generate_from_template( + template=template, + clip_configs=clip_configs, + asset_ids=list(asset_ids), # 每次传新列表 + random_preview=False, # 正式生成 + ) + + assert len(captured_orders) == 10 + # 每次 order 应该是 asset_ids 的一个排列 + expected_set = set(asset_ids) + for order in captured_orders: + assert set(order) == expected_set + + # 10 次调用中应至少出现 2 种不同顺序(概率 > 99.9%) + unique_orders = set(tuple(o) for o in captured_orders) + assert ( + len(unique_orders) >= 2 + ), f"Expected shuffled orders to vary, but got only {len(unique_orders)} unique order(s): {unique_orders}" + + def test_formal_generation_does_not_mutate_original_list(self): + """shuffle 不应修改调用方的原始 asset_ids 列表""" + svc, _ = self._make_service_with_asset_repo() + + template = _make_template("one_take") + clip_configs = _make_clip_configs( + template_id=template.id, + specs=[ + {"clip_type": ClipType.MAIN, "order": i, "min_duration": 3.0, "max_duration": 5.0} for i in range(4) + ], + ) + + original = ["a1", "a2", "a3", "a4"] + original_copy = list(original) + + with patch("apps.api.app.services.plan_generator_service.distribute_assets"): + with patch.object(svc, "_sort_assets_by_smart_score", side_effect=lambda ids: list(ids)): + with patch.object(svc, "_fetch_asset_scene_points", return_value={}): + svc.generate_from_template( + template=template, + clip_configs=clip_configs, + asset_ids=original, + random_preview=False, + ) + + assert original == original_copy, "Original asset_ids list should not be mutated" + + def test_preview_random_mode_unaffected_by_shuffle(self): + """预览随机模式不走 shuffle 路径,行为不变""" + svc, _ = self._make_service_with_asset_repo() + + template = _make_template("one_take") + clip_configs = _make_clip_configs( + template_id=template.id, + specs=[ + {"clip_type": ClipType.MAIN, "order": i, "min_duration": 3.0, "max_duration": 5.0} for i in range(4) + ], + ) + + asset_ids = ["a1", "a2", "a3", "a4"] + + captured_orders = [] + with patch( + "apps.api.app.services.plan_generator_service.distribute_assets", + side_effect=lambda clips, asset_ids, *a, **kw: captured_orders.append(list(asset_ids)), + ): + for _ in range(5): + svc.generate_from_template( + template=template, + clip_configs=clip_configs, + asset_ids=list(asset_ids), + random_preview=True, # 预览随机模式 + ) + + assert len(captured_orders) == 5 + # 预览模式下 random.shuffle 不应被调用(在 _distribute_assets 的 if not random_selection 块内) + # 所以 asset_ids 应该保持调用方传入的顺序(可能已由上层 shuffle 过)