b659b750b6
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 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
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
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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker 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 / PR Build Worker Image (pull_request) Successful in 42s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 45s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m23s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m55s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m53s
AI Code Review / AI Code Review (pull_request) Successful in 6m42s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 6m42s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 7m28s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 8m44s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m21s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 7m7s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 40s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 4m37s
- CreateGenerationTaskRequest 新增 dedup_enabled(默认 True), 经 writeback 落入 plan.config(含 video_index 供种子区分变体) - 新增 micro_transform_pure 纯模块:种子 sha256(task_id:video_index)%10000 可复现;hflip(P1 保守默认不翻)/setpts 0.97~1.03/eq 亮度对比饱和 ±2%/BGM 偏移 2~8s - unified_render_service:直通与 filter_complex 两路径注入片段级 hflip+eq;速度烘焙进 ResolvedClip.playback_speed(trim 多段展开按 config._micro_index 对齐),音频 atempo 自动同步;BGM 偏移复用 现有 audio_offset(-ss seek),用户显式配置优先 - stream copy 门控:启用微变换时强制重编码 - worker:dedup_enabled=False 时跳过 random_edge_crop(读取 plan.config,失败按开启兜底) - 测试:新增 41 个(纯参数 23 + 渲染注入/门控 18),writeback 2 个; 全量 tests/unit 15701 passed / 28 skipped
177 lines
6.4 KiB
Python
177 lines
6.4 KiB
Python
"""智能降重微变换纯逻辑模块 — #1970 PR2.
|
||
|
||
所有函数均为纯函数:不调用 FFmpeg、不读写文件,只负责按可复现种子
|
||
生成每个片段 / 整片的微变换参数与 filter_complex 片段。
|
||
|
||
6 个维度:
|
||
1. hflip 水平翻转(每片段 50%,有字幕/文字的片段不翻转)
|
||
2. 播放速度 0.97~1.03x(视频 setpts + 音频 atempo)
|
||
3. 亮度 ±2%(eq=brightness)
|
||
4. 对比度 ±2%(eq=contrast)
|
||
5. 饱和度 ±2%(eq=saturation)
|
||
6. BGM 起始偏移 2~8 秒(音频 atrim 起点)
|
||
|
||
随机种子 = hash(task_id + video_index) % 10000,保证同一任务同一视频
|
||
可复现;dedup_enabled=False 时不生成本模块任何输出。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import random
|
||
from dataclasses import dataclass, field
|
||
|
||
# ── 常量(与需求文档 §2 对齐)──────────────────────────────────────────────────
|
||
|
||
SPEED_MIN = 0.97
|
||
SPEED_MAX = 1.03
|
||
COLOR_DELTA = 0.02
|
||
HFLIP_PROBABILITY = 0.5
|
||
BGM_OFFSET_MIN = 2.0
|
||
BGM_OFFSET_MAX = 8.0
|
||
SEED_MODULO = 10000
|
||
|
||
|
||
def make_video_seed(task_id: str, video_index: int) -> int:
|
||
"""生成视频级可复现种子:hash(task_id+video_index) % 10000。
|
||
|
||
用 sha256 而非内置 hash():内置 hash 对字符串带进程级随机盐(PYTHONHASHSEED),
|
||
跨进程不可复现。结果映射到 0~9999。
|
||
"""
|
||
import hashlib
|
||
|
||
raw = f"{task_id or ''}:{int(video_index)}"
|
||
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||
return int(digest[:8], 16) % SEED_MODULO
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class ClipMicroTransform:
|
||
"""单个片段的微变换参数。"""
|
||
|
||
clip_index: int
|
||
hflip: bool = False
|
||
speed: float = 1.0
|
||
brightness: float = 0.0
|
||
contrast: float = 1.0
|
||
saturation: float = 1.0
|
||
has_text: bool = False
|
||
|
||
def video_filter_suffix(self) -> str:
|
||
"""返回追加在片段视频处理链上的 filter 后缀(无末尾标签)。
|
||
|
||
顺序:trim/setpts(已有)→ 调速 setpts → hflip → eq → format。
|
||
调速的 setpts 必须位于 trim 之后;hflip/eq 在缩放之后即可,
|
||
concat_engine 按「调速 → hflip → eq」顺序拼接到 scale/fps 之前的
|
||
trim 之后、scale 之后均可,这里只产出独立步骤、由引擎决定插入点。
|
||
"""
|
||
parts: list[str] = []
|
||
# 速度:setpts=PTS/speed(speed>1 时画面加速,时间戳变小)
|
||
if abs(self.speed - 1.0) > 1e-4:
|
||
parts.append(f"setpts=PTS/{self.speed:.5f}")
|
||
# 水平翻转:有文字/字幕片段不翻转
|
||
if self.hflip and not self.has_text:
|
||
parts.append("hflip")
|
||
# 色彩微调:brightness 取值 -1~1(±0.02),contrast/saturation 围绕 1.0
|
||
if abs(self.brightness) > 1e-4 or abs(self.contrast - 1.0) > 1e-4 or abs(self.saturation - 1.0) > 1e-4:
|
||
parts.append(
|
||
f"eq=brightness={self.brightness:+.4f}:"
|
||
f"contrast={self.contrast:.4f}:saturation={self.saturation:.4f}"
|
||
)
|
||
return ",".join(parts)
|
||
|
||
def audio_filter_suffix(self) -> str:
|
||
"""返回片段音频链上的调速 filter(atempo),无调速时返回空串。"""
|
||
if abs(self.speed - 1.0) <= 1e-4:
|
||
return ""
|
||
return f"atempo={self.speed:.5f}"
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class VideoMicroTransformPlan:
|
||
"""一个成片视频的全部微变换参数。"""
|
||
|
||
task_id: str
|
||
video_index: int
|
||
seed: int
|
||
clips: list[ClipMicroTransform] = field(default_factory=list)
|
||
bgm_start_offset: float = 0.0
|
||
|
||
def clip(self, index: int) -> ClipMicroTransform | None:
|
||
for c in self.clips:
|
||
if c.clip_index == index:
|
||
return c
|
||
return None
|
||
|
||
|
||
def _draw_speed(rng: random.Random) -> float:
|
||
return round(rng.uniform(SPEED_MIN, SPEED_MAX), 5)
|
||
|
||
|
||
def _draw_signed_delta(rng: random.Random) -> float:
|
||
return round(rng.uniform(-COLOR_DELTA, COLOR_DELTA), 4)
|
||
|
||
|
||
def build_micro_transform_plan(
|
||
task_id: str,
|
||
video_index: int,
|
||
clip_count: int,
|
||
*,
|
||
clip_has_text: list[bool] | None = None,
|
||
enable_bgm_offset: bool = True,
|
||
) -> VideoMicroTransformPlan:
|
||
"""按可复现种子生成整片的微变换计划。
|
||
|
||
Args:
|
||
task_id: 生成任务 ID(种子输入)
|
||
video_index: 视频在批次中的序号(0 起)
|
||
clip_count: 片段数量
|
||
clip_has_text: 每个片段是否有字幕/文字轨道(True 的片段不翻转);
|
||
None 时按 P1 约定视为无可靠文字检测——保守起见 hflip 一律关闭
|
||
enable_bgm_offset: 是否生成 BGM 起始偏移(无 BGM 时调用方可忽略该值)
|
||
|
||
Returns:
|
||
VideoMicroTransformPlan
|
||
"""
|
||
seed = make_video_seed(task_id, video_index)
|
||
rng = random.Random(seed)
|
||
|
||
# P1 字幕检测约定:无法判断片段是否有文字时,一律不翻转(宁可少一个维度也不误翻字幕)
|
||
safe_has_text = clip_has_text if clip_has_text is not None else [True] * max(clip_count, 0)
|
||
|
||
clips: list[ClipMicroTransform] = []
|
||
for i in range(max(clip_count, 0)):
|
||
has_text = bool(safe_has_text[i]) if i < len(safe_has_text) else True
|
||
do_hflip = (not has_text) and rng.random() < HFLIP_PROBABILITY
|
||
clips.append(
|
||
ClipMicroTransform(
|
||
clip_index=i,
|
||
hflip=do_hflip,
|
||
speed=_draw_speed(rng),
|
||
brightness=_draw_signed_delta(rng),
|
||
contrast=round(1.0 + _draw_signed_delta(rng), 4),
|
||
saturation=round(1.0 + _draw_signed_delta(rng), 4),
|
||
has_text=has_text,
|
||
)
|
||
)
|
||
|
||
bgm_offset = rng.uniform(BGM_OFFSET_MIN, BGM_OFFSET_MAX) if enable_bgm_offset else 0.0
|
||
return VideoMicroTransformPlan(
|
||
task_id=task_id,
|
||
video_index=video_index,
|
||
seed=seed,
|
||
clips=clips,
|
||
bgm_start_offset=round(bgm_offset, 3),
|
||
)
|
||
|
||
|
||
def build_bgm_offset_trim(start_offset: float, bgm_duration: float) -> str:
|
||
"""生成 BGM 起始偏移的 atrim 片段。
|
||
|
||
偏移超出 BGM 长度时回退为 0(从头播放),避免空输入。
|
||
返回的字符串形如 "atrim=start=3.200,",可拼到 BGM filter chain 最前面;
|
||
无需偏移时返回空串。
|
||
"""
|
||
if start_offset <= 0 or bgm_duration <= 0 or start_offset >= bgm_duration - 0.5:
|
||
return ""
|
||
return f"atrim=start={start_offset:.3f},"
|