feat: 支持横竖屏自定义字幕位置,重构剪映导出逻辑

- 新增横竖屏分别的字幕垂直位置配置,默认值分别为85%和82%
- 更新WebUI字幕设置界面,新增独立的横屏/竖屏字幕位置标签页,在预览画面中添加蓝线标注当前字幕位置
- 重构剪映草稿导出逻辑,将相关代码抽离至独立模块,移除requirements.txt中的pyJianYingDraft直接依赖
- 优化媒体时长处理逻辑,新增时长缓存和自动裁剪处理,添加完整的单元测试覆盖
- 更新配置示例文件、数据Schema定义和中英多语言翻译文件
This commit is contained in:
viccy 2026-06-06 12:01:36 +08:00
parent 33c17c2636
commit 5a9775d62d
11 changed files with 1724 additions and 107 deletions

View File

@ -196,6 +196,8 @@ class VideoClipParams(BaseModel):
subtitle_mask_portrait_height_percent: float = 16.0
subtitle_mask_portrait_blur_radius: int = 26
subtitle_mask_portrait_opacity_percent: int = 84
subtitle_position_landscape_y_percent: float = 85.0
subtitle_position_portrait_y_percent: float = 82.0
subtitle_auto_transcribe_enabled: bool = False
subtitle_auto_transcribe_backend: str = "local"
subtitle_auto_transcribe_api_url: str = ""

View File

