Spaces:
Runtime error
Runtime error
Download utils.py from Amrui/Voxtral-Subtitles: direct link, hf CLI and curl.
- Browser
- Download file 21.1 kB
-
https://huggingface.co/spaces/Amrui/Voxtral-Subtitles/resolve/main/utils.py
- Command line
-
hf download hf://spaces/Amrui/Voxtral-Subtitles/utils.py
-
curl -L -o utils.py https://huggingface.co/spaces/Amrui/Voxtral-Subtitles/resolve/main/utils.py
21.1 kB
| import os | |
| import subprocess | |
| import re | |
| import json | |
| import time | |
| import requests | |
| from pysubs2 import SSAFile, SSAEvent | |
| import Levenshtein | |
| from pydantic import BaseModel | |
| from mistralai import Mistral | |
| # Load API key from environment variable | |
| api_key = os.getenv("MISTRAL_API_KEY") | |
| if not api_key: | |
| raise ValueError("No Mistral API key found.") | |
| client = Mistral(api_key=api_key) | |
| model = "voxtral-mini-2602" | |
| translation_model = "mistral-small-latest" | |
| class Segment(BaseModel): | |
| id: int | |
| content: str | |
| class TranslatedSegments(BaseModel): | |
| segments: list[Segment] | |
| def cleanup_files(): | |
| """Remove all temporary and output files.""" | |
| try: | |
| files_to_clean = ["temp_video.mp4", "temp_audio.mp3", "subtitles.srt", "subtitles.ass", "output_video.mp4", "voxtral_output.mp4", "scribe_output.mp4", "gpt4o_output.mp4"] | |
| for file in files_to_clean: | |
| if os.path.exists(file): | |
| os.remove(file) | |
| except Exception as e: | |
| print(e) | |
| def extract_audio_from_video(video_path): | |
| """Extract audio from video or return the audio path if the input is already an audio file.""" | |
| audio_path = "temp_audio.mp3" | |
| audio_extensions = {'.mp3', '.wav', '.ogg', '.flac', '.aac'} | |
| if os.path.splitext(video_path)[1].lower() in audio_extensions: | |
| return video_path | |
| try: | |
| subprocess.run( | |
| ["ffmpeg", "-y", "-i", video_path, "-vn", "-acodec", "libmp3lame", "-q:a", "2", audio_path], | |
| check=True, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE | |
| ) | |
| return audio_path | |
| except Exception as e: | |
| print(e) | |
| cleanup_files() | |
| raise Exception(f"Failed to extract audio: {str(e)}") | |
| def transcribe_audio(audio_path, granularity="word", diarize=False): | |
| """Transcribe audio using Mistral API with the selected granularity and optional diarization.""" | |
| max_retries = 5 | |
| delay_time = 2 | |
| for i in range(max_retries): | |
| try: | |
| with open(audio_path, "rb") as audio_file: | |
| url = "https://api.mistral.ai/v1/audio/transcriptions" | |
| headers = {"x-api-key": api_key} | |
| files = {"file": ("audio.mp3", audio_file)} | |
| data = {"model": model} | |
| if granularity: | |
| data["timestamp_granularities[]"] = granularity | |
| if diarize: | |
| data["diarize"] = "true" | |
| response = requests.post(url, headers=headers, files=files, data=data) | |
| response.raise_for_status() | |
| transcription_response = response.json() | |
| print(f"\n####\n\n- Granularity: {granularity}\n- Diarize: {diarize}\n- Response:\n", transcription_response, "\n\n####\n") | |
| return transcription_response | |
| except Exception as e: | |
| time.sleep(delay_time) | |
| print(e) | |
| raise Exception(f"Failed to transcribe audio: {str(e)}") | |
| def split_segments_by_segment_boundaries(word_segments, segment_segments): | |
| if not word_segments or not segment_segments: | |
| return [] | |
| word_segments = sorted(word_segments, key=lambda x: x["start"]) | |
| segment_segments = sorted(segment_segments, key=lambda x: x["start"]) | |
| segment_groups, current_segment_index, current_group = [], 0, [] | |
| for word in word_segments: | |
| while (current_segment_index < len(segment_segments) and word["start"] >= segment_segments[current_segment_index]["end"]): | |
| if current_group: | |
| segment_groups.extend(split_group_by_punctuation_and_time(current_group)) | |
| current_group = [] | |
| current_segment_index += 1 | |
| if current_segment_index >= len(segment_segments): | |
| break | |
| if current_segment_index < len(segment_segments): | |
| current_group.append(word) | |
| if current_group: | |
| segment_groups.extend(split_group_by_punctuation_and_time(current_group)) | |
| return concatenate_short_segments(segment_groups) | |
| def split_group_by_punctuation_and_time(group): | |
| if not group: | |
| return [] | |
| sub_groups = [] | |
| current_sub_group = [group[0]] | |
| current_length = len(group[0]["text"]) | |
| for i in range(1, len(group)): | |
| prev_word, current_word = group[i-1], group[i] | |
| if current_length + len(current_word["text"]) + 1 > 80: | |
| sub_groups.append(current_sub_group) | |
| current_sub_group = [current_word] | |
| current_length = len(current_word["text"]) | |
| else: | |
| if prev_word["text"][-1] in {'.', ',', '!', '?'} or (current_word["start"] - prev_word["end"] > 0.3): | |
| sub_groups.append(current_sub_group) | |
| current_sub_group = [current_word] | |
| current_length = len(current_word["text"]) | |
| else: | |
| current_sub_group.append(current_word) | |
| current_length += len(current_word["text"]) + 1 | |
| if current_sub_group: | |
| sub_groups.append(current_sub_group) | |
| final_groups = [] | |
| for group in sub_groups: | |
| while True: | |
| group_length = sum(len(w["text"]) + 1 for w in group) - 1 | |
| if group_length <= 80: | |
| final_groups.append(group) | |
| break | |
| else: | |
| max_gap_index = 0 | |
| max_gap = 0 | |
| for j in range(1, len(group)): | |
| gap = group[j]["start"] - group[j-1]["end"] | |
| if gap > max_gap: | |
| max_gap = gap | |
| max_gap_index = j | |
| if max_gap_index > 0: | |
| final_groups.append(group[:max_gap_index]) | |
| group = group[max_gap_index:] | |
| else: | |
| split_index = len(group) // 2 | |
| final_groups.append(group[:split_index]) | |
| group = group[split_index:] | |
| return final_groups | |
| def concatenate_short_segments(groups): | |
| concatenated_groups, current_group = [], [] | |
| for group in groups: | |
| if not current_group: | |
| current_group = group | |
| else: | |
| last_word_text = current_group[-1]["text"] | |
| if last_word_text[-1] in {'.', '!', '?'}: | |
| concatenated_groups.append(current_group) | |
| current_group = group | |
| else: | |
| combined_text = " ".join([w["text"] for w in current_group + group]) | |
| time_diff = group[0]["start"] - current_group[-1]["end"] | |
| if len(combined_text) <= 80 and time_diff <= 0.5: | |
| current_group.extend(group) | |
| else: | |
| concatenated_groups.append(current_group) | |
| current_group = group | |
| if current_group: | |
| concatenated_groups.append(current_group) | |
| return concatenated_groups | |
| def match_words_to_speakers(segment_transcription, word_transcription): | |
| speaker_segments = segment_transcription.get("segments", []) | |
| word_segments = word_transcription.get("segments", []) | |
| first_speaker = speaker_segments[0].get("speaker_id") if speaker_segments else "speaker_null" | |
| for word in word_segments: | |
| word_start, word_end = word["start"], word["end"] | |
| if word_start == word_end: | |
| containing_segments = [ | |
| seg for seg in speaker_segments | |
| if seg["start"] <= word_start <= seg["end"] | |
| ] | |
| if containing_segments: | |
| word["speaker_id"] = containing_segments[0].get("speaker_id") | |
| continue | |
| closest_segment = min( | |
| speaker_segments, | |
| key=lambda seg: min( | |
| abs(word_start - seg["start"]), | |
| abs(word_end - seg["end"]) | |
| ) | |
| ) | |
| word["speaker_id"] = closest_segment.get("speaker_id") | |
| for i, word in enumerate(word_segments): | |
| if "speaker_id" not in word: | |
| prev_word = word_segments[i - 1] if i > 0 else None | |
| next_word = word_segments[i + 1] if i < len(word_segments) - 1 else None | |
| if prev_word and next_word and prev_word.get("speaker_id") == next_word.get("speaker_id"): | |
| word["speaker_id"] = prev_word["speaker_id"] | |
| elif prev_word or next_word: | |
| if prev_word and next_word: | |
| closest_word = prev_word if (word_start - prev_word["end"]) < (next_word["start"] - word_end) else next_word | |
| word["speaker_id"] = closest_word["speaker_id"] | |
| elif prev_word: | |
| word["speaker_id"] = prev_word["speaker_id"] | |
| elif next_word: | |
| word["speaker_id"] = next_word["speaker_id"] | |
| else: | |
| closest_segment = min( | |
| speaker_segments, | |
| key=lambda seg: min( | |
| abs(word_start - seg["start"]), | |
| abs(word_end - seg["end"]) | |
| ) | |
| ) | |
| word["speaker_id"] = closest_segment.get("speaker_id") | |
| for word in word_segments: | |
| if "speaker_id" not in word: | |
| word["speaker_id"] = first_speaker | |
| return word_segments | |
| def hex_to_bgr(hex_color): | |
| hex_color = hex_color.lstrip('#') | |
| bgr = hex_color[4:6] + hex_color[2:4] + hex_color[0:2] | |
| return f"&H{bgr}&" | |
| def generate_subtitles(transcription_response, segment_transcription): | |
| subtitles, word_segments, segment_segments = [], transcription_response.get("segments", []), segment_transcription.get("segments", []) | |
| if not word_segments or not segment_segments: | |
| return subtitles | |
| segment_groups = split_segments_by_segment_boundaries(word_segments, segment_segments) | |
| first_speaker = segment_segments[0].get("speaker_id") if segment_segments else "speaker_null" | |
| for group in segment_groups: | |
| if not group: | |
| continue | |
| start, end, text = group[0]["start"], group[-1]["end"], " ".join([seg["text"] for seg in group]) | |
| speaker = group[0].get("speaker_id", first_speaker) | |
| subtitles.append((start, end, text, group, speaker)) | |
| return subtitles | |
| def create_ass_file( | |
| subtitles, | |
| srt_path="subtitles.ass", | |
| font_size=24, | |
| text_color="#FFFFFF", | |
| speaker_colors=None, | |
| incoming_color="#808080", | |
| font_name="Liberation Sans", | |
| alignment="Bottom Center" | |
| ): | |
| sub = SSAFile() | |
| style = sub.styles["Default"] | |
| style.fontname = font_name | |
| style.fontsize = font_size | |
| style.primarycolor = int(text_color.lstrip('#'), 16) | |
| style.outlinecolor = 0x000000 | |
| style.bold = False | |
| style.italic = False | |
| alignment_map = { | |
| "Bottom Left": 1, | |
| "Bottom Center": 2, | |
| "Bottom Right": 3, | |
| "Middle Left": 4, | |
| "Middle Center": 5, | |
| "Middle Right": 6, | |
| "Top Left": 7, | |
| "Top Center": 8, | |
| "Top Right": 9, | |
| } | |
| style.alignment = alignment_map.get(alignment, 2) | |
| bgr_default = hex_to_bgr(text_color) | |
| bgr_incoming = hex_to_bgr(incoming_color) | |
| has_word_granularity = len(subtitles) > 0 and len(subtitles[0]) == 5 | |
| if has_word_granularity: | |
| first_speaker = subtitles[0][4] if subtitles else "speaker_null" | |
| else: | |
| first_speaker = subtitles[0][3] if subtitles else "speaker_null" | |
| total_subtitles = len(subtitles) | |
| for i, subtitle in enumerate(subtitles, start=1): | |
| if has_word_granularity: | |
| start, end, text, word_segments, speaker = subtitle | |
| else: | |
| start, end, text, speaker = subtitle | |
| word_segments = None | |
| if word_segments and len(word_segments) > 0: | |
| # Word-level highlighting | |
| for j, word_seg in enumerate(word_segments): | |
| line = SSAEvent() | |
| line.start = int(word_seg["start"] * 1000) | |
| line.end = int(word_segments[j + 1]["start"] * 1000) if j < len(word_segments) - 1 else int(word_seg["end"] * 1000) | |
| line.style = "Default" | |
| ass_text = "" | |
| for k, w in enumerate(word_segments): | |
| speaker_id = w.get("speaker_id", first_speaker) | |
| bgr_highlight = hex_to_bgr(speaker_colors.get(speaker_id, speaker_colors.get(first_speaker, text_color))) | |
| if k == j: | |
| ass_text += f"{{\\c{bgr_highlight}}}{w['text']} " | |
| else: | |
| ass_text += f"{{\\c{bgr_default if k < j else bgr_incoming}}}{w['text']} " | |
| line.text = ass_text.strip() | |
| sub.events.append(line) | |
| else: | |
| # Segment-level highlighting (entire subtitle at once) | |
| line = SSAEvent() | |
| line.start = int(start * 1000) | |
| line.end = int(end * 1000) | |
| line.style = "Default" | |
| # Use speaker color for the entire segment | |
| speaker_id = speaker if has_word_granularity else speaker | |
| bgr_highlight = hex_to_bgr(speaker_colors.get(speaker_id, text_color)) | |
| line.text = f"{{\\c{bgr_highlight}}}{text}" | |
| sub.events.append(line) | |
| sub.save(srt_path) | |
| def get_video_duration(video_path): | |
| cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", video_path] | |
| result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) | |
| return float(result.stdout.strip()) | |
| def overlay_subtitles( | |
| video_path, | |
| audio_path, | |
| subtitles, | |
| font_size=24, | |
| background_style="None", | |
| alignment="Bottom Center", | |
| text_color="#FFFFFF", | |
| speaker_colors=None, | |
| incoming_color="#808080", | |
| font_name="Liberation Sans", | |
| output_path="output_video.mp4", | |
| progress=None, | |
| add_logo=False, | |
| logo_path="videologo.png", | |
| logo_scale=0.5, | |
| padding=10 | |
| ): | |
| """ | |
| Overlays subtitles on the video, with an option to add a logo in the bottom right corner. | |
| Args: | |
| add_logo (bool): If True, adds the logo to the video. | |
| logo_path (str): Path to the logo image. | |
| logo_scale (float): Scale factor for the logo (default: 0.1). | |
| padding (int): Padding around the logo (default: 10). | |
| """ | |
| try: | |
| ass_path = "subtitles.ass" | |
| create_ass_file(subtitles, ass_path, font_size, text_color, speaker_colors, incoming_color, font_name, alignment) | |
| if os.path.exists(output_path): | |
| os.remove(output_path) | |
| total_duration = get_video_duration(video_path) | |
| cmd = [ | |
| "ffmpeg", "-y", "-i", video_path, "-i", audio_path, | |
| "-vf", f"ass={ass_path}", | |
| "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "copy", "-strict", "experimental", | |
| output_path | |
| ] | |
| process = subprocess.Popen(cmd, stderr=subprocess.PIPE, universal_newlines=True) | |
| stderr_output = [] | |
| for line in process.stderr: | |
| stderr_output.append(line) | |
| time_match = re.search(r"time=(\d+):(\d+):(\d+\.\d+)", line) | |
| if time_match: | |
| hours, minutes, seconds = time_match.groups() | |
| current_time = int(hours) * 3600 + int(minutes) * 60 + float(seconds) | |
| progress(0.5 + (0.5 * (current_time / total_duration)), desc=f"Overlay creation...") | |
| process.wait() | |
| if process.returncode != 0: | |
| raise Exception(f"FFmpeg failed to overlay subtitles. Error: {''.join(stderr_output)}") | |
| return output_path | |
| except Exception as e: | |
| print(e) | |
| raise Exception(f"Failed to overlay subtitles: {str(e)}") | |
| def generate_raw_subtitles_html(subtitles, speaker_colors, show_timestamps=True): | |
| html = "<div style='white-space: pre-wrap; font-size: 16px; line-height: 1.2; color: #E0E0E0; background: #121212; padding: 10px; border-radius: 8px;'>" | |
| has_word_granularity = len(subtitles) > 0 and len(subtitles[0]) == 5 | |
| if show_timestamps: | |
| for sub in subtitles: | |
| if has_word_granularity: | |
| start, end, text, word_segments, speaker = sub | |
| else: | |
| start, end, text, speaker = sub | |
| word_segments = None | |
| start_time = f"{int(start // 3600):02d}:{int((start % 3600) // 60):02d}:{start % 60:06.3f}".replace('.', ',') | |
| end_time = f"{int(end // 3600):02d}:{int((end % 3600) // 60):02d}:{end % 60:06.3f}".replace('.', ',') | |
| html += f"<div style='margin-bottom: 8px;'><span style='color: #808080;'>{start_time} --> {end_time}</span> " | |
| if word_segments: | |
| current_speaker = None | |
| for word in word_segments: | |
| word_speaker = word.get("speaker_id", speaker) | |
| color = speaker_colors.get(word_speaker, "#FFFFFF") | |
| if word_speaker != current_speaker and current_speaker is not None: | |
| html += "</span>" | |
| if word_speaker != current_speaker: | |
| html += f"<span style='color: {color};'>" | |
| current_speaker = word_speaker | |
| html += f"{word['text']} " | |
| if word_segments: | |
| html += "</span>" | |
| else: | |
| color = speaker_colors.get(speaker, "#FFFFFF") | |
| html += f"<span style='color: {color};'>{text}</span>" | |
| html += "</div>" | |
| else: | |
| prev_speaker = None | |
| for sub in subtitles: | |
| if has_word_granularity: | |
| _, _, _, word_segments, speaker = sub | |
| else: | |
| _, _, _, speaker = sub | |
| word_segments = None | |
| if not word_segments: | |
| continue | |
| current_speaker = word_segments[0].get("speaker_id", speaker) if word_segments else speaker | |
| color = speaker_colors.get(current_speaker, "#FFFFFF") | |
| if current_speaker != prev_speaker and prev_speaker is not None: | |
| html += "</span></div>" | |
| if current_speaker != prev_speaker: | |
| if prev_speaker is not None: | |
| html += "</div>" | |
| html += f"<div style='margin-bottom: 4px;'><span style='color: {color};'><strong>{current_speaker}:</strong> " | |
| if word_segments: | |
| for word in word_segments: | |
| html += f"{word['text']} " | |
| else: | |
| if has_word_granularity: | |
| _, _, text, _, _ = sub | |
| else: | |
| _, _, text, _ = sub | |
| html += f"{text} " | |
| prev_speaker = current_speaker | |
| if prev_speaker is not None: | |
| html += "</span></div>" | |
| html += "</div>" | |
| return html | |
| def translate(segments, target_language): | |
| """ | |
| Translates a list of segments into the target language using the Mistral API. | |
| Retries up to 5 times if the request fails. | |
| Args: | |
| segments: List of Segment objects to translate. | |
| target_language: The language code to translate into (e.g., "fr", "es", "de"). | |
| Returns: | |
| dict: A dictionary containing the translated segments in the format {"segments": [{"id": int, "content": str}, ...]} | |
| """ | |
| max_retries = 5 | |
| retry_delay = 2 # seconds | |
| print(segments) | |
| str_segments = "\n".join([f"{seg["id"]}: {seg["content"]}" for seg in segments]) | |
| print(str_segments) | |
| input_length = len(segments) | |
| for attempt in range(max_retries): | |
| try: | |
| chat_response = client.chat.parse( | |
| model=translation_model, | |
| messages=[ | |
| { | |
| "role": "system", | |
| "content": ( | |
| "You are a segment translation system. " | |
| "You must translate a list of segments into their translation in a target language. " | |
| "The translation must keeo the exact same length of segments, and not modify the contents, provide only the accurate translation. " | |
| "You must answer with the following format: {\"segments\": [{\"id\": int, \"content\": str}, ...]}" | |
| ), | |
| }, | |
| { | |
| "role": "user", | |
| "content": f"# Segments to translate to {target_language}\n{str_segments}", | |
| }, | |
| ], | |
| response_format=TranslatedSegments, | |
| max_tokens=9192, | |
| temperature=0, | |
| ) | |
| translated_segments = json.loads(chat_response.choices[0].message.content) | |
| print(translated_segments) | |
| assert len(translated_segments["segments"]) == input_length, ( | |
| f"Mismatch in segment count: input={input_length}, output={len(translated_segments['segments'])}" | |
| ) | |
| return translated_segments | |
| except Exception as e: | |
| print(e) | |
| if attempt == max_retries - 1: | |
| raise Exception(f"Failed to translate after {max_retries} attempts. Error: {str(e)}") | |
| time.sleep(retry_delay) | |