From 5a9775d62dfff6474d0e23321a4e7aed1551bce1 Mon Sep 17 00:00:00 2001 From: viccy Date: Sat, 6 Jun 2026 12:01:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=A8=AA=E7=AB=96?= =?UTF-8?q?=E5=B1=8F=E8=87=AA=E5=AE=9A=E4=B9=89=E5=AD=97=E5=B9=95=E4=BD=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=8C=E9=87=8D=E6=9E=84=E5=89=AA=E6=98=A0=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增横竖屏分别的字幕垂直位置配置,默认值分别为85%和82% - 更新WebUI字幕设置界面,新增独立的横屏/竖屏字幕位置标签页,在预览画面中添加蓝线标注当前字幕位置 - 重构剪映草稿导出逻辑,将相关代码抽离至独立模块,移除requirements.txt中的pyJianYingDraft直接依赖 - 优化媒体时长处理逻辑,新增时长缓存和自动裁剪处理,添加完整的单元测试覆盖 - 更新配置示例文件、数据Schema定义和中英多语言翻译文件 --- app/models/schema.py | 2 + app/services/generate_video.py | 18 +- app/services/jianying_draft_builder.py | 1452 +++++++++++++++++++ app/services/jianying_task.py | 158 +- app/services/task.py | 2 + app/services/test_jianying_task_unittest.py | 120 +- config.example.toml | 2 + requirements.txt | 3 - webui/components/subtitle_settings.py | 62 +- webui/i18n/en.json | 6 +- webui/i18n/zh.json | 6 +- 11 files changed, 1724 insertions(+), 107 deletions(-) create mode 100644 app/services/jianying_draft_builder.py diff --git a/app/models/schema.py b/app/models/schema.py index 45d3658..e0547e7 100644 --- a/app/models/schema.py +++ b/app/models/schema.py @@ -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 = "" diff --git a/app/services/generate_video.py b/app/services/generate_video.py index f5b808a..d66166b 100644 --- a/app/services/generate_video.py +++ b/app/services/generate_video.py @@ -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)) diff --git a/app/services/jianying_draft_builder.py b/app/services/jianying_draft_builder.py new file mode 100644 index 0000000..3f00422 --- /dev/null +++ b/app/services/jianying_draft_builder.py @@ -0,0 +1,1452 @@ +import json +import os +import re +import shutil +import subprocess +import time +import uuid +from typing import Any, Dict, List, Optional, Set, Tuple + +from loguru import logger + +from app.models.schema import VideoClipParams + + +MICROSECONDS = 1_000_000 +DRAFT_PATH_PLACEHOLDER = "##_draftpath_placeholder_0E685133-18CE-45ED-8CB8-2904A212EC80_##" +DRAFT_PATH_PLACEHOLDER_PATTERN = re.compile(r"^##_draftpath_placeholder_[^#]+_##/") + +MATERIAL_COLLECTION_KEYS = [ + "ai_translates", + "audio_balances", + "audio_effects", + "audio_fades", + "audio_pannings", + "audio_pitch_shifts", + "audio_track_indexes", + "audios", + "beats", + "canvases", + "chromas", + "color_curves", + "common_mask", + "digital_human_model_dressing", + "digital_humans", + "drafts", + "effects", + "flowers", + "green_screens", + "handwrites", + "hsl", + "hsl_curves", + "images", + "log_color_wheels", + "loudnesses", + "manual_beautys", + "manual_deformations", + "material_animations", + "material_colors", + "multi_language_refs", + "placeholder_infos", + "placeholders", + "plugin_effects", + "primary_color_wheels", + "realtime_denoises", + "shapes", + "smart_crops", + "smart_relights", + "sound_channel_mappings", + "speeds", + "stickers", + "tail_leaders", + "text_templates", + "texts", + "time_marks", + "transitions", + "video_effects", + "video_radius", + "video_shadows", + "video_strokes", + "video_trackings", + "videos", + "vocal_beautifys", + "vocal_separations", +] + +DRAFT_PACKAGE_DIRECTORIES = [ + "qr_upload", + "matting", + "common_attachment", + "Resources/audioAlg", + "Resources/digitalHuman", + "Resources/restore_lut", + "Resources/videoAlg", + "subdraft", + "adjust_mask", + "assets/audio", + "assets/video", + "smart_crop", +] + +DEFAULT_DRAFT_COVER_BYTES = bytes([ + 0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, + 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00, 0x14, 0x00, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x01, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0x7F, + 0xFF, 0xD9, +]) + + +def _write_json_file(file_path: str, data: Dict[str, Any]) -> None: + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, separators=(",", ":")) + + +def _floor_duration_to_milliseconds(duration: float) -> float: + return int(max(duration, 0.0) * 1000) / 1000.0 + + +def _seconds_to_microseconds(seconds: float) -> int: + return int(round(max(seconds, 0.0) * MICROSECONDS)) + + +def _get_media_duration_ffprobe(media_file: str) -> float: + cmd = [ + "ffprobe", + "-v", "error", + "-show_entries", "format=duration", + "-of", "csv=p=0", + media_file, + ] + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + return float(result.stdout.strip()) + + +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(requested_duration) + 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 _get_video_metadata_ffprobe( + media_file: str, + metadata_cache: Dict[str, Tuple[int, int, int]], +) -> Tuple[int, int, int]: + if media_file in metadata_cache: + return metadata_cache[media_file] + + try: + cmd = [ + "ffprobe", + "-v", "error", + "-show_entries", "stream=width,height:format=duration", + "-of", "json", + media_file, + ] + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + info = json.loads(result.stdout or "{}") + stream = next( + ( + item for item in info.get("streams", []) + if item.get("width") and item.get("height") + ), + {}, + ) + duration = _floor_duration_to_milliseconds( + float(info.get("format", {}).get("duration") or 0.0) + ) + width = int(stream.get("width") or 1920) + height = int(stream.get("height") or 1080) + metadata_cache[media_file] = (_seconds_to_microseconds(duration), width, height) + except Exception as e: + logger.warning(f"读取视频元信息失败,将使用默认分辨率: {media_file}, {e}") + duration = _floor_duration_to_milliseconds(_get_media_duration_ffprobe(media_file)) + metadata_cache[media_file] = (_seconds_to_microseconds(duration), 1920, 1080) + + return metadata_cache[media_file] + + +def _format_draft_uuid(draft_id: str) -> str: + compact = draft_id.replace("-", "") + if not re.fullmatch(r"[a-fA-F0-9]{32}", compact): + return draft_id + return "-".join([ + compact[0:8], + compact[8:12], + compact[12:16], + compact[16:20], + compact[20:32], + ]).upper() + + +def _detect_platform(draft_root_path: str) -> str: + return "windows" if re.match(r"^(?:[a-zA-Z]:[\\/]|\\\\)", draft_root_path) else "mac" + + +def _create_platform_info(draft_root_path: str) -> Dict[str, Any]: + return { + "app_id": 3704, + "app_source": "lv", + "app_version": "10.6.0", + "device_id": "", + "hard_disk_id": "", + "mac_address": "", + "os": _detect_platform(draft_root_path), + "os_version": "", + } + + +def _default_function_assistant_info() -> Dict[str, Any]: + return { + "audio_noise_segid_list": [], + "auto_adjust": False, + "auto_adjust_fixed": False, + "auto_adjust_fixed_value": 50.0, + "auto_adjust_segid_list": [], + "auto_caption": False, + "auto_caption_segid_list": [], + "auto_caption_template_id": "", + "caption_opt": False, + "caption_opt_segid_list": [], + "color_correction": False, + "color_correction_fixed": False, + "color_correction_fixed_value": 50.0, + "color_correction_segid_list": [], + "deflicker_segid_list": [], + "enhance_quality": False, + "enhance_quality_fixed": False, + "enhance_quality_segid_list": [], + "enhance_voice_segid_list": [], + "enhande_voice": False, + "enhande_voice_fixed": False, + "eye_correction": False, + "eye_correction_segid_list": [], + "fixed_rec_applied": False, + "fps": {"den": 1, "num": 0}, + "normalize_loudness": False, + "normalize_loudness_audio_denoise_segid_list": [], + "normalize_loudness_fixed": False, + "normalize_loudness_segid_list": [], + "retouch": False, + "retouch_fixed": False, + "retouch_segid_list": [], + "smart_rec_applied": False, + "smart_segid_list": [], + "smooth_slow_motion": False, + "smooth_slow_motion_fixed": False, + "video_noise_segid_list": [], + } + + +def _safe_file_name(file_path: str, fallback: str) -> str: + name = os.path.basename(file_path) or fallback + name = re.sub(r'[<>:"|?*\x00-\x1f/\\]+', "_", name).strip(" ._") + return name or fallback + + +def _normalize_asset_path(file_path: Optional[str], fallback: str) -> str: + normalized = (file_path or "").replace("\\", "/").lstrip("./") + without_draft_placeholder = DRAFT_PATH_PLACEHOLDER_PATTERN.sub("", normalized) + if without_draft_placeholder.startswith("assets/"): + return without_draft_placeholder + assets_index = without_draft_placeholder.rfind("/assets/") + if assets_index >= 0: + return without_draft_placeholder[assets_index + 1:] + return fallback + + +def _to_draft_material_path(relative_path: str) -> str: + return f"{DRAFT_PATH_PLACEHOLDER}/{relative_path}" + + +def _unique_relative_asset_path( + directory: str, + file_name: str, + used_paths: Set[str], +) -> str: + base_name, ext = os.path.splitext(file_name) + candidate_name = file_name + counter = 2 + while True: + relative_path = f"{directory}/{candidate_name}" + if relative_path not in used_paths: + used_paths.add(relative_path) + return relative_path + candidate_name = f"{base_name}_{counter}{ext}" + counter += 1 + + +def _copy_asset_into_draft(source_file: str, draft_path: str, relative_path: str) -> None: + destination = os.path.join(draft_path, *relative_path.split("/")) + os.makedirs(os.path.dirname(destination), exist_ok=True) + if os.path.abspath(source_file) != os.path.abspath(destination): + shutil.copy2(source_file, destination) + + +def _register_asset( + source_file: str, + draft_path: str, + asset_dir: str, + fallback_name: str, + used_paths: Set[str], + asset_path_cache: Dict[str, str], +) -> str: + source_key = os.path.abspath(source_file) + if source_key in asset_path_cache: + return asset_path_cache[source_key] + + file_name = _safe_file_name(source_file, fallback_name) + relative_path = _unique_relative_asset_path(asset_dir, file_name, used_paths) + _copy_asset_into_draft(source_file, draft_path, relative_path) + asset_path_cache[source_key] = relative_path + return relative_path + + +def _create_unique_draft_path(drafts_root: str, draft_name: str) -> Tuple[str, str]: + folder_base = _safe_file_name(draft_name, f"NarratoAI_{int(time.time())}") + folder_name = folder_base + counter = 2 + while os.path.exists(os.path.join(drafts_root, folder_name)): + folder_name = f"{folder_base}_{counter}" + counter += 1 + return folder_name, os.path.join(drafts_root, folder_name) + + +def _create_material_collections() -> Dict[str, List[Any]]: + return {key: [] for key in MATERIAL_COLLECTION_KEYS} + + +def _create_draft_template( + draft_id: str, + draft_name: str, + draft_root_path: str, + width: int = 1920, + height: int = 1080, +) -> Dict[str, Any]: + now_us = int(time.time() * MICROSECONDS) + platform_info = _create_platform_info(draft_root_path) + return { + "canvas_config": {"height": height, "ratio": "original", "width": width}, + "color_space": 0, + "config": { + "adjust_max_index": 1, + "attachment_info": [], + "combination_max_index": 1, + "export_range": None, + "extract_audio_last_index": 1, + "lyrics_recognition_id": "", + "lyrics_sync": True, + "lyrics_taskinfo": [], + "maintrack_adsorb": True, + "material_save_mode": 0, + "multi_language_current": "none", + "multi_language_list": [], + "multi_language_main": "none", + "multi_language_mode": "none", + "original_sound_last_index": 1, + "record_audio_last_index": 1, + "sticker_max_index": 1, + "subtitle_keywords_config": None, + "subtitle_recognition_id": "", + "subtitle_sync": True, + "subtitle_taskinfo": [], + "system_font_list": [], + "video_mute": False, + "zoom_info_params": None, + }, + "cover": None, + "create_time": now_us, + "duration": 0, + "extra_info": None, + "fps": 30.0, + "free_render_index_mode_on": False, + "group_container": None, + "id": draft_id, + "keyframe_graph_list": [], + "keyframes": { + "adjusts": [], + "audios": [], + "effects": [], + "filters": [], + "handwrites": [], + "stickers": [], + "texts": [], + "videos": [], + }, + "last_modified_platform": platform_info, + "materials": _create_material_collections(), + "mutable_config": None, + "name": draft_name, + "new_version": "169.0.0", + "relationships": [], + "render_index_track_mode_on": True, + "retouch_cover": None, + "source": "default", + "static_cover_image_path": "", + "time_marks": None, + "tracks": [], + "update_time": now_us, + "version": 360000, + } + + +def _create_track(track_type: str, name: str) -> Dict[str, Any]: + return { + "attribute": 0, + "flag": 0, + "id": uuid.uuid4().hex, + "is_default_name": True, + "name": name, + "segments": [], + "type": track_type, + } + + +def _create_video_material( + relative_path: str, + duration_us: int, + width: int, + height: int, +) -> Dict[str, Any]: + return { + "id": uuid.uuid4().hex, + "path": relative_path, + "type": "video", + "duration": duration_us, + "width": width, + "height": height, + "material_name": os.path.basename(relative_path), + "create_time": int(time.time() * MICROSECONDS), + "crop": { + "lower_left_x": 0.0, + "lower_left_y": 1.0, + "lower_right_x": 1.0, + "lower_right_y": 1.0, + "upper_left_x": 0.0, + "upper_left_y": 0.0, + "upper_right_x": 1.0, + "upper_right_y": 0.0, + }, + "extra_type_option": 0, + "source_platform": 0, + } + + +def _create_audio_material(relative_path: str, duration_us: int) -> Dict[str, Any]: + material_id = uuid.uuid4().hex + return { + "app_id": 0, + "category_id": "", + "category_name": "local", + "check_flag": 1, + "copyright_limit_type": "none", + "duration": duration_us, + "effect_id": "", + "formula_id": "", + "id": material_id, + "intensifies_path": "", + "is_ai_clone_tone": False, + "is_text_edit_overdub": False, + "is_ugc": False, + "local_material_id": material_id, + "music_id": material_id, + "name": os.path.basename(relative_path), + "path": relative_path, + "remote_url": "", + "query": "", + "request_id": "", + "resource_id": "", + "search_id": "", + "source_from": "", + "source_platform": 0, + "team_id": "", + "text_id": "", + "tone_category_id": "", + "tone_category_name": "", + "tone_effect_id": "", + "tone_effect_name": "", + "tone_platform": "", + "tone_second_category_id": "", + "tone_second_category_name": "", + "tone_speaker": "", + "tone_type": "", + "type": "extract_music", + "video_id": "", + "wave_points": [], + } + + +def _create_video_segment( + material_id: str, + source_start_us: int, + duration_us: int, + target_start_us: int, + volume: float, +) -> Dict[str, Any]: + return { + "id": uuid.uuid4().hex, + "material_id": material_id, + "target_timerange": {"start": target_start_us, "duration": duration_us}, + "source_timerange": {"start": source_start_us, "duration": duration_us}, + "speed": 1.0, + "volume": volume, + "enable_adjust": True, + "enable_color_curves": True, + "enable_color_match_adjust": False, + "enable_color_wheels": True, + "enable_lut": True, + "enable_smart_color_adjust": False, + "extra_material_refs": [], + "hdr_settings": {"intensity": 1.0, "mode": 1, "nits": 1000}, + "uniform_scale": {"on": True, "value": 1.0}, + "clip": { + "alpha": 1.0, + "flip": {"horizontal": False, "vertical": False}, + "rotation": 0.0, + "scale": {"x": 1.0, "y": 1.0}, + "transform": {"x": 0.0, "y": 0.0}, + }, + "common_keyframes": [], + } + + +def _create_audio_segment( + material_id: str, + duration_us: int, + target_start_us: int, + volume: float, +) -> Dict[str, Any]: + return { + "id": uuid.uuid4().hex, + "material_id": material_id, + "target_timerange": {"start": target_start_us, "duration": duration_us}, + "source_timerange": {"start": 0, "duration": duration_us}, + "speed": 1.0, + "volume": volume, + "extra_material_refs": [], + "clip": None, + "hdr_settings": None, + "uniform_scale": None, + "common_keyframes": [], + } + + +def _normalize_video_material(material: Dict[str, Any]) -> Dict[str, Any]: + fallback_path = f"assets/video/{material.get('material_name') or 'source.mp4'}" + result = { + "aigc_history_id": "", + "aigc_item_id": "", + "aigc_type": "none", + "audio_fade": None, + "beauty_body_auto_preset": None, + "beauty_body_preset_id": "", + "beauty_face_auto_preset": None, + "beauty_face_auto_preset_infos": [], + "beauty_face_preset_infos": [], + "cartoon_path": "", + "category_id": "", + "category_name": "local", + "check_flag": 65535, + "content_feature_info": None, + "corner_pin": None, + "crop_ratio": "free", + "crop_scale": 1.0, + "formula_id": "", + "freeze": None, + "has_audio": True, + "has_sound_separated": False, + "intensifies_audio_path": "", + "intensifies_path": "", + "is_ai_generate_content": False, + "is_copyright": False, + "is_set_beauty_mode": False, + "is_text_edit_overdub": False, + "is_unified_beauty_mode": False, + "live_photo_cover_path": "", + "live_photo_timestamp": 0, + "local_id": "", + "local_material_from": 0, + "local_material_id": "", + "material_id": "", + "material_url": "", + "matting": None, + "media_path": "", + "multi_camera_info": None, + "object_locked": None, + "origin_material_id": "", + "picture_from": "none", + "picture_set_category_id": "", + "picture_set_category_name": "", + "request_id": "", + "reverse_intensifies_path": "", + "reverse_path": "", + "smart_match_info": None, + "smart_motion": None, + "source": 0, + "stable": None, + "surface_trackings": None, + "team_id": "", + "unique_id": "", + "video_algorithm": None, + "video_mask_shadow": None, + "video_mask_stroke": None, + } + result.update(material) + result["path"] = _to_draft_material_path( + _normalize_asset_path(material.get("path"), fallback_path) + ) + result["type"] = "video" + return result + + +def _normalize_audio_material(material: Dict[str, Any]) -> Dict[str, Any]: + fallback_path = f"assets/audio/{material.get('name') or 'audio.mp3'}" + result = { + "ai_music_enter_from": "", + "ai_music_generate_scene": "", + "ai_music_type": 0, + "aigc_history_id": "", + "aigc_item_id": "", + "app_id": 0, + "category_id": "", + "category_name": "local", + "check_flag": 1, + "cloned_model_type": "", + "copyright_limit_type": "none", + "effect_id": "", + "formula_id": "", + "intensifies_path": "", + "is_ai_clone_tone": False, + "is_ai_clone_tone_post": False, + "is_text_edit_overdub": False, + "is_ugc": False, + "lyric_type": 0, + "mock_tone_speaker": "", + "moyin_emotion": "", + "music_source": "", + "pgc_id": "", + "pgc_name": "", + "query": "", + "request_id": "", + "resource_id": "", + "search_id": "", + "similiar_music_info": None, + "sound_separate_type": 0, + "source_from": "", + "source_platform": 0, + "team_id": "", + "text_id": "", + "third_resource_id": "", + "tone_category_id": "", + "tone_category_name": "", + "tone_effect_id": "", + "tone_effect_name": "", + "tone_emotion_name_key": "", + "tone_emotion_role": "", + "tone_emotion_scale": 0, + "tone_emotion_selection": "", + "tone_emotion_style": "", + "tone_platform": "", + "tone_second_category_id": "", + "tone_second_category_name": "", + "tone_speaker": "", + "tone_type": "", + "tts_benefit_info": None, + "tts_generate_scene": 0, + "tts_task_id": "", + "unique_id": "", + "video_id": "", + "wave_points": [], + } + result.update(material) + result["path"] = _to_draft_material_path( + _normalize_asset_path(material.get("path"), fallback_path) + ) + result["type"] = "extract_music" + return result + + +def _normalize_materials(draft: Dict[str, Any]) -> Dict[str, List[Any]]: + source = draft.get("materials", {}) + materials = { + key: source.get(key, []) if isinstance(source.get(key, []), list) else [] + for key in MATERIAL_COLLECTION_KEYS + } + materials["videos"] = [_normalize_video_material(item) for item in source.get("videos", [])] + materials["audios"] = [_normalize_audio_material(item) for item in source.get("audios", [])] + return materials + + +def _create_responsive_layout() -> Dict[str, Any]: + return { + "enable": False, + "horizontal_pos_layout": 0, + "size_layout": 0, + "target_follow": "", + "vertical_pos_layout": 0, + } + + +def _normalize_segment( + segment: Dict[str, Any], + track_type: str, + track_index: int, + track_attribute: int, +) -> Dict[str, Any]: + is_video = track_type == "video" + result = { + "caption_info": None, + "cartoon": False, + "color_correct_alg_result": "", + "common_keyframes": [], + "desc": "", + "digital_human_template_group_id": "", + "enable_adjust": is_video, + "enable_adjust_mask": is_video, + "enable_color_adjust_pro": False, + "enable_color_correct_adjust": False, + "enable_color_curves": True, + "enable_color_match_adjust": False, + "enable_color_wheels": True, + "enable_hsl": is_video, + "enable_hsl_curves": True, + "enable_lut": is_video, + "enable_mask_shadow": False, + "enable_mask_stroke": False, + "enable_smart_color_adjust": False, + "enable_video_mask": True, + "extra_material_refs": [], + "group_id": "", + "hdr_settings": segment.get("hdr_settings"), + "intensifies_audio": False, + "is_loop": False, + "is_placeholder": False, + "is_tone_modify": False, + "keyframe_refs": [], + "last_nonzero_volume": 1.0, + "lyric_keyframes": None, + "raw_segment_id": "", + "render_index": 0, + "render_timerange": {"duration": 0, "start": 0}, + "responsive_layout": _create_responsive_layout(), + "reverse": False, + "source": "segmentsourcenormal", + "state": 0, + "template_id": "", + "template_scene": "default", + "uniform_scale": {"on": True, "value": 1.0}, + "visible": True, + } + result.update(segment) + result["track_attribute"] = track_attribute + result["track_render_index"] = track_index + return result + + +def _normalize_tracks(draft: Dict[str, Any]) -> List[Dict[str, Any]]: + tracks = [] + for index, track in enumerate(draft.get("tracks", [])): + track_copy = dict(track) + track_attribute = int(track_copy.get("attribute", 0) or 0) + track_copy["segments"] = [ + _normalize_segment(segment, track_copy.get("type", ""), index, track_attribute) + for segment in track.get("segments", []) + ] + tracks.append(track_copy) + return tracks + + +def _create_draft_info( + draft: Dict[str, Any], + draft_name: str, + draft_root_path: str, + new_version: str = "169.0.0", +) -> Dict[str, Any]: + info = json.loads(json.dumps(draft, ensure_ascii=False)) + canvas_config = info.get("canvas_config", {}) + platform_info = _create_platform_info(draft_root_path) + info.update({ + "canvas_config": { + "background": canvas_config.get("background"), + "height": canvas_config.get("height", 1080), + "ratio": canvas_config.get("ratio", "original"), + "width": canvas_config.get("width", 1920), + }, + "draft_type": "video", + "function_assistant_info": _default_function_assistant_info(), + "is_drop_frame_timecode": False, + "last_modified_platform": platform_info, + "lyrics_effects": [], + "materials": _normalize_materials(info), + "name": draft_name, + "new_version": new_version, + "path": "", + "platform": platform_info, + "render_index_track_mode_on": True, + "smart_ads_info": {"draft_url": "", "page_from": "", "routine": ""}, + "tracks": _normalize_tracks(info), + "uneven_animation_template_info": { + "composition": "", + "content": "", + "order": "", + "sub_template_info_list": [], + }, + }) + return info + + +def _create_empty_template(draft: Dict[str, Any], draft_root_path: str) -> Dict[str, Any]: + empty_draft = json.loads(json.dumps(draft, ensure_ascii=False)) + empty_draft["canvas_config"] = { + "background": None, + "height": 0, + "ratio": "original", + "width": 0, + } + empty_draft["color_space"] = -1 + empty_draft["duration"] = 0 + empty_draft["keyframes"] = { + "adjusts": [], + "audios": [], + "effects": [], + "filters": [], + "handwrites": [], + "stickers": [], + "texts": [], + "videos": [], + } + empty_draft["materials"] = _create_material_collections() + empty_draft["tracks"] = [] + return _create_draft_info(empty_draft, "", draft_root_path, "75.0.0") + + +def _create_draft_material_index_item( + material: Dict[str, Any], + file_name: str, + metetype: str, + width: int, + height: int, +) -> Dict[str, Any]: + duration = int(material.get("duration", 0) or 0) + return { + "ai_group_type": "", + "create_time": -1, + "duration": duration, + "enter_from": 0, + "extra_info": file_name, + "file_Path": material.get("path", ""), + "height": height, + "id": material.get("id", ""), + "import_time": -1, + "import_time_ms": -1, + "item_source": 1, + "md5": "", + "metetype": metetype, + "roughcut_time_range": {"duration": duration, "start": 0}, + "sub_time_range": {"duration": -1, "start": -1}, + "type": 0, + "width": width, + } + + +def _create_draft_material_index(draft: Dict[str, Any]) -> List[Dict[str, Any]]: + items: List[Dict[str, Any]] = [] + for video in draft.get("materials", {}).get("videos", []): + relative_path = _normalize_asset_path( + video.get("path"), + f"assets/video/{video.get('material_name') or 'source.mp4'}", + ) + items.append(_create_draft_material_index_item( + {**video, "path": _to_draft_material_path(relative_path)}, + os.path.basename(relative_path), + "video", + int(video.get("width", 0) or 0), + int(video.get("height", 0) or 0), + )) + for audio in draft.get("materials", {}).get("audios", []): + relative_path = _normalize_asset_path( + audio.get("path"), + f"assets/audio/{audio.get('name') or 'audio.mp3'}", + ) + items.append(_create_draft_material_index_item( + {**audio, "path": _to_draft_material_path(relative_path)}, + os.path.basename(relative_path), + "music", + 0, + 0, + )) + return items + + +def _create_meta_info( + draft: Dict[str, Any], + draft_name: str, + draft_id: str, + draft_root_path: str, + draft_path: str, + asset_size: int, +) -> Dict[str, Any]: + return { + "cloud_draft_cover": False, + "cloud_draft_sync": False, + "cloud_package_completed_time": "", + "draft_cloud_capcut_purchase_info": "", + "draft_cloud_last_action_download": False, + "draft_cloud_package_type": "", + "draft_cloud_purchase_info": "", + "draft_cloud_template_id": "", + "draft_cloud_tutorial_info": "", + "draft_cloud_videocut_purchase_info": "", + "draft_cover": "draft_cover.jpg", + "draft_deeplink_url": "", + "draft_enterprise_info": { + "draft_enterprise_extra": "", + "draft_enterprise_id": "", + "draft_enterprise_name": "", + "enterprise_material": [], + }, + "draft_fold_path": draft_path, + "draft_id": _format_draft_uuid(draft_id), + "draft_is_ae_produce": False, + "draft_is_ai_packaging_used": False, + "draft_is_ai_shorts": False, + "draft_is_ai_translate": False, + "draft_is_article_video_draft": False, + "draft_is_cloud_temp_draft": False, + "draft_is_from_deeplink": "false", + "draft_is_invisible": False, + "draft_is_pippit_draft": False, + "draft_is_web_article_video": False, + "draft_materials": [ + {"type": 0, "value": _create_draft_material_index(draft)}, + {"type": 1, "value": []}, + {"type": 2, "value": []}, + {"type": 3, "value": []}, + {"type": 6, "value": []}, + {"type": 7, "value": []}, + {"type": 8, "value": []}, + ], + "draft_materials_copied_info": [], + "draft_name": draft_name, + "draft_need_rename_folder": False, + "draft_new_version": "", + "draft_removable_storage_device": "", + "draft_root_path": draft_root_path, + "draft_segment_extra_info": [], + "draft_timeline_materials_size_": asset_size, + "draft_type": "", + "draft_web_article_video_enter_from": "", + "tm_draft_cloud_completed": "", + "tm_draft_cloud_entry_id": -1, + "tm_draft_cloud_modified": 0, + "tm_draft_cloud_parent_entry_id": -1, + "tm_draft_cloud_space_id": -1, + "tm_draft_cloud_user_id": -1, + "tm_draft_create": draft.get("create_time", 0), + "tm_draft_modified": draft.get("update_time", 0), + "tm_draft_removed": 0, + "tm_duration": draft.get("duration", 0), + } + + +def _create_root_meta_entry( + draft: Dict[str, Any], + draft_name: str, + draft_id: str, + draft_root_path: str, + draft_path: str, + asset_size: int, +) -> Dict[str, Any]: + return { + "cloud_draft_cover": False, + "cloud_draft_sync": False, + "draft_cloud_last_action_download": False, + "draft_cloud_purchase_info": "", + "draft_cloud_template_id": "", + "draft_cloud_tutorial_info": "", + "draft_cloud_videocut_purchase_info": "", + "draft_cover": os.path.join(draft_path, "draft_cover.jpg"), + "draft_fold_path": draft_path, + "draft_id": _format_draft_uuid(draft_id), + "draft_is_ai_shorts": False, + "draft_is_cloud_temp_draft": False, + "draft_is_invisible": False, + "draft_is_web_article_video": False, + "draft_json_file": os.path.join(draft_path, "draft_info.json"), + "draft_name": draft_name, + "draft_new_version": "", + "draft_root_path": draft_root_path, + "draft_timeline_materials_size": asset_size, + "draft_type": "", + "draft_web_article_video_enter_from": "", + "streaming_edit_draft_ready": True, + "tm_draft_cloud_completed": "", + "tm_draft_cloud_entry_id": -1, + "tm_draft_cloud_modified": 0, + "tm_draft_cloud_parent_entry_id": -1, + "tm_draft_cloud_space_id": -1, + "tm_draft_cloud_user_id": -1, + "tm_draft_create": draft.get("create_time", 0), + "tm_draft_modified": draft.get("update_time", 0), + "tm_draft_removed": 0, + "tm_duration": draft.get("duration", 0), + } + + +def _merge_root_meta_info( + existing_value: Any, + next_entry: Dict[str, Any], + root_path: str, +) -> Dict[str, Any]: + existing = existing_value if isinstance(existing_value, dict) else {} + existing_store = existing.get("all_draft_store") + if not isinstance(existing_store, list): + existing_store = [] + + all_draft_store = [ + next_entry, + *[ + entry for entry in existing_store + if ( + isinstance(entry, dict) + and entry.get("draft_id") != next_entry.get("draft_id") + and entry.get("draft_fold_path") != next_entry.get("draft_fold_path") + and entry.get("draft_name") != next_entry.get("draft_name") + ) + ], + ] + return { + "all_draft_store": all_draft_store, + "draft_ids": existing.get("draft_ids") if isinstance(existing.get("draft_ids"), int) else 1, + "root_path": existing.get("root_path") or root_path, + } + + +def _create_draft_settings(draft: Dict[str, Any], draft_root_path: str) -> str: + created_at = round(int(draft.get("create_time", 0) or 0) / MICROSECONDS) + updated_at = round(int(draft.get("update_time", 0) or 0) / MICROSECONDS) + return "\n".join([ + "[General]", + f"cloud_last_modify_platform={_detect_platform(draft_root_path)}", + f"draft_create_time={created_at}", + f"draft_last_edit_time={updated_at}", + "real_edit_keys=1", + "real_edit_seconds=0", + "", + ]) + + +def _create_reference_line_attachment() -> Dict[str, Any]: + return { + "reference_lines_config": { + "horizontal_lines": [], + "is_lock": False, + "is_visible": False, + "vertical_lines": [], + }, + "safe_area_type": 0, + } + + +def _create_editing_attachment() -> Dict[str, Any]: + return { + "editing_draft": { + "ai_remove_filter_words": { + "enter_source": "", + "right_id": "", + }, + "ai_shorts_info": { + "report_params": "", + "type": 0, + }, + "cover_extra_info": { + "draft_id": "", + "position": 0, + "select_segment_id": "", + "select_segment_source_start": 0, + "select_segment_target_start": 0, + "type": 1, + }, + "crop_info_extra": { + "crop_mirror_type": 0, + "crop_rotate": 0, + "crop_rotate_total": 0, + }, + "digital_human_template_to_video_info": { + "has_upload_material": False, + "template_type": 0, + }, + "draft_used_recommend_function": "", + "edit_type": 0, + "eye_correct_enabled_multi_face_time": 0, + "has_adjusted_render_layer": False, + "image_ai_chat_info": { + "before_chat_edit": False, + "draft_modify_time": 0, + "generate_type": "", + "keyword_content": "", + "keyword_type": "", + "message_id": "", + "model_name": "", + "need_restore": False, + "picture_id": "", + "prompt_content": "", + "prompt_from": "", + "sugs_info": [], + }, + "is_open_expand_player": False, + "is_template_text_ai_generate": False, + "is_use_adjust": False, + "is_use_ai_expand": False, + "is_use_ai_remove": False, + "is_use_ai_video": False, + "is_use_audio_separation": False, + "is_use_chroma_key": False, + "is_use_curve_speed": False, + "is_use_digital_human": False, + "is_use_edit_multi_camera": False, + "is_use_lip_sync": False, + "is_use_lock_object": False, + "is_use_loudness_unify": False, + "is_use_noise_reduction": False, + "is_use_one_click_beauty": False, + "is_use_one_click_ultra_hd": False, + "is_use_retouch_face": False, + "is_use_smart_adjust_color": False, + "is_use_smart_body_beautify": False, + "is_use_smart_motion": False, + "is_use_subtitle_recognition": False, + "is_use_text_to_audio": False, + "material_edit_session": { + "material_edit_info": [], + "session_id": "", + "session_time": 0, + }, + "paste_segment_list": [], + "profile_entrance_type": "", + "publish_enter_from": "", + "publish_type": "", + "single_function_type": 0, + "text_convert_case_types": [], + "version": "1.0.0", + "video_recording_create_draft": "", + } + } + + +def _create_draft_virtual_store(draft: Dict[str, Any]) -> Dict[str, Any]: + materials = [ + *draft.get("materials", {}).get("videos", []), + *draft.get("materials", {}).get("audios", []), + ] + return { + "draft_materials": [], + "draft_virtual_store": [ + { + "type": 0, + "value": [ + { + "creation_time": 0, + "display_name": "", + "filter_type": 0, + "id": "", + "import_time": 0, + "import_time_us": 0, + "sort_sub_type": 0, + "sort_type": 0, + "subdraft_filter_type": 0, + } + ], + }, + { + "type": 1, + "value": [ + {"child_id": material.get("id", ""), "parent_id": ""} + for material in materials + ], + }, + {"type": 2, "value": []}, + ], + } + + +def _write_root_meta_info(draft_root_path: str, root_meta_entry: Dict[str, Any]) -> None: + root_meta_path = os.path.join(draft_root_path, "root_meta_info.json") + existing_value: Any = {} + if os.path.exists(root_meta_path): + try: + with open(root_meta_path, "r", encoding="utf-8") as f: + existing_value = json.load(f) + except Exception as e: + logger.warning(f"读取 root_meta_info.json 失败,将重建索引: {e}") + + _write_json_file( + root_meta_path, + _merge_root_meta_info(existing_value, root_meta_entry, draft_root_path), + ) + + +def _write_plaintext_draft_files( + draft_root_path: str, + draft_path: str, + draft_name: str, + draft_id: str, + draft: Dict[str, Any], + asset_size: int, +) -> None: + draft_info = _create_draft_info(draft, draft_name, draft_root_path) + empty_template = _create_empty_template(draft, draft_root_path) + + _write_json_file( + os.path.join(draft_path, "draft_meta_info.json"), + _create_meta_info(draft, draft_name, draft_id, draft_root_path, draft_path, asset_size), + ) + with open(os.path.join(draft_path, "draft_settings"), "w", encoding="utf-8") as f: + f.write(_create_draft_settings(draft, draft_root_path)) + with open(os.path.join(draft_path, "draft_cover.jpg"), "wb") as f: + f.write(DEFAULT_DRAFT_COVER_BYTES) + _write_json_file(os.path.join(draft_path, "draft_info.json"), draft_info) + _write_json_file(os.path.join(draft_path, "template-2.tmp"), draft_info) + _write_json_file(os.path.join(draft_path, "template.tmp"), empty_template) + _write_json_file( + os.path.join(draft_path, "common_attachment", "attachment_pc_timeline.json"), + _create_reference_line_attachment(), + ) + _write_json_file(os.path.join(draft_path, "common_attachment", "attachment_action_scene.json"), {}) + _write_json_file(os.path.join(draft_path, "common_attachment", "attachment_script_video.json"), {}) + _write_json_file(os.path.join(draft_path, "common_attachment", "attachment_gen_ai_info.json"), {}) + _write_json_file(os.path.join(draft_path, "attachment_editing.json"), _create_editing_attachment()) + _write_json_file( + os.path.join(draft_path, "draft_agency_config.json"), + { + "is_auto_agency_enabled": False, + "is_auto_agency_popup": False, + "is_single_agency_mode": False, + "marterials": None, + "use_converter": False, + "video_resolution": draft.get("canvas_config", {}).get("height", 1080), + }, + ) + with open(os.path.join(draft_path, "draft_biz_config.json"), "w", encoding="utf-8"): + pass + _write_json_file( + os.path.join(draft_path, "draft_virtual_store.json"), + _create_draft_virtual_store(draft), + ) + _write_json_file( + os.path.join(draft_path, "performance_opt_info.json"), + {"manual_cancle_precombine_segs": None, "need_auto_precombine_segs": None}, + ) + _write_json_file( + os.path.join(draft_path, "timeline_layout.json"), + { + "activeTimeline": draft_id, + "dockItems": [ + { + "dockIndex": 0, + "ratio": 1, + "timelineIds": [draft_id], + "timelineNames": ["时间线01"], + } + ], + "layoutOrientation": 1, + }, + ) + _write_root_meta_info( + draft_root_path, + _create_root_meta_entry(draft, draft_name, draft_id, draft_root_path, draft_path, asset_size), + ) + + +def _resolve_item_audio_file(item: Dict[str, Any], output_dir: str) -> str: + audio_file = "" + timestamp = item.get("timestamp", "") + if timestamp: + audio_file = os.path.join(output_dir, f"audio_{timestamp.replace(':', '_')}.mp3") + + item_audio_file = item.get("audio", "") + if item_audio_file and os.path.exists(item_audio_file): + audio_file = item_audio_file + + return audio_file + + +def write_plaintext_jianying_draft( + jianying_draft_path: str, + draft_name: str, + new_script_list: List[Dict[str, Any]], + params: VideoClipParams, + output_dir: str, +) -> Tuple[str, str]: + os.makedirs(jianying_draft_path, exist_ok=True) + + display_name = draft_name or f"NarratoAI_{int(time.time())}" + folder_name, draft_path = _create_unique_draft_path(jianying_draft_path, display_name) + os.makedirs(draft_path, exist_ok=False) + for rel_dir in DRAFT_PACKAGE_DIRECTORIES: + os.makedirs(os.path.join(draft_path, *rel_dir.split("/")), exist_ok=True) + + draft_id = uuid.uuid4().hex + draft = _create_draft_template(draft_id, display_name, jianying_draft_path) + video_track = _create_track("video", "视频轨道") + audio_track = _create_track("audio", "音频轨道") + draft["tracks"] = [video_track, audio_track] + + duration_cache: Dict[str, float] = {} + metadata_cache: Dict[str, Tuple[int, int, int]] = {} + used_asset_paths: Set[str] = set() + asset_path_cache: Dict[str, str] = {} + current_time_us = 0 + + for item in new_script_list: + start_time = float(item.get("start_time", 0.0) or 0.0) + requested_duration = float(item.get("duration", 0.0) or 0.0) + timestamp = item.get("timestamp", "") + ost = int(item.get("OST", 0) or 0) + + logger.info( + f"处理片段: OST={ost}, start_time={start_time}, " + f"duration={requested_duration}, timestamp={timestamp}" + ) + + video_file = item.get("video", "") + use_clipped_video = bool(video_file and os.path.exists(video_file)) + if not use_clipped_video: + video_file = params.video_origin_path + + if not video_file or not os.path.exists(video_file): + logger.warning(f"视频素材不存在,跳过片段: {video_file or timestamp}") + continue + + source_start_time = 0.0 if use_clipped_video else start_time + video_duration = _clamp_duration_to_media( + requested_duration, + video_file, + duration_cache, + "视频素材" if use_clipped_video else "原始视频素材", + source_start_time=source_start_time, + ) + + audio_file = _resolve_item_audio_file(item, output_dir) + audio_duration = None + if ost in [0, 2] and audio_file and os.path.exists(audio_file): + audio_duration = _get_cached_media_duration(audio_file, duration_cache) + logger.info( + f"音频文件实际时长: {audio_duration:.6f}秒, 视频片段时长: {video_duration:.3f}秒" + ) + + segment_duration = min( + video_duration, + audio_duration if audio_duration is not None else video_duration, + ) + segment_duration = _floor_duration_to_milliseconds(segment_duration) + if segment_duration <= 0: + logger.warning(f"片段时长无效,跳过: {timestamp}") + continue + + segment_duration_us = _seconds_to_microseconds(segment_duration) + video_material_duration_us, width, height = _get_video_metadata_ffprobe(video_file, metadata_cache) + video_relative_path = _register_asset( + video_file, + draft_path, + "assets/video", + f"video_{len(video_track['segments']) + 1}.mp4", + used_asset_paths, + asset_path_cache, + ) + video_material = _create_video_material(video_relative_path, video_material_duration_us, width, height) + draft["materials"]["videos"].append(video_material) + video_track["segments"].append(_create_video_segment( + video_material["id"], + _seconds_to_microseconds(_floor_duration_to_milliseconds(source_start_time)), + segment_duration_us, + current_time_us, + float(getattr(params, "original_volume", 1.0) or 1.0), + )) + + if ost in [0, 2] and audio_file and os.path.exists(audio_file): + audio_material_duration_us = _seconds_to_microseconds( + _get_cached_media_duration(audio_file, duration_cache) + ) + audio_relative_path = _register_asset( + audio_file, + draft_path, + "assets/audio", + f"audio_{len(audio_track['segments']) + 1}.mp3", + used_asset_paths, + asset_path_cache, + ) + audio_material = _create_audio_material(audio_relative_path, audio_material_duration_us) + draft["materials"]["audios"].append(audio_material) + audio_track["segments"].append(_create_audio_segment( + audio_material["id"], + segment_duration_us, + current_time_us, + float(getattr(params, "tts_volume", 1.0) or 1.0), + )) + elif ost in [0, 2]: + logger.warning(f"音频文件不存在: {audio_file}") + + current_time_us += segment_duration_us + + if not video_track["segments"]: + raise ValueError("没有可写入剪映草稿的视频片段") + + first_video = draft["materials"]["videos"][0] + draft["canvas_config"]["width"] = int(first_video.get("width", 1920) or 1920) + draft["canvas_config"]["height"] = int(first_video.get("height", 1080) or 1080) + draft["duration"] = current_time_us + draft["update_time"] = int(time.time() * MICROSECONDS) + + asset_size = sum( + os.path.getsize(source_file) + for source_file in asset_path_cache.keys() + if os.path.exists(source_file) + ) + _write_plaintext_draft_files( + jianying_draft_path, + draft_path, + display_name, + draft_id, + draft, + asset_size, + ) + + logger.info(f"剪映明文草稿包已写入: {draft_path} (folder={folder_name})") + return draft_path, display_name diff --git a/app/services/jianying_task.py b/app/services/jianying_task.py index 282cf47..e988435 100644 --- a/app/services/jianying_task.py +++ b/app/services/jianying_task.py @@ -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 diff --git a/app/services/task.py b/app/services/task.py index 0b6b138..bf8c45b 100644 --- a/app/services/task.py +++ b/app/services/task.py @@ -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), } diff --git a/app/services/test_jianying_task_unittest.py b/app/services/test_jianying_task_unittest.py index 24a87fe..5e25478 100644 --- a/app/services/test_jianying_task_unittest.py +++ b/app/services/test_jianying_task_unittest.py @@ -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() diff --git a/config.example.toml b/config.example.toml index 89217eb..3e81c08 100644 --- a/config.example.toml +++ b/config.example.toml @@ -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 ########################################## # 代理和网络配置 diff --git a/requirements.txt b/requirements.txt index 799282c..12def4d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -35,6 +35,3 @@ tenacity>=9.0.0 # torch>=2.0.0 # torchvision>=0.15.0 # torchaudio>=2.0.0 - -# 剪映草稿导出依赖 -pyJianYingDraft>=0.1.0 diff --git a/webui/components/subtitle_settings.py b/webui/components/subtitle_settings.py index ac3793b..f719d5e 100644 --- a/webui/components/subtitle_settings.py +++ b/webui/components/subtitle_settings.py @@ -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', diff --git a/webui/i18n/en.json b/webui/i18n/en.json index 5c7b5b1..654185c 100644 --- a/webui/i18n/en.json +++ b/webui/i18n/en.json @@ -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.", diff --git a/webui/i18n/zh.json b/webui/i18n/zh.json index 29945e0..1fc60f0 100644 --- a/webui/i18n/zh.json +++ b/webui/i18n/zh.json @@ -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": "开启后会在最终视频合并完成后,对整条视频转录生成字幕并压入成片",