NickVerri commited on
Commit
0ea9bf1
·
verified ·
1 Parent(s): 20e9ceb

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +128 -68
app.py CHANGED
@@ -8,52 +8,37 @@ import subprocess
8
  import json
9
  import requests
10
  import gc
11
- import pandas as pd
12
  from datetime import timedelta
13
 
14
  # --- CRITICAL ENVIRONMENT FIXES ---
15
- # 1. Fix for Hugging Face millicore OMP_NUM_THREADS error
16
  if os.environ.get("OMP_NUM_THREADS", "").endswith("m"):
17
  os.environ["OMP_NUM_THREADS"] = "1"
18
 
19
- # 2. Force Torchaudio Backend
20
  try:
21
  if "ffmpeg" in torchaudio.list_audio_backends():
22
  torchaudio.set_audio_backend("ffmpeg")
23
  except Exception:
24
  pass
25
 
26
- # 3. GLOBAL PYTORCH SECURITY BYPASS (One-Time Patch)
27
  if not hasattr(torch.load, "_is_patched"):
28
  print("DEBUG: Applying Monkeypatch to torch.load")
29
  _original_torch_load = torch.load
30
 
31
  def patched_torch_load(*args, **kwargs):
32
- # FORCE Disable security check
33
  kwargs['weights_only'] = False
34
  return _original_torch_load(*args, **kwargs)
35
 
36
- # Mark as patched to prevent recursion loop on Streamlit reruns
37
  patched_torch_load._is_patched = True
38
  torch.load = patched_torch_load
39
- else:
40
- print("DEBUG: torch.load is already patched. Skipping.")
41
 
42
- # 4. EXPLICIT SAFE GLOBALS WHITELIST
43
  try:
44
- safe_list = [
45
- typing.Any,
46
- torch.nn.modules.container.ModuleList,
47
- np.dtype,
48
- ]
49
-
50
- # NumPy internals
51
  if hasattr(np, '_core') and hasattr(np._core, 'multiarray'):
52
  safe_list.append(np._core.multiarray.scalar)
53
  elif hasattr(np, 'core') and hasattr(np.core, 'multiarray'):
54
  safe_list.append(np.core.multiarray.scalar)
55
-
56
- # OmegaConf
57
  try:
58
  from omegaconf.listconfig import ListConfig
59
  from omegaconf.dictconfig import DictConfig
@@ -62,7 +47,6 @@ try:
62
  except ImportError:
63
  pass
64
 
65
- # Pyannote internals
66
  try:
67
  from pyannote.audio.core.task import Specifications, Problem, Resolution
68
  from pyannote.audio.core.model import Model
@@ -75,13 +59,12 @@ try:
75
  except Exception as e:
76
  print(f"Safe Globals Warning: {e}")
77
 
78
- # Fix NumPy 2.0+ attribute removal
79
  if not hasattr(np, 'NaN'):
80
  np.NaN = np.nan
81
 
82
  import whisperx
83
 
84
- # --- Configuration & Tokens ---
85
  HARDCODED_HF_TOKEN = "PASTE_YOUR_HF_TOKEN_HERE"
86
  HARDCODED_GEMINI_KEY = ""
87
 
@@ -91,8 +74,10 @@ ENV_GEMINI_KEY = os.environ.get("GEMINI_API_KEY", "")
91
  ACTIVE_HF_TOKEN = ENV_HF_TOKEN if ENV_HF_TOKEN else HARDCODED_HF_TOKEN
92
  ACTIVE_GEMINI_KEY = ENV_GEMINI_KEY if ENV_GEMINI_KEY else HARDCODED_GEMINI_KEY
93
 
 
 
94
  def format_timecode(seconds, fps=25):
95
- """Converts seconds to HH:MM:SS:FF."""
96
  td = timedelta(seconds=seconds)
97
  total_seconds = int(td.total_seconds())
98
  hours = total_seconds // 3600
@@ -101,12 +86,14 @@ def format_timecode(seconds, fps=25):
101
  frames = int((seconds - total_seconds) * fps)
