Files
xiaoxia-saas/packages/domain/plan_generator_utils.py
CI Bot e33502956c
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m5s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m35s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m38s
AI Code Review / AI Code Review (pull_request) Failing after 3m41s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m44s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m6s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m22s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m24s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
feat(segments): 片段重复率控制(受控复用+15%闸门)与素材余量四字段
Task G:任意两条成片画面重复率 ≤15%
- MediaKit 智能选片避让历史区间:历史区间并入冲突检查、0.3s 边缘间隙、
  撞历史时放弃推荐保留原起点(不再挪到已用画面)
- 自动轮回改造为受控复用:历史永不自动清空;used_time_ranges 增加
  use_count/last_used_at;100 次随机失败后复用 last_used_at 最老且
  use_count<3 的区间;单条成片复用占比 >15% 时该素材停止分配(分配闸门 +
  复用回调内预判双闸门);全部素材超限返回 400「素材可切区间不足,请补充新素材」
- 复用回调返回 None 时 calc 直接返回 None(删除末尾/0.0 重叠降级),
  from-assets 轮询内 calc 失败切换下一素材
- 批量 N 条视频片段独立:count>1 时克隆独立 plan,禁止共用
  source_edit_plan_id/clips(合并 Issue #1282);预览同样克隆独立变体
- 常量:REUSE_RATIO_LIMIT=0.15 / SEGMENT_EDGE_GAP=0.3 / MAX_RANGE_USE_COUNT=3

Task H:素材余量透出与已用尽素材排除
- GET /assets 列表与详情响应附加 used_duration/available_duration/
  used_ratio/usable 四字段(非视频/无时长/异常时 None + usable=True,零影响)
- usable=false:零重复可切区间已耗尽(扩边后无 ≥3s 空闲段)且所有历史
  区间 use_count 均达上限
- smart-match 结果层过滤 usable=false 素材(评分逻辑不动)
- from-assets 对已用尽素材天然兜底:calc 返回 None → 轮询跳过 →
  全部素材用尽时 400

测试:新增 test_mediakit_conflicts(10)/test_clone_plan_variant(7)/
test_asset_availability(23),更新 tracker/clips/mediakit 既有用例;
全量 unit 13910 passed
2026-08-30 02:10:06 +08:00

520 lines
20 KiB
Python
Executable File
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.
"""剪辑计划生成 — 纯逻辑工具函数.
从 PlanGeneratorService 提取的纯业务逻辑:
- 素材分配策略(4 种 editing_mode
- 默认 clip 结构生成
- clip_type 按模式映射
- 从 TemplateClipConfig 创建 EditPlanClip
纯函数,无副作用,不依赖 DB/外部服务。
"""
from __future__ import annotations
import logging
import random
from typing import Callable, List
logger = logging.getLogger(__name__)
from packages.domain.edit_plan_clip import EditPlanClip
from packages.domain.editing_mode import EditingMode
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
# ── 默认片段时长(秒) ────────────────────────────────────────────────────────
DEFAULT_CLIP_DURATION = 5.0
DEFAULT_INTRO_DURATION = 3.0
DEFAULT_OUTRO_DURATION = 3.0
# ── 素材分配 ────────────────────────────────────────────────────────────────
def distribute_assets(
clips: List[EditPlanClip],
asset_ids: List[str],
editing_mode: str,
*,
random_selection: bool = False,
asset_durations: dict[str, float] | None = None,
) -> None:
"""按 editing_mode 将素材分配到 clips(就地修改).
分配策略:
- ONE_TAKE: 素材按顺序依次分配给 main 类型 clips
- PIP: 第1个素材→main(全屏背景),其余→overlay clips
- VOICE_OVER: 素材→main clips (B-roll)
- VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll
Args:
clips: 剪辑片段列表(就地修改 asset_id)
asset_ids: 素材 ID 列表
editing_mode: 剪辑模式字符串
random_selection: 是否随机选择素材(用于预览生成)
asset_durations: 素材 ID -> 时长(秒)映射,用于设置随机 start_time
"""
if not asset_ids or not clips:
return
# 如果需要随机选择,先打乱素材顺序
if random_selection:
asset_ids = list(asset_ids) # 复制避免修改原列表
random.shuffle(asset_ids)
if editing_mode == EditingMode.ONE_TAKE.value:
_distribute_one_take(clips, asset_ids, asset_durations)
elif editing_mode == EditingMode.PIP.value:
_distribute_pip(clips, asset_ids, asset_durations)
elif editing_mode == EditingMode.VOICE_OVER.value:
_distribute_voice_over(clips, asset_ids, asset_durations)
elif editing_mode == EditingMode.VOICE_PIP.value:
_distribute_voice_pip(clips, asset_ids, asset_durations)
else:
# 未知模式,退化为 one_take
_distribute_one_take(clips, asset_ids, asset_durations)
def _distribute_one_take(
clips: List[EditPlanClip],
asset_ids: List[str],
asset_durations: dict[str, float] | None = None,
) -> None:
"""ONE_TAKE: 素材按顺序依次分配给 main 类型 clips."""
used_segments: dict[str, list[tuple[float, float]]] = {}
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
for i, clip in enumerate(main_clips):
if i < len(asset_ids):
asset_id = asset_ids[i]
start_time = _calc_random_start_time(asset_id, clip.duration, asset_durations, used_segments)
clip.assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
if asset_id not in used_segments:
used_segments[asset_id] = []
used_segments[asset_id].append((start_time, start_time + clip.duration))
def _distribute_pip(
clips: List[EditPlanClip],
asset_ids: List[str],
asset_durations: dict[str, float] | None = None,
) -> None:
"""PIP: 第1个素材→main(全屏背景),其余→overlay clips."""
used_segments: dict[str, list[tuple[float, float]]] = {}
# 第1个素材 → main clip
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
if main_clips and asset_ids:
asset_id = asset_ids[0]
start_time = _calc_random_start_time(asset_id, main_clips[0].duration, asset_durations, used_segments)
main_clips[0].assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
if asset_id not in used_segments:
used_segments[asset_id] = []
used_segments[asset_id].append((start_time, start_time + main_clips[0].duration))
# 其余素材 → overlay clips
overlay_clips = [c for c in clips if c.clip_type == "overlay"]
remaining = asset_ids[1:]
for i, clip in enumerate(overlay_clips):
if i < len(remaining):
asset_id = remaining[i]
start_time = _calc_random_start_time(asset_id, clip.duration, asset_durations, used_segments)
clip.assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
if asset_id not in used_segments:
used_segments[asset_id] = []
used_segments[asset_id].append((start_time, start_time + clip.duration))
def _distribute_voice_over(
clips: List[EditPlanClip],
asset_ids: List[str],
asset_durations: dict[str, float] | None = None,
) -> None:
"""VOICE_OVER: 素材→main clips (B-roll)."""
used_segments: dict[str, list[tuple[float, float]]] = {}
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
for i, clip in enumerate(main_clips):
if i < len(asset_ids):
asset_id = asset_ids[i]
start_time = _calc_random_start_time(asset_id, clip.duration, asset_durations, used_segments)
clip.assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
if asset_id not in used_segments:
used_segments[asset_id] = []
used_segments[asset_id].append((start_time, start_time + clip.duration))
def _distribute_voice_pip(
clips: List[EditPlanClip],
asset_ids: List[str],
asset_durations: dict[str, float] | None = None,
) -> None:
"""VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll."""
used_segments: dict[str, list[tuple[float, float]]] = {}
bg_clips = [c for c in clips if c.clip_type == "background"]
voice_clips = [c for c in clips if c.clip_type == "corner_voice"]
broll_clips = [c for c in clips if c.clip_type == "b_roll"]
idx = 0
# 第1个 → background
if idx < len(asset_ids) and bg_clips:
asset_id = asset_ids[idx]
start_time = _calc_random_start_time(asset_id, bg_clips[0].duration, asset_durations, used_segments)
bg_clips[0].assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
if asset_id not in used_segments:
used_segments[asset_id] = []
used_segments[asset_id].append((start_time, start_time + bg_clips[0].duration))
idx += 1
# 第2个 → corner_voice
if idx < len(asset_ids) and voice_clips:
asset_id = asset_ids[idx]
start_time = _calc_random_start_time(asset_id, voice_clips[0].duration, asset_durations, used_segments)
voice_clips[0].assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
if asset_id not in used_segments:
used_segments[asset_id] = []
used_segments[asset_id].append((start_time, start_time + voice_clips[0].duration))
idx += 1
# 剩余 → b_roll clips
remaining = asset_ids[idx:]
for i, clip in enumerate(broll_clips):
if i < len(remaining):
asset_id = remaining[i]
start_time = _calc_random_start_time(asset_id, clip.duration, asset_durations, used_segments)
clip.assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
if asset_id not in used_segments:
used_segments[asset_id] = []
used_segments[asset_id].append((start_time, start_time + clip.duration))
# ── 随机 start_time 计算 ────────────────────────────────────────────────────
def _calc_random_start_time(
asset_id: str,
clip_duration: float,
asset_durations: dict[str, float] | None,
used_segments: dict[str, list[tuple[float, float]]] | None = None,
on_exhausted: Callable[[str, float], tuple[float, float] | None] | None = None,
) -> float | None:
"""计算随机 start_time,避开已使用的时间段.
在素材总时长范围内随机取点,确保 clip_duration 不超出素材边界。
如果 asset_durations 为 None 或素材不在其中,返回 None(使用默认 0.0)。
如果提供了 used_segments,会避开已使用的时间区间。
Args:
asset_id: 素材 ID
clip_duration: 片段时长(秒)
asset_durations: 素材 ID -> 时长映射
used_segments: {asset_id: [(start1, end1), (start2, end2), ...]} 已使用的时间段
on_exhausted: 100 次随机都找不到空闲区间时的受控复用回调,入参为
(asset_id, clip_duration),返回 (start, end) 复用区间或 None。
历史记录永不自动清空;回调返回 None(全部达上限/复用占比超闸门)时
本函数返回 None,由调用方轮询下一个素材或报错,不做重叠降级。
Returns:
随机 start_time 或 None
"""
if asset_durations is None:
return None
total_duration = asset_durations.get(asset_id)
if total_duration is None or total_duration <= 0:
return None
# 最大起始点 = 素材总时长 - 片段时长
max_start = max(0.0, total_duration - clip_duration)
if max_start <= 0:
return 0.0
# 如果没有已使用段,直接随机
if not used_segments or asset_id not in used_segments:
return random.uniform(0.0, max_start)
# 尝试找到一个不与已使用段重叠的起始点
used = sorted(used_segments[asset_id])
max_attempts = 100
for _ in range(max_attempts):
candidate = random.uniform(0.0, max_start)
candidate_end = candidate + clip_duration
# 检查是否与任何已使用段重叠
overlap = False
for seg_start, seg_end in used:
# 两个区间 [a, b] 和 [c, d] 重叠的条件: a < d and c < b
if candidate < seg_end and seg_start < candidate_end:
overlap = True
break
if not overlap:
return candidate
# 100 次都找不到空闲区间:进入受控复用,回调从历史区间中选最久未用且
# 使用次数未达上限的区间返回(历史记录永不自动清空)
if on_exhausted is not None:
try:
reused = on_exhausted(asset_id, clip_duration)
except Exception:
logger.warning(
"on_exhausted 受控复用回调异常: asset_id=%s",
asset_id,
exc_info=True,
)
reused = None
if reused is not None:
reuse_start, reuse_end = reused
# 边界保护:不越素材末尾、不为负
reuse_start = max(0.0, min(float(reuse_start), max_start))
logger.info(
"素材可用区间耗尽,受控复用历史区间: asset_id=%s start=%.2f end=%.2f",
asset_id,
reuse_start,
reuse_end,
)
return reuse_start
# 回调存在但拒绝复用(区间全部达 use_count 上限,或复用占比将超 15% 闸门):
# 返回 None,由调用方轮询下一个素材;绝不能末尾/0.0 降级——那会把片段
# 放回到已用过的画面,违反区间避让与重复率控制原则
return None
# 未提供 on_exhausted 回调(向后兼容):降级使用素材末尾空闲位置;
# 末尾也已占满时返回 0.0(旧行为,仅无持久化追踪的调用方会走到这里)
last_used_end = 0.0
for _seg_start, seg_end in used:
last_used_end = max(last_used_end, seg_end)
if last_used_end < total_duration:
# 返回从最后使用点开始的位置
return min(last_used_end, max_start)
return 0.0
# ── clip_type 映射 ────────────────────────────────────────────────────────
def map_clip_types_for_mode(
clips: List[EditPlanClip],
editing_mode: str,
) -> None:
"""将 MAIN 类型片段按 editing_mode 映射为对应角色类型.
模板的 clip_config 使用 ClipType 枚举(main/intro/outro 等),
但 PIP / VOICE_PIP 模式的素材分配和渲染分层依赖特定的 clip_type 命名
overlay / background / corner_voice / b_roll)。
映射规则(仅修改 MAIN 类型片段,非 MAIN 片段保持原类型):
- PIP: 第1个 MAIN → main(背景),其余 MAIN → overlay(画中画)
- VOICE_PIP: 第1个 → background,第2个 → corner_voice,第3+个 → b_roll
- ONE_TAKE / VOICE_OVER: 保持 main 不变
Args:
clips: 剪辑片段列表(就地修改 clip_type)
editing_mode: 剪辑模式字符串
"""
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
if not main_clips:
return
if editing_mode == EditingMode.PIP.value:
# 第1个 main 保持(背景层),其余改为 overlay(画中画层)
for i, clip in enumerate(main_clips):
if i > 0:
clip.clip_type = "overlay"
elif editing_mode == EditingMode.VOICE_PIP.value:
for i, clip in enumerate(main_clips):
if i == 0:
clip.clip_type = "background"
elif i == 1:
clip.clip_type = "corner_voice"
else:
clip.clip_type = "b_roll"
# ONE_TAKE / VOICE_OVER: 保持 main 不变,无需处理
# ── 默认 clip 生成 ────────────────────────────────────────────────────────
def generate_default_clips(
plan_id: str,
editing_mode: str,
asset_count: int,
) -> List[EditPlanClip]:
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构.
- ONE_TAKE: N 个 main clipsN = asset_count,至少1个)
- PIP: 1 个 main + (N-1) 个 overlayN = asset_count
- VOICE_OVER: N 个 main clips + 标记需要配音
- VOICE_PIP: 1 个 background + 1 个 corner_voice + (N-2) 个 b_roll
Args:
plan_id: 剪辑计划 ID
editing_mode: 剪辑模式字符串
asset_count: 素材数量
Returns:
List[EditPlanClip]: 生成的默认剪辑片段列表
"""
n = max(asset_count, 1)
clips: List[EditPlanClip] = []
order = 0
if editing_mode == EditingMode.PIP.value:
# 1 个 main(全屏背景)
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=DEFAULT_CLIP_DURATION,
)
)
order += 1
# 剩余为 overlay
for _ in range(1, n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="overlay",
order=order,
duration=DEFAULT_CLIP_DURATION,
)
)
order += 1
elif editing_mode == EditingMode.VOICE_OVER.value:
# N 个 main clipsB-roll
for _ in range(n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=DEFAULT_CLIP_DURATION,
config={"role": "b_roll"},
)
)
order += 1
elif editing_mode == EditingMode.VOICE_PIP.value:
# 1 个 background
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="background",
order=order,
duration=DEFAULT_CLIP_DURATION,
)
)
order += 1
# 1 个 corner_voice(至少有1个素材就有)
if n >= 2:
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="corner_voice",
order=order,
duration=DEFAULT_CLIP_DURATION,
)
)
order += 1
# 剩余为 b_roll
for _ in range(2, n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="b_roll",
order=order,
duration=DEFAULT_CLIP_DURATION,
)
)
order += 1
else:
# ONE_TAKE / 未知模式:N 个 main clips
for _ in range(n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=DEFAULT_CLIP_DURATION,
)
)
order += 1
return clips
# ── 从配置创建 clips ────────────────────────────────────────────────────────
def create_clips_from_configs(
plan_id: str,
clip_configs: List[TemplateClipConfig],
) -> List[EditPlanClip]:
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表.
Args:
plan_id: 剪辑计划 ID
clip_configs: 模板片段配置列表
Returns:
List[EditPlanClip]: 创建的剪辑片段列表(按 order 排序)
"""
clips: List[EditPlanClip] = []
# 按 order 排序
sorted_configs = sorted(clip_configs, key=lambda c: c.order)
for cfg in sorted_configs:
# 计算时长:取 min_duration 和 max_duration 的中间值
if cfg.min_duration > 0 and cfg.max_duration > 0:
duration = (cfg.min_duration + cfg.max_duration) / 2
elif cfg.min_duration > 0:
duration = cfg.min_duration
elif cfg.max_duration > 0:
duration = cfg.max_duration
else:
duration = DEFAULT_CLIP_DURATION
# clip_type 可能是枚举或字符串
clip_type = cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type
# transition_effect 可能是枚举或字符串
transition = cfg.transition_effect.value if hasattr(cfg.transition_effect, "value") else cfg.transition_effect
# 从 clip config 中解析 playback_speed(兼容 speed_ratio 字段名)
clip_cfg = cfg.config or {}
playback_speed = clip_cfg.get("playback_speed", clip_cfg.get("speed_ratio", 1.0)) or 1.0
clip = EditPlanClip.create(
plan_id=plan_id,
clip_type=clip_type,
order=cfg.order,
template_clip_config_id=cfg.id,
text_content=getattr(cfg, "text_template", "") or "",
duration=duration,
transition_effect=transition or "cut",
playback_speed=playback_speed,
config=clip_cfg,
)
clips.append(clip)
return clips