| """ |
| Custom captioning tool: transcribes audio/video with Whisper (large-v3) and chunks |
| captions by a user-specified number of words per caption, outputting an SRT file |
| ready to import into CapCut or any other editor. |
| |
| Designed for Hugging Face Spaces ZeroGPU (dynamic H200 access). |
| Uses openai-whisper (PyTorch-based) rather than faster-whisper/CTranslate2, |
| since ZeroGPU's GPU-attach mechanism is built around torch.cuda and is most |
| reliable with PyTorch-native models. |
| """ |
|
|
| import os |
| import tempfile |
|
|
| import gradio as gr |
| import spaces |
| import torch |
| import whisper |
|
|
| |
| |
| |
| |
| |
| MODEL_SIZE = "large-v3" |
| model = whisper.load_model(MODEL_SIZE, device="cpu") |
|
|
|
|
| def format_timestamp(seconds: float) -> str: |
| """Convert seconds (float) to SRT timestamp format: HH:MM:SS,mmm""" |
| ms_total = int(round(seconds * 1000)) |
| hours, rem = divmod(ms_total, 3600_000) |
| minutes, rem = divmod(rem, 60_000) |
| secs, ms = divmod(rem, 1000) |
| return f"{hours:02d}:{minutes:02d}:{secs:02d},{ms:03d}" |
|
|
|
|
| def chunk_words(words, words_per_caption, max_chars=None): |
| """ |
| Group a flat list of {"word","start","end"} dicts into caption chunks |
| of `words_per_caption` words each. Optionally caps chunk length by |
| max_chars so long words don't overflow a caption line. |
| """ |
| captions = [] |
| current = [] |
|
|
| def flush(): |
| if current: |
| captions.append({ |
| "text": " ".join(w["word"] for w in current).strip(), |
| "start": current[0]["start"], |
| "end": current[-1]["end"], |
| }) |
|
|
| for w in words: |
| current.append(w) |
| text_len = len(" ".join(x["word"] for x in current)) |
| hit_word_limit = len(current) >= words_per_caption |
| hit_char_limit = max_chars is not None and text_len >= max_chars |
| if hit_word_limit or hit_char_limit: |
| flush() |
| current = [] |
| flush() |
|
|
| return captions |
|
|
|
|
| def write_srt(captions, path): |
| with open(path, "w", encoding="utf-8") as f: |
| for i, cap in enumerate(captions, start=1): |
| f.write(f"{i}\n") |
| f.write(f"{format_timestamp(cap['start'])} --> {format_timestamp(cap['end'])}\n") |
| f.write(f"{cap['text']}\n\n") |
|
|
|
|
| @spaces.GPU(duration=120) |
| def run_transcription(media_path): |
| """The only part that touches CUDA -- kept as small as possible to conserve quota.""" |
| global model |
| model = model.to("cuda") |
| result = model.transcribe(media_path, word_timestamps=True, fp16=True) |
| model = model.to("cpu") |
| torch.cuda.empty_cache() |
| return result |
|
|
|
|
| def transcribe_and_caption(media_file, words_per_caption, max_chars, progress=gr.Progress()): |
| if media_file is None: |
| return None, "Upload an audio or video file first." |
|
|
| words_per_caption = max(1, int(words_per_caption)) |
| max_chars_val = int(max_chars) if max_chars and max_chars > 0 else None |
|
|
| progress(0.2, desc="Requesting GPU and transcribing (uses your ZeroGPU quota)...") |
| result = run_transcription(media_file) |
|
|
| words = [] |
| for segment in result.get("segments", []): |
| for w in segment.get("words", []): |
| words.append({ |
| "word": w["word"].strip(), |
| "start": w["start"], |
| "end": w["end"], |
| }) |
|
|
| if not words: |
| return None, "No speech detected in the file." |
|
|
| progress(0.8, desc="Building captions...") |
| captions = chunk_words(words, words_per_caption, max_chars_val) |
|
|
| out_path = os.path.join(tempfile.gettempdir(), "captions.srt") |
| write_srt(captions, out_path) |
|
|
| preview_lines = [f"[{format_timestamp(c['start'])}] {c['text']}" for c in captions[:15]] |
| preview = "\n".join(preview_lines) |
| if len(captions) > 15: |
| preview += f"\n... ({len(captions) - 15} more captions)" |
|
|
| detected_lang = result.get("language", "unknown") |
| summary = ( |
| f"Detected language: {detected_lang}\n" |
| f"Total captions: {len(captions)}\n\n" |
| f"Preview:\n{preview}" |
| ) |
|
|
| progress(1.0, desc="Done") |
| return out_path, summary |
|
|
|
|
| with gr.Blocks(title="Word-Count Caption Generator") as demo: |
| gr.Markdown( |
| "# Word-Count Caption Generator\n" |
| "Upload a video or audio file, choose how many words should appear per caption, " |
| "and get back an `.srt` file to import into CapCut (or any editor).\n\n" |
| f"Running **{MODEL_SIZE}** on ZeroGPU (H200). Each run uses part of your daily " |
| "GPU quota, so batch your clips rather than testing repeatedly." |
| ) |
|
|
| with gr.Row(): |
| with gr.Column(): |
| media_input = gr.File( |
| label="Audio or video file", |
| file_types=["audio", "video"], |
| ) |
| words_slider = gr.Slider( |
| minimum=1, maximum=10, value=3, step=1, |
| label="Words per caption", |
| ) |
| chars_slider = gr.Slider( |
| minimum=0, maximum=60, value=0, step=1, |
| label="Max characters per caption (0 = no limit)", |
| ) |
| run_btn = gr.Button("Generate captions", variant="primary") |
|
|
| with gr.Column(): |
| srt_output = gr.File(label="Download .srt") |
| summary_output = gr.Textbox(label="Summary / preview", lines=18) |
|
|
| run_btn.click( |
| fn=transcribe_and_caption, |
| inputs=[media_input, words_slider, chars_slider], |
| outputs=[srt_output, summary_output], |
| ) |
|
|
| if __name__ == "__main__": |
| demo.launch() |