102
  return f"{hours:02}:{minutes:02}:{secs:02}:{frames:02}"
103
 
 
 
 
 
104
  def generate_cmx_edl(edl_title, segments, source_name, fps=25):
105
  """Constructs a CMX 3600 formatted EDL."""
106
  edl_lines = [f"TITLE: {edl_title}", "FCM: NON-DROP FRAME\n"]
107
  rec_start = 0.0
108
-
109
- # Sanitize source name for the Reel ID column
110
  reel_id = source_name.replace(" ", "_")
111
 
112
  for i, seg in enumerate(segments, 1):
@@ -120,13 +107,88 @@ def generate_cmx_edl(edl_title, segments, source_name, fps=25):
120
  edl_lines.append(f"* FROM CLIP NAME: {source_name}")
121
  edl_lines.append(f"* {seg.get('note', 'Clip')}\n")
122
 
123
- # --- NEW LOGIC: Support Gaps ---
124
- # Add the duration of this clip AND any requested gap to the record timeline
125
  gap = seg.get('gap', 0.0)
126
  rec_start += duration + gap
127
 
128
  return "\n".join(edl_lines)
129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  def call_gemini_for_edl(transcript_data, story_prompt, api_key):
131
  if not api_key:
132
  st.error("Gemini API Key is missing.")
@@ -134,7 +196,6 @@ def call_gemini_for_edl(transcript_data, story_prompt, api_key):
134
 
135
  url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key={api_key}"
136
 
137
- # --- UPDATED SYSTEM PROMPT ---
138
  system_prompt = (
139
  "You are an expert Documentary Senior Editor. Use the provided transcript JSON "
140
  "(which includes Speaker IDs and word-level timestamps) to create a condensed story. "
@@ -144,9 +205,8 @@ def call_gemini_for_edl(transcript_data, story_prompt, api_key):
144
  "2. REMOVE FLUFF: Delete 'um', 'ah', repeats, and irrelevant filler.\n"
145
  "3. NARRATIVE FLOW: Focus on the subject's high-energy responses and narrative hooks.\n"
146
  "4. TIMESTAMP INTEGRITY: Use only the exact word-level start and end times from the data.\n"
147
- "5. PACING: Group related clips together so they flow seamlessly. HOWEVER, between distinct ideas or sections, "
148
- "add a 'gap': 1.0 property (float, in seconds) to the segment preceding the break. "
149
- "This will leave blank space on the timeline to signal a topic change."
150
  )
151
 
152
  prompt_text = f"Creative Brief: {story_prompt}\n\nTranscript Data:\n{json.dumps(transcript_data)}"
@@ -172,10 +232,11 @@ st.title("Junior Editor")
172
 
173
  st.markdown("""
174
  **Instructions**
175
- * Upload your file here, either an video file or audio.
176
- * Set your timeline FPS, choose the quality of your transcription and ,if you know the language, set this to speed up the transcription phase.
177
- * Junior Editor will transcribe it and separate by speakers and then await your instruction. You can ask it to find the most engaging bits and put them together from “Speaker 1”, remove all of Speaker 2, or construct a narrative around whatever idea you choose.
178
- * It will create an EDL to import back into your editing software. If you want it to reference a Master Clip rather than the proxy, type this in the side bar at the start.
 
179
  """)
180
 
181
  st.divider()
@@ -184,11 +245,21 @@ with st.sidebar:
184
  st.header("Project Settings")
185
  fps = st.number_input("Timeline FPS", value=25)
186
 
 
 
 
 
 
 
 
 
 
 
187
  st.info("���� **Conform Helper**")
188
  custom_reel_name = st.text_input(
189
- "EDL Reel Name",
190
- placeholder="Paste Raw File Name Here...",
191
- help="Leave empty to use the uploaded file name. Use this to link proxies to original camera files."
192
  )
193
 
194
  st.header("Model Settings")
