From ef897cba78489c2d99a8a48848d9f526da925c62 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 16 Jul 2026 10:38:21 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E5=AD=97=E5=B9=95=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20-=20=E5=A2=9E=E5=88=A0=E6=94=B9=E6=9F=A5=20+=20?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=AF=BC=E5=85=A5=EF=BC=88=E5=9F=BA=E4=BA=8E?= =?UTF-8?q?=E7=89=87=E6=AE=B5config=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EditPlanService 字幕管理 6 个方法: - list_subtitles / get_subtitle - add_subtitle / update_subtitle / delete_subtitle - batch_update_subtitles(全量替换,支持SRT/ASR导入) - 字幕存储在 clip.config["subtitles"] 中,无需 migration - 每条字幕: id / start / end / text / style - 自动按时间排序、校验时间合法性 - API: 5个接口(列表/新增/更新/删除/批量更新) - 项目级权限校验 - 16个单元测试全覆盖 --- .coverage | Bin 0 -> 53248 bytes apps/api/app/api/routes/edit_plans.py | 236 +++++++++++++++++++++ apps/api/app/services/edit_plan_service.py | 231 ++++++++++++++++++++ tests/unit/test_edit_plan_service.py | 224 +++++++++++++++++++ 4 files changed, 691 insertions(+) create mode 100644 .coverage diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..240a4c29d9d8d99ca161ea13e0ea58607030d2a2 GIT binary patch literal 53248 zcmeI%TW{h<6bEp;*~JMVN~$PAX&y2!6+sJGp}zF;(57vqZo5*uZF+esJD367Vw>0| zmxroiyVCYU^h@>|^qd(F#tE$UZ6)iE0`b^0<2k>%f&An1XMU>0Fp2}2iVrJ$700Q3 zE<~kLap}E7Z~Nl(Lz|%g&eHL^L$`AJ%Qri_KUeD2Un{%6?YzI+sgLUaZU0;UXzQnK z-)(Q5(gnOA009U}0H&mT~!RE?;L zY3v6wz7>C{+qTY--7Qr&sX0NFD1Q`k4sntdZ;4n9Rjfi!C8iZ8{-9NB{ry+XZS3zm zua%BT#vc7`7owpPc_>eZRFN0O18#jkmZ3LRNn6N8gkD7Lyh%;8aTGjBBQfzq9cGyL zsUL+x-6(ICs=>+t+l*#kgtr_G)%Ah{dSOat_gd1)3$G}dWzPx^sc~UsS@VK?YbvbU z9L|D1k^epmrrbGH!rVBG)|FgWHjT1i-@0#B^kp7zp*t-(_oyi~3;oNPT6BI}{9czn>aX70bQ{OV&g)A(P?)FJ z_tRu?uvHi|i_>d{4qsnCZg`dy2MN!j$wWj$J!QDGFr7oAd3nw}u^2Mui6){fBenFM zjy7&AwL$bJQNK9W^oU6mO%PfOY_{Pn^mwWy9D^5KsaTHGX?E2t3-5X$(a`B`RU7BW z>xRx^2=uy(!`l3?>Nbv!oR?dA7-hYc{c{UL$oAxlWz=2@luTx^T}a-vNZJzlDrx6P zc7?*}xYfql(YkQ5e7$aVusL@hx{ZSaXKrQ$uS@)XR0x94fyM5v%>(xgmiir%TyR+?Sq70cFtIwcdXHIM@{-MLeiYP=q+d)1lU z=qs73)DM*4EV?mzU|q{Zc(J03*+bv`W)gq~*cI;7f*_G00Izz00bZa0SG_<0uX?}3IY87Ux9*C5P$## zAOHafKmY;|fB*y_0D+PT)HohI|1VjuA{+=n00Izz00bZa0SG_<0uX>eQ2@{XiylZp z00Izz00bZa0SG_<0uX=z1j;CY=l^BwRm21V2tWV=5P$##AOHafKmY;|C<@^Df6)Ue z2tWV=5P$##AOHafKmY;|fIt}q@ch4wy^5G1009U<00Izz00bZa0SG_<0!0Bl|1WwV z1px>^00Izz00bZa0SG_<0uU&p0G|Jsu~!ij1Rwwb2tWV=5P$##AOHafK%gjq=l?|y zq#ytR2tWV=5P$##AOHafKmY<|6u|TUGWIHBf&c^{009U<00Izz00bZa0SFWY@ch5% bffNKF009U<00Izz00bZa0SG{#i~|1yt&b7T literal 0 HcmV?d00001 diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py index 0bf998d78..017a5d59c 100755 --- a/apps/api/app/api/routes/edit_plans.py +++ b/apps/api/app/api/routes/edit_plans.py @@ -518,6 +518,242 @@ def copy_plan( current_user.user.id, ) return _to_response(new_plan) +# ── 字幕管理 ──────────────────────────────────────────────────────────────── + + +class SubtitleCreateRequest(BaseModel): + """添加字幕请求体""" + + start: float = Field(..., ge=0, description="开始时间(秒)") + end: float = Field(..., gt=0, description="结束时间(秒)") + text: str = Field(..., min_length=1, max_length=500, description="字幕文本") + style: Optional[dict[str, Any]] = Field(default=None, description="字幕样式") + + +class SubtitleUpdateRequest(BaseModel): + """更新字幕请求体""" + + start: Optional[float] = Field(default=None, ge=0, description="开始时间(秒)") + end: Optional[float] = Field(default=None, gt=0, description="结束时间(秒)") + text: Optional[str] = Field(default=None, min_length=1, max_length=500, description="字幕文本") + style: Optional[dict[str, Any]] = Field(default=None, description="字幕样式") + + +class SubtitleBatchUpdateRequest(BaseModel): + """批量更新字幕请求体""" + + subtitles: list[dict[str, Any]] = Field( + default_factory=list, + description="字幕列表(全量替换),每条包含 start/end/text,可选 id/style", + ) + + +@router.get( + "/clips/{clip_id}/subtitles", + response_model=list[dict[str, Any]], + summary="获取片段的所有字幕", +) +def list_subtitles( + clip_id: str, + current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), + project_repo=Depends(get_project_repository), +) -> list[dict[str, Any]]: + """获取指定片段的所有字幕,按时间排序。""" + service = EditPlanService(db) + + clip = service.get_clip(clip_id) + if clip is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"片段不存在: {clip_id}", + ) + plan = service.get_plan(clip.plan_id) + if plan and plan.project_id: + check_project_access(project_repo, current_user, plan.project_id) + + return service.list_subtitles(clip_id) + + +@router.post( + "/clips/{clip_id}/subtitles", + response_model=dict[str, Any], + summary="添加一条字幕", + status_code=status.HTTP_201_CREATED, +) +def add_subtitle( + clip_id: str, + body: SubtitleCreateRequest, + current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), + project_repo=Depends(get_project_repository), +) -> dict[str, Any]: + """给片段添加一条字幕。""" + service = EditPlanService(db) + + clip = service.get_clip(clip_id) + if clip is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"片段不存在: {clip_id}", + ) + plan = service.get_plan(clip.plan_id) + if plan and plan.project_id: + check_project_access(project_repo, current_user, plan.project_id) + + try: + subtitle = service.add_subtitle( + clip_id, + start=body.start, + end=body.end, + text=body.text, + style=body.style, + ) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + + logger.info("添加字幕: clip_id=%s by user=%s", clip_id, current_user.user.id) + return subtitle + + +@router.put( + "/clips/{clip_id}/subtitles/{subtitle_id}", + response_model=dict[str, Any], + summary="更新一条字幕", +) +def update_subtitle( + clip_id: str, + subtitle_id: str, + body: SubtitleUpdateRequest, + current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), + project_repo=Depends(get_project_repository), +) -> dict[str, Any]: + """更新一条字幕的时间、文本或样式。""" + service = EditPlanService(db) + + clip = service.get_clip(clip_id) + if clip is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"片段不存在: {clip_id}", + ) + plan = service.get_plan(clip.plan_id) + if plan and plan.project_id: + check_project_access(project_repo, current_user, plan.project_id) + + try: + subtitle = service.update_subtitle( + clip_id, + subtitle_id, + start=body.start, + end=body.end, + text=body.text, + style=body.style, + ) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + + logger.info( + "更新字幕: clip_id=%s subtitle_id=%s by user=%s", + clip_id, + subtitle_id, + current_user.user.id, + ) + return subtitle + + +@router.delete( + "/clips/{clip_id}/subtitles/{subtitle_id}", + summary="删除一条字幕", + status_code=status.HTTP_204_NO_CONTENT, +) +def delete_subtitle( + clip_id: str, + subtitle_id: str, + current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), + project_repo=Depends(get_project_repository), +) -> None: + """删除一条字幕。""" + service = EditPlanService(db) + + clip = service.get_clip(clip_id) + if clip is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"片段不存在: {clip_id}", + ) + plan = service.get_plan(clip.plan_id) + if plan and plan.project_id: + check_project_access(project_repo, current_user, plan.project_id) + + deleted = service.delete_subtitle(clip_id, subtitle_id) + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"字幕不存在: {subtitle_id}", + ) + + logger.info( + "删除字幕: clip_id=%s subtitle_id=%s by user=%s", + clip_id, + subtitle_id, + current_user.user.id, + ) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.put( + "/clips/{clip_id}/subtitles", + response_model=list[dict[str, Any]], + summary="批量更新字幕(全量替换)", +) +def batch_update_subtitles( + clip_id: str, + body: SubtitleBatchUpdateRequest, + current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), + project_repo=Depends(get_project_repository), +) -> list[dict[str, Any]]: + """批量更新片段的所有字幕(全量替换)。 + + 用于批量编辑、SRT导入、ASR结果导入等场景。 + 每条字幕包含 start/end/text,已有 id 则保留,否则生成新 id。 + """ + service = EditPlanService(db) + + clip = service.get_clip(clip_id) + if clip is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"片段不存在: {clip_id}", + ) + plan = service.get_plan(clip.plan_id) + if plan and plan.project_id: + check_project_access(project_repo, current_user, plan.project_id) + + try: + subtitles = service.batch_update_subtitles(clip_id, body.subtitles) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + + logger.info( + "批量更新字幕: clip_id=%s count=%d by user=%s", + clip_id, + len(subtitles), + current_user.user.id, + ) + return subtitles # ── BGM 背景音乐 ─────────────────────────────────────────────────────────── diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py index 30c95e54e..c4f331d79 100755 --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -674,6 +674,237 @@ class EditPlanService: return merged_clip + # ── 字幕管理 ────────────────────────────────────────────────────────── + + def list_subtitles(self, clip_id: str) -> List[Dict[str, Any]]: + """获取片段的所有字幕 + + Returns: + List[dict]: 字幕列表,按 start 时间排序 + """ + clip = self.get_clip_or_raise(clip_id) + config = clip.config or {} + subtitles = config.get("subtitles", []) + # 按开始时间排序 + subtitles.sort(key=lambda s: s.get("start", 0)) + return subtitles + + def get_subtitle(self, clip_id: str, subtitle_id: str) -> Optional[Dict[str, Any]]: + """获取单条字幕""" + subtitles = self.list_subtitles(clip_id) + for s in subtitles: + if s.get("id") == subtitle_id: + return s + return None + + def add_subtitle( + self, + clip_id: str, + start: float, + end: float, + text: str, + *, + style: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """添加一条字幕 + + Args: + clip_id: 片段 ID + start: 开始时间(秒,相对于片段) + end: 结束时间(秒) + text: 字幕文本 + style: 样式配置(字体、大小、颜色、位置等) + + Returns: + dict: 新增的字幕条目 + + Raises: + ValueError: 时间非法或文本为空 + """ + clip = self.get_clip_or_raise(clip_id) + + if start < 0 or end <= start: + raise ValueError(f"字幕时间非法: start={start}, end={end}") + if not text.strip(): + raise ValueError("字幕文本不能为空") + if end > clip.duration + 0.001: + raise ValueError(f"字幕结束时间不能超过片段时长: end={end:.3f}, duration={clip.duration:.3f}") + + self._auto_resume_editing(clip.plan_id) + + from uuid import uuid4 + + config = dict(clip.config) if clip.config else {} + subtitles = list(config.get("subtitles", [])) + + subtitle = { + "id": uuid4().hex, + "start": round(start, 3), + "end": round(end, 3), + "text": text.strip(), + "style": style or {}, + } + subtitles.append(subtitle) + subtitles.sort(key=lambda s: s.get("start", 0)) + + config["subtitles"] = subtitles + clip.config = config + updated = self._clip_repo.update(clip) + + logger.info( + "添加字幕: clip_id=%s subtitle_id=%s start=%.3fs end=%.3fs", + clip_id, + subtitle["id"], + start, + end, + ) + + return subtitle + + def update_subtitle( + self, + clip_id: str, + subtitle_id: str, + *, + start: Optional[float] = None, + end: Optional[float] = None, + text: Optional[str] = None, + style: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """更新一条字幕 + + Returns: + dict: 更新后的字幕条目 + + Raises: + ValueError: 字幕不存在或参数非法 + """ + clip = self.get_clip_or_raise(clip_id) + config = dict(clip.config) if clip.config else {} + subtitles = list(config.get("subtitles", [])) + + found = False + for i, s in enumerate(subtitles): + if s.get("id") == subtitle_id: + # 更新字段 + updated_s = dict(s) + if start is not None: + updated_s["start"] = round(start, 3) + if end is not None: + updated_s["end"] = round(end, 3) + if text is not None: + if not text.strip(): + raise ValueError("字幕文本不能为空") + updated_s["text"] = text.strip() + if style is not None: + updated_s["style"] = style + + # 校验时间 + if updated_s["start"] < 0 or updated_s["end"] <= updated_s["start"]: + raise ValueError(f"字幕时间非法: start={updated_s['start']}, end={updated_s['end']}") + if updated_s["end"] > clip.duration + 0.001: + raise ValueError(f"字幕结束时间不能超过片段时长") + + subtitles[i] = updated_s + found = True + break + + if not found: + raise ValueError(f"字幕不存在: {subtitle_id}") + + self._auto_resume_editing(clip.plan_id) + + subtitles.sort(key=lambda s: s.get("start", 0)) + config["subtitles"] = subtitles + clip.config = config + self._clip_repo.update(clip) + + logger.info("更新字幕: clip_id=%s subtitle_id=%s", clip_id, subtitle_id) + + return subtitles[next(i for i, s in enumerate(subtitles) if s["id"] == subtitle_id)] + + def delete_subtitle(self, clip_id: str, subtitle_id: str) -> bool: + """删除一条字幕 + + Returns: + bool: 是否删除成功 + """ + clip = self.get_clip_or_raise(clip_id) + config = dict(clip.config) if clip.config else {} + subtitles = list(config.get("subtitles", [])) + + new_subtitles = [s for s in subtitles if s.get("id") != subtitle_id] + if len(new_subtitles) == len(subtitles): + return False + + self._auto_resume_editing(clip.plan_id) + + config["subtitles"] = new_subtitles + clip.config = config + self._clip_repo.update(clip) + + logger.info("删除字幕: clip_id=%s subtitle_id=%s", clip_id, subtitle_id) + return True + + def batch_update_subtitles( + self, + clip_id: str, + subtitles: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + """批量更新字幕(全量替换,用于批量编辑或导入) + + Args: + clip_id: 片段 ID + subtitles: 字幕列表,每条需包含 start/end/text,已有 id 则保留 + + Returns: + List[dict]: 更新后的字幕列表 + """ + clip = self.get_clip_or_raise(clip_id) + + from uuid import uuid4 + + validated = [] + for s in subtitles: + start = float(s.get("start", 0)) + end = float(s.get("end", 0)) + text = str(s.get("text", "")) + + if start < 0 or end <= start: + raise ValueError(f"字幕时间非法: start={start}, end={end}") + if not text.strip(): + continue # 跳过空字幕 + if end > clip.duration + 0.001: + raise ValueError(f"字幕结束时间不能超过片段时长: end={end}") + + subtitle_id = s.get("id") or uuid4().hex + validated.append( + { + "id": subtitle_id, + "start": round(start, 3), + "end": round(end, 3), + "text": text.strip(), + "style": s.get("style", {}), + } + ) + + validated.sort(key=lambda s: s["start"]) + + self._auto_resume_editing(clip.plan_id) + + config = dict(clip.config) if clip.config else {} + config["subtitles"] = validated + clip.config = config + self._clip_repo.update(clip) + + logger.info( + "批量更新字幕: clip_id=%s count=%d", + clip_id, + len(validated), + ) + + return validated + def get_plan_with_clips(self, plan_id: str) -> Dict[str, Any]: """获取计划及其所有片段 diff --git a/tests/unit/test_edit_plan_service.py b/tests/unit/test_edit_plan_service.py index c67cabb77..99146ca8f 100755 --- a/tests/unit/test_edit_plan_service.py +++ b/tests/unit/test_edit_plan_service.py @@ -802,6 +802,45 @@ class TestClipSplit: def test_split_at_boundary_raises(self): """分割点为0或等于时长时,报错""" + + +# 字幕管理测试 +# =========================================================================== + + +class TestSubtitleManagement: + """字幕管理测试""" + + def test_add_subtitle_basic(self): + """基础:添加一条字幕""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试计划") + svc.transition_status(p.id, EditPlanStatus.EDITING) + clip = svc.create_clip(p.id, "main", 0, duration=10.0) + + subtitle = svc.add_subtitle(clip.id, start=1.0, end=3.0, text="大家好") + + assert subtitle["text"] == "大家好" + assert subtitle["start"] == 1.0 + assert subtitle["end"] == 3.0 + assert "id" in subtitle + assert len(subtitle["id"]) > 0 + + def test_add_subtitle_with_style(self): + """添加带样式的字幕""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试计划") + svc.transition_status(p.id, EditPlanStatus.EDITING) + clip = svc.create_clip(p.id, "main", 0, duration=10.0) + + style = {"font_size": 24, "color": "#ffffff", "position": "bottom"} + subtitle = svc.add_subtitle(clip.id, start=0.0, end=2.0, text="测试", style=style) + + assert subtitle["style"]["font_size"] == 24 + assert subtitle["style"]["color"] == "#ffffff" + + def test_list_subtitles_sorted_by_time(self): + """字幕列表按时间排序""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) @@ -815,6 +854,19 @@ class TestClipSplit: def test_split_negative_time_raises(self): """负数分割点报错""" + + svc.add_subtitle(clip.id, start=5.0, end=6.0, text="第二") + svc.add_subtitle(clip.id, start=1.0, end=2.0, text="第一") + svc.add_subtitle(clip.id, start=8.0, end=9.0, text="第三") + + subtitles = svc.list_subtitles(clip.id) + assert len(subtitles) == 3 + assert subtitles[0]["text"] == "第一" + assert subtitles[1]["text"] == "第二" + assert subtitles[2]["text"] == "第三" + + def test_add_subtitle_invalid_time_raises(self): + """非法时间报错""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) @@ -979,3 +1031,175 @@ class TestClipMerge: assert abs(merged.duration - 10.0) < 0.001 assert svc.count_clips(p.id) == 1 + + # 开始时间为负 + with pytest.raises(ValueError, match="时间非法"): + svc.add_subtitle(clip.id, start=-1.0, end=2.0, text="test") + + # 结束时间 <= 开始时间 + with pytest.raises(ValueError, match="时间非法"): + svc.add_subtitle(clip.id, start=5.0, end=3.0, text="test") + + # 超过片段时长 + with pytest.raises(ValueError, match="不能超过片段时长"): + svc.add_subtitle(clip.id, start=8.0, end=15.0, text="test") + + def test_add_subtitle_empty_text_raises(self): + """空文本报错""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试计划") + svc.transition_status(p.id, EditPlanStatus.EDITING) + clip = svc.create_clip(p.id, "main", 0, duration=10.0) + + with pytest.raises(ValueError, match="不能为空"): + svc.add_subtitle(clip.id, start=1.0, end=2.0, text=" ") + + def test_get_subtitle(self): + """获取单条字幕""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试计划") + svc.transition_status(p.id, EditPlanStatus.EDITING) + clip = svc.create_clip(p.id, "main", 0, duration=10.0) + + sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="测试") + found = svc.get_subtitle(clip.id, sub["id"]) + + assert found is not None + assert found["text"] == "测试" + + # 不存在的返回 None + assert svc.get_subtitle(clip.id, "nonexistent") is None + + def test_update_subtitle_text(self): + """更新字幕文本""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试计划") + svc.transition_status(p.id, EditPlanStatus.EDITING) + clip = svc.create_clip(p.id, "main", 0, duration=10.0) + + sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="原文") + updated = svc.update_subtitle(clip.id, sub["id"], text="修改后") + + assert updated["text"] == "修改后" + assert updated["start"] == 1.0 # 时间不变 + + def test_update_subtitle_time(self): + """更新字幕时间""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试计划") + svc.transition_status(p.id, EditPlanStatus.EDITING) + clip = svc.create_clip(p.id, "main", 0, duration=10.0) + + sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="测试") + updated = svc.update_subtitle(clip.id, sub["id"], start=3.0, end=5.0) + + assert updated["start"] == 3.0 + assert updated["end"] == 5.0 + + def test_update_subtitle_not_found_raises(self): + """更新不存在的字幕报错""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试计划") + svc.transition_status(p.id, EditPlanStatus.EDITING) + clip = svc.create_clip(p.id, "main", 0, duration=10.0) + + with pytest.raises(ValueError, match="字幕不存在"): + svc.update_subtitle(clip.id, "fake-id", text="test") + + def test_delete_subtitle(self): + """删除字幕""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试计划") + svc.transition_status(p.id, EditPlanStatus.EDITING) + clip = svc.create_clip(p.id, "main", 0, duration=10.0) + + sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="要删的") + assert svc.count_clips(p.id) == 1 # 片段还在 + + deleted = svc.delete_subtitle(clip.id, sub["id"]) + assert deleted is True + + subtitles = svc.list_subtitles(clip.id) + assert len(subtitles) == 0 + + def test_delete_subtitle_not_found(self): + """删除不存在的字幕返回 False""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试计划") + svc.transition_status(p.id, EditPlanStatus.EDITING) + clip = svc.create_clip(p.id, "main", 0, duration=10.0) + + deleted = svc.delete_subtitle(clip.id, "nonexistent") + assert deleted is False + + def test_batch_update_subtitles(self): + """批量更新字幕(全量替换)""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试计划") + svc.transition_status(p.id, EditPlanStatus.EDITING) + clip = svc.create_clip(p.id, "main", 0, duration=20.0) + + # 先加一条 + svc.add_subtitle(clip.id, start=1.0, end=2.0, text="旧字幕") + + # 全量替换为 3 条 + new_subs = [ + {"start": 0.0, "end": 3.0, "text": "第一条"}, + {"start": 4.0, "end": 7.0, "text": "第二条"}, + {"start": 8.0, "end": 12.0, "text": "第三条"}, + ] + result = svc.batch_update_subtitles(clip.id, new_subs) + + assert len(result) == 3 + assert result[0]["text"] == "第一条" + # 都有 id + assert all("id" in s for s in result) + # 旧字幕没了 + subtitles = svc.list_subtitles(clip.id) + assert len(subtitles) == 3 + + def test_batch_update_preserves_existing_ids(self): + """批量更新时已有 id 的字幕保留原 id""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试计划") + svc.transition_status(p.id, EditPlanStatus.EDITING) + clip = svc.create_clip(p.id, "main", 0, duration=10.0) + + sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="原字幕") + original_id = sub["id"] + + # 带 id 批量更新,修改文本 + updated_list = svc.batch_update_subtitles( + clip.id, + [{"id": original_id, "start": 1.0, "end": 3.0, "text": "修改了"}], + ) + + assert len(updated_list) == 1 + assert updated_list[0]["id"] == original_id + assert updated_list[0]["text"] == "修改了" + + def test_batch_update_skips_empty_text(self): + """批量更新时空文本自动跳过""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试计划") + svc.transition_status(p.id, EditPlanStatus.EDITING) + clip = svc.create_clip(p.id, "main", 0, duration=10.0) + + subs = [ + {"start": 0.0, "end": 1.0, "text": "有效"}, + {"start": 2.0, "end": 3.0, "text": " "}, # 空白,跳过 + {"start": 4.0, "end": 5.0, "text": "也有效"}, + ] + result = svc.batch_update_subtitles(clip.id, subs) + + assert len(result) == 2 + + def test_empty_clip_returns_empty_list(self): + """没有字幕的片段返回空列表""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试计划") + svc.transition_status(p.id, EditPlanStatus.EDITING) + clip = svc.create_clip(p.id, "main", 0, duration=10.0) + + subtitles = svc.list_subtitles(clip.id) + assert subtitles == [] -- 2.54.0 From 0bd290dadcc58759d943f939934871e41b8a1e0a Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 16 Jul 2026 11:51:18 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20delete=5Fsubtitle=20204=E5=93=8D?= =?UTF-8?q?=E5=BA=94FastAPI=E6=96=AD=E8=A8=80=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加response_model=None和response_class=Response - 修复204状态码与response body冲突的问题 --- apps/api/app/api/routes/edit_plans.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py index 017a5d59c..8a38c6606 100755 --- a/apps/api/app/api/routes/edit_plans.py +++ b/apps/api/app/api/routes/edit_plans.py @@ -673,6 +673,8 @@ def update_subtitle( "/clips/{clip_id}/subtitles/{subtitle_id}", summary="删除一条字幕", status_code=status.HTTP_204_NO_CONTENT, + response_model=None, + response_class=Response, ) def delete_subtitle( clip_id: str, @@ -707,7 +709,6 @@ def delete_subtitle( subtitle_id, current_user.user.id, ) - return Response(status_code=status.HTTP_204_NO_CONTENT) @router.put( -- 2.54.0 From af127b58e766e5e80de5fc86289b17a0a0c05a8a Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 16 Jul 2026 13:53:51 +0800 Subject: [PATCH 3/4] =?UTF-8?q?chore:=20=E7=A7=BB=E9=99=A4=E8=AF=AF?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E7=9A=84.coverage=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .coverage | Bin 53248 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .coverage diff --git a/.coverage b/.coverage deleted file mode 100644 index 240a4c29d9d8d99ca161ea13e0ea58607030d2a2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53248 zcmeI%TW{h<6bEp;*~JMVN~$PAX&y2!6+sJGp}zF;(57vqZo5*uZF+esJD367Vw>0| zmxroiyVCYU^h@>|^qd(F#tE$UZ6)iE0`b^0<2k>%f&An1XMU>0Fp2}2iVrJ$700Q3 zE<~kLap}E7Z~Nl(Lz|%g&eHL^L$`AJ%Qri_KUeD2Un{%6?YzI+sgLUaZU0;UXzQnK z-)(Q5(gnOA009U}0H&mT~!RE?;L zY3v6wz7>C{+qTY--7Qr&sX0NFD1Q`k4sntdZ;4n9Rjfi!C8iZ8{-9NB{ry+XZS3zm zua%BT#vc7`7owpPc_>eZRFN0O18#jkmZ3LRNn6N8gkD7Lyh%;8aTGjBBQfzq9cGyL zsUL+x-6(ICs=>+t+l*#kgtr_G)%Ah{dSOat_gd1)3$G}dWzPx^sc~UsS@VK?YbvbU z9L|D1k^epmrrbGH!rVBG)|FgWHjT1i-@0#B^kp7zp*t-(_oyi~3;oNPT6BI}{9czn>aX70bQ{OV&g)A(P?)FJ z_tRu?uvHi|i_>d{4qsnCZg`dy2MN!j$wWj$J!QDGFr7oAd3nw}u^2Mui6){fBenFM zjy7&AwL$bJQNK9W^oU6mO%PfOY_{Pn^mwWy9D^5KsaTHGX?E2t3-5X$(a`B`RU7BW z>xRx^2=uy(!`l3?>Nbv!oR?dA7-hYc{c{UL$oAxlWz=2@luTx^T}a-vNZJzlDrx6P zc7?*}xYfql(YkQ5e7$aVusL@hx{ZSaXKrQ$uS@)XR0x94fyM5v%>(xgmiir%TyR+?Sq70cFtIwcdXHIM@{-MLeiYP=q+d)1lU z=qs73)DM*4EV?mzU|q{Zc(J03*+bv`W)gq~*cI;7f*_G00Izz00bZa0SG_<0uX?}3IY87Ux9*C5P$## zAOHafKmY;|fB*y_0D+PT)HohI|1VjuA{+=n00Izz00bZa0SG_<0uX>eQ2@{XiylZp z00Izz00bZa0SG_<0uX=z1j;CY=l^BwRm21V2tWV=5P$##AOHafKmY;|C<@^Df6)Ue z2tWV=5P$##AOHafKmY;|fIt}q@ch4wy^5G1009U<00Izz00bZa0SG_<0!0Bl|1WwV z1px>^00Izz00bZa0SG_<0uU&p0G|Jsu~!ij1Rwwb2tWV=5P$##AOHafK%gjq=l?|y zq#ytR2tWV=5P$##AOHafKmY<|6u|TUGWIHBf&c^{009U<00Izz00bZa0SFWY@ch5% bffNKF009U<00Izz00bZa0SG{#i~|1yt&b7T -- 2.54.0 From 7d644eb5844679ff7048e0f3679e696c464829e1 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 17 Jul 2026 09:31:56 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Drebase=E5=86=B2?= =?UTF-8?q?=E7=AA=81=E5=AF=BC=E8=87=B4=E7=9A=84=E6=B5=8B=E8=AF=95=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E9=94=99=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/app/api/routes/edit_plans.py | 2 + apps/api/app/services/edit_plan_service.py | 2 +- tests/unit/test_edit_plan_service.py | 346 +++++++++++---------- 3 files changed, 178 insertions(+), 172 deletions(-) mode change 100755 => 100644 apps/api/app/services/edit_plan_service.py diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py index 8a38c6606..06f44bb4c 100755 --- a/apps/api/app/api/routes/edit_plans.py +++ b/apps/api/app/api/routes/edit_plans.py @@ -518,6 +518,8 @@ def copy_plan( current_user.user.id, ) return _to_response(new_plan) + + # ── 字幕管理 ──────────────────────────────────────────────────────────────── diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py old mode 100755 new mode 100644 index c4f331d79..7696029ad --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -803,7 +803,7 @@ class EditPlanService: if updated_s["start"] < 0 or updated_s["end"] <= updated_s["start"]: raise ValueError(f"字幕时间非法: start={updated_s['start']}, end={updated_s['end']}") if updated_s["end"] > clip.duration + 0.001: - raise ValueError(f"字幕结束时间不能超过片段时长") + raise ValueError("字幕结束时间不能超过片段时长") subtitles[i] = updated_s found = True diff --git a/tests/unit/test_edit_plan_service.py b/tests/unit/test_edit_plan_service.py index 99146ca8f..df4fc6307 100755 --- a/tests/unit/test_edit_plan_service.py +++ b/tests/unit/test_edit_plan_service.py @@ -802,10 +802,51 @@ class TestClipSplit: def test_split_at_boundary_raises(self): """分割点为0或等于时长时,报错""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试计划") + svc.transition_status(p.id, EditPlanStatus.EDITING) + clip = svc.create_clip(p.id, "main", 0, duration=10.0) + with pytest.raises(ValueError, match="分割时间"): + svc.split_clip(clip.id, 0.0) -# 字幕管理测试 -# =========================================================================== + with pytest.raises(ValueError, match="分割时间"): + svc.split_clip(clip.id, 10.0) + + def test_split_negative_time_raises(self): + """负数分割点报错""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试计划") + svc.transition_status(p.id, EditPlanStatus.EDITING) + clip = svc.create_clip(p.id, "main", 0, duration=10.0) + + with pytest.raises(ValueError, match="分割时间"): + svc.split_clip(clip.id, -1.0) + + def test_split_nonexistent_clip_raises(self): + """不存在的片段报错""" + svc = _make_service() + + with pytest.raises(ValueError, match="片段不存在"): + svc.split_clip("nonexistent", 5.0) + + def test_split_with_asset_adds_trim_info(self): + """有素材的片段分割后,添加trim_start/trim_end""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试计划") + svc.transition_status(p.id, EditPlanStatus.EDITING) + clip = svc.create_clip(p.id, "main", 0, duration=10.0, asset_id="asset-001") + + result = svc.split_clip(clip.id, 3.0) + + left = result["left_clip"] + right = result["right_clip"] + # 左半部分有 trim_end + assert left.config.get("trim_end") == 7.0 + # 右半部分有 trim_start + assert right.config.get("trim_start") == 3.0 + # 右半部分也关联同一个素材 + assert right.asset_id == "asset-001" class TestSubtitleManagement: @@ -846,15 +887,6 @@ class TestSubtitleManagement: svc.transition_status(p.id, EditPlanStatus.EDITING) clip = svc.create_clip(p.id, "main", 0, duration=10.0) - with pytest.raises(ValueError, match="分割时间"): - svc.split_clip(clip.id, 0.0) - - with pytest.raises(ValueError, match="分割时间"): - svc.split_clip(clip.id, 10.0) - - def test_split_negative_time_raises(self): - """负数分割点报错""" - svc.add_subtitle(clip.id, start=5.0, end=6.0, text="第二") svc.add_subtitle(clip.id, start=1.0, end=2.0, text="第一") svc.add_subtitle(clip.id, start=8.0, end=9.0, text="第三") @@ -872,166 +904,6 @@ class TestSubtitleManagement: svc.transition_status(p.id, EditPlanStatus.EDITING) clip = svc.create_clip(p.id, "main", 0, duration=10.0) - with pytest.raises(ValueError, match="分割时间"): - svc.split_clip(clip.id, -1.0) - - def test_split_nonexistent_clip_raises(self): - """不存在的片段报错""" - svc = _make_service() - - with pytest.raises(ValueError, match="片段不存在"): - svc.split_clip("nonexistent", 5.0) - - def test_split_with_asset_adds_trim_info(self): - """有素材的片段分割后,添加trim_start/trim_end""" - svc = _make_service() - p = svc.create_plan("tpl-001", "测试计划") - svc.transition_status(p.id, EditPlanStatus.EDITING) - clip = svc.create_clip(p.id, "main", 0, duration=10.0, asset_id="asset-001") - - result = svc.split_clip(clip.id, 3.0) - - left = result["left_clip"] - right = result["right_clip"] - # 左半部分有 trim_end - assert left.config.get("trim_end") == 7.0 - # 右半部分有 trim_start - assert right.config.get("trim_start") == 3.0 - # 右半部分也关联同一个素材 - assert right.asset_id == "asset-001" - - -class TestClipMerge: - """片段合并测试""" - - def test_merge_two_clips(self): - """基础合并:两个5秒片段合并为10秒""" - svc = _make_service() - p = svc.create_plan("tpl-001", "测试计划") - svc.transition_status(p.id, EditPlanStatus.EDITING) - clip1 = svc.create_clip(p.id, "main", 0, duration=5.0, text_content="第一段") - clip2 = svc.create_clip(p.id, "main", 1, duration=5.0, text_content="第二段") - - merged = svc.merge_clips([clip1.id, clip2.id]) - - assert merged.duration == 10.0 - assert merged.order == 0 - assert merged.clip_type == "main" - assert "第一段" in merged.text_content - assert "第二段" in merged.text_content - # 总片段数 = 1 - assert svc.count_clips(p.id) == 1 - - def test_merge_shifts_following_clips(self): - """合并后,后面的片段 order 前移""" - svc = _make_service() - p = svc.create_plan("tpl-001", "测试计划") - svc.transition_status(p.id, EditPlanStatus.EDITING) - clip0 = svc.create_clip(p.id, "main", 0, duration=5.0) - clip1 = svc.create_clip(p.id, "main", 1, duration=5.0) - clip2 = svc.create_clip(p.id, "main", 2, duration=5.0) - clip3 = svc.create_clip(p.id, "main", 3, duration=5.0) - - svc.merge_clips([clip1.id, clip2.id]) - - clips = svc.list_clips(p.id) - order_map = {c.id: c.order for c in clips} - assert order_map[clip0.id] == 0 - assert order_map[clip3.id] == 2 # 原来order=3,前移1位=2 - assert len(clips) == 3 - - def test_merge_three_clips(self): - """合并3个片段""" - svc = _make_service() - p = svc.create_plan("tpl-001", "测试计划") - svc.transition_status(p.id, EditPlanStatus.EDITING) - clips = [] - for i in range(3): - c = svc.create_clip(p.id, "main", i, duration=3.0) - clips.append(c) - - merged = svc.merge_clips([c.id for c in clips]) - - assert merged.duration == 9.0 - assert svc.count_clips(p.id) == 1 - - def test_merge_different_types_raises(self): - """不同类型片段不能合并""" - svc = _make_service() - p = svc.create_plan("tpl-001", "测试计划") - svc.transition_status(p.id, EditPlanStatus.EDITING) - clip1 = svc.create_clip(p.id, "intro", 0, duration=5.0) - clip2 = svc.create_clip(p.id, "main", 1, duration=5.0) - - with pytest.raises(ValueError, match="相同类型"): - svc.merge_clips([clip1.id, clip2.id]) - - def test_merge_non_contiguous_raises(self): - """不连续的片段不能合并""" - svc = _make_service() - p = svc.create_plan("tpl-001", "测试计划") - svc.transition_status(p.id, EditPlanStatus.EDITING) - clip0 = svc.create_clip(p.id, "main", 0, duration=5.0) - svc.create_clip(p.id, "main", 1, duration=5.0) - clip2 = svc.create_clip(p.id, "main", 2, duration=5.0) - - with pytest.raises(ValueError, match="不连续"): - svc.merge_clips([clip0.id, clip2.id]) - - def test_merge_single_clip_raises(self): - """单个片段不能合并""" - svc = _make_service() - p = svc.create_plan("tpl-001", "测试计划") - svc.transition_status(p.id, EditPlanStatus.EDITING) - clip = svc.create_clip(p.id, "main", 0, duration=5.0) - - with pytest.raises(ValueError, match="至少需要 2 个"): - svc.merge_clips([clip.id]) - - def test_merge_different_plans_raises(self): - """不同计划的片段不能合并""" - svc = _make_service() - p1 = svc.create_plan("tpl-001", "计划1") - p2 = svc.create_plan("tpl-001", "计划2") - svc.transition_status(p1.id, EditPlanStatus.EDITING) - svc.transition_status(p2.id, EditPlanStatus.EDITING) - clip1 = svc.create_clip(p1.id, "main", 0, duration=5.0) - clip2 = svc.create_clip(p2.id, "main", 0, duration=5.0) - - with pytest.raises(ValueError, match="同一计划"): - svc.merge_clips([clip1.id, clip2.id]) - - def test_merge_clears_trim_fields(self): - """合并后清理trim字段""" - svc = _make_service() - p = svc.create_plan("tpl-001", "测试计划") - svc.transition_status(p.id, EditPlanStatus.EDITING) - clip1 = svc.create_clip(p.id, "main", 0, duration=5.0, config={"trim_end": 2.0, "filter": "vivid"}) - clip2 = svc.create_clip(p.id, "main", 1, duration=5.0, config={"trim_start": 1.0}) - - merged = svc.merge_clips([clip1.id, clip2.id]) - - assert "trim_start" not in merged.config - assert "trim_end" not in merged.config - # 非 trim 字段保留(后面的覆盖前面的) - assert merged.config.get("filter") == "vivid" - - def test_split_then_merge_recovers(self): - """分割后再合并,时长基本恢复(浮点精度内)""" - svc = _make_service() - p = svc.create_plan("tpl-001", "测试计划") - svc.transition_status(p.id, EditPlanStatus.EDITING) - original = svc.create_clip(p.id, "main", 0, duration=10.0) - - result = svc.split_clip(original.id, 3.5) - left = result["left_clip"] - right = result["right_clip"] - - merged = svc.merge_clips([left.id, right.id]) - - assert abs(merged.duration - 10.0) < 0.001 - assert svc.count_clips(p.id) == 1 - # 开始时间为负 with pytest.raises(ValueError, match="时间非法"): svc.add_subtitle(clip.id, start=-1.0, end=2.0, text="test") @@ -1203,3 +1075,135 @@ class TestClipMerge: subtitles = svc.list_subtitles(clip.id) assert subtitles == [] + + +class TestClipMerge: + """片段合并测试""" + + def test_merge_two_clips(self): + """基础合并:两个5秒片段合并为10秒""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试计划") + svc.transition_status(p.id, EditPlanStatus.EDITING) + clip1 = svc.create_clip(p.id, "main", 0, duration=5.0, text_content="第一段") + clip2 = svc.create_clip(p.id, "main", 1, duration=5.0, text_content="第二段") + + merged = svc.merge_clips([clip1.id, clip2.id]) + + assert merged.duration == 10.0 + assert merged.order == 0 + assert merged.clip_type == "main" + assert "第一段" in merged.text_content + assert "第二段" in merged.text_content + # 总片段数 = 1 + assert svc.count_clips(p.id) == 1 + + def test_merge_shifts_following_clips(self): + """合并后,后面的片段 order 前移""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试计划") + svc.transition_status(p.id, EditPlanStatus.EDITING) + clip0 = svc.create_clip(p.id, "main", 0, duration=5.0) + clip1 = svc.create_clip(p.id, "main", 1, duration=5.0) + clip2 = svc.create_clip(p.id, "main", 2, duration=5.0) + clip3 = svc.create_clip(p.id, "main", 3, duration=5.0) + + svc.merge_clips([clip1.id, clip2.id]) + + clips = svc.list_clips(p.id) + order_map = {c.id: c.order for c in clips} + assert order_map[clip0.id] == 0 + assert order_map[clip3.id] == 2 # 原来order=3,前移1位=2 + assert len(clips) == 3 + + def test_merge_three_clips(self): + """合并3个片段""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试计划") + svc.transition_status(p.id, EditPlanStatus.EDITING) + clips = [] + for i in range(3): + c = svc.create_clip(p.id, "main", i, duration=3.0) + clips.append(c) + + merged = svc.merge_clips([c.id for c in clips]) + + assert merged.duration == 9.0 + assert svc.count_clips(p.id) == 1 + + def test_merge_different_types_raises(self): + """不同类型片段不能合并""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试计划") + svc.transition_status(p.id, EditPlanStatus.EDITING) + clip1 = svc.create_clip(p.id, "intro", 0, duration=5.0) + clip2 = svc.create_clip(p.id, "main", 1, duration=5.0) + + with pytest.raises(ValueError, match="相同类型"): + svc.merge_clips([clip1.id, clip2.id]) + + def test_merge_non_contiguous_raises(self): + """不连续的片段不能合并""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试计划") + svc.transition_status(p.id, EditPlanStatus.EDITING) + clip0 = svc.create_clip(p.id, "main", 0, duration=5.0) + svc.create_clip(p.id, "main", 1, duration=5.0) + clip2 = svc.create_clip(p.id, "main", 2, duration=5.0) + + with pytest.raises(ValueError, match="不连续"): + svc.merge_clips([clip0.id, clip2.id]) + + def test_merge_single_clip_raises(self): + """单个片段不能合并""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试计划") + svc.transition_status(p.id, EditPlanStatus.EDITING) + clip = svc.create_clip(p.id, "main", 0, duration=5.0) + + with pytest.raises(ValueError, match="至少需要 2 个"): + svc.merge_clips([clip.id]) + + def test_merge_different_plans_raises(self): + """不同计划的片段不能合并""" + svc = _make_service() + p1 = svc.create_plan("tpl-001", "计划1") + p2 = svc.create_plan("tpl-001", "计划2") + svc.transition_status(p1.id, EditPlanStatus.EDITING) + svc.transition_status(p2.id, EditPlanStatus.EDITING) + clip1 = svc.create_clip(p1.id, "main", 0, duration=5.0) + clip2 = svc.create_clip(p2.id, "main", 0, duration=5.0) + + with pytest.raises(ValueError, match="同一计划"): + svc.merge_clips([clip1.id, clip2.id]) + + def test_merge_clears_trim_fields(self): + """合并后清理trim字段""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试计划") + svc.transition_status(p.id, EditPlanStatus.EDITING) + clip1 = svc.create_clip(p.id, "main", 0, duration=5.0, config={"trim_end": 2.0, "filter": "vivid"}) + clip2 = svc.create_clip(p.id, "main", 1, duration=5.0, config={"trim_start": 1.0}) + + merged = svc.merge_clips([clip1.id, clip2.id]) + + assert "trim_start" not in merged.config + assert "trim_end" not in merged.config + # 非 trim 字段保留(后面的覆盖前面的) + assert merged.config.get("filter") == "vivid" + + def test_split_then_merge_recovers(self): + """分割后再合并,时长基本恢复(浮点精度内)""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试计划") + svc.transition_status(p.id, EditPlanStatus.EDITING) + original = svc.create_clip(p.id, "main", 0, duration=10.0) + + result = svc.split_clip(original.id, 3.5) + left = result["left_clip"] + right = result["right_clip"] + + merged = svc.merge_clips([left.id, right.id]) + + assert abs(merged.duration - 10.0) < 0.001 + assert svc.count_clips(p.id) == 1 -- 2.54.0