diff --git a/config.yaml b/config.yaml index c4b98cb2..dfc6b5f4 100644 --- a/config.yaml +++ b/config.yaml @@ -56,8 +56,77 @@ subtitle: max_length: 75 # *Translated subtitles are slightly larger than source subtitles, affecting the reference length for subtitle splitting target_multiplier: 1.2 - -# *Summary length, set low to 2k if using local LLM + # *Subtitle format for burning into video: 'srt' or 'ass' + format: 'srt' + # *SRT style settings (only effective when format is 'srt') + # *These map to ffmpeg force_style parameters + srt_style: + source: + fontname: 'Arial' + fontsize: 15 + primary_color: '&HFFFFFF' + outline_color: '&H000000' + outline_width: 1.0 + shadow_color: '&H80000000' + border_style: 1 + alignment: 8 + margin_v: 10 + margin_l: 10 + margin_r: 10 + translation: + fontname: 'Arial' + fontsize: 17 + primary_color: '&H00FFFF' + outline_color: '&H000000' + outline_width: 1.0 + back_color: '&H33000000' + border_style: 4 + alignment: 2 + margin_v: 27 + margin_l: 10 + margin_r: 10 + # *ASS style settings (only effective when format is 'ass') + # *scale_mode: 'absolute' = PlayResX/Y matches video resolution, fontsize = absolute pixels + # * 'relative' = PlayResX/Y from config below, fontsize scaled proportionally + ass_style: + scale_mode: 'absolute' + # --- 仅 relative 模式生效 --- + play_res_x: 1920 + play_res_y: 1080 + # --------------------------- + source: + fontname: 'Arial' + fontsize: 17 + primary_color: '&HFFFFFF' + secondary_color: '&HFFFFFF' + outline_color: '&H000000' + back_color: '&H000000' + bold: 0 + italic: 0 + border_style: 1 + outline_width: 1.0 + shadow: 0.0 + shadow_color: '&H80000000' + alignment: 8 + margin_l: 10 + margin_r: 10 + margin_v: 10 + translation: + fontname: 'Arial' + fontsize: 17 + primary_color: '&H00FFFF' + secondary_color: '&H00FFFF' + outline_color: '&H000000' + back_color: '&H33000000' + bold: 0 + italic: 0 + border_style: 4 + outline_width: 1.0 + shadow: 0.0 + alignment: 2 + margin_l: 10 + margin_r: 10 + margin_v: 27 summary_length: 8000 # *Maximum number of words for the first rough cut, below 18 will cut too finely affecting translation, above 22 is too long and will make subsequent subtitle splitting difficult to align @@ -133,8 +202,7 @@ tolerance: 1.5 # Allowed extension time to the next subtitle - -## ======================== Additional settings ======================== ## +## ======================== Additional settings ========================## # Whisper model directory model_dir: './_model_cache' @@ -163,8 +231,7 @@ spacy_model_map: ja: 'ja_core_news_md' es: 'es_core_news_md' de: 'de_core_news_md' - it: 'it_core_news_md' - zh: 'zh_core_web_md' + it: 'it_core_web_md' # Languages that use space as separator language_split_with_space: @@ -178,4 +245,4 @@ language_split_with_space: # Languages that do not use space as separator language_split_without_space: - 'zh' -- 'ja' +- 'ja' \ No newline at end of file diff --git a/core/_12_dub_to_vid.py b/core/_12_dub_to_vid.py index da7b2895..8c79bd4e 100644 --- a/core/_12_dub_to_vid.py +++ b/core/_12_dub_to_vid.py @@ -1,5 +1,6 @@ import platform import subprocess +import os import cv2 import numpy as np @@ -14,19 +15,61 @@ DUB_VIDEO = "output/output_dub.mp4" DUB_SUB_FILE = 'output/dub.srt' +DUB_ASS_FILE = 'output/dub.ass' DUB_AUDIO = 'output/dub.mp3' -TRANS_FONT_SIZE = 17 -TRANS_FONT_NAME = 'Arial' -if platform.system() == 'Linux': - TRANS_FONT_NAME = 'NotoSansCJK-Regular' -if platform.system() == 'Darwin': - TRANS_FONT_NAME = 'Arial Unicode MS' +SRT_TRANS_DEFAULTS = { + 'fontname': 'Arial', 'fontsize': 17, + 'primary_color': '&H00FFFF', 'outline_color': '&H000000', + 'outline_width': 1.0, 'back_color': '&H33000000', 'border_style': 4, + 'alignment': 2, 'margin_v': 27, 'margin_l': 10, 'margin_r': 10, +} -TRANS_FONT_COLOR = '&H00FFFF' -TRANS_OUTLINE_COLOR = '&H000000' -TRANS_OUTLINE_WIDTH = 1 -TRANS_BACK_COLOR = '&H33000000' +def _platform_fontname(): + if platform.system() == 'Linux': + return 'NotoSansCJK-Regular' + elif platform.system() == 'Darwin': + return 'Arial Unicode MS' + return 'Arial' + +def _build_dub_force_style(): + defaults = SRT_TRANS_DEFAULTS + style = load_key("subtitle.srt_style.translation") or {} + merged = {**defaults, **style} + if 'fontname' not in style: + merged['fontname'] = _platform_fontname() + parts = [ + f"FontSize={merged['fontsize']}", + f"FontName={merged['fontname']}", + f"PrimaryColour={merged['primary_color']}", + f"OutlineColour={merged['outline_color']}", + f"OutlineWidth={merged['outline_width']}", + f"BackColour={merged['back_color']}", + f"Alignment={merged['alignment']}", + f"MarginV={merged['margin_v']}", + f"BorderStyle={merged['border_style']}", + ] + return ','.join(parts) + +def _generate_dub_ass(): + from core.utils.ass_utils import generate_ass + import pandas as pd + + style_config = load_key("subtitle.ass_style") or {} + df, lines, new_sub_times = None, None, None + + from core._11_merge_audio import load_and_flatten_data + df, lines, new_sub_times = load_and_flatten_data(_8_1_AUDIO_TASK) + + rows = [] + for i, ((start_time, end_time), line) in enumerate(zip(new_sub_times, lines)): + rows.append({ + 'timestamp': f"{int(start_time//3600):02d}:{int((start_time%3600)//60):02d}:{int(start_time%60):02d},{int((start_time*1000)%1000):03d} --> {int(end_time//3600):02d}:{int((end_time%3600)//60):02d}:{int(end_time%60):02d},{int((end_time*1000)%1000):03d}", + 'Translation': line, + }) + df_ass = pd.DataFrame(rows) + + generate_ass(df_ass, ['Translation'], DUB_ASS_FILE, style_config) def merge_video_audio(): """Merge video and audio, and reduce video volume""" @@ -56,13 +99,16 @@ def merge_video_audio(): TARGET_HEIGHT = int(video.get(cv2.CAP_PROP_FRAME_HEIGHT)) video.release() rprint(f"[bold green]Video resolution: {TARGET_WIDTH}x{TARGET_HEIGHT}[/bold green]") - - subtitle_filter = ( - f"subtitles={DUB_SUB_FILE}:force_style='FontSize={TRANS_FONT_SIZE}," - f"FontName={TRANS_FONT_NAME},PrimaryColour={TRANS_FONT_COLOR}," - f"OutlineColour={TRANS_OUTLINE_COLOR},OutlineWidth={TRANS_OUTLINE_WIDTH}," - f"BackColour={TRANS_BACK_COLOR},Alignment=2,MarginV=27,BorderStyle=4'" - ) + + subtitle_format = load_key("subtitle.format") or 'srt' + + if subtitle_format == 'ass': + if not os.path.exists(DUB_ASS_FILE): + _generate_dub_ass() + subtitle_filter = f"subtitles={DUB_ASS_FILE}" + else: + force_style = _build_dub_force_style() + subtitle_filter = f"subtitles={DUB_SUB_FILE}:force_style='{force_style}'" cmd = [ 'ffmpeg', '-y', '-i', VIDEO_FILE, '-i', background_file, '-i', normalized_dub_audio, @@ -85,4 +131,4 @@ def merge_video_audio(): rprint(f"[bold green]Video and audio successfully merged into {DUB_VIDEO}[/bold green]") if __name__ == '__main__': - merge_video_audio() + merge_video_audio() \ No newline at end of file diff --git a/core/_6_gen_sub.py b/core/_6_gen_sub.py index c04a47e6..8d03fa17 100644 --- a/core/_6_gen_sub.py +++ b/core/_6_gen_sub.py @@ -6,6 +6,7 @@ import autocorrect_py as autocorrect from core.utils import * from core.utils.models import * +from core.utils.ass_utils import generate_ass, ASS_OUTPUT_CONFIGS console = Console() SUBTITLE_OUTPUT_CONFIGS = [ @@ -153,15 +154,25 @@ def align_timestamp_main(): df_translate = pd.read_excel(_5_SPLIT_SUB) df_translate['Translation'] = df_translate['Translation'].apply(clean_translation) - align_timestamp(df_text, df_translate, SUBTITLE_OUTPUT_CONFIGS, _OUTPUT_DIR) + df_trans_time = align_timestamp(df_text, df_translate, SUBTITLE_OUTPUT_CONFIGS, _OUTPUT_DIR) console.print(Panel("[bold green]🎉📝 Subtitles generation completed! Please check in the `output` folder 👀[/bold green]")) # for audio - df_translate_for_audio = pd.read_excel(_5_REMERGED) # use remerged file to avoid unmatched lines when dubbing + df_translate_for_audio = pd.read_excel(_5_REMERGED) df_translate_for_audio['Translation'] = df_translate_for_audio['Translation'].apply(clean_translation) - align_timestamp(df_text, df_translate_for_audio, AUDIO_SUBTITLE_OUTPUT_CONFIGS, _AUDIO_DIR) + df_trans_time_audio = align_timestamp(df_text, df_translate_for_audio, AUDIO_SUBTITLE_OUTPUT_CONFIGS, _AUDIO_DIR) console.print(Panel(f"[bold green]🎉📝 Audio subtitles generation completed! Please check in the `{_AUDIO_DIR}` folder 👀[/bold green]")) + + subtitle_format = load_key("subtitle.format") or 'srt' + if subtitle_format == 'ass': + style_config = load_key("subtitle.ass_style") or {} + + for filename, columns in ASS_OUTPUT_CONFIGS: + filepath = os.path.join(_OUTPUT_DIR, filename) + generate_ass(df_trans_time, columns, filepath, style_config) + + console.print(Panel("[bold green]🎉📝 ASS subtitles generated! Please check in the `output` folder 👀[/bold green]")) if __name__ == '__main__': diff --git a/core/_7_sub_into_vid.py b/core/_7_sub_into_vid.py index 7a2e253f..d5c0d7b7 100644 --- a/core/_7_sub_into_vid.py +++ b/core/_7_sub_into_vid.py @@ -1,38 +1,61 @@ -import os, subprocess, time +import os, subprocess, time, platform from core._1_ytdlp import find_video_files import cv2 import numpy as np -import platform from core.utils import * - -SRC_FONT_SIZE = 15 -TRANS_FONT_SIZE = 17 -FONT_NAME = 'Arial' -TRANS_FONT_NAME = 'Arial' - -# Linux need to install google noto fonts: apt-get install fonts-noto -if platform.system() == 'Linux': - FONT_NAME = 'NotoSansCJK-Regular' - TRANS_FONT_NAME = 'NotoSansCJK-Regular' -# Mac OS has different font names -elif platform.system() == 'Darwin': - FONT_NAME = 'Arial Unicode MS' - TRANS_FONT_NAME = 'Arial Unicode MS' - -SRC_FONT_COLOR = '&HFFFFFF' -SRC_OUTLINE_COLOR = '&H000000' -SRC_OUTLINE_WIDTH = 1 -SRC_SHADOW_COLOR = '&H80000000' -TRANS_FONT_COLOR = '&H00FFFF' -TRANS_OUTLINE_COLOR = '&H000000' -TRANS_OUTLINE_WIDTH = 1 -TRANS_BACK_COLOR = '&H33000000' +from core.utils.models import * OUTPUT_DIR = "output" OUTPUT_VIDEO = f"{OUTPUT_DIR}/output_sub.mp4" SRC_SRT = f"{OUTPUT_DIR}/src.srt" TRANS_SRT = f"{OUTPUT_DIR}/trans.srt" - +SRC_ASS = f"{OUTPUT_DIR}/src.ass" +TRANS_ASS = f"{OUTPUT_DIR}/trans.ass" + +SRT_SRC_DEFAULTS = { + 'fontname': 'Arial', 'fontsize': 15, + 'primary_color': '&HFFFFFF', 'outline_color': '&H000000', + 'outline_width': 1.0, 'shadow_color': '&H80000000', 'border_style': 1, + 'alignment': 8, 'margin_v': 10, 'margin_l': 10, 'margin_r': 10, +} +SRT_TRANS_DEFAULTS = { + 'fontname': 'Arial', 'fontsize': 17, + 'primary_color': '&H00FFFF', 'outline_color': '&H000000', + 'outline_width': 1.0, 'back_color': '&H33000000', 'border_style': 4, + 'alignment': 2, 'margin_v': 27, 'margin_l': 10, 'margin_r': 10, +} + +def _platform_fontname(): + if platform.system() == 'Linux': + return 'NotoSansCJK-Regular' + elif platform.system() == 'Darwin': + return 'Arial Unicode MS' + return 'Arial' + +def _build_srt_force_style(style_key): + defaults = SRT_SRC_DEFAULTS if style_key == 'source' else SRT_TRANS_DEFAULTS + config_key = f"subtitle.srt_style.{style_key}" + style = load_key(config_key) or {} + merged = {**defaults, **style} + if 'fontname' not in style: + merged['fontname'] = _platform_fontname() + parts = [ + f"FontSize={merged['fontsize']}", + f"FontName={merged['fontname']}", + f"PrimaryColour={merged['primary_color']}", + f"OutlineColour={merged['outline_color']}", + f"OutlineWidth={merged['outline_width']}", + ] + if style_key == 'source': + parts.append(f"ShadowColour={merged['shadow_color']}") + parts.append(f"BorderStyle={merged['border_style']}") + else: + parts.append(f"BackColour={merged['back_color']}") + parts.append(f"Alignment={merged['alignment']}") + parts.append(f"MarginV={merged['margin_v']}") + parts.append(f"BorderStyle={merged['border_style']}") + return ','.join(parts) + def check_gpu_available(): try: result = subprocess.run(['ffmpeg', '-encoders'], capture_output=True, text=True) @@ -44,7 +67,6 @@ def merge_subtitles_to_video(): video_file = find_video_files() os.makedirs(os.path.dirname(OUTPUT_VIDEO), exist_ok=True) - # Check resolution if not load_key("burn_subtitles"): rprint("[bold yellow]Warning: A 0-second black video will be generated as a placeholder as subtitles are not burned in.[/bold yellow]") @@ -58,26 +80,38 @@ def merge_subtitles_to_video(): rprint("[bold green]Placeholder video has been generated.[/bold green]") return - if not os.path.exists(SRC_SRT) or not os.path.exists(TRANS_SRT): - rprint("Subtitle files not found in the 'output' directory.") - exit(1) + subtitle_format = load_key("subtitle.format") or 'srt' video = cv2.VideoCapture(video_file) TARGET_WIDTH = int(video.get(cv2.CAP_PROP_FRAME_WIDTH)) TARGET_HEIGHT = int(video.get(cv2.CAP_PROP_FRAME_HEIGHT)) video.release() rprint(f"[bold green]Video resolution: {TARGET_WIDTH}x{TARGET_HEIGHT}[/bold green]") + + if subtitle_format == 'ass': + if not os.path.exists(SRC_ASS) or not os.path.exists(TRANS_ASS): + rprint("ASS subtitle files not found in the 'output' directory.") + exit(1) + + src_style = f"subtitles={SRC_ASS}" + trans_style = f"subtitles={TRANS_ASS}" + else: + if not os.path.exists(SRC_SRT) or not os.path.exists(TRANS_SRT): + rprint("Subtitle files not found in the 'output' directory.") + exit(1) + + src_force = _build_srt_force_style('source') + trans_force = _build_srt_force_style('translation') + src_style = f"subtitles={SRC_SRT}:force_style='{src_force}'" + trans_style = f"subtitles={TRANS_SRT}:force_style='{trans_force}'" + ffmpeg_cmd = [ 'ffmpeg', '-i', video_file, '-vf', ( f"scale={TARGET_WIDTH}:{TARGET_HEIGHT}:force_original_aspect_ratio=decrease," f"pad={TARGET_WIDTH}:{TARGET_HEIGHT}:(ow-iw)/2:(oh-ih)/2," - f"subtitles={SRC_SRT}:force_style='FontSize={SRC_FONT_SIZE},FontName={FONT_NAME}," - f"PrimaryColour={SRC_FONT_COLOR},OutlineColour={SRC_OUTLINE_COLOR},OutlineWidth={SRC_OUTLINE_WIDTH}," - f"ShadowColour={SRC_SHADOW_COLOR},BorderStyle=1'," - f"subtitles={TRANS_SRT}:force_style='FontSize={TRANS_FONT_SIZE},FontName={TRANS_FONT_NAME}," - f"PrimaryColour={TRANS_FONT_COLOR},OutlineColour={TRANS_OUTLINE_COLOR},OutlineWidth={TRANS_OUTLINE_WIDTH}," - f"BackColour={TRANS_BACK_COLOR},Alignment=2,MarginV=27,BorderStyle=4'" + f"{src_style}," + f"{trans_style}" ).encode('utf-8'), ] diff --git a/core/st_utils/imports_and_utils.py b/core/st_utils/imports_and_utils.py index c6929094..83bfd094 100644 --- a/core/st_utils/imports_and_utils.py +++ b/core/st_utils/imports_and_utils.py @@ -11,7 +11,7 @@ def download_subtitle_zip_button(text: str): with zipfile.ZipFile(zip_buffer, "w") as zip_file: for file_name in os.listdir(output_dir): - if file_name.endswith(".srt"): + if file_name.endswith(".srt") or file_name.endswith(".ass"): file_path = os.path.join(output_dir, file_name) with open(file_path, "rb") as file: zip_file.writestr(file_name, file.read()) diff --git a/core/st_utils/sidebar_setting.py b/core/st_utils/sidebar_setting.py index a300de5b..53a6e70b 100644 --- a/core/st_utils/sidebar_setting.py +++ b/core/st_utils/sidebar_setting.py @@ -226,6 +226,183 @@ def page_setting(): if burn_subtitles != load_key("burn_subtitles"): update_key("burn_subtitles", burn_subtitles) st.rerun() + + subtitle_format = st.selectbox( + t("Subtitle Format"), + options=['srt', 'ass'], + index=['srt', 'ass'].index(load_key("subtitle.format") or 'srt'), + help=t("ASS format supports more style customization"), + ) + if subtitle_format != (load_key("subtitle.format") or 'srt'): + update_key("subtitle.format", subtitle_format) + st.rerun() + + if subtitle_format == 'srt': + with st.expander(t("SRT Style Settings")): + srt_style = load_key("subtitle.srt_style") or {} + src_style = srt_style.get('source', {}) + trans_style = srt_style.get('translation', {}) + + st.subheader(t("Source Subtitle Style")) + src_fontname = st.text_input(t("Source Font Name"), value=src_style.get('fontname', 'Arial')) + src_fontsize = st.number_input(t("Source Font Size"), value=int(src_style.get('fontsize', 15)), min_value=1) + src_primary = st.text_input(t("Source Primary Color"), value=src_style.get('primary_color', '&HFFFFFF')) + src_outline = st.text_input(t("Source Outline Color"), value=src_style.get('outline_color', '&H000000')) + src_outline_w = st.number_input(t("Source Outline Width"), value=float(src_style.get('outline_width', 1.0)), min_value=0.0, step=0.5) + src_shadow_color = st.text_input(t("Source Shadow Color"), value=src_style.get('shadow_color', '&H80000000')) + src_border_opts = sorted(set([1, 3, 4] + [int(src_style.get('border_style', 1))])) + src_border_default = int(src_style.get('border_style', 1)) + src_border = st.selectbox(t("Source Border Style"), options=src_border_opts, index=src_border_opts.index(src_border_default) if src_border_default in src_border_opts else 0) + align_opts = sorted(set(list(range(1, 10)) + [int(src_style.get('alignment', 8))])) + src_align_default = int(src_style.get('alignment', 8)) + src_align = st.selectbox(t("Source Alignment"), options=align_opts, index=align_opts.index(src_align_default) if src_align_default in align_opts else 7) + src_marginv = st.number_input(t("Source Margin V"), value=int(src_style.get('margin_v', 10)), min_value=0) + src_marginl = st.number_input(t("Source Margin L"), value=int(src_style.get('margin_l', 10)), min_value=0) + src_marginr = st.number_input(t("Source Margin R"), value=int(src_style.get('margin_r', 10)), min_value=0) + + st.subheader(t("Translation Subtitle Style")) + trans_fontname = st.text_input(t("Translation Font Name"), value=trans_style.get('fontname', 'Arial')) + trans_fontsize = st.number_input(t("Translation Font Size"), value=int(trans_style.get('fontsize', 17)), min_value=1) + trans_primary = st.text_input(t("Translation Primary Color"), value=trans_style.get('primary_color', '&H00FFFF')) + trans_outline = st.text_input(t("Translation Outline Color"), value=trans_style.get('outline_color', '&H000000')) + trans_outline_w = st.number_input(t("Translation Outline Width"), value=float(trans_style.get('outline_width', 1.0)), min_value=0.0, step=0.5) + trans_back = st.text_input(t("Translation Back Color"), value=trans_style.get('back_color', '&H33000000')) + trans_border_opts = sorted(set([1, 3, 4] + [int(trans_style.get('border_style', 4))])) + trans_border_default = int(trans_style.get('border_style', 4)) + trans_border = st.selectbox(t("Translation Border Style"), options=trans_border_opts, index=trans_border_opts.index(trans_border_default) if trans_border_default in trans_border_opts else 0) + trans_align_opts = sorted(set(list(range(1, 10)) + [int(trans_style.get('alignment', 2))])) + trans_align_default = int(trans_style.get('alignment', 2)) + trans_align = st.selectbox(t("Translation Alignment"), options=trans_align_opts, index=trans_align_opts.index(trans_align_default) if trans_align_default in trans_align_opts else 1) + trans_marginv = st.number_input(t("Translation Margin V"), value=int(trans_style.get('margin_v', 27)), min_value=0) + trans_marginl = st.number_input(t("Translation Margin L"), value=int(trans_style.get('margin_l', 10)), min_value=0) + trans_marginr = st.number_input(t("Translation Margin R"), value=int(trans_style.get('margin_r', 10)), min_value=0) + + if st.button(t("Save SRT Style")): + update_key("subtitle.srt_style.source", { + 'fontname': src_fontname, 'fontsize': src_fontsize, + 'primary_color': src_primary, 'outline_color': src_outline, + 'outline_width': src_outline_w, 'shadow_color': src_shadow_color, + 'border_style': src_border, 'alignment': src_align, + 'margin_v': src_marginv, 'margin_l': src_marginl, 'margin_r': src_marginr, + }) + update_key("subtitle.srt_style.translation", { + 'fontname': trans_fontname, 'fontsize': trans_fontsize, + 'primary_color': trans_primary, 'outline_color': trans_outline, + 'outline_width': trans_outline_w, 'back_color': trans_back, + 'border_style': trans_border, 'alignment': trans_align, + 'margin_v': trans_marginv, 'margin_l': trans_marginl, 'margin_r': trans_marginr, + }) + st.toast(t("SRT style saved")) + elif subtitle_format == 'ass': + with st.expander(t("ASS Style Settings")): + ass_style = load_key("subtitle.ass_style") or {} + + # ---- Import ---- + with st.expander(t("Import ASS Style")): + pasted_style = st.text_area( + t("Paste ASS Style line"), + placeholder='Style: Default,微软雅黑,54,&H00FFFFFF,...', + height=80, + ) + import_target = st.radio( + t("Apply to"), + options=['source', 'translation', 'both'], + format_func=lambda x: { + 'source': t('Source'), + 'translation': t('Translation'), + 'both': t('Both'), + }[x], + horizontal=True, + ) + if st.button(t("Import Style")): + from core.utils.ass_utils import parse_ass_style_line + try: + parsed = parse_ass_style_line(pasted_style) + if import_target in ('source', 'both'): + update_key("subtitle.ass_style.source", parsed) + if import_target in ('translation', 'both'): + update_key("subtitle.ass_style.translation", parsed) + st.toast(t("Style imported successfully")) + st.rerun() + except ValueError as e: + st.error(str(e)) + + scale_mode = st.radio( + t("Font Size Mode"), + options=['absolute', 'relative'], + format_func=lambda x: t("Absolute Pixels") if x == 'absolute' else t("Relative Scaling"), + index=['absolute', 'relative'].index(ass_style.get('scale_mode', 'absolute')), + help=t("Absolute: fontsize = pixels, same as SRT. Relative: fontsize scales with video resolution"), + ) + if scale_mode != ass_style.get('scale_mode', 'absolute'): + update_key("subtitle.ass_style.scale_mode", scale_mode) + st.rerun() + + if scale_mode == 'relative': + prx = st.number_input("PlayResX", value=int(ass_style.get('play_res_x', 1920))) + pry = st.number_input("PlayResY", value=int(ass_style.get('play_res_y', 1080))) + if prx != ass_style.get('play_res_x', 1920): + update_key("subtitle.ass_style.play_res_x", prx) + if pry != ass_style.get('play_res_y', 1080): + update_key("subtitle.ass_style.play_res_y", pry) + + src_style = ass_style.get('source', {}) + trans_style = ass_style.get('translation', {}) + + st.subheader(t("Source Subtitle Style")) + src_fontname = st.text_input(t("Source Font Name"), value=src_style.get('fontname', 'Arial')) + src_fontsize = st.number_input(t("Source Font Size"), value=int(src_style.get('fontsize', 17)), min_value=1) + src_primary = st.text_input(t("Source Primary Color"), value=src_style.get('primary_color', '&HFFFFFF')) + src_secondary = st.text_input(t("Source Secondary Color"), value=src_style.get('secondary_color', '&HFFFFFF')) + src_outline = st.text_input(t("Source Outline Color"), value=src_style.get('outline_color', '&H000000')) + src_outline_w = st.number_input(t("Source Outline Width"), value=float(src_style.get('outline_width', 1.0)), min_value=0.0, step=0.5) + src_shadow_depth = st.number_input(t("Source Shadow Depth"), value=float(src_style.get('shadow', 0.0)), min_value=0.0, step=0.5) + src_border_opts = sorted(set([1, 3, 4] + [int(src_style.get('border_style', 1))])) + src_border_default = int(src_style.get('border_style', 1)) + src_border = st.selectbox(t("Source Border Style"), options=src_border_opts, index=src_border_opts.index(src_border_default) if src_border_default in src_border_opts else 0) + align_opts = sorted(set(list(range(1, 10)) + [int(src_style.get('alignment', 8))])) + src_align_default = int(src_style.get('alignment', 8)) + src_align = st.selectbox(t("Source Alignment"), options=align_opts, index=align_opts.index(src_align_default) if src_align_default in align_opts else 7) + src_marginv = st.number_input(t("Source Margin V"), value=int(src_style.get('margin_v', 10)), min_value=0) + + st.subheader(t("Translation Subtitle Style")) + trans_fontname = st.text_input(t("Translation Font Name"), value=trans_style.get('fontname', 'Arial')) + trans_fontsize = st.number_input(t("Translation Font Size"), value=int(trans_style.get('fontsize', 17)), min_value=1) + trans_primary = st.text_input(t("Translation Primary Color"), value=trans_style.get('primary_color', '&H00FFFF')) + trans_secondary = st.text_input(t("Translation Secondary Color"), value=trans_style.get('secondary_color', '&H00FFFF')) + trans_outline = st.text_input(t("Translation Outline Color"), value=trans_style.get('outline_color', '&H000000')) + trans_outline_w = st.number_input(t("Translation Outline Width"), value=float(trans_style.get('outline_width', 1.0)), min_value=0.0, step=0.5) + trans_shadow_depth = st.number_input(t("Translation Shadow Depth"), value=float(trans_style.get('shadow', 0.0)), min_value=0.0, step=0.5) + trans_back = st.text_input(t("Translation Back Color"), value=trans_style.get('back_color', '&H33000000')) + trans_border_opts = sorted(set([1, 3, 4] + [int(trans_style.get('border_style', 4))])) + trans_border_default = int(trans_style.get('border_style', 4)) + trans_border = st.selectbox(t("Translation Border Style"), options=trans_border_opts, index=trans_border_opts.index(trans_border_default) if trans_border_default in trans_border_opts else 0) + trans_align_opts = sorted(set(list(range(1, 10)) + [int(trans_style.get('alignment', 2))])) + trans_align_default = int(trans_style.get('alignment', 2)) + trans_align = st.selectbox(t("Translation Alignment"), options=trans_align_opts, index=trans_align_opts.index(trans_align_default) if trans_align_default in trans_align_opts else 1) + trans_marginv = st.number_input(t("Translation Margin V"), value=int(trans_style.get('margin_v', 27)), min_value=0) + + if st.button(t("Save ASS Style")): + update_key("subtitle.ass_style.source", { + 'fontname': src_fontname, 'fontsize': src_fontsize, + 'primary_color': src_primary, 'secondary_color': src_secondary, + 'outline_color': src_outline, 'back_color': '&H000000', + 'bold': 0, 'italic': 0, + 'outline_width': src_outline_w, 'shadow': src_shadow_depth, + 'shadow_color': '&H80000000', + 'border_style': src_border, 'alignment': src_align, + 'margin_l': 10, 'margin_r': 10, 'margin_v': src_marginv, + }) + update_key("subtitle.ass_style.translation", { + 'fontname': trans_fontname, 'fontsize': trans_fontsize, + 'primary_color': trans_primary, 'secondary_color': trans_secondary, + 'outline_color': trans_outline, 'back_color': trans_back, + 'bold': 0, 'italic': 0, + 'outline_width': trans_outline_w, 'shadow': trans_shadow_depth, + 'border_style': trans_border, 'alignment': trans_align, + 'margin_l': 10, 'margin_r': 10, 'margin_v': trans_marginv, + }) + st.toast(t("ASS style saved")) with st.expander(t("Dubbing Settings"), expanded=True): tts_methods = [ "azure_tts", diff --git a/core/utils/ass_utils.py b/core/utils/ass_utils.py new file mode 100644 index 00000000..1d4460f2 --- /dev/null +++ b/core/utils/ass_utils.py @@ -0,0 +1,192 @@ +import os +import pandas as pd + +ASS_OUTPUT_CONFIGS = [ + ('src.ass', ['Source']), + ('trans.ass', ['Translation']), + ('src_trans.ass', ['Source', 'Translation']), + ('trans_src.ass', ['Translation', 'Source']), +] + + +def seconds_to_ass_time(seconds): + h = int(seconds // 3600) + m = int((seconds % 3600) // 60) + s = int(seconds % 60) + cs = int((seconds * 100) % 100) + return f"{h}:{m:02d}:{s:02d}.{cs:02d}" + + +def _srt_timestamp_to_seconds(ts_str): + parts = ts_str.strip().split(' --> ') + start_str, end_str = parts[0], parts[1] + + def parse(hmsm): + h, m, s_ms = hmsm.split(':') + s, ms = s_ms.split(',') + return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0 + + return parse(start_str), parse(end_str) + + +def _get_default_style(name): + defaults = { + 'source': { + 'fontname': 'Arial', 'fontsize': 17, + 'primary_color': '&HFFFFFF', 'secondary_color': '&HFFFFFF', + 'outline_color': '&H000000', 'back_color': '&H000000', + 'bold': 0, 'italic': 0, 'border_style': 1, + 'outline_width': 1.0, 'shadow': 0.0, 'alignment': 8, + 'margin_l': 10, 'margin_r': 10, 'margin_v': 10, + 'shadow_color': '&H80000000', + }, + 'translation': { + 'fontname': 'Arial', 'fontsize': 17, + 'primary_color': '&H00FFFF', 'secondary_color': '&H00FFFF', + 'outline_color': '&H000000', 'back_color': '&H33000000', + 'bold': 0, 'italic': 0, 'border_style': 4, + 'outline_width': 1.0, 'shadow': 0.0, 'alignment': 2, + 'margin_l': 10, 'margin_r': 10, 'margin_v': 27, + }, + } + return defaults.get(name, defaults['translation']) + + +def _build_style_line(style_name, config): + d = _get_default_style(style_name) + merged = {**d, **config} + outline = merged['outline_width'] + shadow = merged['shadow'] + if isinstance(outline, float) and outline == int(outline): + outline = int(outline) + if isinstance(shadow, float) and shadow == int(shadow): + shadow = int(shadow) + return ( + f"Style: {style_name.capitalize()}," + f"{merged['fontname']},{merged['fontsize']}," + f"{merged['primary_color']},{merged['secondary_color']}," + f"{merged['outline_color']},{merged['back_color']}," + f"{merged['bold']},{merged['italic']},0,0," + f"100,100,0,0," + f"{merged['border_style']},{outline},{shadow}," + f"{merged['alignment']}," + f"{merged['margin_l']},{merged['margin_r']},{merged['margin_v']},1" + ) + + +def parse_ass_style_line(line): + line = line.strip() + if line.lower().startswith('style:'): + line = line[len('style:'):].strip() + # Remove inline comments + if '*' in line: + line = line[:line.index('*')].strip() + parts = [p.strip() for p in line.split(',')] + if len(parts) < 22: + raise ValueError(f"ASS Style line needs >=22 fields, got {len(parts)}") + # Map: (0-based index in parts, config key, value_type) + # parts[0] = Name (skipped) + field_map = { + 1: ('fontname', str), + 2: ('fontsize', int), + 3: ('primary_color', str), + 4: ('secondary_color', str), + 5: ('outline_color', str), + 6: ('back_color', str), + 7: ('bold', lambda v: 1 if v.lstrip('-') in ('1', '1.0') else 0), + 8: ('italic', lambda v: 1 if v.lstrip('-') in ('1', '1.0') else 0), + 15: ('border_style', int), + 16: ('outline_width', float), + 17: ('shadow', float), + 18: ('alignment', int), + 19: ('margin_l', int), + 20: ('margin_r', int), + 21: ('margin_v', int), + } + result = {} + for idx, (key, conv) in field_map.items(): + val = parts[idx] + if isinstance(conv, type) and issubclass(conv, int): + result[key] = int(float(val)) + elif isinstance(conv, type) and issubclass(conv, float): + result[key] = float(val) + elif callable(conv): + result[key] = conv(val) + else: + result[key] = conv(val) + return result + + +def generate_ass(df, columns, output_path, style_config, video_resolution=None): + scale_mode = style_config.get('scale_mode', 'absolute') + + if scale_mode == 'absolute': + if video_resolution is None: + video_resolution = (1920, 1080) + play_res_x, play_res_y = video_resolution + else: + play_res_x = style_config.get('play_res_x', 1920) + play_res_y = style_config.get('play_res_y', 1080) + + src_style = style_config.get('source', {}) + trans_style = style_config.get('translation', {}) + + style_lines = [] + style_names = [] + has_source = 'Source' in columns + has_translation = 'Translation' in columns + + if has_source: + style_lines.append(_build_style_line('source', src_style)) + style_names.append('Source') + if has_translation: + style_lines.append(_build_style_line('translation', trans_style)) + style_names.append('Translation') + + header = ( + "[Script Info]\n" + f"Title: VideoLingo Generated\n" + f"ScriptType: v4.00+\n" + f"PlayResX: {play_res_x}\n" + f"PlayResY: {play_res_y}\n" + f"ScaledBorderAndShadow: yes\n" + f"WrapStyle: 0\n" + f"\n" + f"[V4+ Styles]\n" + f"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n" + + "\n".join(style_lines) + "\n" + f"\n" + f"[Events]\n" + f"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" + ) + + events = [] + for idx, row in df.iterrows(): + ts_str = row['timestamp'] + start_sec, end_sec = _srt_timestamp_to_seconds(ts_str) + start_ass = seconds_to_ass_time(start_sec) + end_ass = seconds_to_ass_time(end_sec) + + if len(columns) == 1: + col = columns[0] + text = str(row[col]).strip() + style_name = 'Source' if col == 'Source' else 'Translation' + else: + parts = [] + style_name = columns[0].capitalize() if columns[0] == 'Source' else 'Translation' + if 'Source' in columns and 'Translation' in columns: + style_name = 'Source' + for col in columns: + val = str(row[col]).strip() + parts.append(val) + text = '\\N'.join(parts) + + events.append( + f"Dialogue: 0,{start_ass},{end_ass},{style_name},,0,0,0,,{text}" + ) + + content = header + "\n".join(events) + "\n" + + os.makedirs(os.path.dirname(output_path) if os.path.dirname(output_path) else '.', exist_ok=True) + with open(output_path, 'w', encoding='utf-8') as f: + f.write(content) \ No newline at end of file diff --git a/core/utils/models.py b/core/utils/models.py index 8e15c829..95e0e483 100644 --- a/core/utils/models.py +++ b/core/utils/models.py @@ -25,6 +25,16 @@ _AUDIO_SEGS_DIR = "output/audio/segs" _AUDIO_TMP_DIR = "output/audio/tmp" +# ------------------------------------------ +# ASS 字幕文件 +# ------------------------------------------ + +_SRC_ASS = "output/src.ass" +_TRANS_ASS = "output/trans.ass" +_SRC_TRANS_ASS = "output/src_trans.ass" +_TRANS_SRC_ASS = "output/trans_src.ass" +_DUB_ASS = "output/dub.ass" + # ------------------------------------------ # 导出 # ------------------------------------------ @@ -45,5 +55,10 @@ "_BACKGROUND_AUDIO_FILE", "_AUDIO_REFERS_DIR", "_AUDIO_SEGS_DIR", - "_AUDIO_TMP_DIR" + "_AUDIO_TMP_DIR", + "_SRC_ASS", + "_TRANS_ASS", + "_SRC_TRANS_ASS", + "_TRANS_SRC_ASS", + "_DUB_ASS" ] diff --git a/translations/en.json b/translations/en.json index 3a99c0de..2b396580 100644 --- a/translations/en.json +++ b/translations/en.json @@ -122,5 +122,53 @@ "Task completed!": "Task completed!", "Task stopped": "Task stopped", "Task error": "Task error", - "OK": "OK" + "OK": "OK", + "Subtitle Format": "Subtitle Format", + "ASS format supports more style customization": "ASS format supports more style customization", + "ASS Style Settings": "ASS Style Settings", + "Font Size Mode": "Font Size Mode", + "Absolute: fontsize = pixels, same as SRT. Relative: fontsize scales with video resolution": "Absolute: fontsize = pixels, same as SRT. Relative: fontsize scales with video resolution", + "Absolute Pixels": "Absolute Pixels", + "Relative Scaling": "Relative Scaling", + "Source Subtitle Style": "Source Subtitle Style", + "Source Font Name": "Source Font Name", + "Source Font Size": "Source Font Size", + "Source Primary Color": "Source Primary Color", + "Source Outline Color": "Source Outline Color", + "Source Outline Width": "Source Outline Width", + "Source Shadow Color": "Source Shadow Color", + "Source Border Style": "Source Border Style", + "Source Alignment": "Source Alignment", + "Source Margin V": "Source Margin V", + "Translation Subtitle Style": "Translation Subtitle Style", + "Translation Font Name": "Translation Font Name", + "Translation Font Size": "Translation Font Size", + "Translation Primary Color": "Translation Primary Color", + "Translation Outline Color": "Translation Outline Color", + "Translation Outline Width": "Translation Outline Width", + "Translation Back Color": "Translation Back Color", + "Translation Border Style": "Translation Border Style", + "Translation Alignment": "Translation Alignment", + "Translation Margin V": "Translation Margin V", + "Save ASS Style": "Save ASS Style", + "Source Shadow Depth": "Source Shadow Depth", + "Source Secondary Color": "Source Secondary Color", + "Translation Shadow Depth": "Translation Shadow Depth", + "Translation Secondary Color": "Translation Secondary Color", + "ASS style saved": "ASS style saved", + "Import ASS Style": "Import ASS Style", + "Paste ASS Style line": "Paste ASS Style line", + "Apply to": "Apply to", + "Source": "Source", + "Translation": "Translation", + "Both": "Both", + "Import Style": "Import Style", + "Style imported successfully": "Style imported successfully", + "SRT Style Settings": "SRT Style Settings", + "Save SRT Style": "Save SRT Style", + "SRT style saved": "SRT style saved", + "Source Margin L": "Source Margin L", + "Source Margin R": "Source Margin R", + "Translation Margin L": "Translation Margin L", + "Translation Margin R": "Translation Margin R" } diff --git a/translations/zh-CN.json b/translations/zh-CN.json index 052ab034..7120af32 100644 --- a/translations/zh-CN.json +++ b/translations/zh-CN.json @@ -122,5 +122,53 @@ "Task completed!": "任务完成!", "Task stopped": "任务已停止", "Task error": "任务出错", - "OK": "确定" + "OK": "确定", + "Subtitle Format": "字幕格式", + "ASS format supports more style customization": "ASS格式支持更多样式自定义", + "ASS Style Settings": "ASS样式设置", + "Font Size Mode": "字号模式", + "Absolute: fontsize = pixels, same as SRT. Relative: fontsize scales with video resolution": "绝对:字号=像素,同SRT。相对:字号随视频分辨率缩放", + "Absolute Pixels": "绝对像素", + "Relative Scaling": "相对缩放", + "Source Subtitle Style": "源语言字幕样式", + "Source Font Name": "源语言字体名称", + "Source Font Size": "源语言字号", + "Source Primary Color": "源语言主要颜色", + "Source Outline Color": "源语言描边颜色", + "Source Outline Width": "源语言描边宽度", + "Source Shadow Color": "源语言阴影颜色", + "Source Border Style": "源语言边框样式", + "Source Alignment": "源语言对齐方式", + "Source Margin V": "源语言垂直边距", + "Translation Subtitle Style": "译文字幕样式", + "Translation Font Name": "译文字体名称", + "Translation Font Size": "译文字号", + "Translation Primary Color": "译文主要颜色", + "Translation Outline Color": "译文描边颜色", + "Translation Outline Width": "译文描边宽度", + "Translation Back Color": "译文背景颜色", + "Translation Border Style": "译文边框样式", + "Translation Alignment": "译文对齐方式", + "Translation Margin V": "译文垂直边距", + "Save ASS Style": "保存ASS样式", + "Source Shadow Depth": "源语言阴影深度", + "Source Secondary Color": "源语言次要颜色", + "Translation Shadow Depth": "译文阴影深度", + "Translation Secondary Color": "译文次要颜色", + "ASS style saved": "ASS样式已保存", + "Import ASS Style": "导入ASS样式", + "Paste ASS Style line": "粘贴ASS样式行", + "Apply to": "应用到", + "Source": "源语言", + "Translation": "译文", + "Both": "两者", + "Import Style": "导入样式", + "Style imported successfully": "样式导入成功", + "SRT Style Settings": "SRT样式设置", + "Save SRT Style": "保存SRT样式", + "SRT style saved": "SRT样式已保存", + "Source Margin L": "源语言左边距", + "Source Margin R": "源语言右边距", + "Translation Margin L": "译文左边距", + "Translation Margin R": "译文右边距" }