@@ -217,7 +288,7 @@ with st.sidebar:
217
  uploaded_file = st.file_uploader("Upload Video/Audio Clip", type=["mp4", "m4a", "wav", "mp3", "mov"])
218
 
219
  if uploaded_file:
220
- # --- Auto-Reset Logic for New Files ---
221
  if "last_processed_file" not in st.session_state or st.session_state.last_processed_file != uploaded_file.name:
222
  if "transcript" in st.session_state:
223
  del st.session_state.transcript
@@ -235,54 +306,41 @@ if uploaded_file:
235
  progress_bar = st.progress(0)
236
 
237
  try:
238
- # Phase 1: Save File
239
  status_text.markdown("**Phase 1/4: Extracting Audio...**")
240
  with open("temp_input", "wb") as f:
241
  f.write(uploaded_file.getbuffer())
242
 
243
- subprocess.run([
244
- "ffmpeg", "-i", "temp_input",
245
- "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1",
246
- "temp_audio.wav", "-y"
247
- ])
248
  progress_bar.progress(25)
249
 
250
  device = "cuda" if torch.cuda.is_available() else "cpu"
251
- if device == "cpu":
252
- st.warning("⚠️ No GPU detected. This will be slow.")
253
 
254
- # Phase 2: Transcribe
255
  status_text.markdown(f"**Phase 2/4: Transcribing (Whisper {model_size})... This is the longest step.**")
256
-
257
  compute_type = "float16" if device == "cuda" else "int8"
258
  model = whisperx.load_model(model_size, device, compute_type=compute_type)
259
  audio = whisperx.load_audio("temp_audio.wav")
260
-
261
  result = model.transcribe(audio, batch_size=16, language=target_language)
262
-
263
  del model
264
  gc.collect()
265
  torch.cuda.empty_cache()
266
  progress_bar.progress(50)
267
 
268
- # Phase 3: Align
269
  status_text.markdown("**Phase 3/4: Aligning Text...**")
270
  model_a, metadata = whisperx.load_align_model(language_code=result["language"], device=device)
271
  result = whisperx.align(result["segments"], model_a, metadata, audio, device, return_char_alignments=False)
272
-
273
  del model_a
274
  gc.collect()
275
  torch.cuda.empty_cache()
276
  progress_bar.progress(75)
277
 
278
- # Phase 4: Diarize
279
  status_text.markdown("**Phase 4/4: Identifying Speakers...**")
280
  diarize_model = whisperx.DiarizationPipeline(use_auth_token=ACTIVE_HF_TOKEN, device=device)
281
-
282
- diarize_kwargs = {}
283
- if num_speakers > 0:
284
- diarize_kwargs = {"min_speakers": num_speakers, "max_speakers": num_speakers}
285
-
286
  diarize_segments = diarize_model(audio, **diarize_kwargs)
287
 
288
  # Final Merge
@@ -299,18 +357,14 @@ if uploaded_file:
299
  })
300
 
301
  st.session_state.transcript = processed_segments
302
-
303
- # --- CLEANUP ON SUCCESS ---
304
  if os.path.exists("temp_input"): os.remove("temp_input")
305
  if os.path.exists("temp_audio.wav"): os.remove("temp_audio.wav")
306
-
307
  progress_bar.progress(100)
308
  status_text.success(f"Done! Processed {len(processed_segments)} segments.")
309
 
310
  except Exception as e:
311
- status_text.error(f"Error during processing: {e}")
312
  if os.path.exists("temp_input"): os.remove("temp_input")
313
- if os.path.exists("temp_audio.wav"): os.remove("temp_audio.wav")
314
  st.stop()
315
 
316
  if "transcript" in st.session_state:
@@ -320,18 +374,24 @@ if uploaded_file:
320
  st.markdown(f"**{seg['speaker']}:** {seg['text']}")
321
 
322
  st.subheader("Your Instruction")
323
- brief = st.text_area("What should the Junior Editor do?", placeholder="e.g. Find the most engaging bits and put them together from Speaker 1, or remove all of Speaker 2.")
324
 
