Files
xiaoxia-saas/packages/domain/variant_plan_selector.py
xiaoxia 44224cfaf6
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 4s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 7s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 10s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (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 / PR Build Web Image (pull_request) Successful in 36s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 38s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 44s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
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 / Build Staging API Image (push) Successful in 28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 23s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 29s
CI/CD Pipeline / CI Gate (pull_request) Successful in 18s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 1m43s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m57s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m0s
CI/CD Pipeline / Integration Tests (push) Successful in 2m3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m5s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m7s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m10s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m56s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (push) Successful in 5m13s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m24s
AI Code Review / AI Code Review (pull_request) Successful in 6m35s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m33s
CI/CD Pipeline / Unit Tests (push) Failing after 9m24s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
feat: 转场位置与类型随机化 (#1766) (#1786)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 10:17:35 +08:00

445 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""批量变体独立选片核心(#1743 起,#1749 强化素材级去重)。
总原则:多视频 = 单视频逻辑 × N。批量正式生成/批量预览/variant-plans 时,
每个变体**完整重跑单视频的选片流程**:
1. 源 plan 片段骨架(clip_type/order/text/transition)保持不变;
2. 素材池选片(#1749 定稿三轮策略):
- **第一轮 fresh 素材优先**:本批次尚未被任何变体使用过的素材优先分配,
从根上避免跨视频素材重复;
- **第二轮受控复用**:fresh 素材不足时才允许复用已用素材,但必须通过
起点扫描(_best_start_for_asset0.25s 窗口)使与批次内已有区间的
overlap ≤ 20%BATCH_CLIP_OVERLAP_LIMIT),且不得完全重叠;
- **短素材数学上无法错开**(素材时长 < 段长 ×(1−0.20),任何起点
重叠都 >20%)→ **禁止跨变体复用**,跳过该素材继续找;
- **第三轮兜底尽力而为**:池子耗尽时取最优(重叠最小)起点,不报错、
不打断生成(#1749 铁律:任何情况下不得因素材时长/数量报错打断);
3. main 片段之间洗牌顺序;起点走场景镜头洗牌 + 随机起点 + 历史已用区间
避让(pick_scene_aware_start / _resolve_start_time,与单视频同一入口);
4. target_durations#1749 配音时长分配后每段目标段长(voice_duration_planner),
落库到片段 duration;素材短于段长由渲染侧末帧冻结(tpad/apad)铺满。
本模块只产出 clips_data(dict 列表),不碰 DB 事务边界;素材时长/场景点/
已用区间由调用方注入,便于单测。
"""
from __future__ import annotations
import logging
import random
from packages.domain.plan_generator_utils import _resolve_start_time
from packages.domain.transition_randomizer import generate_transition_plan
logger = logging.getLogger(__name__)
# ── 阈值常量 ────────────────────────────────────────────────────────────────
BATCH_CLIP_OVERLAP_LIMIT = 0.20
"""批次内同一素材片段时间区间重叠占比上限(20%)。超过则重选起点/换素材。"""
VARIANT_RESELECT_MAX_ATTEMPTS = 6
"""单片段重叠避让/起点重选的最大尝试次数。"""
START_SCAN_STEP = 0.25
"""复用素材时起点扫描窗口步长(秒)。"""
MAIN_CLIP_TYPES = {"main"}
"""参与素材洗牌重选的片段类型(intro/outro/overlay 等固定角色片段保持源 plan)。"""
def _clip_overlap_ratio(
asset_id: str,
start: float,
duration: float,
batch_segments: dict[str, list[tuple[float, float]]],
) -> float:
"""计算新区间 [start, start+duration) 与批次内同素材已选区间的重叠占比。
返回重叠总时长 / 片段时长。
"""
if not asset_id or duration <= 0:
return 0.0
end = start + duration
overlap = 0.0
for seg_start, seg_end in batch_segments.get(asset_id, []):
ov = max(0.0, min(end, seg_end) - max(start, seg_start))
overlap += ov
return min(1.0, overlap / duration)
def _best_start_for_asset(
asset_id: str,
clip_duration: float,
asset_durations: dict[str, float],
batch_segments: dict[str, list[tuple[float, float]]],
) -> tuple[float, float] | None:
"""在素材可用范围内扫描起点,找重叠最小的 (start, ratio)。
扫描步长 START_SCAN_STEP;返回 (best_start, best_ratio)。
短素材(max_start<=0)直接返回 (0.0, ratio)——由调用方判断 ratio 是否可接受。
"""
total = asset_durations.get(asset_id, 0.0)
if total <= 0:
return None
max_start = max(0.0, total - clip_duration)
if max_start <= 0.0:
return 0.0, _clip_overlap_ratio(asset_id, 0.0, clip_duration, batch_segments)
best_start, best_ratio = 0.0, 1.0
steps = int(max_start / START_SCAN_STEP) + 1
for i in range(steps + 1):
s = min(max_start, i * START_SCAN_STEP)
r = _clip_overlap_ratio(asset_id, s, clip_duration, batch_segments)
if r < best_ratio:
best_start, best_ratio = s, r
if r <= BATCH_CLIP_OVERLAP_LIMIT:
return s, r
return best_start, best_ratio
def reselect_clips_for_variant(
source_clips: list[dict],
candidate_asset_ids: list[str],
*,
asset_durations: dict[str, float],
asset_scene_points: dict[str, list[float]] | None = None,
historical_used_segments: dict[str, list[tuple[float, float]]] | None = None,
batch_segments: dict[str, list[tuple[float, float]]] | None = None,
target_durations: list[float] | dict[int, float] | None = None,
rng: random.Random | None = None,
) -> list[dict]:
"""为一个变体基于源片段骨架重新独立选片。
Args:
source_clips: 源 plan 片段(dict 列表,每项至少含
order/asset_id/start_time/duration/clip_type,可含
playback_speed/transition_effect/transition_duration/text_content)。
candidate_asset_ids: 素材池(源 plan 素材 ∪ 批次任务素材)。
asset_durations: {asset_id: 时长秒},起点避让/区间计算必需。
asset_scene_points: {asset_id: 场景切换点},有则走镜头洗牌选起点。
historical_used_segments: 素材 metadata 中持久化的历史已用区间
(跨任务/跨变体避让),函数内会就地追加本变体选中的区间。
batch_segments: 本批次已选片段区间(变体间素材级去重 + 20% 重叠检查),
函数内会就地追加本变体选中的区间。
target_durations: #1749 配音分配后的每段目标时长(按 order 对齐的 list
或 {order: duration} dict);落库到片段 duration,素材不足由渲染冻结铺满。
rng: 可选随机数生成器(测试可注入固定种子)。
Returns:
clips_data: 与源片段等长、order 对齐的新片段 dict 列表。
Raises:
ValueError: 源片段为空 / 素材池为空 / 素材时长全为 0(无法差异化选片)。
"""
if rng is None:
rng = random.Random()
elif isinstance(rng, int):
rng = random.Random(rng)
if not source_clips:
raise ValueError("源 plan 无片段,无法为变体重新选片")
if not candidate_asset_ids:
raise ValueError("素材池为空,无法为变体独立选片(不允许退回同源成片)")
# 仅保留时长可知(>0)的素材;时长未知无法做区间避让/重叠计算
usable_assets = [a for a in dict.fromkeys(candidate_asset_ids) if asset_durations.get(a, 0.0) > 0]
if not usable_assets:
raise ValueError("素材池时长全部未知(0),无法为变体独立选片")
# 历史已用区间:复制一份,本变体选中的区间就地追加(随 clip record 持久化由调用方负责)
used_segments: dict[str, list[tuple[float, float]]] = (
{k: list(v) for k, v in (historical_used_segments or {}).items()} if historical_used_segments else {}
)
batch_segments = batch_segments if batch_segments is not None else {}
# 按 order 排序源片段,保持骨架顺序
ordered = sorted(source_clips, key=lambda c: c.get("order", 0))
def _target_dur(idx: int, src: dict) -> float:
"""配音分配的目标段长(优先),否则用源片段段长。"""
if target_durations is not None:
if isinstance(target_durations, dict):
v = target_durations.get(idx, target_durations.get(src.get("order", 0)))
else:
v = target_durations[idx] if idx < len(target_durations) else None
if v is not None and float(v) > 0:
return float(v)
return float(src.get("duration", 0.0) or 0.0)
# ── 1. 素材池洗牌(组合随机),分 fresh / 已用两轮 ──────────────────────
shuffled_pool = list(usable_assets)
rng.shuffle(shuffled_pool)
# ── 2. main 片段之间洗牌顺序(顺序随机) ────────────────────────────────
main_indexes = [i for i, c in enumerate(ordered) if c.get("clip_type", "main") in MAIN_CLIP_TYPES]
rng.shuffle(main_indexes)
result: list[dict | None] = [None] * len(ordered)
for idx in main_indexes:
src = ordered[idx]
target_dur = _target_dur(idx, src)
if target_dur <= 0:
# 异常片段:原样保留
result[idx] = _base_clip_data(
src, asset_id=src.get("asset_id", ""), start=float(src.get("start_time", 0.0)), duration=target_dur
)
continue
asset_id, start, eff_dur = _pick_asset_and_start(
clip_duration=target_dur,
shuffled_pool=shuffled_pool,
asset_durations=asset_durations,
asset_scene_points=asset_scene_points,
used_segments=used_segments,
batch_segments=batch_segments,
rng=rng,
)
interval = (start, start + eff_dur)
used_segments.setdefault(asset_id, []).append(interval)
batch_segments.setdefault(asset_id, []).append(interval)
result[idx] = _base_clip_data(src, asset_id=asset_id, start=start, duration=target_dur)
# ── 3. 非 main 片段(intro/outro/overlay 等固定角色):保留源素材,仅重算起点 ──
for idx, c in enumerate(ordered):
if result[idx] is not None:
continue
src = c
aid = src.get("asset_id", "")
target_dur = _target_dur(idx, src)
start = float(src.get("start_time", 0.0))
total = asset_durations.get(aid, 0.0)
if aid and target_dur > 0 and total > 0:
# 固定角色片段也走批次避让(但不换素材)
eff_dur = min(target_dur, total)
scan = _best_start_for_asset(aid, eff_dur, asset_durations, batch_segments)
if scan is not None and scan[1] <= BATCH_CLIP_OVERLAP_LIMIT:
start = scan[0]
else:
cand = _resolve_start_time(aid, eff_dur, asset_durations, used_segments, asset_scene_points)
if cand is not None:
start = cand
elif scan is not None:
start = scan[0]
interval = (start, start + eff_dur)
used_segments.setdefault(aid, []).append(interval)
batch_segments.setdefault(aid, []).append(interval)
result[idx] = _base_clip_data(src, asset_id=aid, start=start, duration=target_dur)
# ── 4. #1766 转场随机化:为相邻 main 片段对生成随机转场序列 ────────
_apply_transition_randomization(result, rng)
return [c for c in result if c is not None]
def _pick_asset_and_start(
*,
clip_duration: float,
shuffled_pool: list[str],
asset_durations: dict[str, float],
asset_scene_points: dict[str, list[float]] | None,
used_segments: dict[str, list[tuple[float, float]]],
batch_segments: dict[str, list[tuple[float, float]]],
rng: random.Random,
) -> tuple[str, float, float]:
"""三轮选片:fresh 优先 → 受控复用(重叠≤20%,短素材禁复用)→ 兜底尽力而为。
Returns:
(asset_id, start, eff_dur)eff_dur = min(段长, 素材时长)
段长超出素材时长的部分由渲染侧末帧冻结铺满。
"""
# ── 第一轮:fresh 素材(本批次未用过)──────────────────────────────────
fresh = [a for a in shuffled_pool if a not in batch_segments]
rng.shuffle(fresh)
for asset_id in fresh:
total = asset_durations.get(asset_id, 0.0)
if total <= 0:
continue
eff_dur = min(clip_duration, total)
cand = _resolve_start_time(asset_id, eff_dur, asset_durations, used_segments, asset_scene_points)
if cand is None:
max_start = max(0.0, total - eff_dur)
cand = rng.uniform(0.0, max_start) if max_start > 0 else 0.0
# fresh 素材批次内无区间,重叠必然为 0,直接采用
return asset_id, cand, eff_dur
# ── 第二轮:受控复用 —— 扫描起点使重叠 ≤20%;短素材数学无法错开则跳过 ──
reused = [a for a in shuffled_pool if a in batch_segments]
rng.shuffle(reused)
fallback: tuple[str, float, float, float] | None = None # (asset, start, eff, ratio)
for asset_id in reused:
total = asset_durations.get(asset_id, 0.0)
if total <= 0:
continue
eff_dur = min(clip_duration, total)
# 短素材判定:素材时长 < 段长 ×(1−0.20) → 任何起点重叠都 >20%,禁跨变体复用
if total < clip_duration * (1.0 - BATCH_CLIP_OVERLAP_LIMIT) - 1e-6:
logger.info(
"素材 %s 时长 %.2fs 短于段长 %.2fs 的 80%,数学上无法错开,禁止跨变体复用",
asset_id,
total,
clip_duration,
)
continue
scan = _best_start_for_asset(asset_id, eff_dur, asset_durations, batch_segments)
if scan is None:
continue
start, ratio = scan
if ratio <= BATCH_CLIP_OVERLAP_LIMIT:
return asset_id, start, eff_dur
if fallback is None or ratio < fallback[3]:
fallback = (asset_id, start, eff_dur, ratio)
# ── 第三轮:兜底尽力而为(池耗尽/全部超阈值)——不报错,取最优 ──────────
if fallback is not None:
asset_id, start, eff_dur, ratio = fallback
logger.info(
"变体选片素材池不足,受控复用重叠 %.0f%%>20%% 阈值,尽力而为不打断): asset=%s",
ratio * 100,
asset_id,
)
return asset_id, start, eff_dur
# 理论不可达(usable_assets 非空);保底取池首
asset_id = shuffled_pool[0]
total = asset_durations.get(asset_id, 0.0)
eff_dur = min(clip_duration, total) if total > 0 else clip_duration
return asset_id, 0.0, eff_dur
def generate_visual_perturbation(rng: random.Random | None = None) -> dict:
"""为一个变体生成随机视觉扰动参数(让批量视频画面本身更不同)。
返回 dict,可直接存入 plan.config["visual_perturbation"]。
渲染侧读取后应用到 ffmpeg filter chain。
参数范围经过校准:
- hflip: 30% 概率水平翻转(画面左右镜像,肉眼立即可见)
- zoom_ratio: 1.0~1.08 随机缩放(最多放大 8%,裁剪后画面略有差异)
- speed_factor: 0.95~1.05 速度微调(±5%,肉眼不太敏感但时间轴不同)
- brightness_shift: -10~+10 亮度偏移(eq=brightness,画面明暗差异)
"""
if rng is None:
rng = random.Random()
elif isinstance(rng, int):
rng = random.Random(rng)
return {
"hflip": rng.random() < 0.3,
"zoom_ratio": round(1.0 + rng.uniform(0, 0.08), 4),
"speed_factor": round(1.0 + rng.uniform(-0.05, 0.05), 4),
"brightness_shift": rng.choice([-10, -5, 0, 0, 0, 5, 10]),
}
def generate_pixel_perturbation(rng: random.Random | int | None = None) -> dict:
"""为一个变体生成像素级扰动滤镜参数(Issue #1765)。
在现有视觉扰动(hflip/zoom/brightness)基础上,额外叠加 2-3 种
像素级滤镜,让同素材不同变体在帧级 SSIM 差异 > 3%,肉眼看不出差异。
滤镜选项(随机选 2-3 种叠加):
- noise: 轻微噪声 (noise=alls=0.015:allf=t+u)
- unsharp: 锐化或柔化 (unsharp=3:3:-0.5 ~ 3:3:0.5)
- curves: 对比度微调 (curves 轻微调整)
- color_balance: RGB 通道偏移 (color_balance 微调)
返回 dict,可直接存入 plan.config["pixel_perturbation"]。
渲染侧读取后追加到 ffmpeg filter chain。
"""
if rng is None:
rng = random.Random()
elif isinstance(rng, int):
rng = random.Random(rng)
# 可用滤镜池
filter_options = ["noise", "unsharp", "curves", "color_balance"]
# 随机选 2-3 种
num_filters = rng.choice([2, 2, 3])
selected = rng.sample(filter_options, num_filters)
result: dict = {"filters": selected}
# 为每种滤镜生成具体参数
if "noise" in selected:
# 噪声强度 0.01~0.02(肉眼不可见)
result["noise_strength"] = round(rng.uniform(0.01, 0.02), 4)
if "unsharp" in selected:
# 锐化/柔化:-0.5 ~ +0.5(正值锐化,负值柔化)
result["unsharp_amount"] = round(rng.uniform(-0.5, 0.5), 2)
if "curves" in selected:
# 对比度微调:0.95 ~ 1.05
result["curves_contrast"] = round(rng.uniform(0.95, 1.05), 3)
if "color_balance" in selected:
# RGB 通道偏移:-5 ~ +5(极轻微色偏)
result["color_r"] = rng.choice([-5, -3, 0, 0, 3, 5])
result["color_g"] = rng.choice([-5, -3, 0, 0, 3, 5])
result["color_b"] = rng.choice([-5, -3, 0, 0, 3, 5])
return result
def _apply_transition_randomization(
result: list[dict | None],
rng: random.Random,
) -> None:
"""#1766 对 result 中相邻 main 片段应用转场随机化(就地修改)。
为每对相邻 main 片段独立选择:
- 转场类型(TRANSITION_POOL 中随机,或硬切)
- 转场时长(0.3s ~ 0.8s,协同片段时长)
- 位置微调 jitter(±0.5s,存入 config["transition_jitter"]
硬切比例保证在 30%-50%。intro/outro 等非 main 片段的转场保持源值不变。
"""
# 收集 main 片段的索引(按 order 排序)
main_indices = [i for i, c in enumerate(result) if c is not None and c.get("clip_type", "main") == "main"]
if len(main_indices) < 2:
# 不足 2 个 main 片段,无转场点可随机化
return
num_transitions = len(main_indices) - 1
# 用 main 片段的 duration 作为协同节奏的输入
clip_durations = [result[i]["duration"] for i in main_indices]
plan = generate_transition_plan(
num_transitions,
clip_durations=clip_durations,
rng=rng,
)
# 将转场计划应用到每对相邻 main 片段
# plan[k] 是 main_indices[k] → main_indices[k+1] 之间的转场
# 转场信息存储在"目标 clip"(即每对的第二个)的 transition_effect/duration
for k, transition_info in enumerate(plan):
target_idx = main_indices[k + 1]
if result[target_idx] is None:
continue
clip = result[target_idx]
clip["transition_effect"] = transition_info["effect"]
clip["transition_duration"] = transition_info["duration"]
# jitter 存入 config,供渲染侧 xfade_builder 读取
cfg = clip.get("config") or {}
cfg["transition_jitter"] = transition_info["jitter"]
clip["config"] = cfg
def _base_clip_data(src: dict, *, asset_id: str, start: float, duration: float | None = None) -> dict:
"""从源片段构造落库 dict(保留骨架/转场/文案/速度,替换素材与起点)。"""
return {
"order": src.get("order", 0),
"asset_id": asset_id,
"start_time": round(float(start), 3),
"duration": float(duration if duration is not None else src.get("duration", 0.0) or 0.0),
"clip_type": src.get("clip_type", "main"),
"playback_speed": float(src.get("playback_speed", 1.0) or 1.0),
"transition_effect": src.get("transition_effect", "cut"),
"transition_duration": float(src.get("transition_duration", 0.0) or 0.0),
"text_content": src.get("text_content", ""),
"config": src.get("config") or {},
}