skylinkd commited on
Commit
5e47ea8
ยท
verified ยท
1 Parent(s): ddd40fb

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +155 -75
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", "-y", audio_path_temp])
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},{int((s-int(s))*1000):03}"
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).replace(',', '.')} --> {format_ts(end).replace(',', '.')}\n{text}\n\n")
824
 
825
- return result["text"], [srt_path, vtt_path]
 
 
 
 
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
- content = f_in.read()
849
- blocks = content.strip().split('\n\n')
850
-
851
- for block in blocks:
852
  lines = block.split('\n')
853
- if len(lines) < 3: # Not a valid block (e.g., just a number and timestamp)
854
  f_out.write(block + '\n\n')
855
  continue
856
-
857
- text_lines = lines[2:]
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
- if current_line: # Add any remaining words
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 burn_subtitles(video_path, srt_file_obj, font_size_scale, font_color, words_per_line):
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
- # Use the new, reformatted path and escape it for FFMPEG
902
- escaped_srt_path = reformatted_srt_path.replace('\\', '/').replace(':', r'\:')
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
- return new_list
 
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
- # For images, we might need a silent audio source for concat to work
1613
- if item_type == "image" and keep_original_audio:
1614
- cmd.extend(["-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100"])
 
 
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
- # --- CORRECTED AUDIO LOGIC ---
1633
  if keep_original_audio:
1634
- # If it's an image, map the silent audio source we added.
1635
  if item_type == "image":
1636
- cmd.extend(["-c:v", "libx264", "-t", str(item_duration), "-pix_fmt", "yuv420p", "-vf", vf_filter])
1637
- output_options = ["-shortest", "-c:a", "aac"]
1638
- # For videos, just re-encode the existing audio to a standard format.
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.extend(["-y", output_clip_path])
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
- # Mux the project audio track onto the silent video
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. After transcribing a video, options to burn subtitles will appear.")
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
- burn_font_color = gr.ColorPicker(value="#FFFFFF", label="Font Color")
 
 
 
 
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=burn_subtitles,
3415
- inputs=[transcribed_video_path_state, transcribe_files, burn_font_size, burn_font_color, burn_words_per_line],
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
- demo.launch(inbrowser=True)
 
 
 
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)