Update app.py
Browse files
app.py
CHANGED
|
@@ -21,7 +21,6 @@ from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageEnhance
|
|
| 21 |
from datetime import datetime
|
| 22 |
from threading import Lock
|
| 23 |
import base64
|
| 24 |
-
import json
|
| 25 |
import io
|
| 26 |
|
| 27 |
# --- Dependency Check ---
|
|
@@ -790,39 +789,82 @@ def remove_video_background(video_path):
|
|
| 790 |
shutil.rmtree(job_temp_dir)
|
| 791 |
return output_video_path
|
| 792 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 793 |
def transcribe_media(media_path, model_name):
|
| 794 |
if media_path is None: raise gr.Error("Please upload a video or audio file first.")
|
| 795 |
model = load_whisper_model(model_name)
|
| 796 |
if model is None: raise gr.Error("Whisper model is not available.")
|
|
|
|
| 797 |
audio_path = media_path.name
|
| 798 |
base_name = os.path.splitext(os.path.basename(media_path.name))[0]
|
| 799 |
|
| 800 |
if audio_path.lower().endswith(('.mp4', '.mov', '.mkv', '.avi', '.webm')):
|
| 801 |
audio_path_temp = os.path.join(TEMP_DIR, f"{base_name}.mp3")
|
| 802 |
try:
|
| 803 |
-
run_ffmpeg_command(["ffmpeg", "-i", audio_path, "-q:a", "0", "-map", "a",
|
| 804 |
audio_path = audio_path_temp
|
| 805 |
except gr.Error as e:
|
| 806 |
if "does not contain any stream" in str(e): raise gr.Error("The uploaded video has no audio track.")
|
| 807 |
else: raise e
|
| 808 |
|
| 809 |
-
result = model.transcribe(audio_path, verbose=False)
|
| 810 |
|
| 811 |
-
def format_ts(s):
|
| 812 |
h, r = divmod(s, 3600); m, s = divmod(r, 60)
|
| 813 |
-
return f"{int(h):02}:{int(m):02}:{int(s):02}
|
| 814 |
|
| 815 |
srt_path = os.path.join(TEMP_DIR, f"{base_name}.srt")
|
| 816 |
vtt_path = os.path.join(TEMP_DIR, f"{base_name}.vtt")
|
|
|
|
| 817 |
|
| 818 |
with open(srt_path, "w", encoding="utf-8") as srt_f, open(vtt_path, "w", encoding="utf-8") as vtt_f:
|
| 819 |
vtt_f.write("WEBVTT\n\n")
|
| 820 |
for i, seg in enumerate(result["segments"]):
|
| 821 |
start, end, text = seg['start'], seg['end'], seg['text'].strip()
|
| 822 |
srt_f.write(f"{i + 1}\n{format_ts(start)} --> {format_ts(end)}\n{text}\n\n")
|
| 823 |
-
vtt_f.write(f"{format_ts(start
|
| 824 |
|
| 825 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 826 |
|
| 827 |
def transcribe_and_prep_burn(media_file, model_name):
|
| 828 |
if not media_file: raise gr.Error("Please upload a file first.")
|
|
@@ -832,10 +874,6 @@ def transcribe_and_prep_burn(media_file, model_name):
|
|
| 832 |
else: return text, files, None, gr.update(visible=False)
|
| 833 |
|
| 834 |
def reformat_srt_for_word_wrap(original_srt_path, words_per_line):
|
| 835 |
-
"""
|
| 836 |
-
Reads an SRT file and adds line breaks to the text blocks without changing timestamps,
|
| 837 |
-
then writes the result to a new temporary SRT file.
|
| 838 |
-
"""
|
| 839 |
if not original_srt_path or not os.path.exists(original_srt_path):
|
| 840 |
return None
|
| 841 |
|
|
@@ -844,20 +882,14 @@ def reformat_srt_for_word_wrap(original_srt_path, words_per_line):
|
|
| 844 |
|
| 845 |
with open(original_srt_path, 'r', encoding='utf-8') as f_in, \
|
| 846 |
open(reformatted_path, 'w', encoding='utf-8') as f_out:
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
blocks = content.strip().split('\n\n')
|
| 850 |
-
|
| 851 |
-
for block in blocks:
|
| 852 |
lines = block.split('\n')
|
| 853 |
-
if len(lines) < 3:
|
| 854 |
f_out.write(block + '\n\n')
|
| 855 |
continue
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
full_text = ' '.join(text_lines)
|
| 859 |
-
words = full_text.split()
|
| 860 |
-
|
| 861 |
new_text_lines = []
|
| 862 |
current_line = []
|
| 863 |
for word in words:
|
|
@@ -865,29 +897,16 @@ def reformat_srt_for_word_wrap(original_srt_path, words_per_line):
|
|
| 865 |
if len(current_line) >= words_per_line:
|
| 866 |
new_text_lines.append(' '.join(current_line))
|
| 867 |
current_line = []
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
new_text_lines.append(' '.join(current_line))
|
| 871 |
-
|
| 872 |
-
# Reconstruct the block with the new text formatting
|
| 873 |
-
f_out.write(lines[0] + '\n') # Index
|
| 874 |
-
f_out.write(lines[1] + '\n') # Timestamp
|
| 875 |
-
f_out.write('\n'.join(new_text_lines) + '\n\n')
|
| 876 |
-
|
| 877 |
return reformatted_path
|
| 878 |
|
| 879 |
-
def
|
| 880 |
-
if not video_path: raise gr.Error("Original video path not found. Please re-transcribe.")
|
| 881 |
-
if not srt_file_obj or not srt_file_obj[0].name: raise gr.Error("SRT file not found. Please re-transcribe.")
|
| 882 |
-
|
| 883 |
original_srt_path = srt_file_obj[0].name
|
| 884 |
reformatted_srt_path = None
|
| 885 |
-
|
| 886 |
try:
|
| 887 |
-
# Reformat the SRT file based on the slider value
|
| 888 |
reformatted_srt_path = reformat_srt_for_word_wrap(original_srt_path, words_per_line)
|
| 889 |
-
if not reformatted_srt_path:
|
| 890 |
-
raise gr.Error("Failed to reformat subtitle file.")
|
| 891 |
|
| 892 |
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 893 |
output_video_path = os.path.join(TEMP_DIR, f"subtitled_video_{timestamp}.mp4")
|
|
@@ -897,21 +916,66 @@ def burn_subtitles(video_path, srt_file_obj, font_size_scale, font_color, words_
|
|
| 897 |
calculated_font_size = int(video_h / divisor)
|
| 898 |
color_bgr = font_color[5:7] + font_color[3:5] + font_color[1:3]
|
| 899 |
ffmpeg_color = f"&H00{color_bgr.upper()}"
|
|
|
|
| 900 |
|
| 901 |
-
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
vf_filter = f"subtitles='{escaped_srt_path}':force_style='Fontsize={calculated_font_size},PrimaryColour={ffmpeg_color},BorderStyle=1,Outline=1,Shadow=0.5,MarginV=15'"
|
| 905 |
-
cmd = ["ffmpeg", "-i", video_path, "-vf", vf_filter, "-c:a", "copy", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-y", output_video_path]
|
| 906 |
-
run_ffmpeg_command(cmd, "Burning subtitles into video...")
|
| 907 |
-
|
| 908 |
return output_video_path
|
| 909 |
-
|
| 910 |
finally:
|
| 911 |
-
# Clean up the temporary reformatted file
|
| 912 |
if reformatted_srt_path and os.path.exists(reformatted_srt_path):
|
| 913 |
os.remove(reformatted_srt_path)
|
| 914 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 915 |
def remove_background_single(input_path, output_path, **kwargs):
|
| 916 |
with Image.open(input_path) as img:
|
| 917 |
remove(img).save(output_path)
|
|
@@ -1546,17 +1610,23 @@ def update_clip_properties(timeline_state, selected_index, new_duration):
|
|
| 1546 |
def handle_timeline_action(timeline_state, selected_index, action):
|
| 1547 |
if selected_index == -1 or not (0 <= selected_index < len(timeline_state)):
|
| 1548 |
gr.Warning("Please select a clip from the timeline first.")
|
| 1549 |
-
return timeline_state
|
| 1550 |
|
| 1551 |
new_list = list(timeline_state)
|
|
|
|
|
|
|
| 1552 |
if action == "up" and selected_index > 0:
|
| 1553 |
new_list.insert(selected_index - 1, new_list.pop(selected_index))
|
|
|
|
| 1554 |
elif action == "down" and selected_index < len(new_list) - 1:
|
| 1555 |
new_list.insert(selected_index + 1, new_list.pop(selected_index))
|
|
|
|
| 1556 |
elif action == "remove":
|
| 1557 |
new_list.pop(selected_index)
|
|
|
|
| 1558 |
|
| 1559 |
-
|
|
|
|
| 1560 |
|
| 1561 |
def set_resolution_from_first_asset(timeline_state):
|
| 1562 |
if not timeline_state:
|
|
@@ -1606,14 +1676,15 @@ def create_animatic(timeline_data, audio_path, out_w, out_h, keep_original_audio
|
|
| 1606 |
output_clip_path = os.path.join(job_temp_dir, f"clip_{i:04d}.mp4")
|
| 1607 |
vf_filter = f"scale={output_res_str}:force_original_aspect_ratio=decrease,pad={output_res_str}:(ow-iw)/2:(oh-ih)/2,setsar=1"
|
| 1608 |
|
| 1609 |
-
cmd = ["ffmpeg"]
|
| 1610 |
|
| 1611 |
# --- Input Handling ---
|
| 1612 |
-
|
| 1613 |
-
|
| 1614 |
-
|
|
|
|
|
|
|
| 1615 |
|
| 1616 |
-
# For videos, handle the start time trim
|
| 1617 |
if item_type == "video":
|
| 1618 |
start_time = item.get('start_time', 0)
|
| 1619 |
if start_time > 0:
|
|
@@ -1629,21 +1700,21 @@ def create_animatic(timeline_data, audio_path, out_w, out_h, keep_original_audio
|
|
| 1629 |
"-vf", vf_filter
|
| 1630 |
]
|
| 1631 |
|
| 1632 |
-
# ---
|
| 1633 |
if keep_original_audio:
|
| 1634 |
-
# If it's an image, map the silent audio source we added.
|
| 1635 |
if item_type == "image":
|
| 1636 |
-
|
| 1637 |
-
|
| 1638 |
-
|
| 1639 |
else:
|
|
|
|
| 1640 |
output_options.extend(["-c:a", "aac"])
|
| 1641 |
else:
|
| 1642 |
# If we don't keep audio, make the clip silent.
|
| 1643 |
output_options.append("-an")
|
| 1644 |
|
| 1645 |
cmd.extend(output_options)
|
| 1646 |
-
cmd.
|
| 1647 |
|
| 1648 |
run_ffmpeg_command(cmd, f"Processing clip {i+1}/{len(timeline_data)}")
|
| 1649 |
clip_paths.append(output_clip_path)
|
|
@@ -1653,22 +1724,19 @@ def create_animatic(timeline_data, audio_path, out_w, out_h, keep_original_audio
|
|
| 1653 |
for path in clip_paths:
|
| 1654 |
f.write(f"file '{os.path.abspath(path)}'\n")
|
| 1655 |
|
| 1656 |
-
# This path now contains the final video, either silent or with original audio
|
| 1657 |
combined_video_path = os.path.join(job_temp_dir, "combined_video.mp4")
|
| 1658 |
run_ffmpeg_command(["ffmpeg", "-f", "concat", "-safe", "0", "-i", file_list_path, "-c", "copy", "-y", combined_video_path], "Joining clips...")
|
| 1659 |
|
| 1660 |
final_output_path = os.path.join(TEMP_DIR, f"animatic_final_{timestamp}.mp4")
|
| 1661 |
|
| 1662 |
-
# Decide what to do with the audio
|
| 1663 |
if not keep_original_audio and audio_path:
|
| 1664 |
-
|
| 1665 |
-
run_ffmpeg_command(["ffmpeg", "-i", combined_video_path, "-i", audio_path, "-c:v", "copy", "-c:a", "aac", "-shortest", "-y", final_output_path], "Muxing audio")
|
| 1666 |
else:
|
| 1667 |
-
# Either we kept original audio, or there's no project audio, so just use the combined video as-is
|
| 1668 |
shutil.move(combined_video_path, final_output_path)
|
| 1669 |
|
| 1670 |
shutil.rmtree(job_temp_dir)
|
| 1671 |
return final_output_path
|
|
|
|
| 1672 |
def detect_bpm(audio_path):
|
| 1673 |
if not audio_path:
|
| 1674 |
return "Please upload an audio track first."
|
|
@@ -2681,9 +2749,9 @@ with gr.Blocks(
|
|
| 2681 |
set_clip_start_btn.click(fn=None, js=storyboard_get_time_js, outputs=clip_start_time_input)
|
| 2682 |
set_clip_end_btn.click(fn=None, js=storyboard_get_time_js, outputs=clip_end_time_input)
|
| 2683 |
|
| 2684 |
-
timeline_up_btn.click(handle_timeline_action, [timeline_state, selected_timeline_index_state, gr.State("up")], timeline_state)
|
| 2685 |
-
timeline_down_btn.click(handle_timeline_action, [timeline_state, selected_timeline_index_state, gr.State("down")], timeline_state)
|
| 2686 |
-
timeline_remove_btn.click(handle_timeline_action, [timeline_state, selected_timeline_index_state, gr.State("remove")], timeline_state)
|
| 2687 |
|
| 2688 |
clear_assets_btn.click(lambda: ([], gr.update(value=None), None), outputs=[assets_state, asset_gallery, asset_preview_gallery])
|
| 2689 |
clear_timeline_btn.click(lambda: [], outputs=[timeline_state])
|
|
@@ -3382,7 +3450,7 @@ with gr.Blocks(
|
|
| 3382 |
|
| 3383 |
with gr.TabItem("Transcription", visible=(whisper is not None)):
|
| 3384 |
gr.Markdown("## Transcribe Speech and Burn Subtitles")
|
| 3385 |
-
gr.Info("Uses OpenAI's Whisper model. First run will download model files.
|
| 3386 |
transcribed_video_path_state = gr.State(None)
|
| 3387 |
with gr.Row():
|
| 3388 |
with gr.Column():
|
|
@@ -3393,17 +3461,27 @@ with gr.Blocks(
|
|
| 3393 |
with gr.Row():
|
| 3394 |
transcribe_text = gr.Textbox(label="Transcription Result", lines=10, interactive=True, elem_id="transcription_textbox")
|
| 3395 |
copy_transcription_btn = gr.Button("๐ Copy")
|
| 3396 |
-
transcribe_files = gr.File(label="Download Subtitle Files (.srt, .vtt)", file_count="multiple", interactive=False)
|
| 3397 |
|
| 3398 |
with gr.Accordion("๐ฅ Burn Subtitles onto Video", open=True, visible=False) as burn_accordion:
|
| 3399 |
gr.Markdown("Set styling and burn the generated subtitles into the video.")
|
| 3400 |
with gr.Row():
|
|
|
|
| 3401 |
burn_font_size = gr.Slider(1, 10, 5, step=1, label="Relative Font Size")
|
|
|
|
| 3402 |
burn_words_per_line = gr.Slider(1, 20, 7, step=1, label="Max Words Per Line", info="Splits long subtitle lines for better readability.")
|
| 3403 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3404 |
burn_btn = gr.Button("๐ฅ Burn Subtitles", variant="primary")
|
| 3405 |
burn_output_video = gr.Video(label="Video with Burned-in Subtitles", interactive=True, show_download_button=True, height=480)
|
|
|
|
|
|
|
|
|
|
| 3406 |
|
|
|
|
| 3407 |
copy_transcription_btn.click(fn=None, js=copy_transcription_js)
|
| 3408 |
transcribe_btn.click(
|
| 3409 |
fn=transcribe_and_prep_burn,
|
|
@@ -3411,8 +3489,8 @@ with gr.Blocks(
|
|
| 3411 |
outputs=[transcribe_text, transcribe_files, transcribed_video_path_state, burn_accordion]
|
| 3412 |
).then(fn=None, js=fire_confetti_and_sound_js)
|
| 3413 |
burn_btn.click(
|
| 3414 |
-
fn=
|
| 3415 |
-
inputs=[transcribed_video_path_state, transcribe_files, burn_font_size,
|
| 3416 |
outputs=burn_output_video
|
| 3417 |
).then(fn=None, js=fire_confetti_and_sound_js)
|
| 3418 |
|
|
@@ -3509,4 +3587,6 @@ if __name__ == "__main__":
|
|
| 3509 |
try: shutil.rmtree(TEMP_DIR)
|
| 3510 |
except OSError as e: print(f"Error removing temp directory {TEMP_DIR}: {e}")
|
| 3511 |
os.makedirs(TEMP_DIR, exist_ok=True)
|
| 3512 |
-
|
|
|
|
|
|
|
|
|
| 21 |
from datetime import datetime
|
| 22 |
from threading import Lock
|
| 23 |
import base64
|
|
|
|
| 24 |
import io
|
| 25 |
|
| 26 |
# --- Dependency Check ---
|
|
|
|
| 789 |
shutil.rmtree(job_temp_dir)
|
| 790 |
return output_video_path
|
| 791 |
|
| 792 |
+
def generate_ass_from_whisper(result):
|
| 793 |
+
"""Generates an ASS subtitle file content from a Whisper result object with word timestamps."""
|
| 794 |
+
ass_content = [
|
| 795 |
+
"[Script Info]",
|
| 796 |
+
"Title: Generated by Skriptz",
|
| 797 |
+
"ScriptType: v4.00+",
|
| 798 |
+
"WrapStyle: 0",
|
| 799 |
+
"PlayResX: 1920",
|
| 800 |
+
"PlayResY: 1080",
|
| 801 |
+
"\n[V4+ Styles]",
|
| 802 |
+
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
|
| 803 |
+
"Style: Default,Arial,55,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,1,2,10,10,25,1",
|
| 804 |
+
"\n[Events]",
|
| 805 |
+
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text"
|
| 806 |
+
]
|
| 807 |
+
|
| 808 |
+
def format_time(s):
|
| 809 |
+
h, r = divmod(s, 3600)
|
| 810 |
+
m, s = divmod(r, 60)
|
| 811 |
+
cs = int((s - int(s)) * 100)
|
| 812 |
+
return f"{int(h)}:{int(m):02}:{int(s):02}.{cs:02}"
|
| 813 |
+
|
| 814 |
+
for segment in result['segments']:
|
| 815 |
+
start_time = format_time(segment['start'])
|
| 816 |
+
end_time = format_time(segment['end'])
|
| 817 |
+
|
| 818 |
+
karaoke_line = ""
|
| 819 |
+
for word_info in segment['words']:
|
| 820 |
+
word = word_info['word'].strip()
|
| 821 |
+
duration_cs = int((word_info['end'] - word_info['start']) * 100)
|
| 822 |
+
karaoke_line += f"{{\\k{duration_cs}}}{word} "
|
| 823 |
+
|
| 824 |
+
dialogue_line = f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{karaoke_line.strip()}"
|
| 825 |
+
ass_content.append(dialogue_line)
|
| 826 |
+
|
| 827 |
+
return "\n".join(ass_content)
|
| 828 |
+
|
| 829 |
def transcribe_media(media_path, model_name):
|
| 830 |
if media_path is None: raise gr.Error("Please upload a video or audio file first.")
|
| 831 |
model = load_whisper_model(model_name)
|
| 832 |
if model is None: raise gr.Error("Whisper model is not available.")
|
| 833 |
+
|
| 834 |
audio_path = media_path.name
|
| 835 |
base_name = os.path.splitext(os.path.basename(media_path.name))[0]
|
| 836 |
|
| 837 |
if audio_path.lower().endswith(('.mp4', '.mov', '.mkv', '.avi', '.webm')):
|
| 838 |
audio_path_temp = os.path.join(TEMP_DIR, f"{base_name}.mp3")
|
| 839 |
try:
|
| 840 |
+
run_ffmpeg_command(["ffmpeg", "-y", "-i", audio_path, "-q:a", "0", "-map", "a", audio_path_temp])
|
| 841 |
audio_path = audio_path_temp
|
| 842 |
except gr.Error as e:
|
| 843 |
if "does not contain any stream" in str(e): raise gr.Error("The uploaded video has no audio track.")
|
| 844 |
else: raise e
|
| 845 |
|
| 846 |
+
result = model.transcribe(audio_path, word_timestamps=True, verbose=False)
|
| 847 |
|
| 848 |
+
def format_ts(s, separator=','):
|
| 849 |
h, r = divmod(s, 3600); m, s = divmod(r, 60)
|
| 850 |
+
return f"{int(h):02}:{int(m):02}:{int(s):02}{separator}{int((s-int(s))*1000):03}"
|
| 851 |
|
| 852 |
srt_path = os.path.join(TEMP_DIR, f"{base_name}.srt")
|
| 853 |
vtt_path = os.path.join(TEMP_DIR, f"{base_name}.vtt")
|
| 854 |
+
ass_path = os.path.join(TEMP_DIR, f"{base_name}.ass")
|
| 855 |
|
| 856 |
with open(srt_path, "w", encoding="utf-8") as srt_f, open(vtt_path, "w", encoding="utf-8") as vtt_f:
|
| 857 |
vtt_f.write("WEBVTT\n\n")
|
| 858 |
for i, seg in enumerate(result["segments"]):
|
| 859 |
start, end, text = seg['start'], seg['end'], seg['text'].strip()
|
| 860 |
srt_f.write(f"{i + 1}\n{format_ts(start)} --> {format_ts(end)}\n{text}\n\n")
|
| 861 |
+
vtt_f.write(f"{format_ts(start, '.')} --> {format_ts(end, '.')}\n{text}\n\n")
|
| 862 |
|
| 863 |
+
ass_content = generate_ass_from_whisper(result)
|
| 864 |
+
with open(ass_path, "w", encoding="utf-8") as ass_f:
|
| 865 |
+
ass_f.write(ass_content)
|
| 866 |
+
|
| 867 |
+
return result["text"], [srt_path, vtt_path, ass_path]
|
| 868 |
|
| 869 |
def transcribe_and_prep_burn(media_file, model_name):
|
| 870 |
if not media_file: raise gr.Error("Please upload a file first.")
|
|
|
|
| 874 |
else: return text, files, None, gr.update(visible=False)
|
| 875 |
|
| 876 |
def reformat_srt_for_word_wrap(original_srt_path, words_per_line):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 877 |
if not original_srt_path or not os.path.exists(original_srt_path):
|
| 878 |
return None
|
| 879 |
|
|
|
|
| 882 |
|
| 883 |
with open(original_srt_path, 'r', encoding='utf-8') as f_in, \
|
| 884 |
open(reformatted_path, 'w', encoding='utf-8') as f_out:
|
| 885 |
+
content = f_in.read().strip().split('\n\n')
|
| 886 |
+
for block in content:
|
|
|
|
|
|
|
|
|
|
| 887 |
lines = block.split('\n')
|
| 888 |
+
if len(lines) < 3:
|
| 889 |
f_out.write(block + '\n\n')
|
| 890 |
continue
|
| 891 |
+
text_lines = ' '.join(lines[2:])
|
| 892 |
+
words = text_lines.split()
|
|
|
|
|
|
|
|
|
|
| 893 |
new_text_lines = []
|
| 894 |
current_line = []
|
| 895 |
for word in words:
|
|
|
|
| 897 |
if len(current_line) >= words_per_line:
|
| 898 |
new_text_lines.append(' '.join(current_line))
|
| 899 |
current_line = []
|
| 900 |
+
if current_line: new_text_lines.append(' '.join(current_line))
|
| 901 |
+
f_out.write(f"{lines[0]}\n{lines[1]}\n{'\n'.join(new_text_lines)}\n\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 902 |
return reformatted_path
|
| 903 |
|
| 904 |
+
def burn_block_subtitles(video_path, srt_file_obj, font_size_scale, font_color, words_per_line):
|
|
|
|
|
|
|
|
|
|
| 905 |
original_srt_path = srt_file_obj[0].name
|
| 906 |
reformatted_srt_path = None
|
|
|
|
| 907 |
try:
|
|
|
|
| 908 |
reformatted_srt_path = reformat_srt_for_word_wrap(original_srt_path, words_per_line)
|
| 909 |
+
if not reformatted_srt_path: raise gr.Error("Failed to reformat subtitle file.")
|
|
|
|
| 910 |
|
| 911 |
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 912 |
output_video_path = os.path.join(TEMP_DIR, f"subtitled_video_{timestamp}.mp4")
|
|
|
|
| 916 |
calculated_font_size = int(video_h / divisor)
|
| 917 |
color_bgr = font_color[5:7] + font_color[3:5] + font_color[1:3]
|
| 918 |
ffmpeg_color = f"&H00{color_bgr.upper()}"
|
| 919 |
+
escaped_srt_path = reformatted_srt_path.replace('\\', '/').replace(':', r'\\:')
|
| 920 |
|
| 921 |
+
vf_filter = f"subtitles='{escaped_srt_path}':force_style='Fontsize={calculated_font_size},PrimaryColour={ffmpeg_color},BorderStyle=1,Outline=1,Shadow=0.5,MarginV=25'"
|
| 922 |
+
cmd = ["ffmpeg", "-y", "-i", video_path, "-vf", vf_filter, "-c:a", "copy", "-c:v", "libx264", "-pix_fmt", "yuv420p", output_video_path]
|
| 923 |
+
run_ffmpeg_command(cmd, "Burning block subtitles into video...")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 924 |
return output_video_path
|
|
|
|
| 925 |
finally:
|
|
|
|
| 926 |
if reformatted_srt_path and os.path.exists(reformatted_srt_path):
|
| 927 |
os.remove(reformatted_srt_path)
|
| 928 |
|
| 929 |
+
def burn_karaoke_subtitles(video_path, subtitle_files, font_size_scale, base_color, highlight_color):
|
| 930 |
+
ass_file_path = subtitle_files[2].name
|
| 931 |
+
temp_ass_path = None
|
| 932 |
+
try:
|
| 933 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 934 |
+
temp_ass_path = os.path.join(TEMP_DIR, f"style_applied_{timestamp}.ass")
|
| 935 |
+
output_video_path = os.path.join(TEMP_DIR, f"karaoke_video_{timestamp}.mp4")
|
| 936 |
+
|
| 937 |
+
_, video_h = get_video_dimensions(video_path)
|
| 938 |
+
if video_h == 0: video_h = 720
|
| 939 |
+
calculated_font_size = int((video_h / 20) * (font_size_scale / 5))
|
| 940 |
+
|
| 941 |
+
def format_ass_color(hex_color):
|
| 942 |
+
if hex_color.startswith('#'): hex_color = hex_color[1:]
|
| 943 |
+
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
|
| 944 |
+
return f"&H00{b.upper()}{g.upper()}{r.upper()}"
|
| 945 |
+
|
| 946 |
+
primary_color_ass = format_ass_color(highlight_color)
|
| 947 |
+
secondary_color_ass = format_ass_color(base_color)
|
| 948 |
+
|
| 949 |
+
with open(ass_file_path, 'r', encoding='utf-8') as f_in, open(temp_ass_path, 'w', encoding='utf-8') as f_out:
|
| 950 |
+
for line in f_in:
|
| 951 |
+
if line.startswith("Style:"):
|
| 952 |
+
parts = line.split(',')
|
| 953 |
+
parts[2] = str(calculated_font_size) # Fontsize
|
| 954 |
+
parts[3] = secondary_color_ass # PrimaryColour (Base text)
|
| 955 |
+
parts[4] = primary_color_ass # SecondaryColour (Karaoke fill)
|
| 956 |
+
f_out.write(','.join(parts))
|
| 957 |
+
else:
|
| 958 |
+
f_out.write(line)
|
| 959 |
+
|
| 960 |
+
escaped_ass_path = temp_ass_path.replace('\\', '/').replace(':', r'\\:')
|
| 961 |
+
vf_filter = f"subtitles='{escaped_ass_path}'"
|
| 962 |
+
cmd = ["ffmpeg", "-y", "-i", video_path, "-vf", vf_filter, "-c:a", "copy", "-c:v", "libx264", "-pix_fmt", "yuv420p", output_video_path]
|
| 963 |
+
run_ffmpeg_command(cmd, "Burning karaoke subtitles into video...")
|
| 964 |
+
return output_video_path
|
| 965 |
+
finally:
|
| 966 |
+
if temp_ass_path and os.path.exists(temp_ass_path):
|
| 967 |
+
os.remove(temp_ass_path)
|
| 968 |
+
|
| 969 |
+
def burn_subtitles_wrapper(video_path, subtitle_files, style, font_size_scale, block_font_color, block_words_per_line, kara_base_color, kara_highlight_color):
|
| 970 |
+
if not video_path or not subtitle_files: raise gr.Error("Missing video or subtitle files. Please transcribe first.")
|
| 971 |
+
|
| 972 |
+
if style == "Block":
|
| 973 |
+
return burn_block_subtitles(video_path, subtitle_files, font_size_scale, block_font_color, block_words_per_line)
|
| 974 |
+
elif style == "Karaoke":
|
| 975 |
+
return burn_karaoke_subtitles(video_path, subtitle_files, font_size_scale, kara_base_color, kara_highlight_color)
|
| 976 |
+
else:
|
| 977 |
+
raise gr.Error("Invalid subtitle style selected.")
|
| 978 |
+
|
| 979 |
def remove_background_single(input_path, output_path, **kwargs):
|
| 980 |
with Image.open(input_path) as img:
|
| 981 |
remove(img).save(output_path)
|
|
|
|
| 1610 |
def handle_timeline_action(timeline_state, selected_index, action):
|
| 1611 |
if selected_index == -1 or not (0 <= selected_index < len(timeline_state)):
|
| 1612 |
gr.Warning("Please select a clip from the timeline first.")
|
| 1613 |
+
return timeline_state, gr.update()
|
| 1614 |
|
| 1615 |
new_list = list(timeline_state)
|
| 1616 |
+
new_index = selected_index
|
| 1617 |
+
|
| 1618 |
if action == "up" and selected_index > 0:
|
| 1619 |
new_list.insert(selected_index - 1, new_list.pop(selected_index))
|
| 1620 |
+
new_index = selected_index - 1
|
| 1621 |
elif action == "down" and selected_index < len(new_list) - 1:
|
| 1622 |
new_list.insert(selected_index + 1, new_list.pop(selected_index))
|
| 1623 |
+
new_index = selected_index + 1
|
| 1624 |
elif action == "remove":
|
| 1625 |
new_list.pop(selected_index)
|
| 1626 |
+
new_index = -1 # Deselect after removing
|
| 1627 |
|
| 1628 |
+
# Return the new list and tell the UI to select the new index
|
| 1629 |
+
return new_list, gr.update(selected_index=new_index if new_index != -1 else None)
|
| 1630 |
|
| 1631 |
def set_resolution_from_first_asset(timeline_state):
|
| 1632 |
if not timeline_state:
|
|
|
|
| 1676 |
output_clip_path = os.path.join(job_temp_dir, f"clip_{i:04d}.mp4")
|
| 1677 |
vf_filter = f"scale={output_res_str}:force_original_aspect_ratio=decrease,pad={output_res_str}:(ow-iw)/2:(oh-ih)/2,setsar=1"
|
| 1678 |
|
| 1679 |
+
cmd = ["ffmpeg", "-y"]
|
| 1680 |
|
| 1681 |
# --- Input Handling ---
|
| 1682 |
+
if item_type == "image":
|
| 1683 |
+
cmd.extend(["-loop", "1"])
|
| 1684 |
+
# For images, we need a silent audio source if we're keeping audio in other clips
|
| 1685 |
+
if keep_original_audio:
|
| 1686 |
+
cmd.extend(["-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100"])
|
| 1687 |
|
|
|
|
| 1688 |
if item_type == "video":
|
| 1689 |
start_time = item.get('start_time', 0)
|
| 1690 |
if start_time > 0:
|
|
|
|
| 1700 |
"-vf", vf_filter
|
| 1701 |
]
|
| 1702 |
|
| 1703 |
+
# --- Corrected Audio Logic ---
|
| 1704 |
if keep_original_audio:
|
|
|
|
| 1705 |
if item_type == "image":
|
| 1706 |
+
# Map the video from the image and the silent audio
|
| 1707 |
+
cmd.extend(["-map", "1:v:0", "-map", "0:a:0"])
|
| 1708 |
+
output_options.extend(["-shortest", "-c:a", "aac"])
|
| 1709 |
else:
|
| 1710 |
+
# For videos, just re-encode the existing audio to a standard format.
|
| 1711 |
output_options.extend(["-c:a", "aac"])
|
| 1712 |
else:
|
| 1713 |
# If we don't keep audio, make the clip silent.
|
| 1714 |
output_options.append("-an")
|
| 1715 |
|
| 1716 |
cmd.extend(output_options)
|
| 1717 |
+
cmd.append(output_clip_path)
|
| 1718 |
|
| 1719 |
run_ffmpeg_command(cmd, f"Processing clip {i+1}/{len(timeline_data)}")
|
| 1720 |
clip_paths.append(output_clip_path)
|
|
|
|
| 1724 |
for path in clip_paths:
|
| 1725 |
f.write(f"file '{os.path.abspath(path)}'\n")
|
| 1726 |
|
|
|
|
| 1727 |
combined_video_path = os.path.join(job_temp_dir, "combined_video.mp4")
|
| 1728 |
run_ffmpeg_command(["ffmpeg", "-f", "concat", "-safe", "0", "-i", file_list_path, "-c", "copy", "-y", combined_video_path], "Joining clips...")
|
| 1729 |
|
| 1730 |
final_output_path = os.path.join(TEMP_DIR, f"animatic_final_{timestamp}.mp4")
|
| 1731 |
|
|
|
|
| 1732 |
if not keep_original_audio and audio_path:
|
| 1733 |
+
run_ffmpeg_command(["ffmpeg", "-y", "-i", combined_video_path, "-i", audio_path, "-c:v", "copy", "-c:a", "aac", "-shortest", final_output_path], "Muxing audio")
|
|
|
|
| 1734 |
else:
|
|
|
|
| 1735 |
shutil.move(combined_video_path, final_output_path)
|
| 1736 |
|
| 1737 |
shutil.rmtree(job_temp_dir)
|
| 1738 |
return final_output_path
|
| 1739 |
+
|
| 1740 |
def detect_bpm(audio_path):
|
| 1741 |
if not audio_path:
|
| 1742 |
return "Please upload an audio track first."
|
|
|
|
| 2749 |
set_clip_start_btn.click(fn=None, js=storyboard_get_time_js, outputs=clip_start_time_input)
|
| 2750 |
set_clip_end_btn.click(fn=None, js=storyboard_get_time_js, outputs=clip_end_time_input)
|
| 2751 |
|
| 2752 |
+
timeline_up_btn.click(handle_timeline_action, [timeline_state, selected_timeline_index_state, gr.State("up")], [timeline_state, timeline_df])
|
| 2753 |
+
timeline_down_btn.click(handle_timeline_action, [timeline_state, selected_timeline_index_state, gr.State("down")], [timeline_state, timeline_df])
|
| 2754 |
+
timeline_remove_btn.click(handle_timeline_action, [timeline_state, selected_timeline_index_state, gr.State("remove")], [timeline_state, timeline_df])
|
| 2755 |
|
| 2756 |
clear_assets_btn.click(lambda: ([], gr.update(value=None), None), outputs=[assets_state, asset_gallery, asset_preview_gallery])
|
| 2757 |
clear_timeline_btn.click(lambda: [], outputs=[timeline_state])
|
|
|
|
| 3450 |
|
| 3451 |
with gr.TabItem("Transcription", visible=(whisper is not None)):
|
| 3452 |
gr.Markdown("## Transcribe Speech and Burn Subtitles")
|
| 3453 |
+
gr.Info("Uses OpenAI's Whisper model with word-level timestamps. First run will download model files.")
|
| 3454 |
transcribed_video_path_state = gr.State(None)
|
| 3455 |
with gr.Row():
|
| 3456 |
with gr.Column():
|
|
|
|
| 3461 |
with gr.Row():
|
| 3462 |
transcribe_text = gr.Textbox(label="Transcription Result", lines=10, interactive=True, elem_id="transcription_textbox")
|
| 3463 |
copy_transcription_btn = gr.Button("๐ Copy")
|
| 3464 |
+
transcribe_files = gr.File(label="Download Subtitle Files (.srt, .vtt, .ass)", file_count="multiple", interactive=False)
|
| 3465 |
|
| 3466 |
with gr.Accordion("๐ฅ Burn Subtitles onto Video", open=True, visible=False) as burn_accordion:
|
| 3467 |
gr.Markdown("Set styling and burn the generated subtitles into the video.")
|
| 3468 |
with gr.Row():
|
| 3469 |
+
burn_style = gr.Radio(["Block", "Karaoke"], value="Block", label="Subtitle Style")
|
| 3470 |
burn_font_size = gr.Slider(1, 10, 5, step=1, label="Relative Font Size")
|
| 3471 |
+
with gr.Row(visible=True) as block_style_row:
|
| 3472 |
burn_words_per_line = gr.Slider(1, 20, 7, step=1, label="Max Words Per Line", info="Splits long subtitle lines for better readability.")
|
| 3473 |
+
burn_block_font_color = gr.ColorPicker(value="#FFFFFF", label="Font Color")
|
| 3474 |
+
with gr.Row(visible=False) as karaoke_style_row:
|
| 3475 |
+
burn_karaoke_base_color = gr.ColorPicker(value="#FFFFFF", label="Base Color")
|
| 3476 |
+
burn_karaoke_highlight_color = gr.ColorPicker(value="#FFFF00", label="Highlight Color")
|
| 3477 |
+
|
| 3478 |
burn_btn = gr.Button("๐ฅ Burn Subtitles", variant="primary")
|
| 3479 |
burn_output_video = gr.Video(label="Video with Burned-in Subtitles", interactive=True, show_download_button=True, height=480)
|
| 3480 |
+
|
| 3481 |
+
def toggle_subtitle_styles(style_choice):
|
| 3482 |
+
return gr.update(visible=(style_choice == "Block")), gr.update(visible=(style_choice == "Karaoke"))
|
| 3483 |
|
| 3484 |
+
burn_style.change(toggle_subtitle_styles, burn_style, [block_style_row, karaoke_style_row])
|
| 3485 |
copy_transcription_btn.click(fn=None, js=copy_transcription_js)
|
| 3486 |
transcribe_btn.click(
|
| 3487 |
fn=transcribe_and_prep_burn,
|
|
|
|
| 3489 |
outputs=[transcribe_text, transcribe_files, transcribed_video_path_state, burn_accordion]
|
| 3490 |
).then(fn=None, js=fire_confetti_and_sound_js)
|
| 3491 |
burn_btn.click(
|
| 3492 |
+
fn=burn_subtitles_wrapper,
|
| 3493 |
+
inputs=[transcribed_video_path_state, transcribe_files, burn_style, burn_font_size, burn_block_font_color, burn_words_per_line, burn_karaoke_base_color, burn_karaoke_highlight_color],
|
| 3494 |
outputs=burn_output_video
|
| 3495 |
).then(fn=None, js=fire_confetti_and_sound_js)
|
| 3496 |
|
|
|
|
| 3587 |
try: shutil.rmtree(TEMP_DIR)
|
| 3588 |
except OSError as e: print(f"Error removing temp directory {TEMP_DIR}: {e}")
|
| 3589 |
os.makedirs(TEMP_DIR, exist_ok=True)
|
| 3590 |
+
if whisper:
|
| 3591 |
+
load_whisper_model("base") # Pre-load the default model on startup
|
| 3592 |
+
demo.launch(inbrowser=True)
|