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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
445 lines
20 KiB
Python
445 lines
20 KiB
Python
"""批量变体独立选片核心(#1743 起,#1749 强化素材级去重)。
|
||
|
||
总原则:多视频 = 单视频逻辑 × N。批量正式生成/批量预览/variant-plans 时,
|
||
每个变体**完整重跑单视频的选片流程**:
|
||
|
||
1. 源 plan 片段骨架(clip_type/order/text/transition)保持不变;
|
||
2. 素材池选片(#1749 定稿三轮策略):
|
||
- **第一轮 fresh 素材优先**:本批次尚未被任何变体使用过的素材优先分配,
|
||
从根上避免跨视频素材重复;
|
||
- **第二轮受控复用**:fresh 素材不足时才允许复用已用素材,但必须通过
|
||
起点扫描(_best_start_for_asset,0.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 {},
|
||
}
|