325
  if st.button("Generate Edit"):
326
  if not ACTIVE_GEMINI_KEY:
327
- st.error("Gemini API Key required in Secrets.")
328
  else:
329
  with st.spinner("Junior Editor is thinking..."):
330
  final_source_name = custom_reel_name.strip() if custom_reel_name.strip() else uploaded_file.name
331
 
332
  edl_segments = call_gemini_for_edl(st.session_state.transcript, brief, ACTIVE_GEMINI_KEY)
333
  if edl_segments:
334
- final_edl = generate_cmx_edl("Junior_Editor_Cut", edl_segments, final_source_name, fps)
 
 
 
 
 
 
335
  st.subheader("Ready for Import")
336
- st.code(final_edl, language="text")
337
- st.download_button("Download .EDL", data=final_edl, file_name="junior_editor_cut.edl")
 
8
  import json
9
  import requests
10
  import gc
11
+ import math
12
  from datetime import timedelta
13
 
14
  # --- CRITICAL ENVIRONMENT FIXES ---
 
15
  if os.environ.get("OMP_NUM_THREADS", "").endswith("m"):
16
  os.environ["OMP_NUM_THREADS"] = "1"
17
 
 
18
  try:
19
  if "ffmpeg" in torchaudio.list_audio_backends():
20
  torchaudio.set_audio_backend("ffmpeg")
21
  except Exception:
22
  pass
23
 
 
24
  if not hasattr(torch.load, "_is_patched"):
25
  print("DEBUG: Applying Monkeypatch to torch.load")
26
  _original_torch_load = torch.load
27
 
28
  def patched_torch_load(*args, **kwargs):
 
29
  kwargs['weights_only'] = False
30
  return _original_torch_load(*args, **kwargs)
31
 
 
32
  patched_torch_load._is_patched = True
33
  torch.load = patched_torch_load
 
 
34
 
 
35
  try:
36
+ safe_list = [typing.Any, torch.nn.modules.container.ModuleList, np.dtype]
 
 
 
 
 
 
37
  if hasattr(np, '_core') and hasattr(np._core, 'multiarray'):
38
  safe_list.append(np._core.multiarray.scalar)
39
  elif hasattr(np, 'core') and hasattr(np.core, 'multiarray'):
40
  safe_list.append(np.core.multiarray.scalar)
41
+
 
42
  try:
43
  from omegaconf.listconfig import ListConfig
44
  from omegaconf.dictconfig import DictConfig
 
47
  except ImportError:
48
  pass
49
 
 
50
  try:
51
  from pyannote.audio.core.task import Specifications, Problem, Resolution
52
  from pyannote.audio.core.model import Model
 
59
  except Exception as e:
60
  print(f"Safe Globals Warning: {e}")
61
 
 
62
  if not hasattr(np, 'NaN'):
63
  np.NaN = np.nan
64
 
65
  import whisperx
66
 
67
+ # --- Configuration ---
68
  HARDCODED_HF_TOKEN = "PASTE_YOUR_HF_TOKEN_HERE"
69
  HARDCODED_GEMINI_KEY = ""
70
 
 
74
  ACTIVE_HF_TOKEN = ENV_HF_TOKEN if ENV_HF_TOKEN else HARDCODED_HF_TOKEN
75
  ACTIVE_GEMINI_KEY = ENV_GEMINI_KEY if ENV_GEMINI_KEY else HARDCODED_GEMINI_KEY
76
 
77
+ # --- HELPER FUNCTIONS ---
78
+
79
  def format_timecode(seconds, fps=25):
80
+ """Converts seconds to HH:MM:SS:FF (for EDL)."""
81
  td = timedelta(seconds=seconds)
82
  total_seconds = int(td.total_seconds())
83
  hours = total_seconds // 3600
 
86
  frames = int((seconds - total_seconds) * fps)
87
  return f"{hours:02}:{minutes:02}:{secs:02}:{frames:02}"
88
 
