style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 18s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 20s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m23s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (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 Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API 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 / ACR Image Cleanup (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) Successful in 2m23s
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled

This commit is contained in:
CI Bot
2026-09-08 02:06:55 +00:00
parent 4c9f5e52b5
commit de1b122159
5 changed files with 36 additions and 48 deletions
@@ -1872,24 +1872,24 @@ class UnifiedRenderService:
# 有转场效果:用 TransitionEngine 构建 xfade 链
# #1766: 提取逐转场时长(每个 clip 的 transition_duration,跳过第一个)
# layer_transition_durations[i] 对应 clip i 的转场,第 0 个忽略
per_transition_durations = [
d for idx, d in enumerate(layer_transition_durations) if idx > 0
]
per_transition_durations = [d for idx, d in enumerate(layer_transition_durations) if idx > 0]
# #1766: 提取每个 clip 的 jitter(存在 config 中),跳过第一个
layer_jitters = [
(all_clips[layer_clip_indices[idx]].config.get("transition_jitter", 0.0)
if isinstance(all_clips[layer_clip_indices[idx]].config, dict)
else 0.0)
(
all_clips[layer_clip_indices[idx]].config.get("transition_jitter", 0.0)
if isinstance(all_clips[layer_clip_indices[idx]].config, dict)
else 0.0
)
for idx in range(len(layer_clip_indices))
]
per_transition_jitters = [
j for idx, j in enumerate(layer_jitters) if idx > 0
]
per_transition_jitters = [j for idx, j in enumerate(layer_jitters) if idx > 0]
xfade_filter, xfade_estimated_dur = self._transition_engine.build_xfade_chain(
clip_durations=layer_durations,
clip_video_labels=layer_labels,
transitions=layer_transitions,
transition_durations=per_transition_durations if any(d > 0 for d in per_transition_durations) else None,
transition_durations=(
per_transition_durations if any(d > 0 for d in per_transition_durations) else None
),
jitters=per_transition_jitters if any(j != 0.0 for j in per_transition_jitters) else None,
output_label=out_label,
)
+13 -15
View File
@@ -24,11 +24,11 @@ logger = logging.getLogger(__name__)
#: 非硬切转场类型池(5 种效果,均为 FFmpeg xfade 支持的 transition 名称)
#: 选择标准:视觉效果差异大、FFmpeg 渲染稳定、肉眼可区分
TRANSITION_POOL: list[str] = [
"dissolve", # 溶解
"zoomin", # 缩放(放大进入)
"slideleft", # 左滑入
"wipeleft", # 左擦除
"fade", # 淡入淡出
"dissolve", # 溶解
"zoomin", # 缩放(放大进入)
"slideleft", # 左滑入
"wipeleft", # 左擦除
"fade", # 淡入淡出
]
#: 硬切(无转场效果),由 build_xfade_filter_chain 特殊处理(concat filter
@@ -164,16 +164,14 @@ def generate_transition_plan(
shorter_clip = min(prev_dur, next_dur)
duration = _apply_jitter(base_dur, jitter, shorter_clip)
jitter_val = (
rng.uniform(-TIMING_JITTER_MAX, TIMING_JITTER_MAX)
if effect != HARD_CUT
else 0.0
jitter_val = rng.uniform(-TIMING_JITTER_MAX, TIMING_JITTER_MAX) if effect != HARD_CUT else 0.0
plan.append(
{
"effect": effect,
"duration": round(duration, 3),
"jitter": round(jitter_val, 3),
}
)
plan.append({
"effect": effect,
"duration": round(duration, 3),
"jitter": round(jitter_val, 3),
})
return plan
+1 -4
View File
@@ -396,10 +396,7 @@ def _apply_transition_randomization(
硬切比例保证在 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"
]
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 片段,无转场点可随机化
+8 -19
View File
@@ -135,18 +135,16 @@ class TestGenerateTransitionPlan:
# 统计波动允许 ±10% 的宽松范围
assert 0.20 <= ratio <= 0.60, f"硬切比例 {ratio:.2%} 超出宽松范围"
# 更严格的范围检查(±5%
assert CUT_RATIO_MIN - 0.05 <= ratio <= CUT_RATIO_MAX + 0.05, (
f"硬切比例 {ratio:.2%} 超出 [25%, 55%] 范围"
)
assert CUT_RATIO_MIN - 0.05 <= ratio <= CUT_RATIO_MAX + 0.05, f"硬切比例 {ratio:.2%} 超出 [25%, 55%] 范围"
def test_transition_duration_in_range(self):
"""非硬切转场的时长在 [0.3, 0.8] 范围内。"""
plan = generate_transition_plan(30, rng=random.Random(42))
for item in plan:
if item["effect"] != HARD_CUT:
assert TRANSITION_DURATION_MIN <= item["duration"] <= TRANSITION_DURATION_MAX, (
f"转场时长 {item['duration']} 超出 [{TRANSITION_DURATION_MIN}, {TRANSITION_DURATION_MAX}]"
)
assert (
TRANSITION_DURATION_MIN <= item["duration"] <= TRANSITION_DURATION_MAX
), f"转场时长 {item['duration']} 超出 [{TRANSITION_DURATION_MIN}, {TRANSITION_DURATION_MAX}]"
def test_cut_duration_is_zero(self):
"""硬切转场的时长必须为 0。"""
@@ -159,9 +157,7 @@ class TestGenerateTransitionPlan:
"""jitter 在 [-0.5, +0.5] 范围内。"""
plan = generate_transition_plan(30, rng=random.Random(42))
for item in plan:
assert -TIMING_JITTER_MAX <= item["jitter"] <= TIMING_JITTER_MAX, (
f"jitter {item['jitter']} 超出范围"
)
assert -TIMING_JITTER_MAX <= item["jitter"] <= TIMING_JITTER_MAX, f"jitter {item['jitter']} 超出范围"
def test_cut_jitter_is_zero(self):
"""硬切转场的 jitter 必须为 0。"""
@@ -175,10 +171,7 @@ class TestGenerateTransitionPlan:
plans_seen = set()
for seed in range(20):
plan = generate_transition_plan(5, rng=random.Random(seed))
plan_sig = tuple(
(item["effect"], item["duration"])
for item in plan
)
plan_sig = tuple((item["effect"], item["duration"]) for item in plan)
plans_seen.add(plan_sig)
assert len(plans_seen) >= 2, "20 个 seed 只产生 1 种转场序列"
@@ -210,9 +203,7 @@ class TestGenerateTransitionPlan:
avg_long = sum(long_durs) / len(long_durs)
avg_short = sum(short_durs) / len(short_durs)
# 长片段平均转场时长 >= 短片段(协同节奏)
assert avg_long >= avg_short * 0.95, (
f"长片段转场 {avg_long:.3f}s 不应显著短于短片段 {avg_short:.3f}s"
)
assert avg_long >= avg_short * 0.95, f"长片段转场 {avg_long:.3f}s 不应显著短于短片段 {avg_short:.3f}s"
def test_clip_durations_none_uses_default(self):
"""clip_durations=None 时使用默认值 5.0。"""
@@ -269,9 +260,7 @@ class TestTransitionRandomizationIntegration:
)
transition_seqs.add(seq)
assert len(transition_seqs) >= 2, (
f"5 个 seed 只产生 {len(transition_seqs)} 种转场序列"
)
assert len(transition_seqs) >= 2, f"5 个 seed 只产生 {len(transition_seqs)} 种转场序列"
def test_reselect_preserves_non_main_transitions(self):
"""非 main 片段(intro/outro)的转场不被随机化。"""
@@ -150,6 +150,7 @@ class TestJitters:
)
# 提取 offset 值
import re
offset_no = float(re.search(r"offset=([\d.]+)", result_no_jitter).group(1))
offset_yes = float(re.search(r"offset=([\d.]+)", result_with_jitter).group(1))
assert offset_yes > offset_no
@@ -170,6 +171,7 @@ class TestJitters:
jitters=[-0.3], # 负 jitter:提前转场
)
import re
offset_no = float(re.search(r"offset=([\d.]+)", result_no_jitter).group(1))
offset_yes = float(re.search(r"offset=([\d.]+)", result_with_jitter).group(1))
assert offset_yes < offset_no
@@ -184,6 +186,7 @@ class TestJitters:
jitters=[-100.0], # 极大负 jitter
)
import re
offset = float(re.search(r"offset=([\d.]+)", result).group(1))
assert offset >= 0.0
@@ -197,6 +200,7 @@ class TestJitters:
jitters=[0.2, -0.1],
)
import re
offsets = [float(m.group(1)) for m in re.finditer(r"offset=([\d.]+)", result)]
assert len(offsets) == 2