mirror of
https://github.com/linyqh/NarratoAI.git
synced 2026-07-23 06:28:25 +00:00
feat: 支持横竖屏自定义字幕位置,重构剪映导出逻辑
- 新增横竖屏分别的字幕垂直位置配置,默认值分别为85%和82% - 更新WebUI字幕设置界面,新增独立的横屏/竖屏字幕位置标签页,在预览画面中添加蓝线标注当前字幕位置 - 重构剪映草稿导出逻辑,将相关代码抽离至独立模块,移除requirements.txt中的pyJianYingDraft直接依赖 - 优化媒体时长处理逻辑,新增时长缓存和自动裁剪处理,添加完整的单元测试覆盖 - 更新配置示例文件、数据Schema定义和中英多语言翻译文件
This commit is contained in:
parent
33c17c2636
commit
5a9775d62d
@ -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 = ""
|
||||
|
||||
@ -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))
|
||||
|
||||
1452
app/services/jianying_draft_builder.py
Normal file
1452
app/services/jianying_draft_builder.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
##########################################
|
||||
# 代理和网络配置
|
||||
|
||||
@ -35,6 +35,3 @@ tenacity>=9.0.0
|
||||
# torch>=2.0.0
|
||||
# torchvision>=0.15.0
|
||||
# torchaudio>=2.0.0
|
||||
|
||||
# 剪映草稿导出依赖
|
||||
pyJianYingDraft>=0.1.0
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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": "开启后会在最终视频合并完成后,对整条视频转录生成字幕并压入成片",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user