From 4e4f456bbe583d860da6363f91fcfd5c850756ae Mon Sep 17 00:00:00 2001 From: CI Bot Date: Wed, 26 Aug 2026 21:06:38 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(editor):=20from-assets=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=E6=8E=A5=E5=85=A5=E9=9A=8F=E6=9C=BA=E8=B5=B7=E5=A7=8B?= =?UTF-8?q?=E6=97=B6=E9=97=B4+=E5=8E=BB=E9=87=8D+=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E6=97=B6=E9=95=BF=E8=87=AA=E9=80=82=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 从数据库获取素材实际时长(不再硬编码 duration=5.0) - 调用 _calc_random_start_time 为每个clip计算随机start_time - 使用 used_segments 追踪已使用时间段,防止重复 - 素材时长不足5s时自动缩短clip duration - 新增4个单测覆盖核心逻辑 --- .../app/api/routes/templates_editor/clips.py | 36 ++- tests/unit/test_editor_clips_random_start.py | 238 ++++++++++++++++++ 2 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_editor_clips_random_start.py diff --git a/apps/api/app/api/routes/templates_editor/clips.py b/apps/api/app/api/routes/templates_editor/clips.py index d43965aa8..99a41e99d 100755 --- a/apps/api/app/api/routes/templates_editor/clips.py +++ b/apps/api/app/api/routes/templates_editor/clips.py @@ -25,6 +25,7 @@ from app.services.edit_template_service import EditTemplateService from fastapi import APIRouter, Depends, HTTPException, Query, status from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository +from packages.domain.plan_generator_utils import _calc_random_start_time from .dependencies import get_draft_plan_id, get_editor_services from .schemas import ( @@ -360,18 +361,49 @@ def create_clips_from_assets_editor( body: ClipsFromAssetsRequest, plan_id: str = Depends(get_draft_plan_id), services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository), current_user: AuthenticatedUser = Depends(get_current_user), ) -> ClipsFromAssetsResponse: - """从素材批量创建片段""" + """从素材批量创建片段(含随机起始时间 + 去重).""" _, plan_svc = services + + # 获取素材实际时长 + _DEFAULT_CLIP_DURATION = 5.0 + asset_durations: dict[str, float] = {} + for asset_id in body.asset_ids: + asset = asset_repo.get(asset_id) + if asset and hasattr(asset, "duration"): + asset_durations[asset_id] = float(asset.duration or 0.0) + + used_segments: dict[str, list[tuple[float, float]]] = {} clips = [] for i, asset_id in enumerate(body.asset_ids): try: + # 根据素材实际时长确定 clip duration(素材不够长则缩短) + asset_total = asset_durations.get(asset_id) + if asset_total is not None and asset_total > 0: + clip_duration = min(_DEFAULT_CLIP_DURATION, asset_total) + else: + clip_duration = _DEFAULT_CLIP_DURATION + + # 计算随机 start_time,避开已使用的时间段 + start_time = _calc_random_start_time( + asset_id, clip_duration, asset_durations, used_segments + ) + if start_time is None: + start_time = 0.0 + + # 记录已使用的时间段 + if asset_id not in used_segments: + used_segments[asset_id] = [] + used_segments[asset_id].append((start_time, start_time + clip_duration)) + clip = plan_svc.create_clip( plan_id, clip_type="main", order=body.start_order + i if hasattr(body, "start_order") else i, - duration=5.0, + duration=clip_duration, + start_time=start_time, asset_id=asset_id, ) clips.append(clip) diff --git a/tests/unit/test_editor_clips_random_start.py b/tests/unit/test_editor_clips_random_start.py new file mode 100644 index 000000000..e3478da80 --- /dev/null +++ b/tests/unit/test_editor_clips_random_start.py @@ -0,0 +1,238 @@ +"""测试编辑器 from-assets 端点的随机起始时间 + 去重逻辑. + +覆盖: +- 素材时长从数据库获取 +- 随机 start_time 计算 +- used_segments 去重 +- 素材时长不足时 clip duration 缩短 +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from unittest.mock import MagicMock, call, patch + +os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") +os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) + +import pytest + +TEST_PLAN_ID = "plan-draft-001" +TEST_USER_ID = "user-001" + + +def _make_auth_user(): + auth = MagicMock() + auth.user.id = TEST_USER_ID + auth.user.email = "test@example.com" + auth.user.display_name = "测试用户" + auth.user_id = TEST_USER_ID + return auth + + +def _make_mock_clip(clip_id, order, duration, start_time=0.0, asset_id=""): + clip = MagicMock() + clip.id = clip_id + clip.plan_id = TEST_PLAN_ID + clip.clip_type = "main" + clip.order = order + clip.duration = duration + clip.start_time = start_time + clip.text_content = "" + clip.transition_effect = "cut" + clip.transition_duration = 0.0 + clip.playback_speed = 1.0 + clip.config = {} + clip.asset_id = asset_id + clip.status = "pending" + clip.template_clip_config_id = "" + clip.created_at = None + clip.updated_at = None + return clip + + +def _make_mock_asset(asset_id, duration): + asset = MagicMock() + asset.id = asset_id + asset.duration = duration + return asset + + +class TestEditorClipsRandomStartTime: + """测试 create_clips_from_assets_editor 随机起始时间逻辑.""" + + @patch("app.api.routes.templates_editor.clips.get_storage_service") + def test_asset_durations_fetched_from_db(self, mock_storage): + """验证素材时长从数据库获取(不再硬编码 5.0).""" + from app.api.routes.templates_editor.clips import ( + create_clips_from_assets_editor, + ) + from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest + + mock_plan_svc = MagicMock() + mock_plan_svc.get_plan_or_raise = MagicMock() + mock_plan_svc.create_clip = MagicMock( + side_effect=lambda plan_id, clip_type, order, duration=0.0, start_time=0.0, asset_id="", **kw: _make_mock_clip( + clip_id=f"clip-{order}", order=order, duration=duration, start_time=start_time, asset_id=asset_id + ) + ) + + # 构造 asset_repo mock:两个素材,时长分别为 30s 和 20s + mock_asset_repo = MagicMock() + mock_asset_repo.get = MagicMock( + side_effect=lambda aid: _make_mock_asset(aid, {"asset-1": 30.0, "asset-2": 20.0}[aid]) + ) + + body = ClipsFromAssetsRequest(asset_ids=["asset-1", "asset-2"]) + + result = create_clips_from_assets_editor( + template_id="tmpl-001", + body=body, + plan_id=TEST_PLAN_ID, + services=(MagicMock(), mock_plan_svc), + asset_repo=mock_asset_repo, + current_user=_make_auth_user(), + ) + + # 验证 asset_repo.get 被调用 + assert mock_asset_repo.get.call_count == 2 + + # 验证 create_clip 被调用,且 duration 不再是硬编码 5.0 + assert mock_plan_svc.create_clip.call_count == 2 + calls = mock_plan_svc.create_clip.call_args_list + # 第一个素材: 30s > 5s, duration 应为 5.0 + assert calls[0].kwargs["duration"] == 5.0 or calls[0][1].get("duration") == 5.0 + # 第二个素材: 20s > 5s, duration 应为 5.0 + assert calls[1].kwargs["duration"] == 5.0 or calls[1][1].get("duration") == 5.0 + + @patch("app.api.routes.templates_editor.clips.get_storage_service") + def test_clip_duration_shortened_for_short_assets(self, mock_storage): + """素材时长不足 5s 时,clip duration 缩短为素材实际时长.""" + from app.api.routes.templates_editor.clips import ( + create_clips_from_assets_editor, + ) + from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest + + mock_plan_svc = MagicMock() + mock_plan_svc.get_plan_or_raise = MagicMock() + mock_plan_svc.create_clip = MagicMock( + side_effect=lambda plan_id, clip_type, order, duration=0.0, start_time=0.0, asset_id="", **kw: _make_mock_clip( + clip_id=f"clip-{order}", order=order, duration=duration, start_time=start_time, asset_id=asset_id + ) + ) + + # 素材时长只有 3s(< 5.0 默认值) + mock_asset_repo = MagicMock() + mock_asset_repo.get = MagicMock( + return_value=_make_mock_asset("asset-short", 3.0) + ) + + body = ClipsFromAssetsRequest(asset_ids=["asset-short"]) + + result = create_clips_from_assets_editor( + template_id="tmpl-001", + body=body, + plan_id=TEST_PLAN_ID, + services=(MagicMock(), mock_plan_svc), + asset_repo=mock_asset_repo, + current_user=_make_auth_user(), + ) + + # 验证 duration 被缩短到 3.0 + calls = mock_plan_svc.create_clip.call_args_list + assert len(calls) == 1 + assert calls[0].kwargs["duration"] == 3.0 or calls[0][1].get("duration") == 3.0 + + @patch("app.api.routes.templates_editor.clips.get_storage_service") + def test_used_segments_prevents_duplicate_ranges(self, mock_storage): + """相同素材被多次使用时,used_segments 应阻止时间段重叠.""" + from app.api.routes.templates_editor.clips import ( + create_clips_from_assets_editor, + ) + from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest + + mock_plan_svc = MagicMock() + mock_plan_svc.get_plan_or_raise = MagicMock() + + created_clips = [] + + def mock_create_clip(plan_id, clip_type, order, duration=0.0, start_time=0.0, asset_id="", **kw): + clip = _make_mock_clip( + clip_id=f"clip-{order}", order=order, duration=duration, + start_time=start_time, asset_id=asset_id + ) + created_clips.append(clip) + return clip + + mock_plan_svc.create_clip = MagicMock(side_effect=mock_create_clip) + + # 同一个素材(30s)被使用 3 次,每次 5s + mock_asset_repo = MagicMock() + mock_asset_repo.get = MagicMock( + return_value=_make_mock_asset("asset-same", 30.0) + ) + + body = ClipsFromAssetsRequest(asset_ids=["asset-same", "asset-same", "asset-same"]) + + result = create_clips_from_assets_editor( + template_id="tmpl-001", + body=body, + plan_id=TEST_PLAN_ID, + services=(MagicMock(), mock_plan_svc), + asset_repo=mock_asset_repo, + current_user=_make_auth_user(), + ) + + # 验证 3 个 clip 都创建了 + assert result.created_count == 3 + + # 验证 start_time 各不相同(去重生效) + start_times = [c.start_time for c in created_clips] + # 至少前两个应该不同(第三个也可能不同,取决于随机结果) + # 但我们不能保证 100% 不重叠(因为是随机的),只验证逻辑被调用了 + assert mock_plan_svc.create_clip.call_count == 3 + + @patch("app.api.routes.templates_editor.clips.get_storage_service") + def test_start_time_passed_to_create_clip(self, mock_storage): + """验证 start_time 被传入 create_clip(不再是固定 0.0).""" + from app.api.routes.templates_editor.clips import ( + create_clips_from_assets_editor, + ) + from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest + + mock_plan_svc = MagicMock() + mock_plan_svc.get_plan_or_raise = MagicMock() + mock_plan_svc.create_clip = MagicMock( + side_effect=lambda plan_id, clip_type, order, duration=0.0, start_time=0.0, asset_id="", **kw: _make_mock_clip( + clip_id=f"clip-{order}", order=order, duration=duration, start_time=start_time, asset_id=asset_id + ) + ) + + mock_asset_repo = MagicMock() + mock_asset_repo.get = MagicMock( + return_value=_make_mock_asset("asset-1", 30.0) + ) + + body = ClipsFromAssetsRequest(asset_ids=["asset-1"]) + + with patch("app.api.routes.templates_editor.clips._calc_random_start_time", return_value=12.5) as mock_calc: + result = create_clips_from_assets_editor( + template_id="tmpl-001", + body=body, + plan_id=TEST_PLAN_ID, + services=(MagicMock(), mock_plan_svc), + asset_repo=mock_asset_repo, + current_user=_make_auth_user(), + ) + + # 验证 _calc_random_start_time 被调用 + assert mock_calc.call_count == 1 + + # 验证 start_time=12.5 被传入 create_clip + calls = mock_plan_svc.create_clip.call_args_list + assert len(calls) == 1 + assert calls[0].kwargs["start_time"] == 12.5 or calls[0][1].get("start_time") == 12.5 -- 2.54.0 From 6fc73567c675405ed27082639da2799fd0d690b6 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Wed, 26 Aug 2026 21:21:50 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(editor):=20=E6=94=AF=E6=8C=81=E5=90=8C?= =?UTF-8?q?=E4=B8=80=E7=B4=A0=E6=9D=90=E5=88=87=E5=A4=9A=E4=B8=AA=E7=89=87?= =?UTF-8?q?=E6=AE=B5+required=5Fclips=5Fcount?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ClipsFromAssetsRequest 增加 required_clips_count 可选参数 - 模板要求N个片段时严格创建N个,素材不足时轮询同一素材 - order 从0连续递增(修复之前的 start_order bug) - 每个片段从素材随机选不重复时间段,used_segments 去重 - 素材时长不足5s时缩短clip duration - 向后兼容:不传 required_clips_count 时等于素材数量 - 7个单测覆盖:数量/轮询/顺序/时长缩短/start_time/used_segments/兼容 --- .../app/api/routes/templates_editor/clips.py | 39 ++- .../api/routes/templates_editor/schemas.py | 1 + tests/unit/test_editor_clips_random_start.py | 299 ++++++++++++------ 3 files changed, 223 insertions(+), 116 deletions(-) diff --git a/apps/api/app/api/routes/templates_editor/clips.py b/apps/api/app/api/routes/templates_editor/clips.py index 99a41e99d..d4a09b5e2 100755 --- a/apps/api/app/api/routes/templates_editor/clips.py +++ b/apps/api/app/api/routes/templates_editor/clips.py @@ -364,11 +364,20 @@ def create_clips_from_assets_editor( asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository), current_user: AuthenticatedUser = Depends(get_current_user), ) -> ClipsFromAssetsResponse: - """从素材批量创建片段(含随机起始时间 + 去重).""" + """从素材批量创建片段(支持同一素材切多个片段 + 随机起始时间 + 去重). + + 逻辑: + 1. 模板要求 N 个片段,必须创建 N 个(不管素材有几个) + 2. 素材数量 < 片段数量时,同一素材轮询切多个片段 + 3. 每个片段从素材中随机选取不重复时间段 + 4. 素材时长不足 5s 时缩短 clip duration + """ _, plan_svc = services - # 获取素材实际时长 _DEFAULT_CLIP_DURATION = 5.0 + required_count = body.required_clips_count if body.required_clips_count else len(body.asset_ids) + + # 获取素材实际时长 asset_durations: dict[str, float] = {} for asset_id in body.asset_ids: asset = asset_repo.get(asset_id) @@ -377,11 +386,14 @@ def create_clips_from_assets_editor( used_segments: dict[str, list[tuple[float, float]]] = {} clips = [] - for i, asset_id in enumerate(body.asset_ids): + + for i in range(required_count): + # 轮询分配素材:素材不够时同一素材切多个片段 + asset_id = body.asset_ids[i % len(body.asset_ids)] try: - # 根据素材实际时长确定 clip duration(素材不够长则缩短) - asset_total = asset_durations.get(asset_id) - if asset_total is not None and asset_total > 0: + # 素材时长不足时缩短 clip duration + asset_total = asset_durations.get(asset_id, 0.0) + if asset_total > 0: clip_duration = min(_DEFAULT_CLIP_DURATION, asset_total) else: clip_duration = _DEFAULT_CLIP_DURATION @@ -393,15 +405,15 @@ def create_clips_from_assets_editor( if start_time is None: start_time = 0.0 - # 记录已使用的时间段 - if asset_id not in used_segments: - used_segments[asset_id] = [] - used_segments[asset_id].append((start_time, start_time + clip_duration)) + # 记录已使用时间段(用于去重) + used_segments.setdefault(asset_id, []).append( + (start_time, start_time + clip_duration) + ) clip = plan_svc.create_clip( plan_id, - clip_type="main", - order=body.start_order + i if hasattr(body, "start_order") else i, + clip_type=body.clip_type or "main", + order=i, duration=clip_duration, start_time=start_time, asset_id=asset_id, @@ -411,9 +423,10 @@ def create_clips_from_assets_editor( pass logger.info( - "模板编辑器从素材创建片段: template_id=%s plan_id=%s count=%d by user=%s", + "模板编辑器从素材创建片段: template_id=%s plan_id=%s required=%d actual=%d by user=%s", template_id, plan_id, + required_count, len(clips), current_user.user.id, ) diff --git a/apps/api/app/api/routes/templates_editor/schemas.py b/apps/api/app/api/routes/templates_editor/schemas.py index 8135c9a5e..2403d22b5 100755 --- a/apps/api/app/api/routes/templates_editor/schemas.py +++ b/apps/api/app/api/routes/templates_editor/schemas.py @@ -167,6 +167,7 @@ class ClipsFromAssetsRequest(BaseModel): asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾") clip_type: str = Field(default="main", description="片段类型,默认 main") + required_clips_count: Optional[int] = Field(default=None, ge=1, le=200, description="要求创建的片段数量;不传则等于素材数量") class ClipsFromAssetsResponse(BaseModel): diff --git a/tests/unit/test_editor_clips_random_start.py b/tests/unit/test_editor_clips_random_start.py index e3478da80..317ac931f 100644 --- a/tests/unit/test_editor_clips_random_start.py +++ b/tests/unit/test_editor_clips_random_start.py @@ -1,10 +1,11 @@ -"""测试编辑器 from-assets 端点的随机起始时间 + 去重逻辑. +"""测试编辑器 from-assets 端点:同一素材切多个片段 + 随机起始 + 去重. 覆盖: -- 素材时长从数据库获取 -- 随机 start_time 计算 -- used_segments 去重 +- required_clips_count 精确控制片段数量 +- 素材不足时同一素材轮询切多个片段 +- 随机 start_time + used_segments 去重 - 素材时长不足时 clip duration 缩短 +- 向后兼容(不传 required_clips_count 时等于素材数量) """ from __future__ import annotations @@ -62,12 +63,19 @@ def _make_mock_asset(asset_id, duration): return asset -class TestEditorClipsRandomStartTime: - """测试 create_clips_from_assets_editor 随机起始时间逻辑.""" +def _create_clips(plan_id, clip_type, order, duration=0.0, start_time=0.0, asset_id="", **kw): + return _make_mock_clip( + clip_id=f"clip-{order}", order=order, duration=duration, + start_time=start_time, asset_id=asset_id + ) + + +class TestEditorClipsRequiredCount: + """测试 required_clips_count 控制片段数量 + 同素材多片段.""" @patch("app.api.routes.templates_editor.clips.get_storage_service") - def test_asset_durations_fetched_from_db(self, mock_storage): - """验证素材时长从数据库获取(不再硬编码 5.0).""" + def test_creates_exactly_required_clips_count(self, mock_storage): + """required_clips_count=4 时,即使只有2个素材也创建4个片段.""" from app.api.routes.templates_editor.clips import ( create_clips_from_assets_editor, ) @@ -75,22 +83,17 @@ class TestEditorClipsRandomStartTime: mock_plan_svc = MagicMock() mock_plan_svc.get_plan_or_raise = MagicMock() - mock_plan_svc.create_clip = MagicMock( - side_effect=lambda plan_id, clip_type, order, duration=0.0, start_time=0.0, asset_id="", **kw: _make_mock_clip( - clip_id=f"clip-{order}", order=order, duration=duration, start_time=start_time, asset_id=asset_id - ) - ) + mock_plan_svc.create_clip = MagicMock(side_effect=_create_clips) - # 构造 asset_repo mock:两个素材,时长分别为 30s 和 20s mock_asset_repo = MagicMock() mock_asset_repo.get = MagicMock( - side_effect=lambda aid: _make_mock_asset(aid, {"asset-1": 30.0, "asset-2": 20.0}[aid]) + side_effect=lambda aid: _make_mock_asset(aid, {"a1": 30.0, "a2": 20.0}[aid]) ) - body = ClipsFromAssetsRequest(asset_ids=["asset-1", "asset-2"]) + body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"], required_clips_count=4) result = create_clips_from_assets_editor( - template_id="tmpl-001", + template_id="tpl-001", body=body, plan_id=TEST_PLAN_ID, services=(MagicMock(), mock_plan_svc), @@ -98,20 +101,87 @@ class TestEditorClipsRandomStartTime: current_user=_make_auth_user(), ) - # 验证 asset_repo.get 被调用 - assert mock_asset_repo.get.call_count == 2 + assert result.created_count == 4 + assert mock_plan_svc.create_clip.call_count == 4 - # 验证 create_clip 被调用,且 duration 不再是硬编码 5.0 - assert mock_plan_svc.create_clip.call_count == 2 + # 验证轮询分配:a1, a2, a1, a2 calls = mock_plan_svc.create_clip.call_args_list - # 第一个素材: 30s > 5s, duration 应为 5.0 - assert calls[0].kwargs["duration"] == 5.0 or calls[0][1].get("duration") == 5.0 - # 第二个素材: 20s > 5s, duration 应为 5.0 - assert calls[1].kwargs["duration"] == 5.0 or calls[1][1].get("duration") == 5.0 + assert calls[0].kwargs["asset_id"] == "a1" + assert calls[1].kwargs["asset_id"] == "a2" + assert calls[2].kwargs["asset_id"] == "a1" + assert calls[3].kwargs["asset_id"] == "a2" + + @patch("app.api.routes.templates_editor.clips.get_storage_service") + def test_orders_are_sequential(self, mock_storage): + """order 必须从 0 开始连续递增(不能用 asset_idx 当 order).""" + from app.api.routes.templates_editor.clips import ( + create_clips_from_assets_editor, + ) + from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest + + mock_plan_svc = MagicMock() + mock_plan_svc.get_plan_or_raise = MagicMock() + mock_plan_svc.create_clip = MagicMock(side_effect=_create_clips) + + mock_asset_repo = MagicMock() + mock_asset_repo.get = MagicMock( + side_effect=lambda aid: _make_mock_asset(aid, 30.0) + ) + + body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=3) + + create_clips_from_assets_editor( + template_id="tpl-001", + body=body, + plan_id=TEST_PLAN_ID, + services=(MagicMock(), mock_plan_svc), + asset_repo=mock_asset_repo, + current_user=_make_auth_user(), + ) + + calls = mock_plan_svc.create_clip.call_args_list + assert calls[0].kwargs["order"] == 0 + assert calls[1].kwargs["order"] == 1 + assert calls[2].kwargs["order"] == 2 + + @patch("app.api.routes.templates_editor.clips.get_storage_service") + def test_backward_compatible_default_count(self, mock_storage): + """不传 required_clips_count 时,片段数等于素材数(向后兼容).""" + from app.api.routes.templates_editor.clips import ( + create_clips_from_assets_editor, + ) + from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest + + mock_plan_svc = MagicMock() + mock_plan_svc.get_plan_or_raise = MagicMock() + mock_plan_svc.create_clip = MagicMock(side_effect=_create_clips) + + mock_asset_repo = MagicMock() + mock_asset_repo.get = MagicMock( + side_effect=lambda aid: _make_mock_asset(aid, 30.0) + ) + + body = ClipsFromAssetsRequest(asset_ids=["a1", "a2", "a3"]) + + result = create_clips_from_assets_editor( + template_id="tpl-001", + body=body, + plan_id=TEST_PLAN_ID, + services=(MagicMock(), mock_plan_svc), + asset_repo=mock_asset_repo, + current_user=_make_auth_user(), + ) + + assert result.created_count == 3 + assert mock_plan_svc.create_clip.call_count == 3 + + +class TestEditorClipsDurationAndStartTime: + """测试素材时长获取、clip duration 缩短、start_time 传入.""" @patch("app.api.routes.templates_editor.clips.get_storage_service") def test_clip_duration_shortened_for_short_assets(self, mock_storage): - """素材时长不足 5s 时,clip duration 缩短为素材实际时长.""" + """素材只有 3s 时 clip duration 缩短到 3.0.""" from app.api.routes.templates_editor.clips import ( create_clips_from_assets_editor, ) @@ -119,22 +189,17 @@ class TestEditorClipsRandomStartTime: mock_plan_svc = MagicMock() mock_plan_svc.get_plan_or_raise = MagicMock() - mock_plan_svc.create_clip = MagicMock( - side_effect=lambda plan_id, clip_type, order, duration=0.0, start_time=0.0, asset_id="", **kw: _make_mock_clip( - clip_id=f"clip-{order}", order=order, duration=duration, start_time=start_time, asset_id=asset_id - ) - ) + mock_plan_svc.create_clip = MagicMock(side_effect=_create_clips) - # 素材时长只有 3s(< 5.0 默认值) mock_asset_repo = MagicMock() mock_asset_repo.get = MagicMock( - return_value=_make_mock_asset("asset-short", 3.0) + return_value=_make_mock_asset("short", 3.0) ) - body = ClipsFromAssetsRequest(asset_ids=["asset-short"]) + body = ClipsFromAssetsRequest(asset_ids=["short"]) - result = create_clips_from_assets_editor( - template_id="tmpl-001", + create_clips_from_assets_editor( + template_id="tpl-001", body=body, plan_id=TEST_PLAN_ID, services=(MagicMock(), mock_plan_svc), @@ -142,63 +207,13 @@ class TestEditorClipsRandomStartTime: current_user=_make_auth_user(), ) - # 验证 duration 被缩短到 3.0 calls = mock_plan_svc.create_clip.call_args_list assert len(calls) == 1 - assert calls[0].kwargs["duration"] == 3.0 or calls[0][1].get("duration") == 3.0 - - @patch("app.api.routes.templates_editor.clips.get_storage_service") - def test_used_segments_prevents_duplicate_ranges(self, mock_storage): - """相同素材被多次使用时,used_segments 应阻止时间段重叠.""" - from app.api.routes.templates_editor.clips import ( - create_clips_from_assets_editor, - ) - from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest - - mock_plan_svc = MagicMock() - mock_plan_svc.get_plan_or_raise = MagicMock() - - created_clips = [] - - def mock_create_clip(plan_id, clip_type, order, duration=0.0, start_time=0.0, asset_id="", **kw): - clip = _make_mock_clip( - clip_id=f"clip-{order}", order=order, duration=duration, - start_time=start_time, asset_id=asset_id - ) - created_clips.append(clip) - return clip - - mock_plan_svc.create_clip = MagicMock(side_effect=mock_create_clip) - - # 同一个素材(30s)被使用 3 次,每次 5s - mock_asset_repo = MagicMock() - mock_asset_repo.get = MagicMock( - return_value=_make_mock_asset("asset-same", 30.0) - ) - - body = ClipsFromAssetsRequest(asset_ids=["asset-same", "asset-same", "asset-same"]) - - result = create_clips_from_assets_editor( - template_id="tmpl-001", - body=body, - plan_id=TEST_PLAN_ID, - services=(MagicMock(), mock_plan_svc), - asset_repo=mock_asset_repo, - current_user=_make_auth_user(), - ) - - # 验证 3 个 clip 都创建了 - assert result.created_count == 3 - - # 验证 start_time 各不相同(去重生效) - start_times = [c.start_time for c in created_clips] - # 至少前两个应该不同(第三个也可能不同,取决于随机结果) - # 但我们不能保证 100% 不重叠(因为是随机的),只验证逻辑被调用了 - assert mock_plan_svc.create_clip.call_count == 3 + assert calls[0].kwargs["duration"] == 3.0 @patch("app.api.routes.templates_editor.clips.get_storage_service") def test_start_time_passed_to_create_clip(self, mock_storage): - """验证 start_time 被传入 create_clip(不再是固定 0.0).""" + """验证 _calc_random_start_time 返回值被传入 create_clip.""" from app.api.routes.templates_editor.clips import ( create_clips_from_assets_editor, ) @@ -206,22 +221,21 @@ class TestEditorClipsRandomStartTime: mock_plan_svc = MagicMock() mock_plan_svc.get_plan_or_raise = MagicMock() - mock_plan_svc.create_clip = MagicMock( - side_effect=lambda plan_id, clip_type, order, duration=0.0, start_time=0.0, asset_id="", **kw: _make_mock_clip( - clip_id=f"clip-{order}", order=order, duration=duration, start_time=start_time, asset_id=asset_id - ) - ) + mock_plan_svc.create_clip = MagicMock(side_effect=_create_clips) mock_asset_repo = MagicMock() mock_asset_repo.get = MagicMock( - return_value=_make_mock_asset("asset-1", 30.0) + return_value=_make_mock_asset("a1", 30.0) ) - body = ClipsFromAssetsRequest(asset_ids=["asset-1"]) + body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=2) - with patch("app.api.routes.templates_editor.clips._calc_random_start_time", return_value=12.5) as mock_calc: - result = create_clips_from_assets_editor( - template_id="tmpl-001", + with patch( + "app.api.routes.templates_editor.clips._calc_random_start_time", + side_effect=[12.5, 18.0], + ) as mock_calc: + create_clips_from_assets_editor( + template_id="tpl-001", body=body, plan_id=TEST_PLAN_ID, services=(MagicMock(), mock_plan_svc), @@ -229,10 +243,89 @@ class TestEditorClipsRandomStartTime: current_user=_make_auth_user(), ) - # 验证 _calc_random_start_time 被调用 - assert mock_calc.call_count == 1 + assert mock_calc.call_count == 2 - # 验证 start_time=12.5 被传入 create_clip calls = mock_plan_svc.create_clip.call_args_list - assert len(calls) == 1 - assert calls[0].kwargs["start_time"] == 12.5 or calls[0][1].get("start_time") == 12.5 + assert calls[0].kwargs["start_time"] == 12.5 + assert calls[1].kwargs["start_time"] == 18.0 + + @patch("app.api.routes.templates_editor.clips.get_storage_service") + def test_asset_durations_fetched_from_db(self, mock_storage): + """验证通过 asset_repo 获取素材实际时长.""" + from app.api.routes.templates_editor.clips import ( + create_clips_from_assets_editor, + ) + from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest + + mock_plan_svc = MagicMock() + mock_plan_svc.get_plan_or_raise = MagicMock() + mock_plan_svc.create_clip = MagicMock(side_effect=_create_clips) + + mock_asset_repo = MagicMock() + mock_asset_repo.get = MagicMock( + side_effect=lambda aid: _make_mock_asset(aid, {"a1": 30.0, "a2": 20.0}[aid]) + ) + + body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"]) + + create_clips_from_assets_editor( + template_id="tpl-001", + body=body, + plan_id=TEST_PLAN_ID, + services=(MagicMock(), mock_plan_svc), + asset_repo=mock_asset_repo, + current_user=_make_auth_user(), + ) + + # 每个素材只查一次(去重) + mock_asset_repo.get.assert_any_call("a1") + mock_asset_repo.get.assert_any_call("a2") + + @patch("app.api.routes.templates_editor.clips.get_storage_service") + def test_used_segments_passed_to_calc(self, mock_storage): + """同一素材切多个片段时,used_segments 应被维护并传入.""" + from app.api.routes.templates_editor.clips import ( + create_clips_from_assets_editor, + ) + from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest + + mock_plan_svc = MagicMock() + mock_plan_svc.get_plan_or_raise = MagicMock() + mock_plan_svc.create_clip = MagicMock(side_effect=_create_clips) + + mock_asset_repo = MagicMock() + mock_asset_repo.get = MagicMock( + return_value=_make_mock_asset("a1", 30.0) + ) + + body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=3) + + captured_used_segments = [] + + def fake_calc(asset_id, clip_duration, asset_durations, used_segments): + # 快照当前 used_segments + captured_used_segments.append( + {aid: list(segs) for aid, segs in (used_segments or {}).items()} + ) + # 返回固定值(确保不重叠) + return (len(captured_used_segments) - 1) * 5.0 + + with patch( + "app.api.routes.templates_editor.clips._calc_random_start_time", + side_effect=fake_calc, + ): + create_clips_from_assets_editor( + template_id="tpl-001", + body=body, + plan_id=TEST_PLAN_ID, + services=(MagicMock(), mock_plan_svc), + asset_repo=mock_asset_repo, + current_user=_make_auth_user(), + ) + + # 第一次调用时 used_segments 为空 + assert captured_used_segments[0] == {} + # 第二次调用时已有一个片段 [0, 5] + assert captured_used_segments[1] == {"a1": [(0.0, 5.0)]} + # 第三次调用时已有两个片段 [0, 5], [5, 10] + assert captured_used_segments[2] == {"a1": [(0.0, 5.0), (5.0, 10.0)]} -- 2.54.0 From ce635a0976c6f34d0958229f151d684a5ef71ba6 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Wed, 26 Aug 2026 13:38:45 +0000 Subject: [PATCH 3/5] style: auto-format with black + isort + prettier [skip ci-format-check] --- tests/unit/test_editor_clips_random_start.py | 35 +++++--------------- 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/tests/unit/test_editor_clips_random_start.py b/tests/unit/test_editor_clips_random_start.py index 317ac931f..e5c9d9ee5 100644 --- a/tests/unit/test_editor_clips_random_start.py +++ b/tests/unit/test_editor_clips_random_start.py @@ -65,8 +65,7 @@ def _make_mock_asset(asset_id, duration): def _create_clips(plan_id, clip_type, order, duration=0.0, start_time=0.0, asset_id="", **kw): return _make_mock_clip( - clip_id=f"clip-{order}", order=order, duration=duration, - start_time=start_time, asset_id=asset_id + clip_id=f"clip-{order}", order=order, duration=duration, start_time=start_time, asset_id=asset_id ) @@ -86,9 +85,7 @@ class TestEditorClipsRequiredCount: mock_plan_svc.create_clip = MagicMock(side_effect=_create_clips) mock_asset_repo = MagicMock() - mock_asset_repo.get = MagicMock( - side_effect=lambda aid: _make_mock_asset(aid, {"a1": 30.0, "a2": 20.0}[aid]) - ) + mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, {"a1": 30.0, "a2": 20.0}[aid])) body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"], required_clips_count=4) @@ -124,9 +121,7 @@ class TestEditorClipsRequiredCount: mock_plan_svc.create_clip = MagicMock(side_effect=_create_clips) mock_asset_repo = MagicMock() - mock_asset_repo.get = MagicMock( - side_effect=lambda aid: _make_mock_asset(aid, 30.0) - ) + mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, 30.0)) body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=3) @@ -157,9 +152,7 @@ class TestEditorClipsRequiredCount: mock_plan_svc.create_clip = MagicMock(side_effect=_create_clips) mock_asset_repo = MagicMock() - mock_asset_repo.get = MagicMock( - side_effect=lambda aid: _make_mock_asset(aid, 30.0) - ) + mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, 30.0)) body = ClipsFromAssetsRequest(asset_ids=["a1", "a2", "a3"]) @@ -192,9 +185,7 @@ class TestEditorClipsDurationAndStartTime: mock_plan_svc.create_clip = MagicMock(side_effect=_create_clips) mock_asset_repo = MagicMock() - mock_asset_repo.get = MagicMock( - return_value=_make_mock_asset("short", 3.0) - ) + mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("short", 3.0)) body = ClipsFromAssetsRequest(asset_ids=["short"]) @@ -224,9 +215,7 @@ class TestEditorClipsDurationAndStartTime: mock_plan_svc.create_clip = MagicMock(side_effect=_create_clips) mock_asset_repo = MagicMock() - mock_asset_repo.get = MagicMock( - return_value=_make_mock_asset("a1", 30.0) - ) + mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0)) body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=2) @@ -262,9 +251,7 @@ class TestEditorClipsDurationAndStartTime: mock_plan_svc.create_clip = MagicMock(side_effect=_create_clips) mock_asset_repo = MagicMock() - mock_asset_repo.get = MagicMock( - side_effect=lambda aid: _make_mock_asset(aid, {"a1": 30.0, "a2": 20.0}[aid]) - ) + mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, {"a1": 30.0, "a2": 20.0}[aid])) body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"]) @@ -294,9 +281,7 @@ class TestEditorClipsDurationAndStartTime: mock_plan_svc.create_clip = MagicMock(side_effect=_create_clips) mock_asset_repo = MagicMock() - mock_asset_repo.get = MagicMock( - return_value=_make_mock_asset("a1", 30.0) - ) + mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0)) body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=3) @@ -304,9 +289,7 @@ class TestEditorClipsDurationAndStartTime: def fake_calc(asset_id, clip_duration, asset_durations, used_segments): # 快照当前 used_segments - captured_used_segments.append( - {aid: list(segs) for aid, segs in (used_segments or {}).items()} - ) + captured_used_segments.append({aid: list(segs) for aid, segs in (used_segments or {}).items()}) # 返回固定值(确保不重叠) return (len(captured_used_segments) - 1) * 5.0 -- 2.54.0 From dc3ce8f114038a8f23e42c39b130c91e7ef619ee Mon Sep 17 00:00:00 2001 From: CI Bot Date: Wed, 26 Aug 2026 21:59:58 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(editor):=20=E4=BF=AE=E5=A4=8DAI=20Revie?= =?UTF-8?q?w=203=E4=B8=AA=E9=98=BB=E5=A1=9E=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. order追加到时间线末尾:查询现有clips最大order+1,不再从0开始覆盖 2. start_time=None时抛HTTPException 400(素材时长缺失),不再强制0.0导致重叠 3. except ValueError记录logger.error并抛400,不再裸except吞异常 4. asset_ids去重后批量查询时长,避免重复DB查询 5. 测试增至10个:新增order追加/空时间线/400异常/去重场景 --- .../app/api/routes/templates_editor/clips.py | 79 ++++---- tests/unit/test_editor_clips_random_start.py | 172 +++++++++++++----- 2 files changed, 172 insertions(+), 79 deletions(-) diff --git a/apps/api/app/api/routes/templates_editor/clips.py b/apps/api/app/api/routes/templates_editor/clips.py index d4a09b5e2..41a05f6f1 100755 --- a/apps/api/app/api/routes/templates_editor/clips.py +++ b/apps/api/app/api/routes/templates_editor/clips.py @@ -156,10 +156,7 @@ def list_draft_clips( url_map = _build_asset_url_map(asset_ids, asset_repo) return EditorClipListResponse( - items=[ - _clip_to_response(c, asset_url=url_map.get(getattr(c, "asset_id", "") or "")) - for c in clips - ], + items=[_clip_to_response(c, asset_url=url_map.get(getattr(c, "asset_id", "") or "")) for c in clips], total=total, ) @@ -273,9 +270,7 @@ def split_draft_clip( try: result = plan_svc.split_clip(clip_id, body.split_time) except ValueError as exc: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc) - ) from exc + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc left = result["left_clip"] right = result["right_clip"] asset_ids = [getattr(left, "asset_id", "") or "", getattr(right, "asset_id", "") or ""] @@ -305,9 +300,7 @@ def merge_draft_clips( try: merged = plan_svc.merge_clips(body.clip_ids) except ValueError as exc: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc) - ) from exc + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc asset_id = getattr(merged, "asset_id", "") or "" url_map = _build_asset_url_map([asset_id], asset_repo) if asset_id else {} return { @@ -371,56 +364,74 @@ def create_clips_from_assets_editor( 2. 素材数量 < 片段数量时,同一素材轮询切多个片段 3. 每个片段从素材中随机选取不重复时间段 4. 素材时长不足 5s 时缩短 clip duration + 5. 新片段追加到时间线末尾(order 在现有最大值基础上递增) """ _, plan_svc = services _DEFAULT_CLIP_DURATION = 5.0 required_count = body.required_clips_count if body.required_clips_count else len(body.asset_ids) - # 获取素材实际时长 + # 去重后批量获取素材实际时长,避免重复查询 + unique_asset_ids = list(dict.fromkeys(body.asset_ids)) asset_durations: dict[str, float] = {} - for asset_id in body.asset_ids: + for asset_id in unique_asset_ids: asset = asset_repo.get(asset_id) if asset and hasattr(asset, "duration"): asset_durations[asset_id] = float(asset.duration or 0.0) + # 计算追加起始 order:当前 plan 已有片段的最大 order + 1 + existing_clips = plan_svc.list_clips(plan_id) + next_order = max((c.order for c in existing_clips), default=-1) + 1 + used_segments: dict[str, list[tuple[float, float]]] = {} clips = [] for i in range(required_count): # 轮询分配素材:素材不够时同一素材切多个片段 asset_id = body.asset_ids[i % len(body.asset_ids)] + + # 素材时长不足时缩短 clip duration + asset_total = asset_durations.get(asset_id, 0.0) + if asset_total > 0: + clip_duration = min(_DEFAULT_CLIP_DURATION, asset_total) + else: + clip_duration = _DEFAULT_CLIP_DURATION + + # 计算随机 start_time,避开已使用的时间段 + start_time = _calc_random_start_time(asset_id, clip_duration, asset_durations, used_segments) + if start_time is None: + # 素材时长信息缺失,无法计算随机起始时间 + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"素材 {asset_id} 时长信息缺失,无法创建片段", + ) + + # 记录已使用时间段(用于去重) + used_segments.setdefault(asset_id, []).append((start_time, start_time + clip_duration)) + try: - # 素材时长不足时缩短 clip duration - asset_total = asset_durations.get(asset_id, 0.0) - if asset_total > 0: - clip_duration = min(_DEFAULT_CLIP_DURATION, asset_total) - else: - clip_duration = _DEFAULT_CLIP_DURATION - - # 计算随机 start_time,避开已使用的时间段 - start_time = _calc_random_start_time( - asset_id, clip_duration, asset_durations, used_segments - ) - if start_time is None: - start_time = 0.0 - - # 记录已使用时间段(用于去重) - used_segments.setdefault(asset_id, []).append( - (start_time, start_time + clip_duration) - ) - clip = plan_svc.create_clip( plan_id, clip_type=body.clip_type or "main", - order=i, + order=next_order + i, duration=clip_duration, start_time=start_time, asset_id=asset_id, ) clips.append(clip) - except ValueError: - pass + except ValueError as exc: + logger.error( + "创建片段失败: plan_id=%s asset_id=%s order=%d error=%s", + plan_id, + asset_id, + next_order + i, + exc, + exc_info=True, + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"创建片段失败: {exc}", + ) from exc logger.info( "模板编辑器从素材创建片段: template_id=%s plan_id=%s required=%d actual=%d by user=%s", diff --git a/tests/unit/test_editor_clips_random_start.py b/tests/unit/test_editor_clips_random_start.py index e5c9d9ee5..e2336ef72 100644 --- a/tests/unit/test_editor_clips_random_start.py +++ b/tests/unit/test_editor_clips_random_start.py @@ -6,6 +6,9 @@ - 随机 start_time + used_segments 去重 - 素材时长不足时 clip duration 缩短 - 向后兼容(不传 required_clips_count 时等于素材数量) +- order 追加到时间线末尾 +- start_time=None 时抛出 400 +- create_clip 失败时抛出 400 并记录日志 """ from __future__ import annotations @@ -13,7 +16,7 @@ from __future__ import annotations import os import sys from pathlib import Path -from unittest.mock import MagicMock, call, patch +from unittest.mock import MagicMock, patch os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") @@ -21,6 +24,7 @@ os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) import pytest +from fastapi import HTTPException TEST_PLAN_ID = "plan-draft-001" TEST_USER_ID = "user-001" @@ -65,10 +69,22 @@ def _make_mock_asset(asset_id, duration): def _create_clips(plan_id, clip_type, order, duration=0.0, start_time=0.0, asset_id="", **kw): return _make_mock_clip( - clip_id=f"clip-{order}", order=order, duration=duration, start_time=start_time, asset_id=asset_id + clip_id=f"clip-{order}", + order=order, + duration=duration, + start_time=start_time, + asset_id=asset_id, ) +def _make_plan_svc(existing_clips=None): + svc = MagicMock() + svc.get_plan_or_raise = MagicMock() + svc.create_clip = MagicMock(side_effect=_create_clips) + svc.list_clips = MagicMock(return_value=existing_clips or []) + return svc + + class TestEditorClipsRequiredCount: """测试 required_clips_count 控制片段数量 + 同素材多片段.""" @@ -80,10 +96,7 @@ class TestEditorClipsRequiredCount: ) from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest - mock_plan_svc = MagicMock() - mock_plan_svc.get_plan_or_raise = MagicMock() - mock_plan_svc.create_clip = MagicMock(side_effect=_create_clips) - + mock_plan_svc = _make_plan_svc() mock_asset_repo = MagicMock() mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, {"a1": 30.0, "a2": 20.0}[aid])) @@ -109,19 +122,48 @@ class TestEditorClipsRequiredCount: assert calls[3].kwargs["asset_id"] == "a2" @patch("app.api.routes.templates_editor.clips.get_storage_service") - def test_orders_are_sequential(self, mock_storage): - """order 必须从 0 开始连续递增(不能用 asset_idx 当 order).""" + def test_orders_append_to_existing_timeline(self, mock_storage): + """时间线已有2个片段时,新片段 order 应从 2 开始连续递增.""" from app.api.routes.templates_editor.clips import ( create_clips_from_assets_editor, ) from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest - mock_plan_svc = MagicMock() - mock_plan_svc.get_plan_or_raise = MagicMock() - mock_plan_svc.create_clip = MagicMock(side_effect=_create_clips) + # 模拟已有 order=0, order=1 的片段 + existing = [_make_mock_clip("old-1", 0, 5.0), _make_mock_clip("old-2", 1, 5.0)] + mock_plan_svc = _make_plan_svc(existing_clips=existing) mock_asset_repo = MagicMock() - mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, 30.0)) + mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0)) + + body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=3) + + create_clips_from_assets_editor( + template_id="tpl-001", + body=body, + plan_id=TEST_PLAN_ID, + services=(MagicMock(), mock_plan_svc), + asset_repo=mock_asset_repo, + current_user=_make_auth_user(), + ) + + calls = mock_plan_svc.create_clip.call_args_list + assert calls[0].kwargs["order"] == 2 + assert calls[1].kwargs["order"] == 3 + assert calls[2].kwargs["order"] == 4 + + @patch("app.api.routes.templates_editor.clips.get_storage_service") + def test_orders_start_at_zero_when_empty(self, mock_storage): + """空时间线时 order 从 0 开始.""" + from app.api.routes.templates_editor.clips import ( + create_clips_from_assets_editor, + ) + from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest + + mock_plan_svc = _make_plan_svc(existing_clips=[]) + + mock_asset_repo = MagicMock() + mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0)) body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=3) @@ -147,10 +189,7 @@ class TestEditorClipsRequiredCount: ) from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest - mock_plan_svc = MagicMock() - mock_plan_svc.get_plan_or_raise = MagicMock() - mock_plan_svc.create_clip = MagicMock(side_effect=_create_clips) - + mock_plan_svc = _make_plan_svc() mock_asset_repo = MagicMock() mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, 30.0)) @@ -180,10 +219,7 @@ class TestEditorClipsDurationAndStartTime: ) from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest - mock_plan_svc = MagicMock() - mock_plan_svc.get_plan_or_raise = MagicMock() - mock_plan_svc.create_clip = MagicMock(side_effect=_create_clips) - + mock_plan_svc = _make_plan_svc() mock_asset_repo = MagicMock() mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("short", 3.0)) @@ -210,10 +246,7 @@ class TestEditorClipsDurationAndStartTime: ) from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest - mock_plan_svc = MagicMock() - mock_plan_svc.get_plan_or_raise = MagicMock() - mock_plan_svc.create_clip = MagicMock(side_effect=_create_clips) - + mock_plan_svc = _make_plan_svc() mock_asset_repo = MagicMock() mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0)) @@ -233,27 +266,24 @@ class TestEditorClipsDurationAndStartTime: ) assert mock_calc.call_count == 2 - calls = mock_plan_svc.create_clip.call_args_list assert calls[0].kwargs["start_time"] == 12.5 assert calls[1].kwargs["start_time"] == 18.0 @patch("app.api.routes.templates_editor.clips.get_storage_service") - def test_asset_durations_fetched_from_db(self, mock_storage): - """验证通过 asset_repo 获取素材实际时长.""" + def test_asset_durations_deduped(self, mock_storage): + """asset_ids 有重复时只查询一次素材时长.""" from app.api.routes.templates_editor.clips import ( create_clips_from_assets_editor, ) from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest - mock_plan_svc = MagicMock() - mock_plan_svc.get_plan_or_raise = MagicMock() - mock_plan_svc.create_clip = MagicMock(side_effect=_create_clips) - + mock_plan_svc = _make_plan_svc() mock_asset_repo = MagicMock() - mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, {"a1": 30.0, "a2": 20.0}[aid])) + mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0)) - body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"]) + # a1 出现 3 次,但时长只应查一次 + body = ClipsFromAssetsRequest(asset_ids=["a1", "a1", "a1"]) create_clips_from_assets_editor( template_id="tpl-001", @@ -264,9 +294,8 @@ class TestEditorClipsDurationAndStartTime: current_user=_make_auth_user(), ) - # 每个素材只查一次(去重) - mock_asset_repo.get.assert_any_call("a1") - mock_asset_repo.get.assert_any_call("a2") + # 去重后只调用 1 次 + assert mock_asset_repo.get.call_count == 1 @patch("app.api.routes.templates_editor.clips.get_storage_service") def test_used_segments_passed_to_calc(self, mock_storage): @@ -276,10 +305,7 @@ class TestEditorClipsDurationAndStartTime: ) from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest - mock_plan_svc = MagicMock() - mock_plan_svc.get_plan_or_raise = MagicMock() - mock_plan_svc.create_clip = MagicMock(side_effect=_create_clips) - + mock_plan_svc = _make_plan_svc() mock_asset_repo = MagicMock() mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0)) @@ -288,9 +314,7 @@ class TestEditorClipsDurationAndStartTime: captured_used_segments = [] def fake_calc(asset_id, clip_duration, asset_durations, used_segments): - # 快照当前 used_segments captured_used_segments.append({aid: list(segs) for aid, segs in (used_segments or {}).items()}) - # 返回固定值(确保不重叠) return (len(captured_used_segments) - 1) * 5.0 with patch( @@ -306,9 +330,67 @@ class TestEditorClipsDurationAndStartTime: current_user=_make_auth_user(), ) - # 第一次调用时 used_segments 为空 assert captured_used_segments[0] == {} - # 第二次调用时已有一个片段 [0, 5] assert captured_used_segments[1] == {"a1": [(0.0, 5.0)]} - # 第三次调用时已有两个片段 [0, 5], [5, 10] assert captured_used_segments[2] == {"a1": [(0.0, 5.0), (5.0, 10.0)]} + + +class TestEditorClipsErrorHandling: + """测试异常处理.""" + + @patch("app.api.routes.templates_editor.clips.get_storage_service") + def test_none_start_time_raises_400(self, mock_storage): + """_calc_random_start_time 返回 None 时应抛出 HTTPException 400.""" + from app.api.routes.templates_editor.clips import ( + create_clips_from_assets_editor, + ) + from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest + + mock_plan_svc = _make_plan_svc() + # asset_repo.get 返回 None → asset_durations 为空 → _calc_random_start_time 返回 None + mock_asset_repo = MagicMock() + mock_asset_repo.get = MagicMock(return_value=None) + + body = ClipsFromAssetsRequest(asset_ids=["missing-asset"]) + + with pytest.raises(HTTPException) as exc_info: + create_clips_from_assets_editor( + template_id="tpl-001", + body=body, + plan_id=TEST_PLAN_ID, + services=(MagicMock(), mock_plan_svc), + asset_repo=mock_asset_repo, + current_user=_make_auth_user(), + ) + + assert exc_info.value.status_code == 400 + assert "时长信息缺失" in exc_info.value.detail + + @patch("app.api.routes.templates_editor.clips.get_storage_service") + def test_create_clip_value_error_raises_400(self, mock_storage): + """create_clip 抛出 ValueError 时应转为 HTTPException 400.""" + from app.api.routes.templates_editor.clips import ( + create_clips_from_assets_editor, + ) + from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest + + mock_plan_svc = _make_plan_svc() + mock_plan_svc.create_clip = MagicMock(side_effect=ValueError("计划不存在")) + + mock_asset_repo = MagicMock() + mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0)) + + body = ClipsFromAssetsRequest(asset_ids=["a1"]) + + with pytest.raises(HTTPException) as exc_info: + create_clips_from_assets_editor( + template_id="tpl-001", + body=body, + plan_id=TEST_PLAN_ID, + services=(MagicMock(), mock_plan_svc), + asset_repo=mock_asset_repo, + current_user=_make_auth_user(), + ) + + assert exc_info.value.status_code == 400 + assert "创建片段失败" in exc_info.value.detail -- 2.54.0 From 46fc84902e47ba2c9b0f2cc103e4dad93e3e1174 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Wed, 26 Aug 2026 22:06:17 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(editor):=20=E5=93=8D=E5=BA=94AI=20Revie?= =?UTF-8?q?w=EF=BC=9B=E9=BB=98=E8=AE=A4=E6=97=B6=E9=95=BF=E6=8F=90?= =?UTF-8?q?=E4=B8=BA=E6=A8=A1=E5=9D=97=E5=B8=B8=E9=87=8F=EF=BC=9Blist=5Fcl?= =?UTF-8?q?ips=E6=98=BE=E5=BC=8FList=E7=B1=BB=E5=9E=8B=E6=A0=87=E6=B3=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/app/api/routes/templates_editor/clips.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/api/app/api/routes/templates_editor/clips.py b/apps/api/app/api/routes/templates_editor/clips.py index 41a05f6f1..515e3eaa4 100755 --- a/apps/api/app/api/routes/templates_editor/clips.py +++ b/apps/api/app/api/routes/templates_editor/clips.py @@ -46,6 +46,9 @@ from .schemas import ( logger = logging.getLogger(__name__) router = APIRouter(tags=["Template Editor"]) +# 编辑器默认片段时长(秒) +_DEFAULT_EDITOR_CLIP_DURATION = 5.0 + def _clip_to_response(clip, asset_url: str | None = None) -> EditorClipResponse: """统一构造片段响应 — 与 edit_plan_clips 表字段完全对齐""" @@ -368,8 +371,7 @@ def create_clips_from_assets_editor( """ _, plan_svc = services - _DEFAULT_CLIP_DURATION = 5.0 - required_count = body.required_clips_count if body.required_clips_count else len(body.asset_ids) + required_count = body.required_clips_count if body.required_clips_count is not None else len(body.asset_ids) # 去重后批量获取素材实际时长,避免重复查询 unique_asset_ids = list(dict.fromkeys(body.asset_ids)) @@ -380,8 +382,9 @@ def create_clips_from_assets_editor( asset_durations[asset_id] = float(asset.duration or 0.0) # 计算追加起始 order:当前 plan 已有片段的最大 order + 1 - existing_clips = plan_svc.list_clips(plan_id) - next_order = max((c.order for c in existing_clips), default=-1) + 1 + # list_clips 返回 List[EditPlanClip] + existing_clips_list = plan_svc.list_clips(plan_id) + next_order = max((c.order for c in existing_clips_list), default=-1) + 1 used_segments: dict[str, list[tuple[float, float]]] = {} clips = [] @@ -393,9 +396,9 @@ def create_clips_from_assets_editor( # 素材时长不足时缩短 clip duration asset_total = asset_durations.get(asset_id, 0.0) if asset_total > 0: - clip_duration = min(_DEFAULT_CLIP_DURATION, asset_total) + clip_duration = min(_DEFAULT_EDITOR_CLIP_DURATION, asset_total) else: - clip_duration = _DEFAULT_CLIP_DURATION + clip_duration = _DEFAULT_EDITOR_CLIP_DURATION # 计算随机 start_time,避开已使用的时间段 start_time = _calc_random_start_time(asset_id, clip_duration, asset_durations, used_segments) -- 2.54.0