89
+ def seconds_to_frames(seconds, fps):
90
+ """Converts seconds to integer frames (for XML)."""
91
+ return int(round(seconds * fps))
92
+
93
  def generate_cmx_edl(edl_title, segments, source_name, fps=25):
94
  """Constructs a CMX 3600 formatted EDL."""
95
  edl_lines = [f"TITLE: {edl_title}", "FCM: NON-DROP FRAME\n"]
96
  rec_start = 0.0
 
 
97
  reel_id = source_name.replace(" ", "_")
98
 
99
  for i, seg in enumerate(segments, 1):
 
107
  edl_lines.append(f"* FROM CLIP NAME: {source_name}")
108
  edl_lines.append(f"* {seg.get('note', 'Clip')}\n")
109
 
 
 
110
  gap = seg.get('gap', 0.0)
111
  rec_start += duration + gap
112
 
113
  return "\n".join(edl_lines)
114
 
115
+ def generate_xml(sequence_name, segments, source_name, fps=25):
116
+ """Constructs a Final Cut Pro 7 XML (compatible with DaVinci Resolve)."""
117
+
118
+ # XML Header
119
+ xml_output = [
120
+ '<?xml version="1.0" encoding="UTF-8"?>',
121
+ '<!DOCTYPE xmeml>',
122
+ '<xmeml version="4">',
123
+ '<sequence>',
124
+ f'\t<name>{sequence_name}</name>',
125
+ '\t<rate>',
126
+ f'\t\t<timebase>{fps}</timebase>',
127
+ '\t</rate>',
128
+ '\t<media>',
129
+ '\t\t<video>',
130
+ '\t\t\t<format>',
131
+ '\t\t\t\t<samplecharacteristics>',
132
+ f'\t\t\t\t\t<rate><timebase>{fps}</timebase></rate>',
133
+ '\t\t\t\t\t<width>1920</width>',
134
+ '\t\t\t\t\t<height>1080</height>',
135
+ '\t\t\t\t\t<pixelaspectratio>square</pixelaspectratio>',
136
+ '\t\t\t\t</samplecharacteristics>',
137
+ '\t\t\t</format>',
138
+ '\t\t\t<track>'
139
+ ]
140
+
141
+ # Timeline Tracker
142
+ timeline_head_frames = 0
143
+
144
+ for i, seg in enumerate(segments, 1):
145
+ # Calculate Frame Data
146
+ src_in_frames = seconds_to_frames(seg['src_start'], fps)
147
+ src_out_frames = seconds_to_frames(seg['src_end'], fps)
148
+ duration_frames = src_out_frames - src_in_frames
149
+
150
+ # Timeline positions
151
+ tl_start = timeline_head_frames
152
+ tl_end = tl_start + duration_frames
153
+
154
+ clip_note = seg.get('note', 'Junior Editor Selection')
155
+
156
+ # XML Clip Item
157
+ xml_output.append(f'\t\t\t\t<clipitem id="clipitem-{i}">')
158
+ xml_output.append(f'\t\t\t\t\t<name>{clip_note}</name>')
159
+ xml_output.append(f'\t\t\t\t\t<duration>{duration_frames}</duration>')
160
+ xml_output.append(f'\t\t\t\t\t<rate><timebase>{fps}</timebase></rate>')
161
+ xml_output.append(f'\t\t\t\t\t<start>{tl_start}</start>')
162
+ xml_output.append(f'\t\t\t\t\t<end>{tl_end}</end>')
163
+ xml_output.append(f'\t\t\t\t\t<in>{src_in_frames}</in>')
164
+ xml_output.append(f'\t\t\t\t\t<out>{src_out_frames}</out>')
165
+
166
+ # File Reference (The Multicam Link)
167
+ xml_output.append(f'\t\t\t\t\t<file id="multicam_file">')
168
+ xml_output.append(f'\t\t\t\t\t\t<name>{source_name}</name>')
169
+ xml_output.append(f'\t\t\t\t\t\t<pathurl>file://localhost/placeholder/{source_name}</pathurl>')
170
+ xml_output.append(f'\t\t\t\t\t\t<rate><timebase>{fps}</timebase></rate>')
171
+ xml_output.append(f'\t\t\t\t\t\t<timecode><string>00:00:00:00</string></timecode>')
172
+ xml_output.append(f'\t\t\t\t\t</file>')
173
+
174
+ xml_output.append(f'\t\t\t\t</clipitem>')
175
+
176
+ # Handle Gaps
177
+ gap_seconds = seg.get('gap', 0.0)
178
+ gap_frames = seconds_to_frames(gap_seconds, fps)
179
+ timeline_head_frames = tl_end + gap_frames
180
+
181
+ # XML Footer
182
+ xml_output.extend([
183
+ '\t\t\t</track>',
184
+ '\t\t</video>',
185
+ '\t</media>',
186
+ '</sequence>',
187
+ '</xmeml>'
188
+ ])
189
+
190
+ return "\n".join(xml_output)
191
+
192
  def call_gemini_for_edl(transcript_data, story_prompt, api_key):
