style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m9s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m39s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 3m43s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m0s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m3s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m7s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m14s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m24s
AI Code Review / AI Code Review (pull_request) Failing after 4m42s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m49s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m58s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m19s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 24s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m9s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m39s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 3m43s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m0s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m3s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m7s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m14s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m24s
AI Code Review / AI Code Review (pull_request) Failing after 4m42s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m49s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m58s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m19s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 24s
This commit is contained in:
@@ -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__)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
- 不冲突场景(间隔大于边缘间隙)
|
||||
- 边缘间隙可配置
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user