Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45c07789e0 | |||
| 3745970515 | |||
| 34e8c56584 | |||
| 41fdd7e066 | |||
| 7e266d5d64 | |||
| 743d5d5467 | |||
| 8e962b3af2 | |||
| ea51a8af26 | |||
| 56766c6a1a | |||
| d45f0d6bdb |
@@ -150,6 +150,7 @@ jobs:
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.cache/pip
|
||||
key: ${{ runner.os }}-pip-style-${{ hashFiles('requirements*.txt') }}
|
||||
@@ -252,6 +253,7 @@ jobs:
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.cache/pip
|
||||
key: ${{ runner.os }}-pip-security-${{ hashFiles('requirements*.txt') }}
|
||||
@@ -352,6 +354,7 @@ jobs:
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.cache/pip
|
||||
key: ${{ runner.os }}-pip-python-${{ hashFiles('requirements*.txt') }}
|
||||
@@ -456,6 +459,7 @@ jobs:
|
||||
run: bash scripts/ci/step_install_ffmpeg.sh
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.cache/pip
|
||||
key: ${{ runner.os }}-pip-unittests-${{ hashFiles('requirements*.txt') }}
|
||||
@@ -664,6 +668,7 @@ jobs:
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.npm
|
||||
key: ${{ runner.os }}-npm-vitest-${{ hashFiles('apps/web/package-lock.json') }}
|
||||
|
||||
@@ -579,39 +579,45 @@ def smart_match_assets(
|
||||
filtered_assets = asset_repository.find_by_library(request.library_id, status=["ready"], limit=10000)
|
||||
total_candidates = len(filtered_assets)
|
||||
|
||||
# 调用统一智能选素材算法(kind 已在 DB 层过滤,无需重复过滤)
|
||||
results = smart_select_assets(
|
||||
filtered_assets,
|
||||
limit=request.limit,
|
||||
kind=None,
|
||||
)
|
||||
# ── 过滤前置:余量 + 高频使用,过滤在评分/截取 limit 之前完成 ──────────
|
||||
# 旧实现先 smart_select_assets(limit=N) 再对这 N 条做过滤,过滤后不回补,
|
||||
# 当排名靠前的素材恰好都被排除时返回空 items(前端回退全选,smart-match 名存实亡)。
|
||||
# 现在先过滤全量候选,每级过滤后为空/不足则回退上一级,最后才评分截取。
|
||||
|
||||
# 结果层过滤:usable=false(零重复可切区间耗尽且历史区间均达复用上限)的素材
|
||||
# 不返回给前端;不动 smart_select_assets 评分逻辑本身
|
||||
filtered_results = []
|
||||
for r in results:
|
||||
# 1) 余量过滤:usable=False(零重复可切区间耗尽且历史区间均达复用上限)的素材排除
|
||||
usable_assets = []
|
||||
exhausted_assets = []
|
||||
for a in filtered_assets:
|
||||
try:
|
||||
avail = compute_asset_availability(r.asset)
|
||||
avail = compute_asset_availability(a)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"smart-match 余量计算失败,按可用处理: asset_id=%s",
|
||||
getattr(r.asset, "id", "?"),
|
||||
getattr(a, "id", "?"),
|
||||
exc_info=True,
|
||||
)
|
||||
avail = None
|
||||
if avail is not None and not avail["usable"]:
|
||||
logger.info(
|
||||
"smart-match 排除已用尽素材: asset_id=%s name=%s",
|
||||
getattr(r.asset, "id", "?"),
|
||||
getattr(r.asset, "name", ""),
|
||||
)
|
||||
continue
|
||||
filtered_results.append(r)
|
||||
exhausted_assets.append(a)
|
||||
else:
|
||||
usable_assets.append(a)
|
||||
|
||||
# 高频使用排除:同一素材在最近 5 个视频中出现超过 3 次则排除
|
||||
if exhausted_assets:
|
||||
logger.info(
|
||||
"smart-match 余量过滤: 候选 %d,可切区间耗尽 %d",
|
||||
len(filtered_assets), len(exhausted_assets),
|
||||
)
|
||||
|
||||
# 回退策略:余量过滤后为空(全部耗尽)时,保留全部候选,不返回空结果。
|
||||
# 宁可让用户在已耗尽素材上复用,也比 smart-match 空结果回退全选更可控
|
||||
# (全选同样会选到这些素材,且不经过评分排序)。
|
||||
pool = usable_assets if usable_assets else filtered_assets
|
||||
|
||||
# 2) 高频使用排除:同一素材在最近 5 个视频中出现超过 3 次则排除
|
||||
MAX_RECENT_USE_COUNT = 3
|
||||
if filtered_results:
|
||||
asset_ids = [getattr(r.asset, "id", "") for r in filtered_results if getattr(r.asset, "id", "")]
|
||||
high_freq_assets = set()
|
||||
if pool:
|
||||
asset_ids = [getattr(a, "id", "") for a in pool if getattr(a, "id", "")]
|
||||
if asset_ids:
|
||||
try:
|
||||
use_counts = get_asset_recent_use_counts(
|
||||
@@ -619,27 +625,35 @@ def smart_match_assets(
|
||||
asset_ids=asset_ids,
|
||||
recent_video_count=5,
|
||||
)
|
||||
high_use_excluded = set()
|
||||
for r in filtered_results:
|
||||
aid = getattr(r.asset, "id", "")
|
||||
for a in pool:
|
||||
aid = getattr(a, "id", "")
|
||||
count = use_counts.get(aid, 0)
|
||||
if count > MAX_RECENT_USE_COUNT:
|
||||
high_freq_assets.add(aid)
|
||||
logger.info(
|
||||
"smart-match 排除高频使用素材: asset_id=%s use_count=%d limit=%d",
|
||||
aid, count, MAX_RECENT_USE_COUNT,
|
||||
)
|
||||
high_use_excluded.add(id(r))
|
||||
# 回退策略:排除后剩余素材不足(为空或不够 limit)时,
|
||||
# 不再全部排除,保留全部可用素材
|
||||
if high_freq_assets:
|
||||
remaining_count = len(pool) - len(high_freq_assets)
|
||||
enough = request.limit is None or remaining_count >= request.limit
|
||||
if remaining_count > 0 and enough:
|
||||
pool = [a for a in pool if getattr(a, "id", "") not in high_freq_assets]
|
||||
else:
|
||||
pass
|
||||
# 如果排除后不够 limit,放宽到不限制
|
||||
remaining = [r for r in filtered_results if id(r) not in high_use_excluded]
|
||||
if len(remaining) >= request.limit:
|
||||
filtered_results = remaining
|
||||
else:
|
||||
logger.info("smart-match 高频排除后素材不足(%d<%d),保留全部", len(remaining), request.limit)
|
||||
logger.info(
|
||||
"smart-match 高频排除后素材不足(%d<%s),保留全部 %d 条",
|
||||
remaining_count,
|
||||
request.limit if request.limit is not None else "不限",
|
||||
len(pool),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("smart-match 高频使用查询失败,跳过排除", exc_info=True)
|
||||
|
||||
# 3) 调用统一智能选素材算法(kind 已在 DB 层过滤,无需重复过滤)
|
||||
results = smart_select_assets(pool, limit=request.limit, kind=None)
|
||||
|
||||
# 扁平结构:SmartMatchItem 继承 AssetResponse,素材字段直接在条目顶层,
|
||||
# 前端无需解析 item.asset 包装层,item.id / item.usable / 余量字段直接可读
|
||||
items = [
|
||||
@@ -648,7 +662,7 @@ def smart_match_assets(
|
||||
score=r.score,
|
||||
breakdown=r.breakdown,
|
||||
)
|
||||
for r in filtered_results
|
||||
for r in results
|
||||
]
|
||||
|
||||
return SmartMatchResponse(items=items, total_candidates=total_candidates)
|
||||
|
||||
@@ -23,7 +23,7 @@ const MaterialModeTabs: React.FC<MaterialModeTabsProps> = ({ mode, onModeChange
|
||||
onClick={() => onModeChange("auto")}
|
||||
type="button"
|
||||
>
|
||||
选择视频库自动匹配
|
||||
AI智能匹配
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -38,9 +38,8 @@ const TitlePresetsGrid: React.FC<TitlePresetsGridProps> = ({
|
||||
className="xx-title-preset-preview-text"
|
||||
style={{ ...p.previewStyle, fontFamily: getFontFamily(fontFamily || "思源黑体") }}
|
||||
>
|
||||
标题
|
||||
T
|
||||
</span>
|
||||
<span className="xx-title-preset-card-label">{p.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1733,8 +1733,8 @@
|
||||
/* 标题预设卡片网格 */
|
||||
.xx-title-presets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-title-preset-card {
|
||||
@@ -1742,7 +1742,8 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14px 8px;
|
||||
aspect-ratio: 1 / 1;
|
||||
padding: 4px;
|
||||
background: var(--bg-secondary);
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -1762,21 +1763,10 @@
|
||||
}
|
||||
|
||||
.xx-title-preset-preview-text {
|
||||
line-height: 1.4;
|
||||
margin-bottom: 6px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.xx-title-preset-card-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-title-preset-card.active .xx-title-preset-card-label {
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 样式按钮组 */
|
||||
.xx-style-btns {
|
||||
display: flex;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@ant-design/icons"
|
||||
import type { ProductItem } from "../types"
|
||||
import { statusConfig, reviewStatusConfig } from "../constants"
|
||||
import { formatTime } from "../utils"
|
||||
import { formatTime, formatSize } from "../utils"
|
||||
|
||||
interface ProductCardProps {
|
||||
product: ProductItem
|
||||
@@ -210,11 +210,20 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
<span className={`xx-product-status ${st.className}`}>{st.text}</span>
|
||||
<span className="xx-product-date">{product.date}</span>
|
||||
</div>
|
||||
{product.duplicateRate > 0 && (
|
||||
<span className={`xx-product-dup-rate ${dupClass}`}>
|
||||
查重率:{product.duplicateRate.toFixed(1)}%
|
||||
<div className="xx-product-meta-grid">
|
||||
<span className="xx-product-meta-item">分辨率:{product.resolution || "-"}</span>
|
||||
<span className="xx-product-meta-item">
|
||||
时长:{product.duration > 0 ? formatTime(product.duration) : "-"}
|
||||
</span>
|
||||
)}
|
||||
<span className="xx-product-meta-item">大小:{formatSize(product.fileSize)}</span>
|
||||
<span
|
||||
className={`xx-product-meta-item xx-product-dup-rate${
|
||||
product.duplicateRate > 0 ? ` ${dupClass}` : ""
|
||||
}`}
|
||||
>
|
||||
查重率:{product.duplicateRate > 0 ? `${product.duplicateRate.toFixed(1)}%` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
|
||||
@@ -276,11 +276,11 @@
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
/* 内联视频播放器 */
|
||||
/* 内联视频播放器(cover 填满容器,竖屏视频不留左右空白) */
|
||||
.xx-product-thumb-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -347,6 +347,21 @@
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/* 卡片信息网格:分辨率/时长 一行,大小/查重率 一行 */
|
||||
.xx-product-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-product-meta-item {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 状态标签 */
|
||||
.xx-product-status {
|
||||
padding: 2px 10px;
|
||||
|
||||
@@ -30,6 +30,14 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends docker-ce-cli docker-buildx-plugin \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Pre-bake ffmpeg: unit-tests run in fresh containers each time; installing ffmpeg
|
||||
# on every job cost ~24 min (apt update + hundreds of codec deps). Bake it into the
|
||||
# image so step_install_ffmpeg.sh detects it and exits instantly.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& ffmpeg -version | head -1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Pre-install base deps (layer cache)
|
||||
COPY requirements-base.txt ./
|
||||
RUN python -m venv "$VIRTUAL_ENV" \
|
||||
|
||||
@@ -256,6 +256,42 @@ def _wrap_title_text(
|
||||
return "\\N".join(wrapped_segments)
|
||||
|
||||
|
||||
def _parse_title_position(
|
||||
title_config: dict[str, Any],
|
||||
video_width: int,
|
||||
video_height: int,
|
||||
) -> tuple[int, int] | None:
|
||||
"""解析标题自由拖拽坐标 pos_x/pos_y(PlayRes 像素坐标系)。
|
||||
|
||||
要求两个字段同时存在、可转 int,且落在 [0, video_width] × [0, video_height]
|
||||
闭区间内。任一条件不满足返回 None,调用方回退 position 三档逻辑。
|
||||
|
||||
Args:
|
||||
title_config: 标题配置 dict
|
||||
video_width: PlayResX(视频宽度像素)
|
||||
video_height: PlayResY(视频高度像素)
|
||||
|
||||
Returns:
|
||||
(x, y) 整数坐标,或 None 表示不使用自由位置
|
||||
"""
|
||||
if "pos_x" not in title_config or "pos_y" not in title_config:
|
||||
return None
|
||||
raw_x = title_config["pos_x"]
|
||||
raw_y = title_config["pos_y"]
|
||||
# 坐标必须是 PlayRes 像素整数:bool 是 int 子类(isinstance(True,int)=True)
|
||||
# 但 True/False 作坐标无意义;float 静默截断会造成拖拽位置偏差,一律按非法回退
|
||||
if isinstance(raw_x, bool) or isinstance(raw_y, bool):
|
||||
return None
|
||||
if not isinstance(raw_x, int) or not isinstance(raw_y, int):
|
||||
return None
|
||||
x, y = raw_x, raw_y
|
||||
if video_width <= 0 or video_height <= 0:
|
||||
return None
|
||||
if not (0 <= x <= video_width and 0 <= y <= video_height):
|
||||
return None
|
||||
return (x, y)
|
||||
|
||||
|
||||
def build_ass_content(
|
||||
*,
|
||||
video_width: int,
|
||||
@@ -343,7 +379,16 @@ def build_ass_content(
|
||||
title_shadow.get("offset_y", 2) if title_shadow.get("enabled", False) else 0,
|
||||
)
|
||||
|
||||
title_alignment = position_to_ass_alignment(title_config.get("position", "top"))
|
||||
# ── 自由位置拖拽(工单 #1405 方案 B)────────────────────────────
|
||||
# pos_x/pos_y 为 PlayRes 坐标系像素整数(PlayResX/Y = video_width/height)。
|
||||
# 合法时:TitleStyle Alignment 固定 5(\an5 中对齐,使 \pos 锚点为文本块中心),
|
||||
# Dialogue 文本前注入 {\pos(x,y)}。字段缺失/非法/越界时一律回退
|
||||
# position → alignment 三档逻辑,现有输出保持一字节不变。
|
||||
title_pos = _parse_title_position(title_config, video_width, video_height)
|
||||
|
||||
title_alignment = 5 if title_pos is not None else position_to_ass_alignment(
|
||||
title_config.get("position", "top")
|
||||
)
|
||||
|
||||
styles.append(
|
||||
build_ass_style(
|
||||
@@ -370,6 +415,10 @@ def build_ass_content(
|
||||
safe_title_text_raw = escape_ass_text(title_text)
|
||||
safe_title_text = _wrap_title_text(safe_title_text_raw, video_width, title_font_size)
|
||||
|
||||
# 自由位置:在文本前注入 \pos override tag(锚点为文本块中心,配合 \an5)
|
||||
if title_pos is not None:
|
||||
safe_title_text = f"{{\\pos({title_pos[0]},{title_pos[1]})}}{safe_title_text}"
|
||||
|
||||
events.append(
|
||||
"Dialogue: 0,0:00:00.00," f"{format_ass_time(video_duration)}," "TitleStyle,,0,0,0,," f"{safe_title_text}"
|
||||
)
|
||||
|
||||
@@ -602,3 +602,148 @@ class TestFontsizeCompensation:
|
||||
result = build_ass_style("S", font_size=0)
|
||||
parts = result.split(",")
|
||||
assert int(parts[2]) >= 1
|
||||
|
||||
|
||||
# ── 标题自由位置拖拽(工单 #1405 方案 B)──────────────────────────────────────
|
||||
|
||||
|
||||
def _title_style_line(content: str) -> str:
|
||||
return [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
|
||||
|
||||
|
||||
def _title_dialogue_line(content: str) -> str:
|
||||
return [line for line in content.splitlines() if line.startswith("Dialogue:") and "TitleStyle" in line][0]
|
||||
|
||||
|
||||
class TestTitleFreePosition:
|
||||
"""pos_x/pos_y 合法时注入 \\pos 且 Alignment=5;非法/缺失时回退原逻辑。"""
|
||||
|
||||
def _base_kwargs(self):
|
||||
return dict(
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
video_duration=8.0,
|
||||
title_text="测试标题",
|
||||
)
|
||||
|
||||
def test_valid_position_injects_pos_tag_and_alignment_5(self):
|
||||
content = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "top", "size": 36, "pos_x": 540, "pos_y": 300},
|
||||
)
|
||||
# Dialogue 文本前注入 {\pos(540,300)}
|
||||
dialogue = _title_dialogue_line(content)
|
||||
assert "{\\pos(540,300)}" in dialogue
|
||||
# TitleStyle Alignment 固定 5(\an5 中对齐,\pos 锚点为文本块中心)
|
||||
fields = [f.strip() for f in _title_style_line(content).split(",")]
|
||||
assert fields[18] == "5"
|
||||
|
||||
def test_boundary_coordinates_zero_and_max_accepted(self):
|
||||
"""边界值 0 和 video_width/video_height 合法(闭区间)。"""
|
||||
content = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"pos_x": 0, "pos_y": 1920},
|
||||
)
|
||||
assert "{\\pos(0,1920)}" in _title_dialogue_line(content)
|
||||
|
||||
content2 = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"pos_x": 1080, "pos_y": 0},
|
||||
)
|
||||
assert "{\\pos(1080,0)}" in _title_dialogue_line(content2)
|
||||
|
||||
def test_no_coords_output_identical_to_before(self):
|
||||
"""不传坐标 → 输出与现有断言完全一致(回归保护)。"""
|
||||
content = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "top", "size": 36},
|
||||
)
|
||||
# 无 \pos 注入
|
||||
assert "\\pos(" not in content
|
||||
# Alignment 走 position 映射(top → 8)
|
||||
fields = [f.strip() for f in _title_style_line(content).split(",")]
|
||||
assert fields[18] == "8"
|
||||
|
||||
def test_out_of_bounds_falls_back(self):
|
||||
"""越界坐标 → 回退 position 三档逻辑,输出与无坐标一致。"""
|
||||
base = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "top", "size": 36},
|
||||
)
|
||||
for pos_x, pos_y in [(-1, 300), (540, -1), (1081, 300), (540, 1921), (99999, 99999)]:
|
||||
content = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "top", "size": 36, "pos_x": pos_x, "pos_y": pos_y},
|
||||
)
|
||||
assert "\\pos(" not in content, f"({pos_x},{pos_y}) should be rejected"
|
||||
assert content == base, f"({pos_x},{pos_y}) output differs from fallback"
|
||||
|
||||
def test_invalid_coords_falls_back(self):
|
||||
"""非法类型坐标 → 回退原逻辑。"""
|
||||
base = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "top", "size": 36},
|
||||
)
|
||||
for pos_x, pos_y in [("abc", 300), (540, None), (None, None), (True, 300), (540, False), (540.5, 300.9)]:
|
||||
content = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "top", "size": 36, "pos_x": pos_x, "pos_y": pos_y},
|
||||
)
|
||||
assert "\\pos(" not in content, f"({pos_x!r},{pos_y!r}) should be rejected"
|
||||
assert content == base, f"({pos_x!r},{pos_y!r}) output differs from fallback"
|
||||
|
||||
def test_only_one_coord_falls_back(self):
|
||||
"""只传 pos_x 或 pos_y → 回退原逻辑。"""
|
||||
base = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "center", "size": 36},
|
||||
)
|
||||
content_x = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "center", "size": 36, "pos_x": 540},
|
||||
)
|
||||
content_y = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "center", "size": 36, "pos_y": 300},
|
||||
)
|
||||
assert content_x == base
|
||||
assert content_y == base
|
||||
assert "\\pos(" not in content_x
|
||||
assert "\\pos(" not in content_y
|
||||
|
||||
def test_position_three_levels_unchanged_without_coords(self):
|
||||
"""无坐标时 top/center/bottom 三档 Alignment 输出不变。"""
|
||||
for position, expected_align in [("top", "8"), ("center", "5"), ("bottom", "2")]:
|
||||
content = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": position, "size": 36},
|
||||
)
|
||||
fields = [f.strip() for f in _title_style_line(content).split(",")]
|
||||
assert fields[18] == expected_align
|
||||
|
||||
def test_pos_overrides_position_alignment(self):
|
||||
"""有合法坐标时,无论 position 是什么,Alignment 都固定为 5。"""
|
||||
for position in ["top", "center", "bottom"]:
|
||||
content = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": position, "size": 36, "pos_x": 100, "pos_y": 200},
|
||||
)
|
||||
fields = [f.strip() for f in _title_style_line(content).split(",")]
|
||||
assert fields[18] == "5"
|
||||
assert "{\\pos(100,200)}" in _title_dialogue_line(content)
|
||||
|
||||
def test_subtitle_not_affected_by_pos(self):
|
||||
"""pos_x/pos_y 只影响 Title,Subtitle 输出不变。"""
|
||||
content = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"pos_x": 540, "pos_y": 300},
|
||||
subtitle_text="配音字幕",
|
||||
subtitle_config={"position": "bottom", "size": 24},
|
||||
)
|
||||
sub_style = [line for line in content.splitlines() if line.startswith("Style: SubtitleStyle")][0]
|
||||
sub_fields = [f.strip() for f in sub_style.split(",")]
|
||||
assert sub_fields[18] == "2" # bottom
|
||||
sub_dialogue = [
|
||||
line for line in content.splitlines() if line.startswith("Dialogue:") and "SubtitleStyle" in line
|
||||
][0]
|
||||
assert "\\pos(" not in sub_dialogue
|
||||
|
||||
@@ -395,11 +395,13 @@ class TestSmartMatchFiltersExhausted:
|
||||
# 返回的素材全部 usable=True
|
||||
assert all(item.usable for item in resp.items)
|
||||
|
||||
def test_all_exhausted_returns_empty(self):
|
||||
"""全部素材已用尽时返回空列表(不报错,前端显示空结果)。"""
|
||||
def test_all_exhausted_falls_back_to_all(self):
|
||||
"""全部素材已用尽时回退保留全部(不返回空——空结果会让前端回退全选,
|
||||
反而绕过评分排序;耗尽素材仍可走复用区间)。"""
|
||||
assets = [_exhausted_asset("a-ex-1"), _exhausted_asset("a-ex-2")]
|
||||
resp = self._call(assets)
|
||||
assert resp.items == []
|
||||
returned_ids = {item.id for item in resp.items}
|
||||
assert returned_ids == {"a-ex-1", "a-ex-2"}
|
||||
assert resp.total_candidates == 2
|
||||
|
||||
def test_fresh_assets_all_returned(self):
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""smart-match 过滤回退策略测试:余量过滤/高频排除导致结果集为空时必须回退。
|
||||
|
||||
线上事故:路由旧实现先 smart_select_assets(limit=N) 截取,再对这 N 条做
|
||||
usable / 高频过滤,过滤后不回补——排名靠前素材全部被排除时返回空 items,
|
||||
前端回退全选。修复后过滤全部前置,且每级过滤后为空/不足时回退保留。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "apps" / "api"))
|
||||
sys.path.insert(0, str(ROOT / "packages"))
|
||||
|
||||
from app.api.routes.assets import router
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
|
||||
from packages.domain import (
|
||||
Asset,
|
||||
AssetLibrary,
|
||||
AssetLibraryKind,
|
||||
AssetStatus,
|
||||
Project,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
class _StubProjectRepo:
|
||||
def __init__(self, projects):
|
||||
self._projects = projects
|
||||
|
||||
def find_by_id(self, pid):
|
||||
return self._projects.get(pid)
|
||||
|
||||
|
||||
class _StubAssetLibraryRepo:
|
||||
def __init__(self, libraries):
|
||||
self._libraries = libraries
|
||||
|
||||
def get(self, lid):
|
||||
return self._libraries.get(lid)
|
||||
|
||||
|
||||
class _StubAssetRepo:
|
||||
"""模拟仓储;session 属性供 get_asset_recent_use_counts 使用(测试中会被 patch)。"""
|
||||
|
||||
def __init__(self, assets):
|
||||
self._assets = assets
|
||||
self.session = MagicMock(name="stub-session")
|
||||
|
||||
def find_by_library(self, lid, skip=0, limit=100, status=None):
|
||||
result = [a for a in self._assets if a.library_id == lid]
|
||||
if status:
|
||||
result = [a for a in result if (a.status.value if hasattr(a.status, "value") else a.status) in status]
|
||||
return result[skip : skip + limit]
|
||||
|
||||
def find_by_library_and_file_type(self, lid, file_type, skip=0, limit=100, status=None):
|
||||
result = [a for a in self._assets if a.library_id == lid and a.file_type == file_type]
|
||||
if status:
|
||||
result = [a for a in result if (a.status.value if hasattr(a.status, "value") else a.status) in status]
|
||||
return result[skip : skip + limit]
|
||||
|
||||
|
||||
def _make_app(asset_repo, lib_repo, proj_repo):
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/assets")
|
||||
fake_user = MagicMock()
|
||||
fake_user.user = User(id="user-1", email="test@test.com", display_name="Test")
|
||||
app.dependency_overrides[get_current_user] = lambda: AuthenticatedUser(user=fake_user.user)
|
||||
app.dependency_overrides[get_asset_repository] = lambda: asset_repo
|
||||
app.dependency_overrides[get_asset_library_repository] = lambda: lib_repo
|
||||
app.dependency_overrides[get_project_repository] = lambda: proj_repo
|
||||
app.dependency_overrides[get_storage_service] = lambda: MagicMock()
|
||||
return app
|
||||
|
||||
|
||||
def _library():
|
||||
return AssetLibrary(id="lib-1", project_id="proj-1", name="Videos", kind=AssetLibraryKind.VIDEO)
|
||||
|
||||
|
||||
def _exhausted_ranges(duration=15.0):
|
||||
"""构造 used_time_ranges:整片覆盖 + 每区间 use_count 达上限 3 → usable=False。"""
|
||||
return [
|
||||
{"start": 0.0, "end": duration, "use_count": 3, "plan_id": "p1"},
|
||||
]
|
||||
|
||||
|
||||
def _video_asset(name, duration=15.0, quality=90, used_ranges=None):
|
||||
meta = {"used_time_ranges": used_ranges} if used_ranges is not None else {}
|
||||
return Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name=name,
|
||||
storage_key=f"key-{name}",
|
||||
mime_type="video/mp4",
|
||||
metadata=meta,
|
||||
quality_score=quality,
|
||||
duration=duration,
|
||||
status=AssetStatus.READY,
|
||||
)
|
||||
|
||||
|
||||
class TestSmartMatchAvailabilityFallback:
|
||||
"""余量过滤回退:全部素材 usable=False 时不返回空。"""
|
||||
|
||||
def test_all_exhausted_returns_assets_instead_of_empty(self):
|
||||
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
|
||||
assets = [
|
||||
_video_asset("v1.mp4", used_ranges=_exhausted_ranges(15)),
|
||||
_video_asset("v2.mp4", used_ranges=_exhausted_ranges(25)),
|
||||
]
|
||||
repo = _StubAssetRepo(assets)
|
||||
app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project}))
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/assets/smart-match", json={"library_id": "lib-1"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
# 回退:保留全部候选,不返回空
|
||||
assert len(data["items"]) == 2
|
||||
assert data["total_candidates"] == 2
|
||||
|
||||
def test_mixed_exhausted_and_fresh_excludes_exhausted(self):
|
||||
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
|
||||
assets = [
|
||||
_video_asset("exhausted.mp4", quality=99, used_ranges=_exhausted_ranges(15)),
|
||||
_video_asset("fresh.mp4", quality=50, used_ranges=None),
|
||||
]
|
||||
repo = _StubAssetRepo(assets)
|
||||
app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project}))
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/assets/smart-match", json={"library_id": "lib-1"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = [item["name"] for item in resp.json()["items"]]
|
||||
assert "fresh.mp4" in names
|
||||
assert "exhausted.mp4" not in names
|
||||
|
||||
def test_limit_backfills_from_lower_ranked_when_top_exhausted(self):
|
||||
"""limit=1 且排名第一的素材耗尽时,必须回补排名靠后的可用素材,不返回空。"""
|
||||
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
|
||||
assets = [
|
||||
_video_asset("top-exhausted.mp4", quality=100, used_ranges=_exhausted_ranges(15)),
|
||||
_video_asset("second-fresh.mp4", quality=40, used_ranges=None),
|
||||
_video_asset("third-fresh.mp4", quality=30, used_ranges=None),
|
||||
]
|
||||
repo = _StubAssetRepo(assets)
|
||||
app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project}))
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/assets/smart-match", json={"library_id": "lib-1", "limit": 1})
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 1
|
||||
# 不能是空,也不能是耗尽的高分素材
|
||||
assert data["items"][0]["name"] == "second-fresh.mp4"
|
||||
|
||||
|
||||
class TestSmartMatchHighFreqFallback:
|
||||
"""高频排除回退:排除后为空/不足 limit 时保留全部可用素材。"""
|
||||
|
||||
def test_all_high_freq_keeps_all(self, monkeypatch):
|
||||
import app.api.routes.assets as routes_mod
|
||||
|
||||
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
|
||||
assets = [_video_asset("v1.mp4", quality=90), _video_asset("v2.mp4", quality=80)]
|
||||
repo = _StubAssetRepo(assets)
|
||||
|
||||
# 全部素材在最近 5 个视频中使用 5 次(> 3)
|
||||
fake_counts = {assets[0].id: 5, assets[1].id: 5}
|
||||
monkeypatch.setattr(
|
||||
routes_mod, "get_asset_recent_use_counts", lambda db, asset_ids, recent_video_count=5: fake_counts
|
||||
)
|
||||
|
||||
app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project}))
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/assets/smart-match", json={"library_id": "lib-1"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
# 全部高频 → 回退保留全部
|
||||
assert len(data["items"]) == 2
|
||||
|
||||
def test_high_freq_partial_exclusion_with_enough_remaining(self, monkeypatch):
|
||||
import app.api.routes.assets as routes_mod
|
||||
|
||||
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
|
||||
assets = [
|
||||
_video_asset("hot.mp4", quality=99),
|
||||
_video_asset("cool1.mp4", quality=80),
|
||||
_video_asset("cool2.mp4", quality=70),
|
||||
]
|
||||
repo = _StubAssetRepo(assets)
|
||||
fake_counts = {assets[0].id: 9, assets[1].id: 1, assets[2].id: 0}
|
||||
monkeypatch.setattr(
|
||||
routes_mod, "get_asset_recent_use_counts", lambda db, asset_ids, recent_video_count=5: fake_counts
|
||||
)
|
||||
|
||||
app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project}))
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/assets/smart-match", json={"library_id": "lib-1"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = [item["name"] for item in resp.json()["items"]]
|
||||
assert "hot.mp4" not in names
|
||||
assert set(names) == {"cool1.mp4", "cool2.mp4"}
|
||||
|
||||
def test_high_freq_insufficient_for_limit_keeps_all(self, monkeypatch):
|
||||
"""3 个素材、limit=5、2 个高频 → 剩余 1 < limit → 保留全部。"""
|
||||
import app.api.routes.assets as routes_mod
|
||||
|
||||
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
|
||||
assets = [
|
||||
_video_asset("hot1.mp4", quality=99),
|
||||
_video_asset("hot2.mp4", quality=98),
|
||||
_video_asset("cool.mp4", quality=50),
|
||||
]
|
||||
repo = _StubAssetRepo(assets)
|
||||
fake_counts = {assets[0].id: 8, assets[1].id: 7, assets[2].id: 0}
|
||||
monkeypatch.setattr(
|
||||
routes_mod, "get_asset_recent_use_counts", lambda db, asset_ids, recent_video_count=5: fake_counts
|
||||
)
|
||||
|
||||
app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project}))
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/assets/smart-match", json={"library_id": "lib-1", "limit": 5})
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = [item["name"] for item in resp.json()["items"]]
|
||||
# 剩余 1 < limit=5 → 回退保留全部 3 条
|
||||
assert set(names) == {"hot1.mp4", "hot2.mp4", "cool.mp4"}
|
||||
|
||||
def test_high_freq_query_failure_skips_exclusion(self, monkeypatch):
|
||||
import app.api.routes.assets as routes_mod
|
||||
|
||||
def _boom(db, asset_ids, recent_video_count=5):
|
||||
raise RuntimeError("DB down")
|
||||
|
||||
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
|
||||
assets = [_video_asset("v1.mp4", quality=90), _video_asset("v2.mp4", quality=80)]
|
||||
repo = _StubAssetRepo(assets)
|
||||
monkeypatch.setattr(routes_mod, "get_asset_recent_use_counts", _boom)
|
||||
|
||||
app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project}))
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/assets/smart-match", json={"library_id": "lib-1"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert len(resp.json()["items"]) == 2
|
||||
Reference in New Issue
Block a user