193
  if not api_key:
194
  st.error("Gemini API Key is missing.")
 
196
 
197
  url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key={api_key}"
198
 
 
199
  system_prompt = (
200
  "You are an expert Documentary Senior Editor. Use the provided transcript JSON "
201
  "(which includes Speaker IDs and word-level timestamps) to create a condensed story. "
 
205
  "2. REMOVE FLUFF: Delete 'um', 'ah', repeats, and irrelevant filler.\n"
206
  "3. NARRATIVE FLOW: Focus on the subject's high-energy responses and narrative hooks.\n"
207
  "4. TIMESTAMP INTEGRITY: Use only the exact word-level start and end times from the data.\n"
208
+ "5. PACING: Group related clips together. Between distinct ideas, add a 'gap': 1.0 (float seconds) "
209
+ "to the segment preceding the break."
 
210
  )
211
 
212
  prompt_text = f"Creative Brief: {story_prompt}\n\nTranscript Data:\n{json.dumps(transcript_data)}"
 
232
 
233
  st.markdown("""
234
  **Instructions**
235
+ * **For Multicam Workflows:** Use the **XML** option in the sidebar and enter the **exact name** of your Multicam Sequence.
236
+ * Upload your file here (video or audio).
237
+ * Set your timeline FPS and transcription quality.
238
+ * Junior Editor will transcribe and separate speakers. You can then instruct it to find engaging bits or construct a narrative.
239
+ * It will create an EDL or XML to import back into your editing software (Resolve, Premiere).
240
  """)
241
 
242
  st.divider()
 
245
  st.header("Project Settings")
246
  fps = st.number_input("Timeline FPS", value=25)
247
 
248
+ st.header("Export Settings")
249
+ export_format = st.radio("Output Format", ["EDL", "XML (Multicam)"], index=0)
250
+
251
+ input_label = "EDL Reel Name"
252
+ input_help = "Leave empty to use the uploaded file name."
253
+
254
+ if export_format == "XML (Multicam)":
255
+ input_label = "Multicam Sequence Name"
256
+ input_help = "EXACT name of your Multicam Clip in Resolve."
257
+
258
  st.info("���� **Conform Helper**")
259
  custom_reel_name = st.text_input(
260
+ input_label,
261
+ placeholder="e.g. Interview_Day1_Multi",
262
+ help=input_help
263
  )
264
 
265
  st.header("Model Settings")
 
288
  uploaded_file = st.file_uploader("Upload Video/Audio Clip", type=["mp4", "m4a", "wav", "mp3", "mov"])
289
 
290
  if uploaded_file:
291
+ # --- Auto-Reset Logic ---
292
  if "last_processed_file" not in st.session_state or st.session_state.last_processed_file != uploaded_file.name:
293
  if "transcript" in st.session_state:
294
  del st.session_state.transcript
 
306
  progress_bar = st.progress(0)
307
 
308
  try:
309
+ # Phase 1
310
  status_text.markdown("**Phase 1/4: Extracting Audio...**")
