diff --git a/apps/api/app/api/routes/assets.py b/apps/api/app/api/routes/assets.py index 7ce5b67e4..3a407e81b 100755 --- a/apps/api/app/api/routes/assets.py +++ b/apps/api/app/api/routes/assets.py @@ -26,9 +26,9 @@ from app.schemas.asset import ( UpdateAssetReviewRequest, ) from app.schemas.tag import TagAssetsRequest +from app.services.asset_segment_tracker import compute_asset_availability from fastapi import APIRouter, Depends, HTTPException, Query, Response -from app.services.asset_segment_tracker import compute_asset_availability from packages.domain.smart_match import smart_select_assets logger = logging.getLogger(__name__) diff --git a/apps/api/app/schemas/asset.py b/apps/api/app/schemas/asset.py index aadb840af..1f9d2927c 100755 --- a/apps/api/app/schemas/asset.py +++ b/apps/api/app/schemas/asset.py @@ -54,19 +54,12 @@ class AssetResponse(BaseModel): uploaded_by_user_id: str tag_ids: list[str] = Field(default_factory=list) # 片段级余量信息(仅视频素材返回,非视频/无时长记录为 None,前端按可用处理) - used_duration: float | None = Field( - default=None, description="已使用片段时长(秒,历史区间合并去重后)" - ) - available_duration: float | None = Field( - default=None, description="剩余可用时长(秒)= 素材总时长 - 已用时长" - ) - used_ratio: float | None = Field( - default=None, description="已用时长占比(0~1)" - ) + used_duration: float | None = Field(default=None, description="已使用片段时长(秒,历史区间合并去重后)") + available_duration: float | None = Field(default=None, description="剩余可用时长(秒)= 素材总时长 - 已用时长") + used_ratio: float | None = Field(default=None, description="已用时长占比(0~1)") usable: bool = Field( default=True, - description="是否仍可用于新片段:零重复可切区间耗尽且所有历史区间复用次数" - "(use_count)均达上限时为 false", + description="是否仍可用于新片段:零重复可切区间耗尽且所有历史区间复用次数" "(use_count)均达上限时为 false", ) diff --git a/apps/api/app/services/asset_segment_tracker.py b/apps/api/app/services/asset_segment_tracker.py index 04041ce0e..3e351bc2f 100644 --- a/apps/api/app/services/asset_segment_tracker.py +++ b/apps/api/app/services/asset_segment_tracker.py @@ -181,8 +181,7 @@ def remove_used_segment( for r in ranges: try: match = ( - abs(float(r["start"]) - float(start)) <= tolerance - and abs(float(r["end"]) - float(end)) <= tolerance + abs(float(r["start"]) - float(start)) <= tolerance and abs(float(r["end"]) - float(end)) <= tolerance ) except (KeyError, TypeError, ValueError): remaining.append(r) @@ -239,17 +238,12 @@ def _merge_intervals(intervals: list[tuple[float, float]]) -> list[tuple[float, return merged -def _has_free_gap( - used: list[tuple[float, float]], total: float, min_free: float = _MIN_FREE_CLIP_DURATION -) -> bool: +def _has_free_gap(used: list[tuple[float, float]], total: float, min_free: float = _MIN_FREE_CLIP_DURATION) -> bool: """素材 [0, total] 中是否存在长度 ≥ min_free 的空闲段(考虑边缘间隙)。""" if total <= 0: return False # 历史区间按边缘间隙扩边后判定空闲(与选片冲突检测同一口径) - expanded = [ - (max(0.0, s - SEGMENT_EDGE_GAP), min(total, e + SEGMENT_EDGE_GAP)) - for s, e in used - ] + expanded = [(max(0.0, s - SEGMENT_EDGE_GAP), min(total, e + SEGMENT_EDGE_GAP)) for s, e in used] merged = _merge_intervals(expanded) cursor = 0.0 for start, end in merged: @@ -349,11 +343,7 @@ def find_reusable_range( if model is None: return None meta = _read_meta(model) - ranges = [ - r - for r in (meta.get(USED_RANGES_KEY) or []) - if int(r.get("use_count", 1)) < max_use_count - ] + ranges = [r for r in (meta.get(USED_RANGES_KEY) or []) if int(r.get("use_count", 1)) < max_use_count] if not ranges: return None @@ -423,9 +413,7 @@ def make_reuse_callback( return None result = find_reusable_range(db, asset_id, clip_duration, total) except Exception: - logger.warning( - "[片段追踪] 受控复用查询异常: asset_id=%s", asset_id, exc_info=True - ) + logger.warning("[片段追踪] 受控复用查询异常: asset_id=%s", asset_id, exc_info=True) return None if result is not None and reused_tracker is not None: reused_tracker[asset_id] = reused_tracker.get(asset_id, 0.0) + clip_duration diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py index d451a592f..5ed03ae07 100755 --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -9,6 +9,12 @@ from __future__ import annotations import logging from typing import Any, Dict, List, Optional +from app.services.asset_segment_tracker import ( + REUSE_RATIO_LIMIT, + get_used_segments, + make_reuse_callback, + record_used_segments, +) from sqlalchemy.orm import Session from packages.adapters.sqlalchemy_impl import ( @@ -24,13 +30,6 @@ from packages.domain.clip_operations import validate_split_time as _validate_spl from packages.domain.edit_plan import EditPlan, EditPlanStatus from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus -from app.services.asset_segment_tracker import ( - REUSE_RATIO_LIMIT, - get_used_segments, - make_reuse_callback, - record_used_segments, -) - logger = logging.getLogger(__name__) @@ -460,7 +459,6 @@ class EditPlanService: logger.exception("事务性替换片段失败: plan_id=%s", plan_id) raise - def clone_plan_for_variant( self, source_plan_id: str, @@ -525,9 +523,7 @@ class EditPlanService: reused: dict[str, float] = reuse_tracker if reuse_tracker is not None else {} asset_assigned: dict[str, float] = {} # 回调内部预判复用后占比超 15% 则拒绝复用(calc 返回 None → 保留原起点) - reuse_cb = make_reuse_callback( - db, durations, reused, assigned_tracker=asset_assigned - ) + reuse_cb = make_reuse_callback(db, durations, reused, assigned_tracker=asset_assigned) clips_data: list[dict] = [] for i, c in enumerate(clips): @@ -541,14 +537,10 @@ class EditPlanService: assigned = asset_assigned.get(aid, 0.0) eff_dur = min(dur, total) reused_amt = reused.get(aid, 0.0) - ratio_blocked = ( - assigned > 0 and reused_amt / assigned > REUSE_RATIO_LIMIT - ) + ratio_blocked = assigned > 0 and reused_amt / assigned > REUSE_RATIO_LIMIT start = None if not ratio_blocked: - start = _calc_random_start_time( - aid, eff_dur, durations, used_segments, on_exhausted=reuse_cb - ) + start = _calc_random_start_time(aid, eff_dur, durations, used_segments, on_exhausted=reuse_cb) if start is None: start = float(c.start_time or 0.0) asset_assigned[aid] = assigned + eff_dur diff --git a/tests/unit/test_asset_availability.py b/tests/unit/test_asset_availability.py index a6a3e0b67..40e62f53c 100755 --- a/tests/unit/test_asset_availability.py +++ b/tests/unit/test_asset_availability.py @@ -8,8 +8,8 @@ """ import json -import sys import os +import sys from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock @@ -114,9 +114,7 @@ class TestComputeAssetAvailability: def test_overlapping_ranges_merged(self): """重叠区间合并后计算 used_duration,不重复计时。""" - info = compute_asset_availability( - _make_asset(ranges=[_range(0.0, 10.0), _range(5.0, 20.0)]) - ) + info = compute_asset_availability(_make_asset(ranges=[_range(0.0, 10.0), _range(5.0, 20.0)])) # 合并后 [0,20] → 20s assert info["used_duration"] == pytest.approx(20.0, abs=0.01) assert info["used_ratio"] == pytest.approx(20.0 / 60.0, abs=0.001) @@ -220,9 +218,7 @@ class TestComputeAssetAvailability: def test_broken_classification_json_treated_as_unused(self): """classification_result 是非法 JSON 时按无历史区间处理。""" - info = compute_asset_availability( - _make_asset(classification_result="not-json{{{") - ) + info = compute_asset_availability(_make_asset(classification_result="not-json{{{")) assert info is not None assert info["used_duration"] == 0.0 assert info["usable"] is True diff --git a/tests/unit/test_asset_segment_tracker.py b/tests/unit/test_asset_segment_tracker.py index 759cfb9a4..b4a3671f9 100644 --- a/tests/unit/test_asset_segment_tracker.py +++ b/tests/unit/test_asset_segment_tracker.py @@ -132,10 +132,12 @@ def test_get_used_segments_aggregates_ranges(patched_model): models = { "a1": FakeModel( "a1", - {"used_time_ranges": [ - {"start": 1.0, "end": 5.0, "plan_id": "p1", "use_count": 2}, - {"start": 9.0, "end": 12.0, "plan_id": "p2"}, - ]}, + { + "used_time_ranges": [ + {"start": 1.0, "end": 5.0, "plan_id": "p1", "use_count": 2}, + {"start": 9.0, "end": 12.0, "plan_id": "p2"}, + ] + }, ), "a2": FakeModel("a2", {"other": 1}), "a3": FakeModel("a3"), @@ -169,10 +171,23 @@ def test_record_appends_new_range_with_use_count_one(patched_model): def test_record_reuse_same_range_increments_use_count(patched_model): """新片段与历史区间高度重叠(复用)→ 累加 use_count,不新增记录。""" - models = {"a1": FakeModel("a1", {"used_time_ranges": [ - {"start": 10.0, "end": 20.0, "plan_id": "p1", "use_count": 1, - "created_at": "2026-01-01T00:00:00+00:00", "last_used_at": "2026-01-01T00:00:00+00:00"}, - ]})} + models = { + "a1": FakeModel( + "a1", + { + "used_time_ranges": [ + { + "start": 10.0, + "end": 20.0, + "plan_id": "p1", + "use_count": 1, + "created_at": "2026-01-01T00:00:00+00:00", + "last_used_at": "2026-01-01T00:00:00+00:00", + }, + ] + }, + ) + } db = _db(models) # 同一起点复用(find_reusable_range 返回的就是历史区间起点) record_used_segments(db, "a1", 10.0, 20.0, "p2") @@ -183,9 +198,16 @@ def test_record_reuse_same_range_increments_use_count(patched_model): def test_record_distinct_range_appends(patched_model): - models = {"a1": FakeModel("a1", {"used_time_ranges": [ - {"start": 10.0, "end": 20.0, "plan_id": "p1", "use_count": 1}, - ]})} + models = { + "a1": FakeModel( + "a1", + { + "used_time_ranges": [ + {"start": 10.0, "end": 20.0, "plan_id": "p1", "use_count": 1}, + ] + }, + ) + } db = _db(models) record_used_segments(db, "a1", 25.0, 35.0, "p2") ranges = _ranges(db) @@ -202,10 +224,17 @@ def test_record_missing_asset_no_raise(patched_model): def test_remove_matching_range(patched_model): - models = {"a1": FakeModel("a1", {"used_time_ranges": [ - {"start": 1.0, "end": 5.0, "plan_id": "p1"}, - {"start": 9.0, "end": 12.0, "plan_id": "p2"}, - ]})} + models = { + "a1": FakeModel( + "a1", + { + "used_time_ranges": [ + {"start": 1.0, "end": 5.0, "plan_id": "p1"}, + {"start": 9.0, "end": 12.0, "plan_id": "p2"}, + ] + }, + ) + } db = _db(models) assert remove_used_segment(db, "a1", 1.0, 5.0, plan_id="p1") is True assert len(_ranges(db)) == 1 @@ -221,9 +250,16 @@ def test_remove_plan_mismatch_keeps_range(patched_model): def test_remove_legacy_range_without_plan_id(patched_model): """旧数据记录缺 plan_id → 按时间匹配可删除。""" - models = {"a1": FakeModel("a1", {"used_time_ranges": [ - {"start": 2.0, "end": 12.0, "created_at": "2026-01-01T00:00:00"}, - ]})} + models = { + "a1": FakeModel( + "a1", + { + "used_time_ranges": [ + {"start": 2.0, "end": 12.0, "created_at": "2026-01-01T00:00:00"}, + ] + }, + ) + } db = _db(models) assert remove_used_segment(db, "a1", 2.0, 12.0, plan_id="plan-new") is True assert _ranges(db) == [] @@ -233,9 +269,17 @@ def test_remove_legacy_range_without_plan_id(patched_model): def test_reset_clears_ranges_keeps_other_fields(patched_model): - models = {"a1": FakeModel("a1", {"generation_use_count": 3, "used_time_ranges": [ - {"start": 1.0, "end": 5.0}, - ]})} + models = { + "a1": FakeModel( + "a1", + { + "generation_use_count": 3, + "used_time_ranges": [ + {"start": 1.0, "end": 5.0}, + ], + }, + ) + } db = _db(models) reset_used_segments(db, "a1") meta = json.loads(models["a1"].classification_result) @@ -248,12 +292,22 @@ def test_reset_clears_ranges_keeps_other_fields(patched_model): def test_find_reusable_prefers_oldest_unused(patched_model): """选 last_used_at 最老、use_count 未达上限的区间;能容纳 clip_duration。""" - models = {"a1": FakeModel("a1", {"used_time_ranges": [ - {"start": 0.0, "end": 8.0, "use_count": 1, - "last_used_at": "2026-08-01T00:00:00+00:00"}, - {"start": 10.0, "end": 20.0, "use_count": 1, - "last_used_at": "2026-01-01T00:00:00+00:00"}, # 最久未用 - ]})} + models = { + "a1": FakeModel( + "a1", + { + "used_time_ranges": [ + {"start": 0.0, "end": 8.0, "use_count": 1, "last_used_at": "2026-08-01T00:00:00+00:00"}, + { + "start": 10.0, + "end": 20.0, + "use_count": 1, + "last_used_at": "2026-01-01T00:00:00+00:00", + }, # 最久未用 + ] + }, + ) + } db = _db(models) result = find_reusable_range(db, "a1", clip_duration=5.0, asset_total=30.0) assert result is not None @@ -263,19 +317,33 @@ def test_find_reusable_prefers_oldest_unused(patched_model): def test_find_reusable_excludes_max_use_count(patched_model): """use_count 达到上限(3)的区间不再参与复用;全部达上限返回 None。""" - models = {"a1": FakeModel("a1", {"used_time_ranges": [ - {"start": 0.0, "end": 10.0, "use_count": 3, "last_used_at": "2026-01-01T00:00:00"}, - ]})} + models = { + "a1": FakeModel( + "a1", + { + "used_time_ranges": [ + {"start": 0.0, "end": 10.0, "use_count": 3, "last_used_at": "2026-01-01T00:00:00"}, + ] + }, + ) + } db = _db(models) assert find_reusable_range(db, "a1", 5.0, 30.0) is None def test_find_reusable_fourth_use_rejected(patched_model): """同区间复用第 4 次被拒绝:use_count=2 的可复用,use_count=3 的不可复用。""" - models = {"a1": FakeModel("a1", {"used_time_ranges": [ - {"start": 0.0, "end": 10.0, "use_count": 2, "last_used_at": "2026-03-01T00:00:00"}, - {"start": 10.0, "end": 20.0, "use_count": 3, "last_used_at": "2026-01-01T00:00:00"}, - ]})} + models = { + "a1": FakeModel( + "a1", + { + "used_time_ranges": [ + {"start": 0.0, "end": 10.0, "use_count": 2, "last_used_at": "2026-03-01T00:00:00"}, + {"start": 10.0, "end": 20.0, "use_count": 3, "last_used_at": "2026-01-01T00:00:00"}, + ] + }, + ) + } db = _db(models) result = find_reusable_range(db, "a1", 5.0, 30.0) # 只能选 use_count=2 的区间(start=0),不能选 use_count=3 的(虽然它更老) @@ -284,9 +352,16 @@ def test_find_reusable_fourth_use_rejected(patched_model): def test_find_reusable_clamps_to_asset_bounds(patched_model): """历史区间起点 + clip_duration 会越素材末尾时,起点钳制到 max_start。""" - models = {"a1": FakeModel("a1", {"used_time_ranges": [ - {"start": 25.0, "end": 30.0, "use_count": 1, "last_used_at": "2026-01-01T00:00:00"}, - ]})} + models = { + "a1": FakeModel( + "a1", + { + "used_time_ranges": [ + {"start": 25.0, "end": 30.0, "use_count": 1, "last_used_at": "2026-01-01T00:00:00"}, + ] + }, + ) + } db = _db(models) result = find_reusable_range(db, "a1", clip_duration=10.0, asset_total=30.0) assert result is not None @@ -304,9 +379,16 @@ def test_find_reusable_no_ranges_returns_none(patched_model): def test_reuse_callback_returns_range_and_tracks_duration(patched_model): - models = {"a1": FakeModel("a1", {"used_time_ranges": [ - {"start": 10.0, "end": 20.0, "use_count": 1, "last_used_at": "2026-01-01T00:00:00"}, - ]})} + models = { + "a1": FakeModel( + "a1", + { + "used_time_ranges": [ + {"start": 10.0, "end": 20.0, "use_count": 1, "last_used_at": "2026-01-01T00:00:00"}, + ] + }, + ) + } db = _db(models) reused: dict[str, float] = {} cb = make_reuse_callback(db, {"a1": 30.0}, reused) @@ -332,6 +414,7 @@ def test_reuse_callback_db_error_returns_none(patched_model): def test_calc_random_start_uses_reuse_callback_when_exhausted(monkeypatch): """素材区间被占满、100 次随机找不到空位时,调用复用回调返回历史区间。""" import packages.domain.plan_generator_utils as pgu + monkeypatch.setattr(pgu.random, "uniform", lambda a, b: 0.5) # 固定候选点必撞区间 durations = {"a1": 30.0} @@ -354,6 +437,7 @@ def test_calc_random_start_reuse_callback_none_returns_none(monkeypatch): 由调用方轮询下一个素材或报 400;历史记录不被清空。 """ import packages.domain.plan_generator_utils as pgu + monkeypatch.setattr(pgu.random, "uniform", lambda a, b: 0.5) durations = {"a1": 30.0} @@ -367,6 +451,7 @@ def test_calc_random_start_reuse_callback_none_returns_none(monkeypatch): def test_calc_random_start_no_callback_backward_compatible(monkeypatch): """不传 on_exhausted 时行为与旧版兼容(100 次失败走降级)。""" import packages.domain.plan_generator_utils as pgu + monkeypatch.setattr(pgu.random, "uniform", lambda a, b: 0.5) result = _calc_random_start_time("a1", 8.0, {"a1": 30.0}, {"a1": [(0.0, 30.0)]}) assert result is not None diff --git a/tests/unit/test_clone_plan_variant.py b/tests/unit/test_clone_plan_variant.py index f3c8e2512..d39f21ca1 100755 --- a/tests/unit/test_clone_plan_variant.py +++ b/tests/unit/test_clone_plan_variant.py @@ -7,6 +7,7 @@ - 复用占比闸门触发时保留原起点(不重复抽取) - 源 plan 无片段时抛出 ValueError """ + from __future__ import annotations import os @@ -22,8 +23,6 @@ import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) sys.path.insert(0, str(Path(__file__).resolve().parent)) # tests/unit,便于复用同目录 stub -from packages.domain.edit_plan_clip import EditPlanClip - # 复用 test_edit_plan_service 里的内存 stub 仓储 from test_edit_plan_service import ( # noqa: E402 StubEditPlanClipRepository, @@ -31,6 +30,8 @@ from test_edit_plan_service import ( # noqa: E402 _make_service, ) +from packages.domain.edit_plan_clip import EditPlanClip + @pytest.fixture def svc_with_source(): @@ -64,9 +65,7 @@ def _clone_with_fake_calc(svc, source, starts, *, used=None): def fake_calc(asset_id, clip_duration, durations, used_segments, on_exhausted=None): idx = len(calc_calls) - calc_calls.append( - {"asset_id": asset_id, "clip_duration": clip_duration, "on_exhausted": on_exhausted} - ) + calc_calls.append({"asset_id": asset_id, "clip_duration": clip_duration, "on_exhausted": on_exhausted}) return starts[idx] with ( @@ -93,12 +92,12 @@ def _clone_with_fake_calc(svc, source, starts, *, used=None): ) as mock_asset_model, ): # db.query(AssetModel).filter(...).all() → 返回带 duration 的 mock 素材 - m1 = MagicMock(id="a1"); m1.duration = 60.0 - m2 = MagicMock(id="a2"); m2.duration = 60.0 + m1 = MagicMock(id="a1") + m1.duration = 60.0 + m2 = MagicMock(id="a2") + m2.duration = 60.0 svc._clip_repo.session.query.return_value.filter.return_value.all.return_value = [m1, m2] - new_plan = svc.clone_plan_for_variant( - source.id, created_by_user_id="u1", name_suffix="变体" - ) + new_plan = svc.clone_plan_for_variant(source.id, created_by_user_id="u1", name_suffix="变体") clips_data = mock_replace.call_args.args[1] return new_plan, clips_data, calc_calls @@ -179,9 +178,7 @@ class TestClonePlanForVariant: """复用占比闸门触发(calc 返回 None)时保留源片段原起点。""" svc, source = svc_with_source # 第 3 个片段 calc 返回 None(模拟复用占比超 15% 拒绝复用) - new_plan, clips_data, _ = _clone_with_fake_calc( - svc, source, [10.0, 20.0, None] # type: ignore[list-item] - ) + new_plan, clips_data, _ = _clone_with_fake_calc(svc, source, [10.0, 20.0, None]) # type: ignore[list-item] starts = [c["start_time"] for c in clips_data] assert starts[0] == 10.0 assert starts[1] == 20.0 diff --git a/tests/unit/test_mediakit_conflicts.py b/tests/unit/test_mediakit_conflicts.py index 07c68df36..5c5a7cf96 100755 --- a/tests/unit/test_mediakit_conflicts.py +++ b/tests/unit/test_mediakit_conflicts.py @@ -5,6 +5,7 @@ - 不冲突场景(间隔大于边缘间隙) - 边缘间隙可配置 """ + from __future__ import annotations import os diff --git a/tests/unit/test_mediakit_smart_clips.py b/tests/unit/test_mediakit_smart_clips.py index 4486586f9..61f114d28 100644 --- a/tests/unit/test_mediakit_smart_clips.py +++ b/tests/unit/test_mediakit_smart_clips.py @@ -73,9 +73,7 @@ class TestRecommendedTimeConflicts: # 落在扩边范围内 → 判为冲突(避免观感重复) assert _recommended_time_conflicts(10.0, 5.0, [(0.0, 10.0)]) is True # 显式 edge_gap=0 时退回纯区间重叠判定:相接不算重叠 - assert ( - _recommended_time_conflicts(10.0, 5.0, [(0.0, 10.0)], edge_gap=0.0) is False - ) + assert _recommended_time_conflicts(10.0, 5.0, [(0.0, 10.0)], edge_gap=0.0) is False # 间隙大于边缘间隙(0.5 > 0.3)→ 不冲突 assert _recommended_time_conflicts(10.5, 5.0, [(0.0, 10.0)]) is False