diff --git a/apps/api/app/api/routes/templates_editor/clips.py b/apps/api/app/api/routes/templates_editor/clips.py index 44dcdd16e..3243dee4d 100755 --- a/apps/api/app/api/routes/templates_editor/clips.py +++ b/apps/api/app/api/routes/templates_editor/clips.py @@ -23,6 +23,10 @@ import re from app.auth import AuthenticatedUser, get_current_user from app.core.storage import get_storage_service from app.dependencies import get_asset_repository, get_db_session + +# 默认转场时长(与 worker 端保持一致) +_DEFAULT_TRANSITION_DURATION = 0.5 + from app.services.asset_segment_tracker import ( REUSE_RATIO_LIMIT, SEGMENT_EDGE_GAP, @@ -706,9 +710,20 @@ def create_clips_from_assets_editor( # 素材耗尽标志:某轮循环中所有素材均被跳过时为 True all_assets_exhausted = False + # 计算转场重叠补偿:每个 clip 需要额外增加的时长 + # 目标:渲染后视频总时长 = 模板设定的各片段时长之和 + # 公式:每 clip 增加 (n_segments - 1) * td / n_segments + n_segments = len(segments) + if n_segments > 1: + transition_compensation = (n_segments - 1) * _DEFAULT_TRANSITION_DURATION / n_segments + else: + transition_compensation = 0.0 + for i, (_seg_order, dur_min, dur_max) in enumerate(segments): # 在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数) raw_duration = random.uniform(dur_min, dur_max) + # 加上转场补偿,确保最终输出时长 = 模板设定总时长 + raw_duration += transition_compensation # 贪心分配素材:按"已使用次数"升序排列候选素材(使用最少的优先), # 同次数随机打散,避免"A-B-C-D"的固定组合反复出现。 diff --git a/apps/worker/video_processing/unified_render_service.py b/apps/worker/video_processing/unified_render_service.py index 3906869b0..4dd3323a4 100755 --- a/apps/worker/video_processing/unified_render_service.py +++ b/apps/worker/video_processing/unified_render_service.py @@ -200,6 +200,22 @@ class UnifiedRenderService: # 3. 计算视频总时长(用于字幕显示时长) video_duration = self._estimate_total_duration(layers) + # Debug: 输出各图层时长明细 + for layer in layers: + layer_total = sum(UnifiedRenderService._clip_adjusted_duration(c) for c in layer.clips) + clip_details = [ + f"{c.clip_id}(dur={c.duration:.3f},actual={c.actual_duration:.3f},speed={getattr(c, 'playback_speed', 1.0):.4f})" + for c in layer.clips + ] + logger.info( + "[debug] layer=%s clips=%d total=%.3f transition_duration=%.3f details=%s", + layer.role, + len(layer.clips), + layer_total, + self.transition_duration, + ", ".join(clip_details), + ) + logger.info("[debug] estimated video_duration=%.3f", video_duration) # 3.5 TTS 配音生成(如果配置了) self._maybe_add_voiceover_layer(layers, video_duration=video_duration) @@ -1422,6 +1438,7 @@ class UnifiedRenderService: if trim_segments and len(trim_segments) > 1: # 多段裁剪:展开为多个 clip resolved_segments = TrimEngine.resolve_segments(trim_segments, actual_duration) + configured_speed = getattr(clip, "playback_speed", 1.0) or 1.0 for i, seg in enumerate(resolved_segments): # 每个段生成一个独立的 ResolvedClip seg_clip_id = f"{clip.id}_seg_{seg.segment_id}" @@ -1429,6 +1446,19 @@ class UnifiedRenderService: seg_start = seg.trim.start_time seg_duration = seg.trim.duration + # 多段裁剪:如果段的时长超过素材实际时长,减速补偿 + seg_speed = configured_speed + if actual_duration > 0 and seg_duration > actual_duration + 0.05: + seg_speed = max(0.25, round(configured_speed * actual_duration / seg_duration, 4)) + logger.info( + "[debug] multi-seg clip=%s seg=%s duration=%.3f actual=%.3f → speed=%.4f", + clip.id, + seg.segment_id, + seg_duration, + actual_duration, + seg_speed, + ) + rc = ResolvedClip( clip_id=seg_clip_id, asset_id=asset_id, @@ -1439,7 +1469,7 @@ class UnifiedRenderService: duration=seg_duration, transition_effect=clip.transition_effect or "cut", transition_duration=getattr(clip, "transition_duration", 0.0) or 0.0, - playback_speed=getattr(clip, "playback_speed", 1.0) or 1.0, + playback_speed=seg_speed, config={**clip_config, "_segment_id": seg.segment_id}, actual_duration=actual_duration, trim_config=seg.trim, @@ -1461,6 +1491,7 @@ class UnifiedRenderService: effective_trim: TrimConfig | None = None final_start = clip.start_time final_duration = clip.duration + configured_speed = getattr(clip, "playback_speed", 1.0) or 1.0 if trim_config is not None and actual_duration > 0: effective_trim = trim_config.validate_and_resolve(actual_duration) @@ -1474,6 +1505,25 @@ class UnifiedRenderService: final_start = 0.0 final_duration = actual_duration + # 素材实际时长不足以覆盖配置的时长时,降低播放速度来补偿 + # 例如:配置4s但素材只有3s → speed=0.75x,用满3s素材达到4s输出 + if actual_duration > 0 and final_duration > actual_duration + 0.05: + compensated_speed = actual_duration / final_duration + # 保留用户设置的速度(如果已减速则叠加) + final_speed = configured_speed * compensated_speed + # 下限 0.25x + final_speed = max(0.25, round(final_speed, 4)) + logger.info( + "[debug] clip=%s duration=%.3f actual=%.3f → 减速补偿 speed=%.4f (configured=%.3f)", + clip.id, + final_duration, + actual_duration, + final_speed, + configured_speed, + ) + else: + final_speed = configured_speed + rc = ResolvedClip( clip_id=clip.id, asset_id=asset_id, @@ -1484,13 +1534,25 @@ class UnifiedRenderService: duration=final_duration, transition_effect=clip.transition_effect or "cut", transition_duration=getattr(clip, "transition_duration", 0.0) or 0.0, - playback_speed=getattr(clip, "playback_speed", 1.0) or 1.0, + playback_speed=final_speed, config=clip_config, actual_duration=actual_duration, trim_config=effective_trim, ) resolved.append(rc) + # Debug日志:记录每个clip的时长信息 + eff_dur = _clip_effective_duration_pure(final_duration, actual_duration) + logger.info( + "[debug] resolved clip=%s duration=%.3f actual=%.3f effective=%.3f speed=%.4f start=%.3f", + clip.id, + final_duration, + actual_duration, + eff_dur, + final_speed, + final_start, + ) + # 按 order 排序 resolved.sort(key=lambda c: c.order) return resolved @@ -1683,7 +1745,7 @@ class UnifiedRenderService: if d > 0: layer_dur = d break - xfade_filter, _ = self._transition_engine.build_xfade_chain( + xfade_filter, xfade_estimated_dur = self._transition_engine.build_xfade_chain( clip_durations=layer_durations, clip_video_labels=layer_labels, transitions=layer_transitions, @@ -1692,6 +1754,13 @@ class UnifiedRenderService: ) if xfade_filter: filter_parts.append(xfade_filter) + logger.info( + "[unified-render] layer=%s xfade: clips=%d durations=%s estimated_dur=%.3f", + layer.role, + len(layer_labels), + [round(d, 3) for d in layer_durations], + xfade_estimated_dur, + ) layer_output_labels[layer.role] = out_label # Step 3: 合成各层 @@ -1915,8 +1984,14 @@ class UnifiedRenderService: def _clip_effective_duration(clip: ResolvedClip) -> float: """计算 clip 的有效时长(原速 trim 后时长)。 + 如果 playback_speed < 1(为补偿素材不足而减速),返回配置的 duration, + 而非 min(duration, actual_duration)。 实际实现移至 packages.domain.render_layer_utils.clip_effective_duration。 """ + speed = getattr(clip, "playback_speed", 1.0) or 1.0 + # 减速场景:duration 已通过降低 playback_speed 补偿,返回配置的 duration + if speed < 1.0 - 1e-6 and clip.duration > 0: + return clip.duration return _clip_effective_duration_pure(clip.duration, clip.actual_duration) # ── 画中画(PiP)相关方法 ────────────────────────────────────────────────── diff --git a/packages/domain/xfade_builder.py b/packages/domain/xfade_builder.py index c5ec2a505..a4345e7c3 100755 --- a/packages/domain/xfade_builder.py +++ b/packages/domain/xfade_builder.py @@ -150,8 +150,11 @@ def build_xfade_filter_chain( else: first_input_dur = cumulative - total_transition - # 原始 offset 计算 - offset = max(0.0, cumulative - transition_duration * i) + # 正确的 offset 计算:offset 应相对于累积输出时长 + # offset = 累积输出中,转场开始的时间点 + # = first_input_dur - transition_duration + # 这样每个转场之间的"纯内容"时长等于原始 clip 时长 + offset = max(0.0, first_input_dur - transition_duration) # 安全钳制:offset + td 不能超过第一个输入的时长 available = max(0.0, first_input_dur - offset) diff --git a/tests/unit/test_asset_availability.py b/tests/unit/test_asset_availability.py index c37874394..4192a5ca8 100755 --- a/tests/unit/test_asset_availability.py +++ b/tests/unit/test_asset_availability.py @@ -197,14 +197,14 @@ class TestComputeAssetAvailability: def test_large_gap_remains_usable(self): """区间之间留有 ≥3s 空闲段(扩边后仍 ≥3s)→ usable=True。""" - # [0,2] 扩边到 [0,2.3],[5.3,10] 扩边前为 [5,10] 扩边起 4.7;空闲 [2.3,4.7]=2.4s <3 - # 改用更大间隙:[0,2] 与 [6,10],扩边后空闲 [2.3,5.7]=3.4s ≥3 + # [0,2] 扩边到 [0,3.5],[9,10] 扩边到 [7.5,10];空闲 [3.5,7.5]=4.0s >=3 + # 使用 [0,2] 与 [9,10],扩边后空闲 [3.5,7.5]=4.0s ≥3 → usable info = compute_asset_availability( _make_asset( duration=10.0, ranges=[ _range(0.0, 2.0, use_count=MAX_RANGE_USE_COUNT), - _range(6.0, 10.0, use_count=MAX_RANGE_USE_COUNT), + _range(9.0, 10.0, use_count=MAX_RANGE_USE_COUNT), ], ) ) @@ -236,8 +236,8 @@ class TestComputeAssetAvailability: assert info["usable"] is True def test_segment_edge_gap_constant(self): - """边缘间隙常量为 0.3s(与 MediaKit 冲突检测同口径)。""" - assert SEGMENT_EDGE_GAP == 0.3 + """边缘间隙常量为 1.5s(与 MediaKit 冲突检测同口径)。""" + assert SEGMENT_EDGE_GAP == 1.5 def test_domain_entity_metadata_dict_form(self): """领域实体形态(metadata 为 dict,无 classification_result)也能读到区间。 diff --git a/tests/unit/test_duration_compensation.py b/tests/unit/test_duration_compensation.py new file mode 100644 index 000000000..abd4c08da --- /dev/null +++ b/tests/unit/test_duration_compensation.py @@ -0,0 +1,97 @@ +"""Tests for duration compensation when source video is shorter than configured duration.""" + +from pathlib import Path + +import pytest + +from apps.worker.video_processing.unified_render_service import ResolvedClip, UnifiedRenderService + + +class TestClipEffectiveDurationWithSpeedCompensation: + """Test _clip_effective_duration handles speed < 1 correctly.""" + + def test_normal_speed_returns_min(self): + """When speed=1.0, effective_duration = min(duration, actual_duration).""" + clip = ResolvedClip( + clip_id="c1", + asset_id="a1", + local_path=Path("/tmp/fake.mp4"), + clip_type="main", + order=0, + start_time=0.0, + duration=4.0, + actual_duration=3.0, # shorter than configured + playback_speed=1.0, + ) + # Without speed compensation, effective = min(4, 3) = 3 + assert UnifiedRenderService._clip_effective_duration(clip) == 3.0 + + def test_compensated_speed_returns_configured_duration(self): + """When speed < 1 (compensated), effective_duration = configured duration.""" + clip = ResolvedClip( + clip_id="c1", + asset_id="a1", + local_path=Path("/tmp/fake.mp4"), + clip_type="main", + order=0, + start_time=0.0, + duration=4.0, + actual_duration=3.0, # shorter than configured + playback_speed=0.75, # compensated: 3/4 = 0.75 + ) + # With speed compensation, effective = configured duration = 4.0 + assert UnifiedRenderService._clip_effective_duration(clip) == 4.0 + + def test_zero_actual_duration_returns_configured(self): + """When actual_duration=0, effective_duration = configured duration.""" + clip = ResolvedClip( + clip_id="c1", + asset_id="a1", + local_path=Path("/tmp/fake.mp4"), + clip_type="main", + order=0, + start_time=0.0, + duration=4.0, + actual_duration=0.0, + playback_speed=1.0, + ) + assert UnifiedRenderService._clip_effective_duration(clip) == 4.0 + + def test_compensated_speed_with_actual_zero(self): + """When speed < 1 and actual=0, still returns configured duration.""" + clip = ResolvedClip( + clip_id="c1", + asset_id="a1", + local_path=Path("/tmp/fake.mp4"), + clip_type="main", + order=0, + start_time=0.0, + duration=4.0, + actual_duration=0.0, + playback_speed=0.5, + ) + assert UnifiedRenderService._clip_effective_duration(clip) == 4.0 + + +class TestClipAdjustedDurationWithSpeedCompensation: + """Test _clip_adjusted_duration accounts for compensated speed.""" + + def test_adjusted_duration_with_compensation(self): + """Adjusted duration = min(duration, actual) / speed. + With compensation: min(4,3)/0.75 = 3/0.75 = 4.0 + This equals the configured duration, which is the goal. + """ + clip = ResolvedClip( + clip_id="c1", + asset_id="a1", + local_path=Path("/tmp/fake.mp4"), + clip_type="main", + order=0, + start_time=0.0, + duration=4.0, + actual_duration=3.0, + playback_speed=0.75, # compensated + ) + adjusted = UnifiedRenderService._clip_adjusted_duration(clip) + # min(4,3)/0.75 = 3/0.75 = 4.0 (matches configured duration) + assert abs(adjusted - 4.0) < 0.01 diff --git a/tests/unit/test_editor_clips_random_start.py b/tests/unit/test_editor_clips_random_start.py index d3fb9a1ee..ac92e52b0 100644 --- a/tests/unit/test_editor_clips_random_start.py +++ b/tests/unit/test_editor_clips_random_start.py @@ -632,7 +632,8 @@ class TestReuseRatioGate: clips_data = _get_clips_data_from_call(mock_plan_svc) assert len(clips_data) == 13 # 1 个复用片段,占比 1/13 ≈ 7.7% ≤ 15% - assert reused.get("a1", 0.0) == 5.0 + # 转场补偿: raw_duration = 5.0 + (13-1)*0.5/13 ≈ 5.462 → round(5.462,1) = 5.5 + assert abs(reused.get("a1", 0.0) - 5.5) < 0.1 @patch("app.api.routes.templates_editor.clips.get_storage_service") def test_reuse_ratio_exceeded_returns_400(self, mock_storage): diff --git a/tests/unit/test_mediakit_conflicts.py b/tests/unit/test_mediakit_conflicts.py index 5c5a7cf96..c9f257be7 100755 --- a/tests/unit/test_mediakit_conflicts.py +++ b/tests/unit/test_mediakit_conflicts.py @@ -32,16 +32,16 @@ class TestRecommendedTimeConflicts: assert _recommended_time_conflicts(10.0, 5.0, [(10.0, 15.0)]) is True def test_touching_endpoint_conflicts_due_to_edge_gap(self): - """首尾紧贴(推荐 15 开始,已用 [10,15]):0.3s 扩边内 → 冲突。""" + """首尾紧贴(推荐 15 开始,已用 [10,15]):1.5s 扩边内 → 冲突。""" assert _recommended_time_conflicts(15.0, 5.0, [(10.0, 15.0)]) is True def test_gap_within_edge_gap_conflicts(self): - """间隔 0.2s(< 0.3s 边缘间隙)→ 冲突。""" + """间隔 0.2s(< 1.5s 边缘间隙)→ 冲突。""" assert _recommended_time_conflicts(15.2, 5.0, [(10.0, 15.0)]) is True def test_gap_beyond_edge_gap_no_conflict(self): - """间隔 0.5s(> 0.3s 边缘间隙)→ 不冲突。""" - assert _recommended_time_conflicts(15.5, 5.0, [(10.0, 15.0)]) is False + """间隔 2.0s(> 1.5s 边缘间隙)→ 不冲突。""" + assert _recommended_time_conflicts(17.0, 5.0, [(10.0, 15.0)]) is False def test_far_apart_no_conflict(self): """相隔很远 → 不冲突。""" @@ -54,7 +54,9 @@ class TestRecommendedTimeConflicts: """多个已用区间,任一冲突即返回 True。""" used = [(0.0, 5.0), (10.0, 15.0), (20.0, 25.0)] assert _recommended_time_conflicts(12.0, 2.0, used) is True - assert _recommended_time_conflicts(6.0, 2.0, used) is False + assert ( + _recommended_time_conflicts(6.5, 2.0, used) is False + ) # range [6.5,8.5], just outside all expanded used ranges def test_custom_edge_gap(self): """edge_gap 可配置:gap=0 时紧贴不冲突(端点相接不算重叠)。""" @@ -64,5 +66,5 @@ class TestRecommendedTimeConflicts: assert _recommended_time_conflicts(15.5, 5.0, [(10.0, 15.0)], edge_gap=1.0) is True def test_default_edge_gap_constant(self): - """默认边缘间隙常量为 0.3s(配置常量)。""" - assert SEGMENT_EDGE_GAP == 0.3 + """默认边缘间隙常量为 1.5s(配置常量)。""" + assert SEGMENT_EDGE_GAP == 1.5