@ -224,6 +224,14 @@ def apply_subtitle_mask(video_clip, options):
return video_clip.transform(mask_frame)
def _resolve_orientation_subtitle_y_percent(video_width, video_height, options):
orientation = "portrait" if video_height > video_width else "landscape"
key = f"subtitle_position_{orientation}_y_percent"
if key not in options:
return None
return _clamp(_get_numeric_option(options, key, 85 if orientation == "landscape" else 82), 0, 99)
def is_valid_subtitle_file(subtitle_path: str) -> bool:
"""
检查字幕文件是否有效
@ -476,6 +484,7 @@ def merge_materials(
# 处理视频尺寸
video_width, video_height = video_clip.size
orientation_subtitle_y_percent = _resolve_orientation_subtitle_y_percent(video_width, video_height, options)
if subtitle_enabled and subtitle_mask_enabled:
video_clip = apply_subtitle_mask(video_clip, options)
@ -525,7 +534,14 @@ def merge_materials(
_clip = _clip.with_duration(duration)
# 设置字幕位置
if subtitle_position == "bottom":
if orientation_subtitle_y_percent is not None:
margin = 10
max_y = video_height - _clip.h - margin
min_y = margin
custom_y = (video_height - _clip.h) * (orientation_subtitle_y_percent / 100)
custom_y = max(min_y, min(custom_y, max_y))
_clip = _clip.with_position(("center", custom_y))
elif subtitle_position == "bottom":
_clip = _clip.with_position(("center", video_height * 0.95 - _clip.h))
elif subtitle_position == "top":
_clip = _clip.with_position(("center", video_height * 0.05))

File diff suppressed because it is too large Load Diff

View File

@ -3,25 +3,27 @@ import os
import subprocess
import time
from os import path
from typing import Dict
from loguru import logger
from app.config import config
from app.models import const
from app.models.schema import VideoClipParams
from app.services import voice, clip_video, update_script
from app.services.jianying_draft_builder import write_plaintext_jianying_draft
from app.services import state as sm
from app.utils import utils
def get_audio_duration_ffprobe(audio_file: str) -> float:
def get_media_duration_ffprobe(media_file: str) -> float:
"""
使用ffprobe获取音频文件的精确时长
使用ffprobe获取媒体文件的精确时长
Args:
audio_file: 音频文件路径
media_file: 媒体文件路径
Returns:
float: 音频时长精确到微秒
float: 媒体时长精确到微秒
"""
try:
cmd = [
@ -29,20 +31,24 @@ def get_audio_duration_ffprobe(audio_file: str) -> float:
'-v', 'error',
'-show_entries', 'format=duration',
'-of', 'csv=p=0',
audio_file
media_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
duration = float(result.stdout.strip())
logger.debug(f"使用ffprobe获取音频时长: {duration:.6f}")
logger.debug(f"使用ffprobe获取媒体时长: {duration:.6f}, 文件: {media_file}")
return duration
except subprocess.CalledProcessError as e:
logger.error(f"ffprobe执行失败: {e.stderr}")
raise
except Exception as e:
logger.error(f"获取音频时长失败: {str(e)}")
logger.error(f"获取媒体时长失败: {str(e)}")
raise
def get_audio_duration_ffprobe(audio_file: str) -> float:
return get_media_duration_ffprobe(audio_file)
def _strip_indextts2_prefix(voice_name: str) -> str:
prefix = "indextts2:"
if voice_name.startswith(prefix):
@ -54,6 +60,45 @@ def _floor_duration_to_milliseconds(duration: float) -> float:
return int(duration * 1000) / 1000.0
def _format_seconds_for_trange(seconds: float) -> str:
return f"{seconds:.3f}s"
def _get_cached_media_duration(media_file: str, duration_cache: Dict[str, float]) -> float:
if media_file not in duration_cache:
duration_cache[media_file] = _floor_duration_to_milliseconds(
get_media_duration_ffprobe(media_file)
)
return duration_cache[media_file]
def _clamp_duration_to_media(
requested_duration: float,
media_file: str,
duration_cache: Dict[str, float],
media_label: str,
source_start_time: float = 0.0,
) -> float:
requested_duration = _floor_duration_to_milliseconds(max(requested_duration, 0.0))
actual_duration = _get_cached_media_duration(media_file, duration_cache)
available_duration = _floor_duration_to_milliseconds(
max(actual_duration - max(source_start_time, 0.0), 0.0)
)
safe_duration = min(requested_duration, available_duration)
logger.info(
f"{media_label}实际时长: {actual_duration:.6f}秒, "
f"可用时长: {available_duration:.6f}秒, 请求时长: {requested_duration:.3f}"
)
if safe_duration < requested_duration:
logger.warning(
f"{media_label}短于脚本时长,已将剪映片段时长从 "
f"{requested_duration:.3f}秒 调整为 {safe_duration:.3f}"
)
return safe_duration
def _normalize_indextts2_reference_audio(params: VideoClipParams) -> None:
"""Ensure IndexTTS2 uses the configured reference audio instead of a stale UI voice."""
if params.tts_engine != "indextts2":
@ -158,103 +203,26 @@ def start_export_jianying_draft(task_id: str, params: VideoClipParams):
logger.info("\n\n## 4. 导出到剪映草稿")
try:
import pyJianYingDraft
from pyJianYingDraft import DraftFolder, VideoSegment, AudioSegment, trange, TrackType
jianying_draft_path = config.ui.get("jianying_draft_path", "")
if not jianying_draft_path:
raise ValueError("剪映草稿路径未配置")
# 创建DraftFolder实例
draft_folder = DraftFolder(jianying_draft_path)
# 使用从参数中获取的草稿名称,如果为空则使用默认名称
draft_name = getattr(params, 'draft_name', "")
logger.debug(f"从params获取的草稿名称: '{draft_name}' (类型: {type(draft_name)})")
if not draft_name:
draft_name = f"NarratoAI_{int(time.time())}"
logger.debug(f"使用默认草稿名称: '{draft_name}'")
# 创建新草稿
script = draft_folder.create_draft(draft_name, 1920, 1080)
# 添加视频轨道和音频轨道
script.add_track(TrackType.video, '视频轨道')
script.add_track(TrackType.audio, '音频轨道')
# 处理脚本数据
current_time = 0
output_dir = utils.task_dir(task_id)
for item in new_script_list:
# 获取时间信息
start_time = float(item.get('start_time', 0.0))
duration = float(item.get('duration', 0.0))
timestamp = item.get('timestamp', '')
logger.info(f"处理片段: OST={item['OST']}, start_time={start_time}, duration={duration}, timestamp={timestamp}")
# 生成音频文件路径
audio_file = ""
if timestamp:
timestamp_formatted = timestamp.replace(':', '_')
audio_file = os.path.join(
output_dir,
f"audio_{timestamp_formatted}.mp3"
)
# 检查是否有裁剪后的视频文件
video_file = item.get('video', '')
if video_file and not os.path.exists(video_file):
video_file = ""
# 添加视频片段
if video_file:
# 使用裁剪后的视频文件
# 对于裁剪后的视频target_timerange的第二个参数是持续时间
video_segment = VideoSegment(
video_file,
trange(f"{current_time}s", f"{duration}s")
)
else:
# 使用原始视频文件
# source_timerange是从原始视频中截取的部分
# target_timerange是片段在时间轴上的位置
video_segment = VideoSegment(
params.video_origin_path,
trange(f"{current_time}s", f"{duration}s"),
source_timerange=trange(f"{start_time}s", f"{duration}s")
)
script.add_segment(video_segment, '视频轨道')
# 处理音频
if item['OST'] in [0, 2]: # 需要TTS的片段
if os.path.exists(audio_file):
# 使用ffprobe获取精确的音频时长避免因TTS引擎差异导致时长不匹配
actual_audio_duration = get_audio_duration_ffprobe(audio_file)
actual_audio_duration = _floor_duration_to_milliseconds(actual_audio_duration)
logger.info(f"音频文件实际时长: {actual_audio_duration:.6f}秒, 脚本时长(视频): {duration:.3f}")
# 使用音频实际时长和视频时长中的较小值,确保不超过素材时长
# 当TTS语速调整时音频可能比视频长或短取较小值可以避免超出素材
safe_duration = min(actual_audio_duration, duration)
logger.info(f"使用时长: {safe_duration:.6f}秒 (取音频和视频时长的较小值)")
audio_segment = AudioSegment(
audio_file,
trange(f"{current_time}s", f"{safe_duration}s")
)
script.add_segment(audio_segment, '音频轨道')
else:
logger.warning(f"音频文件不存在: {audio_file}")
# OST=1的片段保留原声不需要添加额外音频
# 更新当前时间
current_time += duration
# 保存草稿
script.save()
draft_path = os.path.join(jianying_draft_path, draft_name)
draft_path, draft_name = write_plaintext_jianying_draft(
jianying_draft_path=jianying_draft_path,
draft_name=draft_name,
new_script_list=new_script_list,
params=params,
output_dir=output_dir,
)
logger.success(f"成功导出到剪映草稿: {draft_name}")
logger.info(f"草稿已保存到: {draft_path}")
@ -263,10 +231,6 @@ def start_export_jianying_draft(task_id: str, params: VideoClipParams):
sm.state.update_task(task_id, state=const.TASK_STATE_COMPLETE, progress=100, draft_path=draft_path, draft_name=draft_name)
return {"draft_path": draft_path, "draft_name": draft_name}
except ImportError as e:
logger.error(f"导入pyJianYingDraft失败: {e}")
raise ImportError(f"pyJianYingDraft库导入失败: {e}\n请确保已正确安装该库")
except Exception as e:
logger.error(f"导出到剪映草稿失败: {e}")
import traceback

View File

@ -49,6 +49,8 @@ def _build_subtitle_mask_options(params: VideoClipParams, enabled=None) -> dict:
'subtitle_mask_portrait_height_percent': getattr(params, "subtitle_mask_portrait_height_percent", 16.0),
'subtitle_mask_portrait_blur_radius': getattr(params, "subtitle_mask_portrait_blur_radius", 26),
'subtitle_mask_portrait_opacity_percent': getattr(params, "subtitle_mask_portrait_opacity_percent", 84),
'subtitle_position_landscape_y_percent': getattr(params, "subtitle_position_landscape_y_percent", 85.0),
'subtitle_position_portrait_y_percent': getattr(params, "subtitle_position_portrait_y_percent", 82.0),
}

View File

@ -1,10 +1,14 @@
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from app.models.schema import VideoClipParams
from app.services import jianying_task
from app.services import jianying_draft_builder, jianying_task
DraftPathPlaceholder = "##_draftpath_placeholder_0E685133-18CE-45ED-8CB8-2904A212EC80_##"
class JianyingTaskTests(unittest.TestCase):
@ -38,6 +42,120 @@ class JianyingTaskTests(unittest.TestCase):
self.assertAlmostEqual(6.997, jianying_task._floor_duration_to_milliseconds(6.997333))
self.assertAlmostEqual(7.0, jianying_task._floor_duration_to_milliseconds(7.000999))
def test_clamp_duration_to_media_uses_actual_media_duration(self):
duration_cache = {}
with patch.object(jianying_task, "get_media_duration_ffprobe", return_value=4.2809):
duration = jianying_task._clamp_duration_to_media(
requested_duration=4.31,
media_file="/tmp/clip.mp4",
duration_cache=duration_cache,
media_label="视频素材",
)
self.assertAlmostEqual(4.28, duration)
def test_clamp_duration_to_media_respects_source_start_time(self):
duration_cache = {}
with patch.object(jianying_task, "get_media_duration_ffprobe", return_value=10.0):
duration = jianying_task._clamp_duration_to_media(
requested_duration=4.0,
media_file="/tmp/original.mp4",
duration_cache=duration_cache,
media_label="原始视频素材",
source_start_time=8.5,
)
self.assertAlmostEqual(1.5, duration)
def test_format_seconds_for_trange_uses_millisecond_precision(self):
self.assertEqual("4.280s", jianying_task._format_seconds_for_trange(4.28))
def test_write_plaintext_jianying_draft_creates_root_package(self):
with tempfile.TemporaryDirectory() as temp_dir:
root_path = Path(temp_dir) / "drafts"
output_dir = Path(temp_dir) / "task"
root_path.mkdir()
output_dir.mkdir()
video_path = output_dir / "clip:01.mp4"
audio_path = output_dir / "audio_00_00_00,000-00_00_04,310.mp3"
video_path.write_bytes(b"fake video")
audio_path.write_bytes(b"fake audio")
params = VideoClipParams(
video_origin_path=str(video_path),
original_volume=0.4,
tts_volume=0.9,
)
script = [
{
"OST": 0,
"start_time": 0.0,
"duration": 4.31,
"timestamp": "00:00:00,000-00:00:04,310",
"video": str(video_path),
"audio": str(audio_path),
}
]
def fake_duration(file_path):
return 4.2809 if file_path == str(video_path) else 5.0
with (
patch.object(jianying_draft_builder, "_get_media_duration_ffprobe", side_effect=fake_duration),
patch.object(
jianying_draft_builder,
"_get_video_metadata_ffprobe",
return_value=(4_280_000, 720, 1280),
),
):
draft_path, draft_name = jianying_draft_builder.write_plaintext_jianying_draft(
str(root_path),
"NarratoAI_test",
script,
params,
str(output_dir),
)
draft_dir = Path(draft_path)
self.assertEqual("NarratoAI_test", draft_name)
self.assertTrue((draft_dir / "draft_info.json").exists())
self.assertTrue((draft_dir / "template-2.tmp").exists())
self.assertTrue((draft_dir / "template.tmp").exists())
self.assertTrue((draft_dir / "draft_cover.jpg").exists())
self.assertFalse((draft_dir / "draft_content_legacy.json").exists())
self.assertFalse((draft_dir / "Timelines" / "project.json").exists())
self.assertTrue((draft_dir / "assets" / "video" / "clip_01.mp4").exists())
self.assertTrue((draft_dir / "assets" / "audio" / audio_path.name).exists())
draft_info = json.loads((draft_dir / "draft_info.json").read_text(encoding="utf-8"))
self.assertEqual("169.0.0", draft_info["new_version"])
self.assertEqual("NarratoAI_test", draft_info["name"])
self.assertEqual(54, len(draft_info["materials"]))
self.assertEqual(
f"{DraftPathPlaceholder}/assets/video/clip_01.mp4",
draft_info["materials"]["videos"][0]["path"],
)
self.assertEqual(
f"{DraftPathPlaceholder}/assets/audio/{audio_path.name}",
draft_info["materials"]["audios"][0]["path"],
)
self.assertEqual(4_280_000, draft_info["tracks"][0]["segments"][0]["source_timerange"]["duration"])
self.assertEqual(4_280_000, draft_info["tracks"][1]["segments"][0]["source_timerange"]["duration"])
attachment_editing = json.loads((draft_dir / "attachment_editing.json").read_text(encoding="utf-8"))
self.assertEqual("1.0.0", attachment_editing["editing_draft"]["version"])
self.assertFalse(attachment_editing["editing_draft"]["is_use_audio_separation"])
empty_template = json.loads((draft_dir / "template.tmp").read_text(encoding="utf-8"))
self.assertEqual("75.0.0", empty_template["new_version"])
self.assertEqual([], empty_template["tracks"])
root_meta = json.loads((root_path / "root_meta_info.json").read_text(encoding="utf-8"))
self.assertEqual("NarratoAI_test", root_meta["all_draft_store"][0]["draft_name"])
self.assertEqual(str(draft_dir / "draft_info.json"), root_meta["all_draft_store"][0]["draft_json_file"])
if __name__ == "__main__":
unittest.main()

View File

@ -186,6 +186,8 @@
subtitle_mask_portrait_height_percent = 16
subtitle_mask_portrait_blur_radius = 26
subtitle_mask_portrait_opacity_percent = 84
subtitle_position_landscape_y_percent = 85
subtitle_position_portrait_y_percent = 82
##########################################
# 代理和网络配置

View File

@ -35,6 +35,3 @@ tenacity>=9.0.0
# torch>=2.0.0
# torchvision>=0.15.0
# torchaudio>=2.0.0
# 剪映草稿导出依赖
pyJianYingDraft>=0.1.0

View File

@ -25,6 +25,15 @@ SUBTITLE_MASK_DEFAULTS = {
},
}
SUBTITLE_POSITION_DEFAULTS = {
"landscape": {
"y_percent": 85,
},
"portrait": {
"y_percent": 82,
},
}
VIDEO_PREVIEW_UPLOAD_TYPES = ["mp4", "mov", "avi", "flv", "mkv", "mpeg4"]
@ -71,6 +80,21 @@ def _set_subtitle_mask_value(orientation, field, value):
st.session_state[key] = value
def _subtitle_position_key(orientation, field):
return f"subtitle_position_{orientation}_{field}"
def _get_orientation_subtitle_position_value(orientation, field):
key = _subtitle_position_key(orientation, field)
return config.ui.get(key, SUBTITLE_POSITION_DEFAULTS[orientation][field])
def _set_orientation_subtitle_position_value(orientation, field, value):
key = _subtitle_position_key(orientation, field)
config.ui[key] = value
st.session_state[key] = value
def _format_preview_time(seconds):
seconds = max(0.0, float(seconds or 0))
minutes = int(seconds // 60)
@ -162,6 +186,10 @@ def _build_subtitle_mask_preview_options():
for orientation in ("landscape", "portrait"):
for field in ("x_percent", "y_percent", "width_percent", "height_percent", "blur_radius", "opacity_percent"):
options[_subtitle_mask_key(orientation, field)] = _get_subtitle_mask_value(orientation, field)
options[_subtitle_position_key(orientation, "y_percent")] = _get_orientation_subtitle_position_value(
orientation,
"y_percent",
)
return options
@ -187,6 +215,14 @@ def _draw_subtitle_mask_preview(frame):
outline=(255, 75, 85, 235),
width=max(2, round(min(image.width, image.height) * 0.004)),
)
subtitle_y_percent = _get_orientation_subtitle_position_value(region["orientation"], "y_percent")
subtitle_y = round((image.height - 1) * subtitle_y_percent / 100)
line_width = max(2, round(min(image.width, image.height) * 0.004))
draw.line(
(0, subtitle_y, image.width, subtitle_y),
fill=(59, 130, 246, 220),
width=line_width,
)
image.alpha_composite(overlay)
return image.convert("RGB"), region
@ -341,6 +377,18 @@ def _render_subtitle_mask_region_controls(tr, orientation):
_set_subtitle_mask_value(orientation, "opacity_percent", opacity_percent)
def _render_subtitle_position_controls(tr, orientation):
y_percent = st.slider(
tr("Subtitle Burn Position"),
min_value=0,
max_value=99,
value=int(_get_orientation_subtitle_position_value(orientation, "y_percent")),
help=tr("Subtitle Burn Position Help"),
key=f"{orientation}_subtitle_burn_y_percent",
)
_set_orientation_subtitle_position_value(orientation, "y_percent", y_percent)
def _render_subtitle_mask_dialog(tr):
@st.dialog(tr("Subtitle Mask Settings"), width="large")
def subtitle_mask_dialog():
@ -349,14 +397,20 @@ def _render_subtitle_mask_dialog(tr):
with settings_col:
st.caption(tr("Subtitle Mask Settings Caption"))
st.caption(tr("Subtitle Mask Preview Caption"))
landscape_tab, portrait_tab = st.tabs([
landscape_mask_tab, portrait_mask_tab, landscape_position_tab, portrait_position_tab = st.tabs([
tr("Landscape Subtitle Mask"),
tr("Portrait Subtitle Mask"),
tr("Landscape Subtitle Position"),
tr("Portrait Subtitle Position"),
])
with landscape_tab:
with landscape_mask_tab:
_render_subtitle_mask_region_controls(tr, "landscape")
with portrait_tab:
with portrait_mask_tab:
_render_subtitle_mask_region_controls(tr, "portrait")
with landscape_position_tab:
_render_subtitle_position_controls(tr, "landscape")
with portrait_position_tab:
_render_subtitle_position_controls(tr, "portrait")
with preview_col:
_render_subtitle_mask_preview(tr)
@ -627,6 +681,8 @@ def get_subtitle_params():
'subtitle_mask_portrait_height_percent': _get_subtitle_mask_value("portrait", "height_percent"),
'subtitle_mask_portrait_blur_radius': _get_subtitle_mask_value("portrait", "blur_radius"),
'subtitle_mask_portrait_opacity_percent': _get_subtitle_mask_value("portrait", "opacity_percent"),
'subtitle_position_landscape_y_percent': _get_orientation_subtitle_position_value("landscape", "y_percent"),
'subtitle_position_portrait_y_percent': _get_orientation_subtitle_position_value("portrait", "y_percent"),
'subtitle_auto_transcribe_enabled': st.session_state.get('subtitle_auto_transcribe_enabled', False),
'subtitle_auto_transcribe_backend': st.session_state.get(
'subtitle_auto_transcribe_backend',

View File

@ -71,6 +71,8 @@
"Subtitle Mask Settings Caption": "Save landscape and portrait mask regions as frame percentages. The mask is applied before new subtitles are burned in.",
"Landscape Subtitle Mask": "Landscape Mask",
"Portrait Subtitle Mask": "Portrait Mask",
"Landscape Subtitle Position": "Landscape Subtitle Position",
"Portrait Subtitle Position": "Portrait Subtitle Position",
"Save Subtitle Mask Settings": "Save Subtitle Mask Settings",
"Subtitle Mask Left": "Left Position",
"Subtitle Mask Left Help": "Mask distance from the left edge as a frame percentage.",
@ -84,6 +86,8 @@
"Subtitle Mask Blur Radius Help": "Blur strength for the mask background and edge.",
"Subtitle Mask Opacity": "Mask Strength",
"Subtitle Mask Opacity Help": "Mask blend strength. Higher values cover source subtitles more strongly.",
"Subtitle Burn Position": "Subtitle Position",
"Subtitle Burn Position Help": "New subtitle distance from the top edge as a frame percentage. The blue line in preview shows this position.",
"Subtitle Mask Preview": "Source Subtitle Mask Preview",
"Subtitle Mask Preview Caption": "Upload a source video for preview, or use the currently selected source video. Uploaded files here are only used for mask preview.",
"Upload Subtitle Mask Preview Video": "Upload Preview Source Video",
@ -93,7 +97,7 @@
"Subtitle Mask Preview Empty": "Upload a preview video, or select a source video above first.",
"Subtitle Mask Preview Timeline": "Preview Timeline (seconds)",
"Subtitle Mask Preview Timeline Help": "Drag to a frame where the source subtitles appear, then fine-tune the mask region.",
"Subtitle Mask Preview Frame Caption": "{time} · {orientation} · red outline shows the current mask region",
"Subtitle Mask Preview Frame Caption": "{time} · {orientation} · red outline is the mask, blue line is the subtitle position",
"Subtitle Mask Preview Failed": "Unable to read this video preview. Please try another video file.",
"Enable Auto Transcription": "Enable Auto Transcription",
"Enable Auto Transcription Help": "After the final video is merged, transcribe the whole video into subtitles and burn them into the output.",

View File

@ -61,6 +61,8 @@
"Subtitle Mask Settings Caption": "按画面百分比保存横屏和竖屏遮罩区域;生成视频时会先叠加柔化遮罩,再烧录新字幕。",
"Landscape Subtitle Mask": "横屏遮罩",
"Portrait Subtitle Mask": "竖屏遮罩",
"Landscape Subtitle Position": "横屏字幕位置",
"Portrait Subtitle Position": "竖屏字幕位置",
"Save Subtitle Mask Settings": "保存字幕遮罩设置",
"Subtitle Mask Left": "左侧位置",
"Subtitle Mask Left Help": "遮罩距离画面左侧的百分比",
@ -74,6 +76,8 @@
"Subtitle Mask Blur Radius Help": "遮罩边缘和背景的模糊强度",
"Subtitle Mask Opacity": "遮罩强度",
"Subtitle Mask Opacity Help": "遮罩融合强度,数值越高越容易遮住原字幕",
"Subtitle Burn Position": "字幕位置",
"Subtitle Burn Position Help": "新字幕距离画面顶部的百分比;预览中的蓝线表示当前字幕位置",
"Subtitle Mask Preview": "原字幕遮罩预览",
"Subtitle Mask Preview Caption": "可上传一段原视频作为预览,也可直接使用当前已选择的原视频;上传内容仅用于预览遮罩位置。",
"Upload Subtitle Mask Preview Video": "上传预览原视频",
@ -83,7 +87,7 @@
"Subtitle Mask Preview Empty": "请上传预览视频,或先在上方选择原视频",
"Subtitle Mask Preview Timeline": "预览时间轴(秒)",
"Subtitle Mask Preview Timeline Help": "拖动到原字幕出现的画面,方便微调遮罩区域",
"Subtitle Mask Preview Frame Caption": "{time} · {orientation} · 红框为当前遮罩覆盖区域",
"Subtitle Mask Preview Frame Caption": "{time} · {orientation} · 红框为遮罩区域,蓝线为字幕位置",
"Subtitle Mask Preview Failed": "无法读取该视频预览,请尝试更换视频文件",
"Enable Auto Transcription": "启用自动转录",
"Enable Auto Transcription Help": "开启后会在最终视频合并完成后,对整条视频转录生成字幕并压入成片",