quicktools.videotools
Video utilities: transcription, audio extraction, metadata, frame capture, and speaker diarization — powered by PyAV, faster-whisper, and Pyannote.
Requires the optional 'av' package: pip install av (This is already installed automatically as a dependency of faster-whisper.)
Supports common video containers: MP4, MOV, MKV, AVI, WEBM, and more — since PyAV uses FFmpeg's decoding engine internally, the same one that powers audiotools.
1"""Video utilities: transcription, audio extraction, metadata, frame capture, 2and speaker diarization — powered by PyAV, faster-whisper, and Pyannote. 3 4Requires the optional 'av' package: pip install av 5(This is already installed automatically as a dependency of faster-whisper.) 6 7Supports common video containers: MP4, MOV, MKV, AVI, WEBM, and more — since PyAV 8uses FFmpeg's decoding engine internally, the same one that powers audiotools. 9""" 10import os 11import shutil 12import tempfile 13import random 14import subprocess 15import requests 16 17 18def get_video_info(path: str) -> dict: 19 """Return basic metadata about a video file: duration (seconds), width, height, and frame rate.""" 20 import av 21 container = av.open(path) 22 stream = container.streams.video[0] 23 duration = float(container.duration / av.time_base) if container.duration else None 24 info = { 25 "duration_seconds": duration, 26 "width": stream.width, 27 "height": stream.height, 28 "fps": float(stream.average_rate) if stream.average_rate else None, 29 } 30 container.close() 31 return info 32 33 34def get_video_duration(path: str) -> float: 35 """Return the duration of a video file in seconds.""" 36 return get_video_info(path)["duration_seconds"] 37 38 39def extract_audio_from_video(video_path: str, output_audio_path: str) -> None: 40 """Extract the audio track from a video file and save it as a standalone audio file 41 (format is inferred from output_audio_path's extension, e.g. .mp3, .wav, .m4a).""" 42 import av 43 44 input_container = av.open(video_path) 45 audio_stream = input_container.streams.audio[0] 46 47 output_container = av.open(output_audio_path, mode="w") 48 output_stream = output_container.add_stream("aac" if output_audio_path.endswith((".m4a", ".mp4")) else "mp3") 49 50 for frame in input_container.decode(audio_stream): 51 for packet in output_stream.encode(frame): 52 output_container.mux(packet) 53 54 for packet in output_stream.encode(None): 55 output_container.mux(packet) 56 57 output_container.close() 58 input_container.close() 59 60 61def extract_video_frame(video_path: str, timestamp_seconds: float, output_image_path: str) -> None: 62 """Extract a single frame from a video at the given timestamp (seconds) and save it as an image.""" 63 import av 64 65 container = av.open(video_path) 66 stream = container.streams.video[0] 67 68 target_pts = int(timestamp_seconds / stream.time_base) 69 container.seek(target_pts, stream=stream) 70 71 for frame in container.decode(stream): 72 if frame.time >= timestamp_seconds: 73 frame.to_image().save(output_image_path) 74 break 75 76 container.close() 77 78 79def transcribe_video(path: str, model_size: str = "base", language: str | None = None) -> str: 80 """Transcribe the spoken audio in a video file to plain text. Works directly on 81 video containers (MP4, MOV, MKV, etc.) — the audio track is extracted automatically.""" 82 from quicktools.audiotools import transcribe_audio 83 return transcribe_audio(path, model_size=model_size, language=language) 84 85 86def transcribe_video_with_timestamps(path: str, model_size: str = "base") -> list[dict]: 87 """Transcribe a video's audio into timestamped segments, each with 'start', 'end', and 'text'.""" 88 from quicktools.audiotools import transcribe_audio_with_timestamps 89 return transcribe_audio_with_timestamps(path, model_size=model_size) 90 91 92def transcribe_video_word_level(path: str, model_size: str = "base") -> list[dict]: 93 """Transcribe a video's audio into word-by-word timestamps, each with 'word', 'start', and 'end'. 94 Useful for generating captions synced precisely to speech.""" 95 from quicktools.audiotools import transcribe_audio_word_level 96 return transcribe_audio_word_level(path, model_size=model_size) 97 98 99# --- NEW DIARIZATION AND STYLING FEATURES --- 100 101from quicktools import audiotools 102 103def transcribe_video_with_speakers(path_or_url: str, hf_token: str, model_size: str = "base", device: str = "auto") -> list[dict]: 104 """ 105 Extracts audio from a local video file or web URL (YouTube, TikTok, IG, X), 106 transcribes it, and maps the text to specific speakers. 107 """ 108 print(f"Processing video source: {path_or_url}") 109 return audiotools.transcribe_with_speakers(path_or_url, hf_token, model_size, device) 110 111 112def save_video_script_to_docx(transcript_data: list[dict], output_path: str, title_text: str = "Video Script & Transcript") -> None: 113 """ 114 Formats speaker-mapped video transcript data as a professional script in Word (.docx). 115 """ 116 try: 117 from docx import Document 118 from docx.shared import Pt, RGBColor 119 except ImportError: 120 raise ImportError("Saving to Word requires python-docx. Run: pip install python-docx") 121 122 doc = Document() 123 title = doc.add_heading(title_text, level=1) 124 title.alignment = 1 # Center align 125 126 last_speaker = None 127 128 for entry in transcript_data: 129 p = doc.add_paragraph() 130 131 # Format timestamp as [MM:SS] 132 start_m, start_s = divmod(int(entry["start"]), 60) 133 time_str = f"[{start_m:02d}:{start_s:02d}]" 134 135 # Speaker header line if speaker changed 136 if entry["speaker"] != last_speaker: 137 speaker_run = p.add_run(f"{time_str} {entry['speaker']}:\n") 138 speaker_run.bold = True 139 speaker_run.font.color.rgb = RGBColor(112, 48, 160) # Distinct Purple highlight for video speakers 140 last_speaker = entry["speaker"] 141 142 p.add_run(entry["text"]) 143 p.paragraph_format.space_after = Pt(8) 144 145 doc.save(output_path) 146 147 148 149def download_video(url: str, output_dir: str = ".", filename: str | None = None, resolution: str = "best", cookiefile: str | None = None) -> str: 150 try: 151 import yt_dlp 152 except ImportError: 153 raise ImportError("Downloading videos requires 'yt-dlp'. Install it with: pip install yt-dlp") 154 155 os.makedirs(output_dir, exist_ok=True) 156 out_template = f"{filename}.%(ext)s" if filename else "%(title)s.%(ext)s" 157 target_path = os.path.join(output_dir, out_template) 158 159 # 1. RESOLVE SHORTLINKS & NORMALIZE X.COM 160 if "x.com" in url: 161 url = url.replace("x.com", "twitter.com") 162 163 final_url = url 164 if "vt.tiktok.com" in url or "vm.tiktok.com" in url: 165 try: 166 resp = requests.head(url, allow_redirects=True, timeout=8, headers={'User-Agent': 'Mozilla/5.0'}) 167 final_url = resp.url 168 print(f" [quicktools] Resolved shortlink: {url} -> {final_url}") 169 except Exception as e_redirect: 170 print(f" [quicktools] Shortlink resolution warning: {str(e_redirect)}") 171 172 if not cookiefile and os.path.exists('/app/cookies.txt'): 173 cookiefile = '/app/cookies.txt' 174 175 has_ffmpeg = shutil.which("ffmpeg") is not None 176 if resolution == "best": 177 fmt = 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best' if has_ffmpeg else 'best[ext=mp4]/best' 178 else: 179 fmt = 'worst' 180 181 ydl_opts = { 182 'format': fmt, 183 'outtmpl': target_path, 184 'quiet': False, 185 'no_warnings': True, 186 'socket_timeout': 15, 187 'merge_output_format': 'mp4' if has_ffmpeg else None, 188 'http_headers': { 189 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 190 }, 191 # === THE CORRECTED API BACKDOORS (Wrapped in Lists!) === 192 'extractor_args': { 193 'youtube': {'player_client': ['android']}, 194 'twitter': {'api': ['syndication']}, 195 'tiktok': {'app_version': ['123456']} 196 } 197 } 198 199 if has_ffmpeg: 200 ydl_opts['postprocessor_args'] = ['-movflags', '+faststart'] 201 202 if cookiefile and os.path.exists(cookiefile): 203 ydl_opts['cookiefile'] = cookiefile 204 205 def attempt_download(opts: dict, target_url: str) -> str: 206 with yt_dlp.YoutubeDL(opts) as ydl: 207 info = ydl.extract_info(target_url, download=True) 208 filepath = ydl.prepare_filename(info) 209 base, _ = os.path.splitext(filepath) 210 if os.path.exists(f"{base}.mp4"): 211 filepath = f"{base}.mp4" 212 if not os.path.exists(filepath) or os.path.getsize(filepath) == 0: 213 raise RuntimeError("Download succeeded but file is missing or empty.") 214 return os.path.abspath(filepath) 215 216 # === ATTEMPT 1: Direct IPv6 === 217 print(f" [Attempt 1] Direct IPv6 download from {final_url}...") 218 ydl_opts_ipv6 = ydl_opts.copy() 219 ydl_opts_ipv6['source_address'] = '0::0' 220 try: 221 return attempt_download(ydl_opts_ipv6, final_url) 222 except Exception as e_ipv6: 223 print(f" IPv6 Attempt Failed: {str(e_ipv6)}") 224 225 # === ATTEMPT 2: Direct IPv4 === 226 print(f" [Attempt 2] Direct IPv4 download...") 227 ydl_opts_ipv4 = ydl_opts.copy() 228 try: 229 return attempt_download(ydl_opts_ipv4, final_url) 230 except Exception as e_ipv4: 231 print(f" IPv4 Attempt Failed: {str(e_ipv4)}") 232 233 # === ATTEMPT 3: Rotating Proxy Endpoint === 234 proxy_url = os.getenv("RESIDENTIAL_PROXY") 235 if proxy_url: 236 print(" [Attempt 3] Routing through Rotating Proxy Endpoint...") 237 ydl_opts_proxy = ydl_opts.copy() 238 ydl_opts_proxy['proxy'] = proxy_url.strip() 239 try: 240 return attempt_download(ydl_opts_proxy, final_url) 241 except Exception as e_proxy: 242 raise RuntimeError(f"Rotating Proxy failed: {str(e_proxy)}") 243 else: 244 raise RuntimeError(f"Direct downloads failed (IPv6/IPv4), and no rotating proxy configured. Last error: {str(e_ipv4)}") 245 246 247import subprocess 248def convert_video_to_animated(input_path: str, output_path: str, target_format: str = "gif", fps: int = 15, width: int = 512) -> None: 249 """Converts a video to an animated GIF or WebP sticker using FFmpeg.""" 250 if not shutil.which("ffmpeg"): 251 raise RuntimeError("FFmpeg is required for video-to-animation conversion. Please install it.") 252 253 # WhatsApp/Telegram stickers prefer a 512px boundary. We scale proportionally. 254 scale_filter = f"fps={fps},scale={width}:-1:flags=lanczos" 255 256 if target_format.lower() == "gif": 257 # High-quality GIF generation using a 2-pass color palette 258 vf_cmd = f"{scale_filter},split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" 259 cmd = ["ffmpeg", "-y", "-i", input_path, "-vf", vf_cmd, "-loop", "0", output_path] 260 elif target_format.lower() == "webp": 261 # Force exact 512x512 transparent canvas, 15fps, and loop infinitely for WhatsApp 262 wa_filter = "fps=15,scale=512:512:force_original_aspect_ratio=decrease,format=rgba,pad=512:512:(ow-iw)/2:(oh-ih)/2:color=#00000000" 263 cmd = [ 264 "ffmpeg", "-y", "-i", input_path, 265 "-vcodec", "libwebp", 266 "-vf", wa_filter, 267 "-lossless", "0", 268 "-compression_level", "6", 269 "-q:v", "40", 270 "-loop", "0", 271 "-preset", "picture", 272 "-an", "-vsync", "0", 273 output_path 274 ] 275 else: 276 raise ValueError("Target format must be 'gif' or 'webp'") 277 278 # Execute FFmpeg silently 279 result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) 280 if result.returncode != 0: 281 raise RuntimeError(f"FFmpeg conversion failed: {result.stderr}")
19def get_video_info(path: str) -> dict: 20 """Return basic metadata about a video file: duration (seconds), width, height, and frame rate.""" 21 import av 22 container = av.open(path) 23 stream = container.streams.video[0] 24 duration = float(container.duration / av.time_base) if container.duration else None 25 info = { 26 "duration_seconds": duration, 27 "width": stream.width, 28 "height": stream.height, 29 "fps": float(stream.average_rate) if stream.average_rate else None, 30 } 31 container.close() 32 return info
Return basic metadata about a video file: duration (seconds), width, height, and frame rate.
35def get_video_duration(path: str) -> float: 36 """Return the duration of a video file in seconds.""" 37 return get_video_info(path)["duration_seconds"]
Return the duration of a video file in seconds.
40def extract_audio_from_video(video_path: str, output_audio_path: str) -> None: 41 """Extract the audio track from a video file and save it as a standalone audio file 42 (format is inferred from output_audio_path's extension, e.g. .mp3, .wav, .m4a).""" 43 import av 44 45 input_container = av.open(video_path) 46 audio_stream = input_container.streams.audio[0] 47 48 output_container = av.open(output_audio_path, mode="w") 49 output_stream = output_container.add_stream("aac" if output_audio_path.endswith((".m4a", ".mp4")) else "mp3") 50 51 for frame in input_container.decode(audio_stream): 52 for packet in output_stream.encode(frame): 53 output_container.mux(packet) 54 55 for packet in output_stream.encode(None): 56 output_container.mux(packet) 57 58 output_container.close() 59 input_container.close()
Extract the audio track from a video file and save it as a standalone audio file (format is inferred from output_audio_path's extension, e.g. .mp3, .wav, .m4a).
62def extract_video_frame(video_path: str, timestamp_seconds: float, output_image_path: str) -> None: 63 """Extract a single frame from a video at the given timestamp (seconds) and save it as an image.""" 64 import av 65 66 container = av.open(video_path) 67 stream = container.streams.video[0] 68 69 target_pts = int(timestamp_seconds / stream.time_base) 70 container.seek(target_pts, stream=stream) 71 72 for frame in container.decode(stream): 73 if frame.time >= timestamp_seconds: 74 frame.to_image().save(output_image_path) 75 break 76 77 container.close()
Extract a single frame from a video at the given timestamp (seconds) and save it as an image.
80def transcribe_video(path: str, model_size: str = "base", language: str | None = None) -> str: 81 """Transcribe the spoken audio in a video file to plain text. Works directly on 82 video containers (MP4, MOV, MKV, etc.) — the audio track is extracted automatically.""" 83 from quicktools.audiotools import transcribe_audio 84 return transcribe_audio(path, model_size=model_size, language=language)
Transcribe the spoken audio in a video file to plain text. Works directly on video containers (MP4, MOV, MKV, etc.) — the audio track is extracted automatically.
87def transcribe_video_with_timestamps(path: str, model_size: str = "base") -> list[dict]: 88 """Transcribe a video's audio into timestamped segments, each with 'start', 'end', and 'text'.""" 89 from quicktools.audiotools import transcribe_audio_with_timestamps 90 return transcribe_audio_with_timestamps(path, model_size=model_size)
Transcribe a video's audio into timestamped segments, each with 'start', 'end', and 'text'.
93def transcribe_video_word_level(path: str, model_size: str = "base") -> list[dict]: 94 """Transcribe a video's audio into word-by-word timestamps, each with 'word', 'start', and 'end'. 95 Useful for generating captions synced precisely to speech.""" 96 from quicktools.audiotools import transcribe_audio_word_level 97 return transcribe_audio_word_level(path, model_size=model_size)
Transcribe a video's audio into word-by-word timestamps, each with 'word', 'start', and 'end'. Useful for generating captions synced precisely to speech.
104def transcribe_video_with_speakers(path_or_url: str, hf_token: str, model_size: str = "base", device: str = "auto") -> list[dict]: 105 """ 106 Extracts audio from a local video file or web URL (YouTube, TikTok, IG, X), 107 transcribes it, and maps the text to specific speakers. 108 """ 109 print(f"Processing video source: {path_or_url}") 110 return audiotools.transcribe_with_speakers(path_or_url, hf_token, model_size, device)
Extracts audio from a local video file or web URL (YouTube, TikTok, IG, X), transcribes it, and maps the text to specific speakers.
113def save_video_script_to_docx(transcript_data: list[dict], output_path: str, title_text: str = "Video Script & Transcript") -> None: 114 """ 115 Formats speaker-mapped video transcript data as a professional script in Word (.docx). 116 """ 117 try: 118 from docx import Document 119 from docx.shared import Pt, RGBColor 120 except ImportError: 121 raise ImportError("Saving to Word requires python-docx. Run: pip install python-docx") 122 123 doc = Document() 124 title = doc.add_heading(title_text, level=1) 125 title.alignment = 1 # Center align 126 127 last_speaker = None 128 129 for entry in transcript_data: 130 p = doc.add_paragraph() 131 132 # Format timestamp as [MM:SS] 133 start_m, start_s = divmod(int(entry["start"]), 60) 134 time_str = f"[{start_m:02d}:{start_s:02d}]" 135 136 # Speaker header line if speaker changed 137 if entry["speaker"] != last_speaker: 138 speaker_run = p.add_run(f"{time_str} {entry['speaker']}:\n") 139 speaker_run.bold = True 140 speaker_run.font.color.rgb = RGBColor(112, 48, 160) # Distinct Purple highlight for video speakers 141 last_speaker = entry["speaker"] 142 143 p.add_run(entry["text"]) 144 p.paragraph_format.space_after = Pt(8) 145 146 doc.save(output_path)
Formats speaker-mapped video transcript data as a professional script in Word (.docx).
150def download_video(url: str, output_dir: str = ".", filename: str | None = None, resolution: str = "best", cookiefile: str | None = None) -> str: 151 try: 152 import yt_dlp 153 except ImportError: 154 raise ImportError("Downloading videos requires 'yt-dlp'. Install it with: pip install yt-dlp") 155 156 os.makedirs(output_dir, exist_ok=True) 157 out_template = f"{filename}.%(ext)s" if filename else "%(title)s.%(ext)s" 158 target_path = os.path.join(output_dir, out_template) 159 160 # 1. RESOLVE SHORTLINKS & NORMALIZE X.COM 161 if "x.com" in url: 162 url = url.replace("x.com", "twitter.com") 163 164 final_url = url 165 if "vt.tiktok.com" in url or "vm.tiktok.com" in url: 166 try: 167 resp = requests.head(url, allow_redirects=True, timeout=8, headers={'User-Agent': 'Mozilla/5.0'}) 168 final_url = resp.url 169 print(f" [quicktools] Resolved shortlink: {url} -> {final_url}") 170 except Exception as e_redirect: 171 print(f" [quicktools] Shortlink resolution warning: {str(e_redirect)}") 172 173 if not cookiefile and os.path.exists('/app/cookies.txt'): 174 cookiefile = '/app/cookies.txt' 175 176 has_ffmpeg = shutil.which("ffmpeg") is not None 177 if resolution == "best": 178 fmt = 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best' if has_ffmpeg else 'best[ext=mp4]/best' 179 else: 180 fmt = 'worst' 181 182 ydl_opts = { 183 'format': fmt, 184 'outtmpl': target_path, 185 'quiet': False, 186 'no_warnings': True, 187 'socket_timeout': 15, 188 'merge_output_format': 'mp4' if has_ffmpeg else None, 189 'http_headers': { 190 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 191 }, 192 # === THE CORRECTED API BACKDOORS (Wrapped in Lists!) === 193 'extractor_args': { 194 'youtube': {'player_client': ['android']}, 195 'twitter': {'api': ['syndication']}, 196 'tiktok': {'app_version': ['123456']} 197 } 198 } 199 200 if has_ffmpeg: 201 ydl_opts['postprocessor_args'] = ['-movflags', '+faststart'] 202 203 if cookiefile and os.path.exists(cookiefile): 204 ydl_opts['cookiefile'] = cookiefile 205 206 def attempt_download(opts: dict, target_url: str) -> str: 207 with yt_dlp.YoutubeDL(opts) as ydl: 208 info = ydl.extract_info(target_url, download=True) 209 filepath = ydl.prepare_filename(info) 210 base, _ = os.path.splitext(filepath) 211 if os.path.exists(f"{base}.mp4"): 212 filepath = f"{base}.mp4" 213 if not os.path.exists(filepath) or os.path.getsize(filepath) == 0: 214 raise RuntimeError("Download succeeded but file is missing or empty.") 215 return os.path.abspath(filepath) 216 217 # === ATTEMPT 1: Direct IPv6 === 218 print(f" [Attempt 1] Direct IPv6 download from {final_url}...") 219 ydl_opts_ipv6 = ydl_opts.copy() 220 ydl_opts_ipv6['source_address'] = '0::0' 221 try: 222 return attempt_download(ydl_opts_ipv6, final_url) 223 except Exception as e_ipv6: 224 print(f" IPv6 Attempt Failed: {str(e_ipv6)}") 225 226 # === ATTEMPT 2: Direct IPv4 === 227 print(f" [Attempt 2] Direct IPv4 download...") 228 ydl_opts_ipv4 = ydl_opts.copy() 229 try: 230 return attempt_download(ydl_opts_ipv4, final_url) 231 except Exception as e_ipv4: 232 print(f" IPv4 Attempt Failed: {str(e_ipv4)}") 233 234 # === ATTEMPT 3: Rotating Proxy Endpoint === 235 proxy_url = os.getenv("RESIDENTIAL_PROXY") 236 if proxy_url: 237 print(" [Attempt 3] Routing through Rotating Proxy Endpoint...") 238 ydl_opts_proxy = ydl_opts.copy() 239 ydl_opts_proxy['proxy'] = proxy_url.strip() 240 try: 241 return attempt_download(ydl_opts_proxy, final_url) 242 except Exception as e_proxy: 243 raise RuntimeError(f"Rotating Proxy failed: {str(e_proxy)}") 244 else: 245 raise RuntimeError(f"Direct downloads failed (IPv6/IPv4), and no rotating proxy configured. Last error: {str(e_ipv4)}")
249def convert_video_to_animated(input_path: str, output_path: str, target_format: str = "gif", fps: int = 15, width: int = 512) -> None: 250 """Converts a video to an animated GIF or WebP sticker using FFmpeg.""" 251 if not shutil.which("ffmpeg"): 252 raise RuntimeError("FFmpeg is required for video-to-animation conversion. Please install it.") 253 254 # WhatsApp/Telegram stickers prefer a 512px boundary. We scale proportionally. 255 scale_filter = f"fps={fps},scale={width}:-1:flags=lanczos" 256 257 if target_format.lower() == "gif": 258 # High-quality GIF generation using a 2-pass color palette 259 vf_cmd = f"{scale_filter},split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" 260 cmd = ["ffmpeg", "-y", "-i", input_path, "-vf", vf_cmd, "-loop", "0", output_path] 261 elif target_format.lower() == "webp": 262 # Force exact 512x512 transparent canvas, 15fps, and loop infinitely for WhatsApp 263 wa_filter = "fps=15,scale=512:512:force_original_aspect_ratio=decrease,format=rgba,pad=512:512:(ow-iw)/2:(oh-ih)/2:color=#00000000" 264 cmd = [ 265 "ffmpeg", "-y", "-i", input_path, 266 "-vcodec", "libwebp", 267 "-vf", wa_filter, 268 "-lossless", "0", 269 "-compression_level", "6", 270 "-q:v", "40", 271 "-loop", "0", 272 "-preset", "picture", 273 "-an", "-vsync", "0", 274 output_path 275 ] 276 else: 277 raise ValueError("Target format must be 'gif' or 'webp'") 278 279 # Execute FFmpeg silently 280 result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) 281 if result.returncode != 0: 282 raise RuntimeError(f"FFmpeg conversion failed: {result.stderr}")
Converts a video to an animated GIF or WebP sticker using FFmpeg.