Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32990194d4 | |||
| 11dde783b9 | |||
| ddb1a3544d | |||
| 323bd2da5e | |||
| 058bfac5c2 |
@@ -84,7 +84,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template])
|
||||
|
||||
// ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ──
|
||||
const TITLE_MARGIN_TOP = 60
|
||||
const TITLE_MARGIN_TOP = 120
|
||||
const TITLE_MARGIN_BOTTOM = 60
|
||||
const TITLE_MARGIN_SIDE = 40
|
||||
const playRes = (() => {
|
||||
|
||||
@@ -40,7 +40,7 @@ interface PreviewVideoPanelProps {
|
||||
}
|
||||
|
||||
/* ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ── */
|
||||
const TITLE_MARGIN_TOP = 60
|
||||
const TITLE_MARGIN_TOP = 120
|
||||
const TITLE_MARGIN_BOTTOM = 60
|
||||
const TITLE_MARGIN_SIDE = 40
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
|
||||
className="xx-slider"
|
||||
type="range"
|
||||
min={12}
|
||||
max={48}
|
||||
max={128}
|
||||
value={settings.size}
|
||||
onChange={(e) => onUpdateSize(Number(e.target.value))}
|
||||
/>
|
||||
|
||||
@@ -512,6 +512,9 @@ def mix_with_independent_audio(
|
||||
clip_filters.append("asetpts=PTS-STARTPTS")
|
||||
else:
|
||||
clip_filters.append("asetpts=PTS-STARTPTS")
|
||||
vol = _clip_volume(clip)
|
||||
if abs(vol - 1.0) >= 1e-6:
|
||||
clip_filters.append(f"volume={vol:.4f}")
|
||||
# aformat 归一化:concat/amix 前统一音频参数,否则不同采样率/声道会失败
|
||||
clip_filters.append(AFORMAT)
|
||||
filter_parts.append(f"[{input_idx}:a]{','.join(clip_filters)}[ma{input_idx}]")
|
||||
|
||||
@@ -824,6 +824,17 @@ class UnifiedRenderService:
|
||||
)
|
||||
audio_layer.clips.append(vo_clip)
|
||||
|
||||
# replace 模式:静音原视频音轨(main + broll 图层)
|
||||
if tts_config.overlap_mode == "replace":
|
||||
for layer in layers:
|
||||
if layer.role in ("main", "broll"):
|
||||
for clip in layer.clips:
|
||||
clip.config["volume"] = 0
|
||||
logger.info(
|
||||
"TTS replace 模式:已静音原视频音轨: plan_id=%s",
|
||||
self.plan.id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"TTS 配音已添加: plan_id=%s voice_id=%s segments=%d total_%.2fs",
|
||||
self.plan.id,
|
||||
@@ -889,6 +900,16 @@ class UnifiedRenderService:
|
||||
)
|
||||
audio_layer.clips.append(vo_clip)
|
||||
|
||||
# 配音素材库默认替换原音:静音原视频音轨(main + broll 图层)
|
||||
for layer in layers:
|
||||
if layer.role in ("main", "broll"):
|
||||
for clip in layer.clips:
|
||||
clip.config["volume"] = 0
|
||||
logger.info(
|
||||
"配音素材库:已静音原视频音轨: plan_id=%s",
|
||||
self.plan.id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"配音素材库音频已添加到 audio 图层: plan_id=%s duration=%.2fs",
|
||||
self.plan.id,
|
||||
|
||||
@@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Title/Subtitle 默认边距(像素)
|
||||
TITLE_MARGIN_TOP = 60
|
||||
TITLE_MARGIN_TOP = 120
|
||||
TITLE_MARGIN_BOTTOM = 60
|
||||
TITLE_MARGIN_SIDE = 40
|
||||
|
||||
@@ -147,6 +147,8 @@ def escape_ass_text(text: str) -> str:
|
||||
Returns:
|
||||
转义后的 ASS 文本
|
||||
"""
|
||||
# 用户手动换行符(半角/全角斜杠)转为 ASS 硬换行(在自动换行之前优先处理)
|
||||
text = text.replace("/", "\\N").replace("/", "\\N")
|
||||
# 将实际换行转为 ASS 硬换行
|
||||
text = text.replace("\r\n", "\\N").replace("\n", "\\N").replace("\r", "\\N")
|
||||
# 转义大括号(ASS 用它做样式覆盖标签)
|
||||
@@ -195,26 +197,33 @@ def _wrap_title_text(
|
||||
if available_width <= 0:
|
||||
return text
|
||||
|
||||
lines: list[str] = []
|
||||
current_line = ""
|
||||
current_width = 0.0
|
||||
# 先按已有 \N 分段,每段独立自动换行,最后用 \N 拼回
|
||||
segments = text.split("\\N")
|
||||
wrapped_segments: list[str] = []
|
||||
|
||||
for ch in text:
|
||||
# CJK 字符按全角估算,其他按半角
|
||||
char_width = float(font_size) if ord(ch) > 0x2E80 else font_size * 0.55
|
||||
for seg in segments:
|
||||
lines: list[str] = []
|
||||
current_line = ""
|
||||
current_width = 0.0
|
||||
|
||||
if current_width + char_width > available_width and current_line:
|
||||
for ch in seg:
|
||||
# CJK 字符按全角估算,其他按半角
|
||||
char_width = float(font_size) if ord(ch) > 0x2E80 else font_size * 0.55
|
||||
|
||||
if current_width + char_width > available_width and current_line:
|
||||
lines.append(current_line)
|
||||
current_line = ch
|
||||
current_width = char_width
|
||||
else:
|
||||
current_line += ch
|
||||
current_width += char_width
|
||||
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
current_line = ch
|
||||
current_width = char_width
|
||||
else:
|
||||
current_line += ch
|
||||
current_width += char_width
|
||||
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
wrapped_segments.append("\\N".join(lines))
|
||||
|
||||
return "\\N".join(lines)
|
||||
return "\\N".join(wrapped_segments)
|
||||
|
||||
|
||||
def build_ass_content(
|
||||
|
||||
@@ -218,6 +218,14 @@ class TestEscapeAssText:
|
||||
def test_chinese_text(self):
|
||||
assert escape_ass_text("你好世界") == "你好世界"
|
||||
|
||||
def test_slash_converted_to_newline(self):
|
||||
"""半角斜杠 / 应转为 ASS 硬换行。"""
|
||||
assert escape_ass_text("标题一/标题二") == "标题一\\N标题二"
|
||||
|
||||
def test_fullwidth_slash_converted_to_newline(self):
|
||||
"""全角斜杠 / 应转为 ASS 硬换行。"""
|
||||
assert escape_ass_text("标题一/标题二") == "标题一\\N标题二"
|
||||
|
||||
def test_backslash_n_in_input(self):
|
||||
# 文本里本身有 \n 字符串(不是换行符)
|
||||
result = escape_ass_text("\\n")
|
||||
|
||||
@@ -190,6 +190,19 @@ class TestEscapeAssText:
|
||||
def test_chinese_text(self):
|
||||
assert escape_ass_text("你好世界") == "你好世界"
|
||||
|
||||
def test_slash_converted_to_newline(self):
|
||||
"""半角斜杠 / 应转为 ASS 硬换行。"""
|
||||
assert escape_ass_text("第一行/第二行") == "第一行\\N第二行"
|
||||
|
||||
def test_fullwidth_slash_converted_to_newline(self):
|
||||
"""全角斜杠 / 应转为 ASS 硬换行。"""
|
||||
assert escape_ass_text("第一行/第二行") == "第一行\\N第二行"
|
||||
|
||||
def test_mixed_slashes_and_newlines(self):
|
||||
"""斜杠和换行符都应转为硬换行。"""
|
||||
result = escape_ass_text("A/B\nC/D")
|
||||
assert result == "A\\NB\\NC\\ND"
|
||||
|
||||
|
||||
# ── 时间格式化 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -440,7 +453,7 @@ class TestBuildAssContent:
|
||||
|
||||
class TestConstants:
|
||||
def test_title_margin_top(self):
|
||||
assert TITLE_MARGIN_TOP == 60
|
||||
assert TITLE_MARGIN_TOP == 120
|
||||
|
||||
def test_title_margin_bottom(self):
|
||||
assert TITLE_MARGIN_BOTTOM == 60
|
||||
@@ -491,6 +504,35 @@ class TestWrapTitleText:
|
||||
"""字号为0时直接返回原文。"""
|
||||
assert _wrap_title_text("测试", 1080, 0) == "测试"
|
||||
|
||||
def test_preserves_explicit_newline(self):
|
||||
"""已有的 \\N 换行标记应保留,不被当普通字符算宽度。"""
|
||||
text = "第一行\\N第二行"
|
||||
result = _wrap_title_text(text, video_width=1080, font_size=48)
|
||||
assert result == text
|
||||
|
||||
def test_explicit_newline_each_segment_wraps_independently(self):
|
||||
"""\\N 分段后,每段各自自动换行。"""
|
||||
# 480px 宽,48px 字号,可用 400px,每段约8个中文字
|
||||
text = "这是第一段很长很长很长的内容\\N这是第二段也很长很长的内容"
|
||||
result = _wrap_title_text(text, video_width=480, font_size=48)
|
||||
# 应该有多个 \N:用户手动的 + 自动换行的
|
||||
assert "\\N" in result
|
||||
segments = result.split("\\N")
|
||||
# 至少3行(两段都需要换行)
|
||||
assert len(segments) >= 3
|
||||
# 验证包含两段的文字
|
||||
joined = result.replace("\\N", "")
|
||||
assert "第一段" in joined
|
||||
assert "第二段" in joined
|
||||
|
||||
def test_multiple_explicit_newlines(self):
|
||||
"""多个 \\N 分段都应保留。"""
|
||||
text = "A\\NB\\NC"
|
||||
result = _wrap_title_text(text, video_width=1080, font_size=48)
|
||||
assert result == text
|
||||
assert result.count("\\N") == 2
|
||||
|
||||
|
||||
def test_build_ass_content_integration(self):
|
||||
"""集成测试:build_ass_content 中的标题应该自动换行。"""
|
||||
long_title = "这是一段非常长的标题文字用于测试自动换行功能是否正常工作"
|
||||
|
||||
@@ -11,6 +11,7 @@ from video_processing.render_audio import (
|
||||
RenderContext,
|
||||
clip_effective_duration,
|
||||
clip_has_audio,
|
||||
mix_with_independent_audio,
|
||||
)
|
||||
from video_processing.unified_render_service import ResolvedClip
|
||||
|
||||
@@ -153,3 +154,93 @@ class TestRenderContext:
|
||||
"""音频缓存初始为空."""
|
||||
ctx = _make_ctx()
|
||||
assert ctx._audio_cache == {}
|
||||
|
||||
|
||||
class TestMixWithIndependentAudioVolume:
|
||||
"""测试 mix_with_independent_audio 中主视频 clip 音量滤镜是否生效。"""
|
||||
|
||||
def _make_clip(self, volume=None, clip_id="c1", duration=5.0):
|
||||
"""创建测试用 ResolvedClip。"""
|
||||
config = {}
|
||||
if volume is not None:
|
||||
config["volume"] = volume
|
||||
return ResolvedClip(
|
||||
clip_id=clip_id,
|
||||
asset_id=f"a_{clip_id}",
|
||||
local_path=Path(f"/tmp/{clip_id}.mp4"),
|
||||
clip_type="main",
|
||||
order=0,
|
||||
duration=duration,
|
||||
actual_duration=duration,
|
||||
config=config,
|
||||
)
|
||||
|
||||
def test_main_clip_volume_zero_applied_in_filter(self):
|
||||
"""volume=0 的主视频 clip 应在 filter_complex 中包含 volume=0.0000。"""
|
||||
from unittest.mock import patch
|
||||
|
||||
ctx = _make_ctx()
|
||||
main_clip = self._make_clip(volume=0, clip_id="m1")
|
||||
captured_cmd = {}
|
||||
|
||||
def fake_run_ffmpeg(cmd):
|
||||
captured_cmd["cmd"] = cmd
|
||||
|
||||
with patch("video_processing.render_audio.run_ffmpeg", side_effect=fake_run_ffmpeg):
|
||||
mix_with_independent_audio(
|
||||
ctx=ctx,
|
||||
main_clips=[main_clip],
|
||||
audio_clips=[],
|
||||
output_path=Path("/tmp/out.m4a"),
|
||||
video_duration=5.0,
|
||||
)
|
||||
|
||||
filter_complex = captured_cmd["cmd"][captured_cmd["cmd"].index("-filter_complex") + 1]
|
||||
assert "volume=0.0000" in filter_complex, f"volume filter missing in: {filter_complex}"
|
||||
|
||||
def test_main_clip_default_volume_no_filter(self):
|
||||
"""默认 volume=1.0 时不应添加 volume 滤镜。"""
|
||||
from unittest.mock import patch
|
||||
|
||||
ctx = _make_ctx()
|
||||
main_clip = self._make_clip(clip_id="m1") # no volume set
|
||||
captured_cmd = {}
|
||||
|
||||
def fake_run_ffmpeg(cmd):
|
||||
captured_cmd["cmd"] = cmd
|
||||
|
||||
with patch("video_processing.render_audio.run_ffmpeg", side_effect=fake_run_ffmpeg):
|
||||
mix_with_independent_audio(
|
||||
ctx=ctx,
|
||||
main_clips=[main_clip],
|
||||
audio_clips=[],
|
||||
output_path=Path("/tmp/out.m4a"),
|
||||
video_duration=5.0,
|
||||
)
|
||||
|
||||
filter_complex = captured_cmd["cmd"][captured_cmd["cmd"].index("-filter_complex") + 1]
|
||||
# 默认音量不应出现 volume= 滤镜
|
||||
assert "volume=" not in filter_complex, f"unexpected volume filter in: {filter_complex}"
|
||||
|
||||
def test_main_clip_partial_volume_applied(self):
|
||||
"""volume=0.5 的主视频 clip 应包含 volume=0.5000。"""
|
||||
from unittest.mock import patch
|
||||
|
||||
ctx = _make_ctx()
|
||||
main_clip = self._make_clip(volume=0.5, clip_id="m1")
|
||||
captured_cmd = {}
|
||||
|
||||
def fake_run_ffmpeg(cmd):
|
||||
captured_cmd["cmd"] = cmd
|
||||
|
||||
with patch("video_processing.render_audio.run_ffmpeg", side_effect=fake_run_ffmpeg):
|
||||
mix_with_independent_audio(
|
||||
ctx=ctx,
|
||||
main_clips=[main_clip],
|
||||
audio_clips=[],
|
||||
output_path=Path("/tmp/out.m4a"),
|
||||
video_duration=5.0,
|
||||
)
|
||||
|
||||
filter_complex = captured_cmd["cmd"][captured_cmd["cmd"].index("-filter_complex") + 1]
|
||||
assert "volume=0.5000" in filter_complex, f"volume filter missing in: {filter_complex}"
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure packages is importable
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "packages"))
|
||||
|
||||
@@ -239,3 +241,39 @@ class TestFullStyleConsistency:
|
||||
assert int(fields[17]) == 2
|
||||
# Alignment = 8 (top)
|
||||
assert int(fields[18]) == 8
|
||||
|
||||
|
||||
class TestTitleSlashNewline:
|
||||
"""用户输入 / 或 / 应触发标题换行。"""
|
||||
|
||||
def test_halfwidth_slash_in_title(self):
|
||||
content = build_ass_content(
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
video_duration=10.0,
|
||||
title_text="第一行/第二行",
|
||||
title_config={"size": 48},
|
||||
)
|
||||
for line in content.splitlines():
|
||||
if "Dialogue" in line and "TitleStyle" in line:
|
||||
assert "\\N" in line, f"斜杠应转为换行: {line}"
|
||||
assert "第一行" in line
|
||||
assert "第二行" in line
|
||||
break
|
||||
else:
|
||||
pytest.fail("未找到 TitleStyle Dialogue 行")
|
||||
|
||||
def test_fullwidth_slash_in_title(self):
|
||||
content = build_ass_content(
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
video_duration=10.0,
|
||||
title_text="第一行/第二行",
|
||||
title_config={"size": 48},
|
||||
)
|
||||
for line in content.splitlines():
|
||||
if "Dialogue" in line and "TitleStyle" in line:
|
||||
assert "\\N" in line, f"全角斜杠应转为换行: {line}"
|
||||
break
|
||||
else:
|
||||
pytest.fail("未找到 TitleStyle Dialogue 行")
|
||||
|
||||
Reference in New Issue
Block a user