Download app.py from NickVerri/JuniorEditorTest_XML: direct link, hf CLI and curl.
- Browser
- Download file 40.2 kB
-
https://huggingface.co/spaces/NickVerri/JuniorEditorTest_XML/resolve/main/app.py
- Command line
-
hf download hf://spaces/NickVerri/JuniorEditorTest_XML/app.py
-
curl -L -o app.py https://huggingface.co/spaces/NickVerri/JuniorEditorTest_XML/resolve/main/app.py
40.2 kB
| import os | |
| import numpy as np | |
| import torch | |
| import typing | |
| import torchaudio | |
| import streamlit as st | |
| import subprocess | |
| import json | |
| import requests | |
| import gc | |
| import math | |
| import uuid | |
| import re | |
| import time | |
| from urllib.parse import quote | |
| from datetime import timedelta | |
| # --- CRITICAL ENVIRONMENT FIXES --- | |
| if os.environ.get("OMP_NUM_THREADS", "").endswith("m"): | |
| os.environ["OMP_NUM_THREADS"] = "1" | |
| try: | |
| if "ffmpeg" in torchaudio.list_audio_backends(): | |
| torchaudio.set_audio_backend("ffmpeg") | |
| except Exception: | |
| pass | |
| if not hasattr(torch.load, "_is_patched"): | |
| _original_torch_load = torch.load | |
| def patched_torch_load(*args, **kwargs): | |
| kwargs['weights_only'] = False | |
| return _original_torch_load(*args, **kwargs) | |
| patched_torch_load._is_patched = True | |
| torch.load = patched_torch_load | |
| try: | |
| safe_list = [typing.Any, torch.nn.modules.container.ModuleList, np.dtype] | |
| if hasattr(np, '_core') and hasattr(np._core, 'multiarray'): | |
| safe_list.append(np._core.multiarray.scalar) | |
| elif hasattr(np, 'core') and hasattr(np.core, 'multiarray'): | |
| safe_list.append(np.core.multiarray.scalar) | |
| try: | |
| from omegaconf.listconfig import ListConfig | |
| from omegaconf.dictconfig import DictConfig | |
| from omegaconf.base import ContainerMetadata, Metadata, Node | |
| safe_list.extend([ListConfig, DictConfig, ContainerMetadata, Metadata, Node]) | |
| except ImportError: | |
| pass | |
| try: | |
| from pyannote.audio.core.task import Specifications, Problem, Resolution | |
| from pyannote.audio.core.model import Model | |
| from pyannote.audio.pipelines.speaker_diarization import SpeakerDiarization | |
| safe_list.extend([Specifications, Problem, Resolution, Model, SpeakerDiarization]) | |
| except ImportError: | |
| pass | |
| torch.serialization.add_safe_globals(safe_list) | |
| except Exception as e: | |
| print(f"Safe Globals Warning: {e}") | |
| if not hasattr(np, 'NaN'): | |
| np.NaN = np.nan | |
| import whisperx | |
| # --- Configuration --- | |
| HARDCODED_HF_TOKEN = "PASTE_YOUR_HF_TOKEN_HERE" | |
| HARDCODED_GEMINI_KEY = "" | |
| ENV_HF_TOKEN = os.environ.get("HF_TOKEN", "") | |
| ENV_GEMINI_KEY = os.environ.get("GEMINI_API_KEY", "") | |
| ACTIVE_HF_TOKEN = ENV_HF_TOKEN if ENV_HF_TOKEN else HARDCODED_HF_TOKEN | |
| ACTIVE_GEMINI_KEY = ENV_GEMINI_KEY if ENV_GEMINI_KEY else HARDCODED_GEMINI_KEY | |
| # --- HELPER FUNCTIONS --- | |
| def clean_json_response(text): | |
| """Strips markdown code fences from Gemini output to prevent JSON errors.""" | |
| try: | |
| pattern = r"`{3}(?:json)?\s*(.*?)`{3}" | |
| match = re.search(pattern, text, re.DOTALL) | |
| if match: | |
| return match.group(1).strip() | |
| return text.strip() | |
| except Exception: | |
| return text | |
| def escape_xml(text): | |
| """Safely escapes special characters for XML to prevent DOM parser errors.""" | |
| if not text: | |
| return "" | |
| text = str(text) | |
| text = text.replace("&", "&") | |
| text = text.replace("<", "<") | |
| text = text.replace(">", ">") | |
| text = text.replace('"', """) | |
| text = text.replace("'", "'") | |
| return text | |
| def timecode_to_frames(tc, fps): | |
| """Converts HH:MM:SS:FF to absolute integer frames based on timebase.""" | |
| if not tc or not re.match(r"\d{2}:\d{2}:\d{2}[:\.]\d{2}", tc): | |
| return 0 | |
| parts = re.split(r'[:\.]', tc) | |
| h, m, s, f = map(int, parts) | |
| timebase = int(round(fps)) # 29.97 becomes 30, 23.98 becomes 24 | |
| return (h * 3600 * timebase) + (m * 60 * timebase) + (s * timebase) + f | |
| def frames_to_timecode(frames, fps): | |
| """Converts absolute integer frames back to HH:MM:SS:FF (NDF).""" | |
| timebase = int(round(fps)) | |
| hours = frames // (3600 * timebase) | |
| minutes = (frames // (60 * timebase)) % 60 | |
| secs = (frames // timebase) % 60 | |
| f = frames % timebase | |
| return f"{hours:02}:{minutes:02}:{secs:02}:{f:02}" | |
| def seconds_to_frames(seconds, fps): | |
| """Converts real seconds to integer frames.""" | |
| return int(round(seconds * fps)) | |
| def generate_cmx_edl(edl_title, segments, source_name, fps=25, start_offset_frames=0): | |
| """Constructs a CMX 3600 formatted EDL with source offset.""" | |
| edl_lines = [f"TITLE: {edl_title}", "FCM: NON-DROP FRAME\n"] | |
| rec_start_frames = 0 | |
| reel_id = source_name.replace(" ", "_")[:8] # EDL reel IDs are traditionally short max 8 chars | |
| for i, seg in enumerate(segments, 1): | |
| seg_type = seg.get('type', 'clip') | |
| is_vo = seg_type == 'vo' or ('text' in seg and seg_type != 'graphic') | |
| is_graphic = seg_type == 'graphic' | |
| if is_vo or is_graphic: | |
| text_content = seg.get('text', 'Placeholder') | |
| default_dur = max(1.0, len(text_content.split()) / 2.5) if is_vo else 4.0 | |
| duration_sec = float(seg.get('duration', default_dur)) | |
| duration_frames = seconds_to_frames(duration_sec, fps) | |
| src_in_frames = 0 | |
| src_out_frames = duration_frames | |
| current_source = "GEN_VO" if is_vo else "GEN_GFX" | |
| note_text = f"{'VO SCRIPT' if is_vo else 'GRAPHIC'}: {text_content}" | |
| else: | |
| src_start_val = float(seg.get('src_start', seg.get('start', 0.0))) | |
| src_end_val = float(seg.get('src_end', seg.get('end', 0.0))) | |
| src_in_frames = seconds_to_frames(src_start_val, fps) + start_offset_frames | |
| src_out_frames = seconds_to_frames(src_end_val, fps) + start_offset_frames | |
| duration_frames = src_out_frames - src_in_frames | |
| current_source = reel_id | |
| note_text = seg.get('note', 'Clip') | |
| src_in = frames_to_timecode(src_in_frames, fps) | |
| src_out = frames_to_timecode(src_out_frames, fps) | |
| rec_in = frames_to_timecode(rec_start_frames, fps) | |
| rec_out = frames_to_timecode(rec_start_frames + duration_frames, fps) | |
| edl_lines.append(f"{i:03} {current_source:8} V C {src_in} {src_out} {rec_in} {rec_out}") | |
| if not is_vo and not is_graphic: | |
| edl_lines.append(f"* FROM CLIP NAME: {source_name}") | |
| edl_lines.append(f"* {note_text}\n") | |
| gap_frames = seconds_to_frames(float(seg.get('gap', 0.0)), fps) | |
| rec_start_frames += duration_frames + gap_frames | |
| return "\n".join(edl_lines) | |
| def generate_xml(sequence_name, segments, source_name, fps=25, start_offset_frames=0): | |
| """Constructs a Robust XML with source timecode offset for Premiere AND Resolve.""" | |
| master_id = "masterfile-1" | |
| timebase = int(round(fps)) | |
| is_ntsc = "TRUE" if fps % 1 != 0 else "FALSE" | |
| start_tc_string = frames_to_timecode(start_offset_frames, fps) | |
| encoded_name = quote(source_name) | |
| path_url = f"file://localhost/{encoded_name}" | |
| lines = [] | |
| lines.append('<?xml version="1.0" encoding="UTF-8"?>') | |
| lines.append('<!DOCTYPE xmeml>') | |
| lines.append('<xmeml version="4">') | |
| lines.append('<sequence>') | |
| lines.append(f'\t<name>{escape_xml(sequence_name)}</name>') | |
| lines.append('\t<rate>') | |
| lines.append(f'\t\t<timebase>{timebase}</timebase>') | |
| lines.append(f'\t\t<ntsc>{is_ntsc}</ntsc>') | |
| lines.append('\t</rate>') | |
| lines.append('\t<media>') | |
| # --- VIDEO TRACK --- | |
| lines.append('\t\t<video>') | |
| lines.append('\t\t\t<format>') | |
| lines.append('\t\t\t\t<samplecharacteristics>') | |
| lines.append(f'\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>') | |
| lines.append('\t\t\t\t\t<width>1920</width>') | |
| lines.append('\t\t\t\t\t<height>1080</height>') | |
| lines.append('\t\t\t\t\t<anamorphic>FALSE</anamorphic>') | |
| lines.append('\t\t\t\t\t<pixelaspectratio>square</pixelaspectratio>') | |
| lines.append('\t\t\t\t</samplecharacteristics>') | |
| lines.append('\t\t\t</format>') | |
| lines.append('\t\t\t<track>') | |
| file_defined = False | |
| vo_file_defined = False | |
| gfx_file_defined = False | |
| timeline_head_frames = 0 | |
| for i, seg in enumerate(segments, 1): | |
| seg_type = seg.get('type', 'clip') | |
| is_vo = seg_type == 'vo' or ('text' in seg and seg_type != 'graphic') | |
| is_graphic = seg_type == 'graphic' | |
| if is_vo or is_graphic: | |
| text_content = seg.get('text', 'Placeholder') | |
| default_dur = max(1.0, len(text_content.split()) / 2.5) if is_vo else 4.0 | |
| duration_sec = float(seg.get('duration', default_dur)) | |
| duration_frames = seconds_to_frames(duration_sec, fps) | |
| src_in_frames = 0 | |
| src_out_frames = duration_frames | |
| master_id_to_use = "masterfile-vo" if is_vo else "masterfile-gfx" | |
| source_name_to_use = "Generated_VO_Placeholder" if is_vo else "Generated_Graphic_Placeholder" | |
| prefix = "VO SCRIPT" if is_vo else "GRAPHIC CARD" | |
| clip_note = f"{prefix}: {text_content}" | |
| clip_name_disp = (clip_note[:75] + '..') if len(clip_note) > 75 else clip_note | |
| else: | |
| src_start_val = float(seg.get('src_start', seg.get('start', 0.0))) | |
| src_end_val = float(seg.get('src_end', seg.get('end', 0.0))) | |
| # FIX: Removed the '+ start_offset_frames' from the video track so it matches the audio track! | |
| src_in_frames = seconds_to_frames(src_start_val, fps) | |
| src_out_frames = seconds_to_frames(src_end_val, fps) | |
| duration_frames = src_out_frames - src_in_frames | |
| master_id_to_use = master_id | |
| source_name_to_use = source_name | |
| raw_note = seg.get('note', 'Junior Editor Selection') | |
| clip_note = raw_note | |
| clip_name_disp = source_name | |
| tl_start = timeline_head_frames | |
| tl_end = tl_start + duration_frames | |
| lines.append(f'\t\t\t\t<clipitem id="clipitem-v-{i}">') | |
| lines.append(f'\t\t\t\t\t<name>{escape_xml(clip_name_disp)}</name>') | |
| lines.append(f'\t\t\t\t\t<duration>8640000</duration>') | |
| lines.append(f'\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>') | |
| lines.append(f'\t\t\t\t\t<start>{tl_start}</start>') | |
| lines.append(f'\t\t\t\t\t<end>{tl_end}</end>') | |
| lines.append(f'\t\t\t\t\t<in>{src_in_frames}</in>') | |
| lines.append(f'\t\t\t\t\t<out>{src_out_frames}</out>') | |
| lines.append(f'\t\t\t\t\t<masterclipid>{master_id_to_use}</masterclipid>') | |
| # --- ROBUST FILE DEFINITIONS --- | |
| if is_vo: | |
| if not vo_file_defined: | |
| lines.append(f'\t\t\t\t\t<file id="{master_id_to_use}">') | |
| lines.append(f'\t\t\t\t\t\t<name>{escape_xml(source_name_to_use)}</name>') | |
| lines.append(f'\t\t\t\t\t\t<pathurl>file://localhost/{escape_xml(source_name_to_use)}.wav</pathurl>') | |
| lines.append(f'\t\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>') | |
| lines.append(f'\t\t\t\t\t\t<duration>8640000</duration>') | |
| lines.append(f'\t\t\t\t\t\t<timecode>') | |
| lines.append(f'\t\t\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>') | |
| lines.append(f'\t\t\t\t\t\t\t<string>00:00:00:00</string>') | |
| lines.append(f'\t\t\t\t\t\t\t<frame>0</frame>') | |
| lines.append(f'\t\t\t\t\t\t\t<displayformat>NDF</displayformat>') | |
| lines.append(f'\t\t\t\t\t\t</timecode>') | |
| lines.append('\t\t\t\t\t\t<media>') | |
| lines.append('\t\t\t\t\t\t\t<video><samplecharacteristics><width>1920</width><height>1080</height></samplecharacteristics></video>') | |
| lines.append('\t\t\t\t\t\t\t<audio><samplecharacteristics><depth>16</depth><samplerate>48000</samplerate></samplecharacteristics><channelcount>2</channelcount></audio>') | |
| lines.append('\t\t\t\t\t\t</media>') | |
| lines.append('\t\t\t\t\t</file>') | |
| vo_file_defined = True | |
| else: | |
| lines.append(f'\t\t\t\t\t<file id="{master_id_to_use}"/>') | |
| elif is_graphic: | |
| if not gfx_file_defined: | |
| lines.append(f'\t\t\t\t\t<file id="{master_id_to_use}">') | |
| lines.append(f'\t\t\t\t\t\t<name>{escape_xml(source_name_to_use)}</name>') | |
| lines.append(f'\t\t\t\t\t\t<pathurl>file://localhost/{escape_xml(source_name_to_use)}.mov</pathurl>') | |
| lines.append(f'\t\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>') | |
| lines.append(f'\t\t\t\t\t\t<duration>8640000</duration>') | |
| lines.append(f'\t\t\t\t\t\t<timecode>') | |
| lines.append(f'\t\t\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>') | |
| lines.append(f'\t\t\t\t\t\t\t<string>00:00:00:00</string>') | |
| lines.append(f'\t\t\t\t\t\t\t<frame>0</frame>') | |
| lines.append(f'\t\t\t\t\t\t\t<displayformat>NDF</displayformat>') | |
| lines.append(f'\t\t\t\t\t\t</timecode>') | |
| lines.append('\t\t\t\t\t\t<media>') | |
| lines.append('\t\t\t\t\t\t\t<video><samplecharacteristics><width>1920</width><height>1080</height></samplecharacteristics></video>') | |
| # Intentionally omitting audio block for graphics placeholder to keep NLE track structure clean | |
| lines.append('\t\t\t\t\t\t</media>') | |
| lines.append('\t\t\t\t\t</file>') | |
| gfx_file_defined = True | |
| else: | |
| lines.append(f'\t\t\t\t\t<file id="{master_id_to_use}"/>') | |
| else: | |
| if not file_defined: | |
| lines.append(f'\t\t\t\t\t<file id="{master_id_to_use}">') | |
| lines.append(f'\t\t\t\t\t\t<name>{escape_xml(source_name_to_use)}</name>') | |
| lines.append(f'\t\t\t\t\t\t<pathurl>{path_url}</pathurl>') | |
| lines.append(f'\t\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>') | |
| lines.append(f'\t\t\t\t\t\t<duration>8640000</duration>') | |
| lines.append(f'\t\t\t\t\t\t<timecode>') | |
| lines.append(f'\t\t\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>') | |
| lines.append(f'\t\t\t\t\t\t\t<string>{start_tc_string}</string>') | |
| lines.append(f'\t\t\t\t\t\t\t<frame>{start_offset_frames}</frame>') | |
| lines.append(f'\t\t\t\t\t\t\t<displayformat>NDF</displayformat>') | |
| lines.append(f'\t\t\t\t\t\t</timecode>') | |
| lines.append('\t\t\t\t\t\t<media>') | |
| lines.append('\t\t\t\t\t\t\t<video><samplecharacteristics><width>1920</width><height>1080</height></samplecharacteristics></video>') | |
| lines.append('\t\t\t\t\t\t\t<audio><samplecharacteristics><depth>16</depth><samplerate>48000</samplerate></samplecharacteristics><channelcount>2</channelcount></audio>') | |
| lines.append('\t\t\t\t\t\t</media>') | |
| lines.append('\t\t\t\t\t</file>') | |
| file_defined = True | |
| else: | |
| lines.append(f'\t\t\t\t\t<file id="{master_id_to_use}"/>') | |
| lines.append('\t\t\t\t\t<marker>') | |
| lines.append(f'\t\t\t\t\t\t<name>{escape_xml(clip_note)}</name>') | |
| lines.append(f'\t\t\t\t\t\t<in>{src_in_frames}</in>') | |
| # Extends the marker duration to span the entire clip for VO and Graphics | |
| if is_vo or is_graphic: | |
| lines.append(f'\t\t\t\t\t\t<out>{src_out_frames}</out>') | |
| else: | |
| lines.append(f'\t\t\t\t\t\t<out>{src_in_frames}</out>') | |
| lines.append('\t\t\t\t\t</marker>') | |
| lines.append('\t\t\t\t</clipitem>') | |
| gap_frames = seconds_to_frames(float(seg.get('gap', 0.0)), fps) | |
| timeline_head_frames = tl_end + gap_frames | |
| lines.append('\t\t\t</track>') | |
| lines.append('\t\t</video>') | |
| # --- AUDIO TRACK --- | |
| lines.append('\t\t<audio>') | |
| lines.append('\t\t\t<track>') | |
| timeline_head_frames = 0 | |
| for i, seg in enumerate(segments, 1): | |
| seg_type = seg.get('type', 'clip') | |
| is_vo = seg_type == 'vo' or ('text' in seg and seg_type != 'graphic') | |
| is_graphic = seg_type == 'graphic' | |
| if is_graphic: | |
| # Graphics have no audio. We just advance the playhead so the timeline stays perfectly in sync! | |
| duration_sec = float(seg.get('duration', 4.0)) | |
| duration_frames = seconds_to_frames(duration_sec, fps) | |
| gap_frames = seconds_to_frames(float(seg.get('gap', 0.0)), fps) | |
| timeline_head_frames += duration_frames + gap_frames | |
| continue | |
| if is_vo: | |
| vo_text = seg.get('text', 'VO') | |
| duration_sec = float(seg.get('duration', max(1.0, len(vo_text.split()) / 2.5))) | |
| duration_frames = seconds_to_frames(duration_sec, fps) | |
| src_in_frames = 0 | |
| src_out_frames = duration_frames | |
| master_id_to_use = "masterfile-vo" | |
| source_name_to_use = "Generated_VO_Placeholder" | |
| else: | |
| src_start_val = float(seg.get('src_start', seg.get('start', 0.0))) | |
| src_end_val = float(seg.get('src_end', seg.get('end', 0.0))) | |
| src_in_frames = seconds_to_frames(src_start_val, fps) | |
| src_out_frames = seconds_to_frames(src_end_val, fps) | |
| duration_frames = src_out_frames - src_in_frames | |
| master_id_to_use = master_id | |
| source_name_to_use = source_name | |
| tl_start = timeline_head_frames | |
| tl_end = tl_start + duration_frames | |
| lines.append(f'\t\t\t\t<clipitem id="clipitem-a-{i}">') | |
| lines.append(f'\t\t\t\t\t<name>{escape_xml(source_name_to_use)}</name>') | |
| lines.append(f'\t\t\t\t\t<masterclipid>{master_id_to_use}</masterclipid>') | |
| lines.append(f'\t\t\t\t\t<duration>8640000</duration>') | |
| lines.append(f'\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>') | |
| lines.append(f'\t\t\t\t\t<start>{tl_start}</start>') | |
| lines.append(f'\t\t\t\t\t<end>{tl_end}</end>') | |
| lines.append(f'\t\t\t\t\t<in>{src_in_frames}</in>') | |
| lines.append(f'\t\t\t\t\t<out>{src_out_frames}</out>') | |
| lines.append(f'\t\t\t\t\t<file id="{master_id_to_use}"/>') | |
| lines.append('\t\t\t\t\t<sourcetrack><mediatype>audio</mediatype><trackindex>1</trackindex></sourcetrack>') | |
| lines.append('\t\t\t\t</clipitem>') | |
| timeline_head_frames = tl_end + seconds_to_frames(float(seg.get('gap', 0.0)), fps) | |
| lines.append('\t\t\t</track>') | |
| lines.append('\t\t</audio>') | |
| lines.append('\t</media>') | |
| lines.append('</sequence>') | |
| lines.append('</xmeml>') | |
| return "\n".join(lines) | |
| def generate_transcript_txt(sequence_name, segments, fps=25, start_offset_frames=0, transcript_data=None): | |
| """Generates a human-readable text document of the edit decisions and exact transcription.""" | |
| lines = [f"Sequence Transcript: {sequence_name}", "=" * 50, ""] | |
| for i, seg in enumerate(segments, 1): | |
| seg_type = seg.get('type', 'clip') | |
| is_vo = seg_type == 'vo' or ('text' in seg and seg_type != 'graphic') | |
| is_graphic = seg_type == 'graphic' | |
| if is_vo: | |
| vo_text = seg.get('text', 'VO') | |
| duration = float(seg.get('duration', max(1.0, len(vo_text.split()) / 2.5))) | |
| lines.append(f"Clip {i:02} | [GENERATED VOICE-OVER] | Est. Duration: {duration:.1f}s") | |
| lines.append(f"Script: \"{vo_text}\"") | |
| elif is_graphic: | |
| gfx_text = seg.get('text', 'Graphic Text') | |
| duration = float(seg.get('duration', 4.0)) | |
| lines.append(f"Clip {i:02} | [GRAPHIC CARD] | Est. Duration: {duration:.1f}s") | |
| lines.append(f"Text: \"{gfx_text}\"") | |
| else: | |
| src_start_val = float(seg.get('src_start', seg.get('start', 0.0))) | |
| src_end_val = float(seg.get('src_end', seg.get('end', 0.0))) | |
| src_in_frames = seconds_to_frames(src_start_val, fps) + start_offset_frames | |
| src_out_frames = seconds_to_frames(src_end_val, fps) + start_offset_frames | |
| src_in = frames_to_timecode(src_in_frames, fps) | |
| src_out = frames_to_timecode(src_out_frames, fps) | |
| note = seg.get('note', 'No note provided.') | |
| actual_text = "" | |
| if transcript_data: | |
| clip_words = [] | |
| for t_seg in transcript_data: | |
| if max(src_start_val, t_seg['start']) < min(src_end_val, t_seg['end']): | |
| clip_words.append(t_seg['text']) | |
| actual_text = " ".join(clip_words).strip() | |
| lines.append(f"Clip {i:02} | IN: {src_in} --> OUT: {src_out}") | |
| lines.append(f"AI Summary: {note}") | |
| if actual_text: | |
| lines.append(f"Exact Audio Text: \"{actual_text}\"") | |
| lines.append("") | |
| return "\n".join(lines) | |
| def call_gemini_for_edl(transcript_data, story_prompt, api_key, previous_edit=None): | |
| if not api_key: | |
| st.error("Gemini API Key is missing.") | |
| return None | |
| system_prompt = ( | |
| "You are an expert Documentary Senior Editor. Use the provided transcript JSON " | |
| "(which includes Speaker IDs and word-level timestamps) to create a condensed story.\n\n" | |
| "NEW CAPABILITIES:\n" | |
| "1. You can generate Voice-Over (VO) to bridge gaps, introduce topics, or summarize.\n" | |
| "2. You can generate Graphic Cards (Title Cards) to display text on screen (e.g., location, date, or chapter title).\n" | |
| "CRITICAL: ONLY generate 'vo' or 'graphic' segments if the user explicitly requests voice over, narration, or graphic/title cards in their creative brief. Otherwise, ONLY use 'clip' segments.\n\n" | |
| "Output ONLY a valid JSON array of segments. Every segment MUST be a 'clip', a 'vo', or a 'graphic'.\n\n" | |
| "For 'clip' segments (extracting from the subject):\n" | |
| "{\"type\": \"clip\", \"src_start\": 12.5, \"src_end\": 25.0, \"note\": \"Subject talks about X\", \"gap\": 0.0}\n" | |
| "- IGNORE ALL INTERVIEWER COMMENTS.\n" | |
| "- REMOVE FLUFF: Delete 'um', 'ah', repeats, and irrelevant filler.\n" | |
| "- TIMESTAMP INTEGRITY: Use only the exact word-level start and end times from the data.\n\n" | |
| "For 'vo' segments (generating new voice-over):\n" | |
| "{\"type\": \"vo\", \"text\": \"The journey began years ago...\", \"duration\": 3.5, \"gap\": 0.0}\n" | |
| "- DURATION: Estimate duration accurately based on reading speed (approx 2.5 words per second).\n" | |
| "- Keep VO concise and in the style of a documentary narrator.\n\n" | |
| "For 'graphic' segments (generating text on screen):\n" | |
| "{\"type\": \"graphic\", \"text\": \"Chapter 1: The Beginning\", \"duration\": 4.0, \"gap\": 0.0}\n\n" | |
| "PACING: Group related clips. Add a 'gap' (in seconds) between distinct ideas." | |
| ) | |
| if previous_edit: | |
| prompt_text = ( | |
| f"Transcript Data:\n{json.dumps(transcript_data)}\n\n" | |
| f"Previous Edit You Generated:\n{json.dumps(previous_edit)}\n\n" | |
| f"Director's Note for Revision: {story_prompt}\n\n" | |
| "Please output a NEW JSON array of segments applying these requested changes to the previous edit." | |
| ) | |
| else: | |
| prompt_text = f"Creative Brief: {story_prompt}\n\nTranscript Data:\n{json.dumps(transcript_data)}" | |
| payload = { | |
| "contents": [ | |
| { | |
| "role": "user", | |
| "parts": [{"text": prompt_text}] | |
| } | |
| ], | |
| "systemInstruction": { | |
| "parts": [{"text": system_prompt}] | |
| }, | |
| "generationConfig": { | |
| "responseMimeType": "application/json" | |
| } | |
| } | |
| headers = { | |
| "Content-Type": "application/json" | |
| } | |
| # Prioritizes Pro models, falls back to Flash. All "lite" versions explicitly removed. | |
| models_to_try = [ | |
| "gemini-3.1-pro-preview", | |
| "gemini-3-flash-preview", | |
| "gemini-2.5-pro", | |
| "gemini-2.5-flash", | |
| "gemini-2.0-flash" | |
| ] | |
| endpoints_to_try = ["v1beta", "v1"] | |
| last_error = "" | |
| for model_name in models_to_try: | |
| for api_version in endpoints_to_try: | |
| url = f"https://generativelanguage.googleapis.com/{api_version}/models/{model_name}:generateContent?key={api_key}" | |
| try: | |
| res = requests.post(url, json=payload, headers=headers) | |
| # Success | |
| if res.status_code == 200: | |
| result_json = res.json() | |
| raw_text = result_json['candidates'][0]['content']['parts'][0]['text'] | |
| cleaned_text = clean_json_response(raw_text) | |
| return json.loads(cleaned_text), model_name, api_version | |
| # Handle High Demand (503) or Rate Limits (429) | |
| elif res.status_code in [503, 429]: | |
| last_error = f"Model {model_name} busy ({res.status_code}) on {api_version}." | |
| st.warning(f"Debug: {last_error} Skipping to next model...") | |
| break | |
| # Handle endpoints that don't exist (404) or API rejections (400) | |
| else: | |
| try: | |
| error_details = res.json().get('error', {}).get('message', res.text) | |
| except Exception: | |
| error_details = res.text | |
| last_error = f"Model {model_name} rejected request ({res.status_code}) on {api_version}: {error_details}" | |
| st.warning(f"Debug: {last_error} Trying fallback endpoint/model...") | |
| continue | |
| except json.JSONDecodeError: | |
| last_error = f"Model {model_name} returned invalid JSON on {api_version}." | |
| st.warning(f"Debug: {last_error} Trying fallback endpoint/model...") | |
| continue | |
| except requests.exceptions.RequestException as e: | |
| last_error = f"Network error connecting to {model_name} on {api_version}: {e}" | |
| st.warning(f"Debug: {last_error} Trying fallback endpoint/model...") | |
| continue | |
| except Exception as e: | |
| last_error = f"Error with {model_name} on {api_version}: {e}" | |
| st.warning(f"Debug: {last_error} Trying fallback endpoint/model...") | |
| continue | |
| st.error(f"All Gemini models failed or are currently experiencing high demand. Last error: {last_error}") | |
| return None, None, None | |
| # --- Streamlit UI --- | |
| st.set_page_config(page_title="Junior Editor", layout="wide") | |
| st.title("Junior Editor") | |
| st.markdown(""" | |
| **Instructions** | |
| * Upload your file here (video or audio). | |
| * Set your timeline FPS, Source Timecode, and transcription quality in the sidebar. | |
| * Junior Editor will transcribe and separate speakers. You can then instruct it to find engaging bits or construct a narrative. | |
| * **Note: If you want me to write voice over suggestions or title cards, please request it.** | |
| * It will create an EDL or XML to import back into your editing software (Resolve, Premiere). | |
| * **For Multicam Workflows:** Use the **XML** option in the sidebar and enter the **exact name** of your Multicam Sequence. | |
| * **Resolve Users:** Uncheck "Automatically import source clips into media pool" during import. | |
| * **Premiere Users:** Use the EDL option if XML conformance fails for Multicam sequences. | |
| """) | |
| st.divider() | |
| with st.sidebar: | |
| clip_settings_container = st.container() | |
| transcription_settings_container = st.container() | |
| with clip_settings_container: | |
| st.header("Clip Settings") | |
| source_start_tc = st.text_input("Source Start Timecode", value="00:00:00:00", help="Format: HH:MM:SS:FF") | |
| export_format = st.radio("Output Format", ["EDL", "XML (Multicam)"], index=0) | |
| input_label = "EDL Reel Name" | |
| input_help = "Leave empty to use the uploaded file name." | |
| if export_format == "XML (Multicam)": | |
| input_label = "Multicam Sequence Name" | |
| input_help = "EXACT name of your Multicam Clip in Resolve." | |
| custom_reel_name = st.text_input( | |
| input_label, | |
| placeholder="e.g. Interview_Day1_Multi", | |
| help=input_help | |
| ) | |
| fps_options = [23.98, 24, 25, 29.97, 30, 50, 59.94, 60] | |
| fps = st.selectbox("Timeline FPS", fps_options, index=2) | |
| with transcription_settings_container: | |
| st.header("Transcription Settings") | |
| language_map = { | |
| "Auto-Detect": None, | |
| "English": "en", | |
| "Spanish": "es", | |
| "French": "fr", | |
| "German": "de", | |
| "Italian": "it", | |
| "Portuguese": "pt" | |
| } | |
| selected_lang_label = st.selectbox("Audio Language", list(language_map.keys()), index=1) | |
| target_language = language_map[selected_lang_label] | |
| num_speakers = st.number_input("Speakers (0=Auto)", min_value=0, value=0) | |
| uploaded_file = st.file_uploader("Upload Video/Audio Clip", type=["mp4", "m4a", "wav", "mp3", "mov"]) | |
| if uploaded_file: | |
| # --- Auto-Reset Logic --- | |
| if "last_processed_file" not in st.session_state or st.session_state.last_processed_file != uploaded_file.name: | |
| if "transcript" in st.session_state: | |
| del st.session_state.transcript | |
| if "edit_generated" in st.session_state: | |
| del st.session_state.edit_generated | |
| st.session_state.last_processed_file = uploaded_file.name | |
| # --- Auto-Process Logic --- | |
| if "transcript" not in st.session_state: | |
| if not ACTIVE_HF_TOKEN or "PASTE_YOUR_HF_TOKEN" in ACTIVE_HF_TOKEN: | |
| st.error("Please provide a valid Hugging Face Token in the Sidebar/Secrets.") | |
| else: | |
| progress_container = st.container() | |
| with progress_container: | |
| st.info("🤖 **Junior Editor is processing your file...**") | |
| status_text = st.empty() | |
| progress_bar = st.progress(0) | |
| try: | |
| # Phase 1 | |
| status_text.markdown("**Phase 1/4: Extracting Audio...**") | |
| with open("temp_input", "wb") as f: | |
| f.write(uploaded_file.getbuffer()) | |
| subprocess.run(["ffmpeg", "-i", "temp_input", "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", "temp_audio.wav", "-y"]) | |
| progress_bar.progress(25) | |
| device = "cuda" if torch.cuda.is_available() else "cpu" | |
| if device == "cpu": st.warning("⚠️ No GPU detected.") | |
| # Phase 2 | |
| status_text.markdown("**Phase 2/4: Transcribing (Whisper)... This is the longest step.**") | |
| compute_type = "float16" if device == "cuda" else "int8" | |
| model = whisperx.load_model("large-v2", device, compute_type=compute_type) | |
| audio = whisperx.load_audio("temp_audio.wav") | |
| result = model.transcribe(audio, batch_size=4, language=target_language) | |
| del model | |
| gc.collect() | |
| torch.cuda.empty_cache() | |
| progress_bar.progress(50) | |
| # Phase 3 | |
| status_text.markdown("**Phase 3/4: Aligning Text...**") | |
| model_a, metadata = whisperx.load_align_model(language_code=result["language"], device=device) | |
| result = whisperx.align(result["segments"], model_a, metadata, audio, device, return_char_alignments=False) | |
| del model_a | |
| gc.collect() | |
| torch.cuda.empty_cache() | |
| progress_bar.progress(75) | |
| # Phase 4 | |
| status_text.markdown("**Phase 4/4: Identifying Speakers...**") | |
| diarize_model = whisperx.DiarizationPipeline(use_auth_token=ACTIVE_HF_TOKEN, device=device) | |
| diarize_kwargs = {"min_speakers": num_speakers, "max_speakers": num_speakers} if num_speakers > 0 else {} | |
| diarize_segments = diarize_model(audio, **diarize_kwargs) | |
| # Final Merge | |
| status_text.markdown("**Finalizing...**") | |
| final_result = whisperx.assign_word_speakers(diarize_segments, result) | |
| processed_segments = [] | |
| for segment in final_result["segments"]: | |
| processed_segments.append({ | |
| "speaker": segment.get("speaker", "Unknown"), | |
| "text": segment["text"].strip(), | |
| "start": segment["start"], | |
| "end": segment["end"] | |
| }) | |
| st.session_state.transcript = processed_segments | |
| if os.path.exists("temp_input"): os.remove("temp_input") | |
| if os.path.exists("temp_audio.wav"): os.remove("temp_audio.wav") | |
| progress_bar.progress(100) | |
| status_text.success(f"Done! Processed {len(processed_segments)} segments.") | |
| except Exception as e: | |
| status_text.error(f"Error: {e}") | |
| if os.path.exists("temp_input"): os.remove("temp_input") | |
| st.stop() | |
| if "transcript" in st.session_state: | |
| st.divider() | |
| with st.expander("Transcript Preview", expanded=False): | |
| for seg in st.session_state.transcript: | |
| st.markdown(f"**{seg['speaker']}:** {seg['text']}") | |
| st.subheader("Your Instruction") | |
| brief = st.text_area("What should the Junior Editor do?", placeholder="e.g. Find the most engaging bits. If you want me to write voice over suggestions or title cards, please request it.") | |
| if st.button("Generate Edit"): | |
| if not ACTIVE_GEMINI_KEY: | |
| st.error("Gemini API Key required.") | |
| else: | |
| with st.spinner("Junior Editor is thinking..."): | |
| final_source_name = custom_reel_name.strip() if custom_reel_name.strip() else uploaded_file.name | |
| offset_frames = timecode_to_frames(source_start_tc, fps) | |
| edl_segments, used_model, used_endpoint = call_gemini_for_edl(st.session_state.transcript, brief, ACTIVE_GEMINI_KEY) | |
| if edl_segments: | |
| safe_filename = "".join([c for c in final_source_name if c.isalnum() or c in (' ', '_', '-')]).strip() | |
| safe_filename = safe_filename.replace(' ', '_') | |
| if not safe_filename: | |
| safe_filename = "junior_editor_cut" | |
| # Generate the selected edit format (EDL/XML) | |
| if export_format == "EDL": | |
| final_output = generate_cmx_edl(final_source_name, edl_segments, final_source_name, fps, offset_frames) | |
| ext = "edl" | |
| else: | |
| final_output = generate_xml(final_source_name, edl_segments, final_source_name, fps, offset_frames) | |
| ext = "xml" | |
| # Generate the companion human-readable transcript | |
| transcript_txt = generate_transcript_txt(final_source_name, edl_segments, fps, offset_frames, st.session_state.transcript) | |
| # Save to session state so buttons don't disappear on click | |
| st.session_state.edit_generated = True | |
| st.session_state.revision_count = 1 | |
| st.session_state.base_filename = safe_filename | |
| st.session_state.final_output = final_output | |
| st.session_state.ext = ext | |
| st.session_state.transcript_txt = transcript_txt | |
| st.session_state.safe_filename = safe_filename | |
| st.session_state.used_model = used_model | |
| st.session_state.used_endpoint = used_endpoint | |
| st.session_state.edl_segments = edl_segments | |
| # Render the download buttons outside the Generate block using session state | |
| if st.session_state.get("edit_generated"): | |
| st.subheader("Ready for Import") | |
| st.success(f"✅ Edit generated successfully using **{st.session_state.used_model}** (via {st.session_state.used_endpoint}).") | |
| # Display downloads in a neat row | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| st.download_button(f"Download .{st.session_state.ext.upper()} Sequence", data=st.session_state.final_output, file_name=f"{st.session_state.safe_filename}.{st.session_state.ext}") | |
| with col2: | |
| st.download_button(f"Download Reference Transcript (.TXT)", data=st.session_state.transcript_txt, file_name=f"{st.session_state.safe_filename}-Transcript.txt") | |
| st.info("📝 **Note:** There seems to be a bug in Resolve that the first time you import the sequence into a bin, sometimes it won't relink. Just Delete the sequence and Import again and it should work.") | |
| st.divider() | |
| st.subheader("Revise Edit") | |
| revision_brief = st.text_area("Want changes? Tell Junior Editor:", placeholder="e.g. Make it twice as long, add a graphic card for location, or write a VO intro.") | |
| if st.button("Apply Revisions"): | |
| with st.spinner("Junior Editor is revising the edit..."): | |
| final_source_name = custom_reel_name.strip() if custom_reel_name.strip() else uploaded_file.name | |
| offset_frames = timecode_to_frames(source_start_tc, fps) | |
| new_edl_segments, used_model, used_endpoint = call_gemini_for_edl( | |
| st.session_state.transcript, | |
| revision_brief, | |
| ACTIVE_GEMINI_KEY, | |
| previous_edit=st.session_state.edl_segments | |
| ) | |
| if new_edl_segments: | |
| # Increment version count | |
| st.session_state.revision_count = st.session_state.get("revision_count", 1) + 1 | |
| rev_seq_name = f"{final_source_name} V{st.session_state.revision_count}" | |
| if export_format == "EDL": | |
| final_output = generate_cmx_edl(rev_seq_name, new_edl_segments, final_source_name, fps, offset_frames) | |
| ext = "edl" | |
| else: | |
| final_output = generate_xml(rev_seq_name, new_edl_segments, final_source_name, fps, offset_frames) | |
| ext = "xml" | |
| transcript_txt = generate_transcript_txt(rev_seq_name, new_edl_segments, fps, offset_frames, st.session_state.transcript) | |
| st.session_state.final_output = final_output | |
| st.session_state.ext = ext | |
| st.session_state.transcript_txt = transcript_txt | |
| st.session_state.used_model = used_model | |
| st.session_state.used_endpoint = used_endpoint | |
| st.session_state.edl_segments = new_edl_segments | |
| # Update safe filename for the download buttons to show _V2, _V3, etc. | |
| st.session_state.safe_filename = f"{st.session_state.base_filename}_V{st.session_state.revision_count}" | |
| st.rerun() | |