311
  with open("temp_input", "wb") as f:
312
  f.write(uploaded_file.getbuffer())
313
 
314
+ subprocess.run(["ffmpeg", "-i", "temp_input", "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", "temp_audio.wav", "-y"])
 
 
 
 
315
  progress_bar.progress(25)
316
 
317
  device = "cuda" if torch.cuda.is_available() else "cpu"
318
+ if device == "cpu": st.warning("⚠️ No GPU detected.")
 
319
 
320
+ # Phase 2
321
  status_text.markdown(f"**Phase 2/4: Transcribing (Whisper {model_size})... This is the longest step.**")
 
322
  compute_type = "float16" if device == "cuda" else "int8"
323
  model = whisperx.load_model(model_size, device, compute_type=compute_type)
324
  audio = whisperx.load_audio("temp_audio.wav")
 
325
  result = model.transcribe(audio, batch_size=16, language=target_language)
 
326
  del model
327
  gc.collect()
328
  torch.cuda.empty_cache()
329
  progress_bar.progress(50)
330
 
331
+ # Phase 3
332
  status_text.markdown("**Phase 3/4: Aligning Text...**")
333
  model_a, metadata = whisperx.load_align_model(language_code=result["language"], device=device)
334
  result = whisperx.align(result["segments"], model_a, metadata, audio, device, return_char_alignments=False)
 
335
  del model_a
336
  gc.collect()
337
  torch.cuda.empty_cache()
338
  progress_bar.progress(75)
339
 
340
+ # Phase 4
341
  status_text.markdown("**Phase 4/4: Identifying Speakers...**")
342
  diarize_model = whisperx.DiarizationPipeline(use_auth_token=ACTIVE_HF_TOKEN, device=device)
343
+ diarize_kwargs = {"min_speakers": num_speakers, "max_speakers": num_speakers} if num_speakers > 0 else {}
 
 
 
 
344
  diarize_segments = diarize_model(audio, **diarize_kwargs)
345
 
346
  # Final Merge
 
357
  })
358
 
359
  st.session_state.transcript = processed_segments
 
 
360
  if os.path.exists("temp_input"): os.remove("temp_input")
361
  if os.path.exists("temp_audio.wav"): os.remove("temp_audio.wav")
 
362
  progress_bar.progress(100)
363
  status_text.success(f"Done! Processed {len(processed_segments)} segments.")
364
 
365
  except Exception as e:
366
+ status_text.error(f"Error: {e}")
367
  if os.path.exists("temp_input"): os.remove("temp_input")
 
368
  st.stop()
369
 
370
  if "transcript" in st.session_state:
 
374
  st.markdown(f"**{seg['speaker']}:** {seg['text']}")
375
 
376
  st.subheader("Your Instruction")
377
+ brief = st.text_area("What should the Junior Editor do?", placeholder="e.g. Find the most engaging bits and put them together from Speaker 1.")
378
 
379
  if st.button("Generate Edit"):
380
  if not ACTIVE_GEMINI_KEY:
381
+ st.error("Gemini API Key required.")
382
  else:
383
  with st.spinner("Junior Editor is thinking..."):
384
  final_source_name = custom_reel_name.strip() if custom_reel_name.strip() else uploaded_file.name
385
 
386
  edl_segments = call_gemini_for_edl(st.session_state.transcript, brief, ACTIVE_GEMINI_KEY)
387
  if edl_segments:
388
+ if export_format == "EDL":
389
+ final_output = generate_cmx_edl("Junior_Editor_Cut", edl_segments, final_source_name, fps)
390
+ ext = "edl"
391
+ else:
392
+ final_output = generate_xml("Junior_Editor_Cut", edl_segments, final_source_name, fps)
393
+ ext = "xml"
394
+
395
  st.subheader("Ready for Import")
396
+ st.code(final_output, language="xml" if ext == "xml" else "text")
397
+ st.download_button(f"Download .{ext.upper()}", data=final_output, file_name=f"junior_editor_cut.{ext}")