NickVerri commited on
Commit
d6173f9
·
verified ·
1 Parent(s): f495376

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +266 -432
app.py CHANGED
@@ -8,12 +8,8 @@ import subprocess
8
  import json
9
  import requests
10
  import gc
11
- import math
12
- import uuid
13
  import re
14
- import time
15
  from urllib.parse import quote
16
- from datetime import timedelta
17
 
18
  # --- CRITICAL ENVIRONMENT FIXES ---
19
  if os.environ.get("OMP_NUM_THREADS", "").endswith("m"):
@@ -80,7 +76,6 @@ ACTIVE_GEMINI_KEY = ENV_GEMINI_KEY if ENV_GEMINI_KEY else HARDCODED_GEMINI_KEY
80
  # --- HELPER FUNCTIONS ---
81
 
82
  def clean_json_response(text):
83
- """Strips markdown code fences from Gemini output to prevent JSON errors."""
84
  try:
85
  pattern = r"`{3}(?:json)?\s*(.*?)`{3}"
86
  match = re.search(pattern, text, re.DOTALL)
@@ -91,28 +86,18 @@ def clean_json_response(text):
91
  return text
92
 
93
  def escape_xml(text):
94
- """Safely escapes special characters for XML to prevent DOM parser errors."""
95
- if not text:
96
- return ""
97
  text = str(text)
98
- text = text.replace("&", "&")
99
- text = text.replace("<", "&lt;")
100
- text = text.replace(">", "&gt;")
101
- text = text.replace('"', "&quot;")
102
- text = text.replace("'", "&apos;")
103
- return text
104
 
105
  def timecode_to_frames(tc, fps):
106
- """Converts HH:MM:SS:FF to absolute integer frames based on timebase."""
107
- if not tc or not re.match(r"\d{2}:\d{2}:\d{2}[:\.]\d{2}", tc):
108
- return 0
109
  parts = re.split(r'[:\.]', tc)
110
  h, m, s, f = map(int, parts)
111
- timebase = int(round(fps)) # 29.97 becomes 30, 23.98 becomes 24
112
  return (h * 3600 * timebase) + (m * 60 * timebase) + (s * timebase) + f
113
 
114
  def frames_to_timecode(frames, fps):
115
- """Converts absolute integer frames back to HH:MM:SS:FF (NDF)."""
116
  timebase = int(round(fps))
117
  hours = frames // (3600 * timebase)
118
  minutes = (frames // (60 * timebase)) % 60
@@ -121,14 +106,14 @@ def frames_to_timecode(frames, fps):
121
  return f"{hours:02}:{minutes:02}:{secs:02}:{f:02}"
122
 
123
  def seconds_to_frames(seconds, fps):
124
- """Converts real seconds to integer frames."""
125
  return int(round(seconds * fps))
126
 
127
- def generate_cmx_edl(edl_title, segments, source_name, fps=25, start_offset_frames=0):
128
- """Constructs a CMX 3600 formatted EDL with source offset."""
 
 
129
  edl_lines = [f"TITLE: {edl_title}", "FCM: NON-DROP FRAME\n"]
130
  rec_start_frames = 0
131
- reel_id = source_name.replace(" ", "_")[:8] # EDL reel IDs are traditionally short max 8 chars
132
 
133
  for i, seg in enumerate(segments, 1):
134
  seg_type = seg.get('type', 'clip')
@@ -144,18 +129,24 @@ def generate_cmx_edl(edl_title, segments, source_name, fps=25, start_offset_fram
144
  src_out_frames = duration_frames
145
  current_source = "GEN_VO" if is_vo else "GEN_GFX"
146
  note_text = f"{'VO SCRIPT' if is_vo else 'GRAPHIC'}: {text_content}"
 
147
  else:
 
 
 
 
148
  src_start_val = float(seg.get('src_start', seg.get('start', 0.0)))
149
  src_end_val = float(seg.get('src_end', seg.get('end', 0.0)))
150
  src_in_frames = seconds_to_frames(src_start_val, fps) + start_offset_frames
151
  src_out_frames = seconds_to_frames(src_end_val, fps) + start_offset_frames
152
  duration_frames = src_out_frames - src_in_frames
153
- current_source = reel_id
 
154
  note_text = seg.get('note', 'Clip')
 
155
 
156
  src_in = frames_to_timecode(src_in_frames, fps)
157
  src_out = frames_to_timecode(src_out_frames, fps)
158
-
159
  rec_in = frames_to_timecode(rec_start_frames, fps)
160
  rec_out = frames_to_timecode(rec_start_frames + duration_frames, fps)
161
 
@@ -169,16 +160,10 @@ def generate_cmx_edl(edl_title, segments, source_name, fps=25, start_offset_fram
169
 
170
  return "\n".join(edl_lines)
171
 
172
- def generate_xml(sequence_name, segments, source_name, fps=25, start_offset_frames=0):
173
- """Constructs a Robust XML with source timecode offset for Premiere AND Resolve."""
174
- master_id = "masterfile-1"
175
  timebase = int(round(fps))
176
  is_ntsc = "TRUE" if fps % 1 != 0 else "FALSE"
177
-
178
- start_tc_string = frames_to_timecode(start_offset_frames, fps)
179
-
180
- encoded_name = quote(source_name)
181
- path_url = f"file://localhost/{encoded_name}"
182
 
183
  lines = []
184
  lines.append('<?xml version="1.0" encoding="UTF-8"?>')
@@ -205,9 +190,7 @@ def generate_xml(sequence_name, segments, source_name, fps=25, start_offset_fram
205
  lines.append('\t\t\t</format>')
206
  lines.append('\t\t\t<track>')
207
 
208
- file_defined = False
209
- vo_file_defined = False
210
- gfx_file_defined = False
211
  timeline_head_frames = 0
212
 
213
  for i, seg in enumerate(segments, 1):
@@ -222,25 +205,35 @@ def generate_xml(sequence_name, segments, source_name, fps=25, start_offset_fram
222
  duration_frames = seconds_to_frames(duration_sec, fps)
223
  src_in_frames = 0
224
  src_out_frames = duration_frames
225
- master_id_to_use = "masterfile-vo" if is_vo else "masterfile-gfx"
226
- source_name_to_use = "Generated_VO_Placeholder" if is_vo else "Generated_Graphic_Placeholder"
 
 
 
 
 
227
  prefix = "VO SCRIPT" if is_vo else "GRAPHIC CARD"
228
  clip_note = f"{prefix}: {text_content}"
229
  clip_name_disp = (clip_note[:75] + '..') if len(clip_note) > 75 else clip_note
230
  else:
 
 
 
 
 
231
  src_start_val = float(seg.get('src_start', seg.get('start', 0.0)))
232
  src_end_val = float(seg.get('src_end', seg.get('end', 0.0)))
233
-
234
- # FIX: Removed the '+ start_offset_frames' from the video track so it matches the audio track!
235
  src_in_frames = seconds_to_frames(src_start_val, fps)
236
  src_out_frames = seconds_to_frames(src_end_val, fps)
237
 
238
  duration_frames = src_out_frames - src_in_frames
239
- master_id_to_use = master_id
240
- source_name_to_use = source_name
241
- raw_note = seg.get('note', 'Junior Editor Selection')
242
- clip_note = raw_note
243
- clip_name_disp = source_name
 
 
244
 
245
  tl_start = timeline_head_frames
246
  tl_end = tl_start + duration_frames
@@ -255,83 +248,37 @@ def generate_xml(sequence_name, segments, source_name, fps=25, start_offset_fram
255
  lines.append(f'\t\t\t\t\t<out>{src_out_frames}</out>')
256
  lines.append(f'\t\t\t\t\t<masterclipid>{master_id_to_use}</masterclipid>')
257
 
258
- # --- ROBUST FILE DEFINITIONS ---
259
- if is_vo:
260
- if not vo_file_defined:
261
- lines.append(f'\t\t\t\t\t<file id="{master_id_to_use}">')
262
- lines.append(f'\t\t\t\t\t\t<name>{escape_xml(source_name_to_use)}</name>')
263
- lines.append(f'\t\t\t\t\t\t<pathurl>file://localhost/{escape_xml(source_name_to_use)}.wav</pathurl>')
264
- lines.append(f'\t\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>')
265
- lines.append(f'\t\t\t\t\t\t<duration>8640000</duration>')
266
- lines.append(f'\t\t\t\t\t\t<timecode>')
267
- lines.append(f'\t\t\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>')
268
- lines.append(f'\t\t\t\t\t\t\t<string>00:00:00:00</string>')
269
- lines.append(f'\t\t\t\t\t\t\t<frame>0</frame>')
270
- lines.append(f'\t\t\t\t\t\t\t<displayformat>NDF</displayformat>')
271
- lines.append(f'\t\t\t\t\t\t</timecode>')
272
- lines.append('\t\t\t\t\t\t<media>')
273
- lines.append('\t\t\t\t\t\t\t<video><samplecharacteristics><width>1920</width><height>1080</height></samplecharacteristics></video>')
274
  lines.append('\t\t\t\t\t\t\t<audio><samplecharacteristics><depth>16</depth><samplerate>48000</samplerate></samplecharacteristics><channelcount>2</channelcount></audio>')
275
- lines.append('\t\t\t\t\t\t</media>')
276
- lines.append('\t\t\t\t\t</file>')
277
- vo_file_defined = True
278
- else:
279
- lines.append(f'\t\t\t\t\t<file id="{master_id_to_use}"/>')
280
- elif is_graphic:
281
- if not gfx_file_defined:
282
- lines.append(f'\t\t\t\t\t<file id="{master_id_to_use}">')
283
- lines.append(f'\t\t\t\t\t\t<name>{escape_xml(source_name_to_use)}</name>')
284
- lines.append(f'\t\t\t\t\t\t<pathurl>file://localhost/{escape_xml(source_name_to_use)}.mov</pathurl>')
285
- lines.append(f'\t\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>')
286
- lines.append(f'\t\t\t\t\t\t<duration>8640000</duration>')
287
- lines.append(f'\t\t\t\t\t\t<timecode>')
288
- lines.append(f'\t\t\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>')
289
- lines.append(f'\t\t\t\t\t\t\t<string>00:00:00:00</string>')
290
- lines.append(f'\t\t\t\t\t\t\t<frame>0</frame>')
291
- lines.append(f'\t\t\t\t\t\t\t<displayformat>NDF</displayformat>')
292
- lines.append(f'\t\t\t\t\t\t</timecode>')
293
- lines.append('\t\t\t\t\t\t<media>')
294
- lines.append('\t\t\t\t\t\t\t<video><samplecharacteristics><width>1920</width><height>1080</height></samplecharacteristics></video>')
295
- # Intentionally omitting audio block for graphics placeholder to keep NLE track structure clean
296
- lines.append('\t\t\t\t\t\t</media>')
297
- lines.append('\t\t\t\t\t</file>')
298
- gfx_file_defined = True
299
- else:
300
- lines.append(f'\t\t\t\t\t<file id="{master_id_to_use}"/>')
301
  else:
302
- if not file_defined:
303
- lines.append(f'\t\t\t\t\t<file id="{master_id_to_use}">')
304
- lines.append(f'\t\t\t\t\t\t<name>{escape_xml(source_name_to_use)}</name>')
305
- lines.append(f'\t\t\t\t\t\t<pathurl>{path_url}</pathurl>')
306
- lines.append(f'\t\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>')
307
- lines.append(f'\t\t\t\t\t\t<duration>8640000</duration>')
308
- lines.append(f'\t\t\t\t\t\t<timecode>')
309
- lines.append(f'\t\t\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>')
310
- lines.append(f'\t\t\t\t\t\t\t<string>{start_tc_string}</string>')
311
- lines.append(f'\t\t\t\t\t\t\t<frame>{start_offset_frames}</frame>')
312
- lines.append(f'\t\t\t\t\t\t\t<displayformat>NDF</displayformat>')
313
- lines.append(f'\t\t\t\t\t\t</timecode>')
314
- lines.append('\t\t\t\t\t\t<media>')
315
- lines.append('\t\t\t\t\t\t\t<video><samplecharacteristics><width>1920</width><height>1080</height></samplecharacteristics></video>')
316
- lines.append('\t\t\t\t\t\t\t<audio><samplecharacteristics><depth>16</depth><samplerate>48000</samplerate></samplecharacteristics><channelcount>2</channelcount></audio>')
317
- lines.append('\t\t\t\t\t\t</media>')
318
- lines.append('\t\t\t\t\t</file>')
319
- file_defined = True
320
- else:
321
- lines.append(f'\t\t\t\t\t<file id="{master_id_to_use}"/>')
322
 
323
  lines.append('\t\t\t\t\t<marker>')
324
  lines.append(f'\t\t\t\t\t\t<name>{escape_xml(clip_note)}</name>')
325
  lines.append(f'\t\t\t\t\t\t<in>{src_in_frames}</in>')
326
-
327
- # Extends the marker duration to span the entire clip for VO and Graphics
328
  if is_vo or is_graphic:
329
  lines.append(f'\t\t\t\t\t\t<out>{src_out_frames}</out>')
330
  else:
331
  lines.append(f'\t\t\t\t\t\t<out>{src_in_frames}</out>')
332
-
333
  lines.append('\t\t\t\t\t</marker>')
334
-
335
  lines.append('\t\t\t\t</clipitem>')
336
 
337
  gap_frames = seconds_to_frames(float(seg.get('gap', 0.0)), fps)
@@ -351,11 +298,8 @@ def generate_xml(sequence_name, segments, source_name, fps=25, start_offset_fram
351
  is_graphic = seg_type == 'graphic'
352
 
353
  if is_graphic:
354
- # Graphics have no audio. We just advance the playhead so the timeline stays perfectly in sync!
355
  duration_sec = float(seg.get('duration', 4.0))
356
- duration_frames = seconds_to_frames(duration_sec, fps)
357
- gap_frames = seconds_to_frames(float(seg.get('gap', 0.0)), fps)
358
- timeline_head_frames += duration_frames + gap_frames
359
  continue
360
 
361
  if is_vo:
@@ -364,22 +308,23 @@ def generate_xml(sequence_name, segments, source_name, fps=25, start_offset_fram
364
  duration_frames = seconds_to_frames(duration_sec, fps)
365
  src_in_frames = 0
366
  src_out_frames = duration_frames
367
- master_id_to_use = "masterfile-vo"
368
- source_name_to_use = "Generated_VO_Placeholder"
369
  else:
 
370
  src_start_val = float(seg.get('src_start', seg.get('start', 0.0)))
371
  src_end_val = float(seg.get('src_end', seg.get('end', 0.0)))
372
  src_in_frames = seconds_to_frames(src_start_val, fps)
373
  src_out_frames = seconds_to_frames(src_end_val, fps)
374
  duration_frames = src_out_frames - src_in_frames
375
- master_id_to_use = master_id
376
- source_name_to_use = source_name
 
377
 
378
  tl_start = timeline_head_frames
379
  tl_end = tl_start + duration_frames
380
 
381
  lines.append(f'\t\t\t\t<clipitem id="clipitem-a-{i}">')
382
- lines.append(f'\t\t\t\t\t<name>{escape_xml(source_name_to_use)}</name>')
383
  lines.append(f'\t\t\t\t\t<masterclipid>{master_id_to_use}</masterclipid>')
384
  lines.append(f'\t\t\t\t\t<duration>8640000</duration>')
385
  lines.append(f'\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>')
@@ -400,8 +345,7 @@ def generate_xml(sequence_name, segments, source_name, fps=25, start_offset_fram
400
 
401
  return "\n".join(lines)
402
 
403
- def generate_transcript_txt(sequence_name, segments, fps=25, start_offset_frames=0, transcript_data=None):
404
- """Generates a human-readable text document of the edit decisions and exact transcription."""
405
  lines = [f"Sequence Transcript: {sequence_name}", "=" * 50, ""]
406
 
407
  for i, seg in enumerate(segments, 1):
@@ -420,397 +364,287 @@ def generate_transcript_txt(sequence_name, segments, fps=25, start_offset_frames
420
  lines.append(f"Clip {i:02} | [GRAPHIC CARD] | Est. Duration: {duration:.1f}s")
421
  lines.append(f"Text: \"{gfx_text}\"")
422
  else:
 
 
 
423
  src_start_val = float(seg.get('src_start', seg.get('start', 0.0)))
424
  src_end_val = float(seg.get('src_end', seg.get('end', 0.0)))
425
- src_in_frames = seconds_to_frames(src_start_val, fps) + start_offset_frames
426
- src_out_frames = seconds_to_frames(src_end_val, fps) + start_offset_frames
427
 
428
  src_in = frames_to_timecode(src_in_frames, fps)
429
  src_out = frames_to_timecode(src_out_frames, fps)
430
  note = seg.get('note', 'No note provided.')
431
 
432
  actual_text = ""
433
- if transcript_data:
434
  clip_words = []
435
- for t_seg in transcript_data:
436
  if max(src_start_val, t_seg['start']) < min(src_end_val, t_seg['end']):
437
  clip_words.append(t_seg['text'])
438
  actual_text = " ".join(clip_words).strip()
439
 
440
- lines.append(f"Clip {i:02} | IN: {src_in} --> OUT: {src_out}")
441
  lines.append(f"AI Summary: {note}")
442
  if actual_text:
443
  lines.append(f"Exact Audio Text: \"{actual_text}\"")
444
 
445
  lines.append("")
446
-
447
  return "\n".join(lines)
448
 
449
 
450
- def call_gemini_for_edl(transcript_data, story_prompt, api_key, previous_edit=None):
451
  if not api_key:
452
  st.error("Gemini API Key is missing.")
453
- return None
454
 
455
  system_prompt = (
456
- "You are an expert Documentary Senior Editor. Use the provided transcript JSON "
457
- "(which includes Speaker IDs and word-level timestamps) to create a condensed story.\n\n"
458
  "NEW CAPABILITIES:\n"
459
  "1. You can generate Voice-Over (VO) to bridge gaps, introduce topics, or summarize.\n"
460
- "2. You can generate Graphic Cards (Title Cards) to display text on screen (e.g., location, date, or chapter title).\n"
461
- "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"
462
  "Output ONLY a valid JSON array of segments. Every segment MUST be a 'clip', a 'vo', or a 'graphic'.\n\n"
463
  "For 'clip' segments (extracting from the subject):\n"
464
- "{\"type\": \"clip\", \"src_start\": 12.5, \"src_end\": 25.0, \"note\": \"Subject talks about X\", \"gap\": 0.0}\n"
 
465
  "- IGNORE ALL INTERVIEWER COMMENTS.\n"
466
- "- REMOVE FLUFF: Delete 'um', 'ah', repeats, and irrelevant filler.\n"
467
- "- TIMESTAMP INTEGRITY: Use only the exact word-level start and end times from the data.\n\n"
468
- "For 'vo' segments (generating new voice-over):\n"
469
- "{\"type\": \"vo\", \"text\": \"The journey began years ago...\", \"duration\": 3.5, \"gap\": 0.0}\n"
470
- "- DURATION: Estimate duration accurately based on reading speed (approx 2.5 words per second).\n"
471
- "- Keep VO concise and in the style of a documentary narrator.\n\n"
472
- "For 'graphic' segments (generating text on screen):\n"
473
- "{\"type\": \"graphic\", \"text\": \"Chapter 1: The Beginning\", \"duration\": 4.0, \"gap\": 0.0}\n\n"
474
- "PACING: Group related clips. Add a 'gap' (in seconds) between distinct ideas."
475
  )
476
 
477
  if previous_edit:
478
  prompt_text = (
479
- f"Transcript Data:\n{json.dumps(transcript_data)}\n\n"
480
  f"Previous Edit You Generated:\n{json.dumps(previous_edit)}\n\n"
481
  f"Director's Note for Revision: {story_prompt}\n\n"
482
- "Please output a NEW JSON array of segments applying these requested changes to the previous edit."
483
  )
484
  else:
485
- prompt_text = f"Creative Brief: {story_prompt}\n\nTranscript Data:\n{json.dumps(transcript_data)}"
486
 
487
  payload = {
488
- "contents": [
489
- {
490
- "role": "user",
491
- "parts": [{"text": prompt_text}]
492
- }
493
- ],
494
- "systemInstruction": {
495
- "parts": [{"text": system_prompt}]
496
- },
497
- "generationConfig": {
498
- "responseMimeType": "application/json"
499
- }
500
- }
501
-
502
- headers = {
503
- "Content-Type": "application/json"
504
  }
505
 
506
- # Prioritizes Pro models, falls back to Flash. All "lite" versions explicitly removed.
507
- models_to_try = [
508
- "gemini-3.1-pro-preview",
509
- "gemini-3-flash-preview",
510
- "gemini-2.5-pro",
511
- "gemini-2.5-flash",
512
- "gemini-2.0-flash"
513
- ]
514
 
 
515
  endpoints_to_try = ["v1beta", "v1"]
516
  last_error = ""
517
 
518
  for model_name in models_to_try:
519
  for api_version in endpoints_to_try:
520
  url = f"https://generativelanguage.googleapis.com/{api_version}/models/{model_name}:generateContent?key={api_key}"
521
-
522
  try:
523
  res = requests.post(url, json=payload, headers=headers)
524
-
525
- # Success
526
  if res.status_code == 200:
527
  result_json = res.json()
528
  raw_text = result_json['candidates'][0]['content']['parts'][0]['text']
529
- cleaned_text = clean_json_response(raw_text)
530
- return json.loads(cleaned_text), model_name, api_version
531
-
532
- # Handle High Demand (503) or Rate Limits (429)
533
  elif res.status_code in [503, 429]:
534
  last_error = f"Model {model_name} busy ({res.status_code}) on {api_version}."
535
- st.warning(f"Debug: {last_error} Skipping to next model...")
536
- break
537
-
538
- # Handle endpoints that don't exist (404) or API rejections (400)
539
  else:
540
- try:
541
- error_details = res.json().get('error', {}).get('message', res.text)
542
- except Exception:
543
- error_details = res.text
544
- last_error = f"Model {model_name} rejected request ({res.status_code}) on {api_version}: {error_details}"
545
- st.warning(f"Debug: {last_error} Trying fallback endpoint/model...")
546
  continue
547
-
548
- except json.JSONDecodeError:
549
- last_error = f"Model {model_name} returned invalid JSON on {api_version}."
550
- st.warning(f"Debug: {last_error} Trying fallback endpoint/model...")
551
- continue
552
- except requests.exceptions.RequestException as e:
553
- last_error = f"Network error connecting to {model_name} on {api_version}: {e}"
554
- st.warning(f"Debug: {last_error} Trying fallback endpoint/model...")
555
- continue
556
  except Exception as e:
557
  last_error = f"Error with {model_name} on {api_version}: {e}"
558
- st.warning(f"Debug: {last_error} Trying fallback endpoint/model...")
559
  continue
560
 
561
- st.error(f"All Gemini models failed or are currently experiencing high demand. Last error: {last_error}")
562
  return None, None, None
563
 
564
  # --- Streamlit UI ---
565
- st.set_page_config(page_title="Junior Editor", layout="wide")
566
- st.title("Junior Editor")
567
 
568
  st.markdown("""
569
  **Instructions**
570
- * Upload your file here (video or audio).
571
- * Set your timeline FPS, Source Timecode, and transcription quality in the sidebar.
572
- * Junior Editor will transcribe and separate speakers. You can then instruct it to find engaging bits or construct a narrative.
573
- * **Note: If you want me to write voice over suggestions or title cards, please request it.**
574
- * It will create an EDL or XML to import back into your editing software (Resolve, Premiere).
575
- * **For Multicam Workflows:** Use the **XML** option in the sidebar and enter the **exact name** of your Multicam Sequence.
576
- * **Resolve Users:** Uncheck "Automatically import source clips into media pool" during import.
577
- * **Premiere Users:** Use the EDL option if XML conformance fails for Multicam sequences.
578
  """)
579
 
580
  st.divider()
581
 
 
582
  with st.sidebar:
583
- clip_settings_container = st.container()
584
- transcription_settings_container = st.container()
585
-
586
- with clip_settings_container:
587
- st.header("Clip Settings")
588
- source_start_tc = st.text_input("Source Start Timecode", value="00:00:00:00", help="Format: HH:MM:SS:FF")
589
-
590
- export_format = st.radio("Output Format", ["EDL", "XML (Multicam)"], index=0)
591
-
592
- input_label = "EDL Reel Name"
593
- input_help = "Leave empty to use the uploaded file name."
594
- if export_format == "XML (Multicam)":
595
- input_label = "Multicam Sequence Name"
596
- input_help = "EXACT name of your Multicam Clip in Resolve."
597
-
598
- custom_reel_name = st.text_input(
599
- input_label,
600
- placeholder="e.g. Interview_Day1_Multi",
601
- help=input_help
602
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
603
 
604
- fps_options = [23.98, 24, 25, 29.97, 30, 50, 59.94, 60]
605
- fps = st.selectbox("Timeline FPS", fps_options, index=2)
606
-
607
- with transcription_settings_container:
608
- st.header("Transcription Settings")
609
 
610
- language_map = {
611
- "Auto-Detect": None,
612
- "English": "en",
613
- "Spanish": "es",
614
- "French": "fr",
615
- "German": "de",
616
- "Italian": "it",
617
- "Portuguese": "pt"
618
- }
619
- selected_lang_label = st.selectbox("Audio Language", list(language_map.keys()), index=1)
620
- target_language = language_map[selected_lang_label]
621
 
622
- num_speakers = st.number_input("Speakers (0=Auto)", min_value=0, value=0)
623
-
624
- uploaded_file = st.file_uploader("Upload Video/Audio Clip", type=["mp4", "m4a", "wav", "mp3", "mov"])
625
-
626
- if uploaded_file:
627
- # --- Auto-Reset Logic ---
628
- if "last_processed_file" not in st.session_state or st.session_state.last_processed_file != uploaded_file.name:
629
- if "transcript" in st.session_state:
630
- del st.session_state.transcript
631
- if "edit_generated" in st.session_state:
632
- del st.session_state.edit_generated
633
- st.session_state.last_processed_file = uploaded_file.name
634
-
635
- # --- Auto-Process Logic ---
636
- if "transcript" not in st.session_state:
637
- if not ACTIVE_HF_TOKEN or "PASTE_YOUR_HF_TOKEN" in ACTIVE_HF_TOKEN:
638
- st.error("Please provide a valid Hugging Face Token in the Sidebar/Secrets.")
639
- else:
640
- progress_container = st.container()
641
- with progress_container:
642
- st.info("🤖 **Junior Editor is processing your file...**")
643
- status_text = st.empty()
644
- progress_bar = st.progress(0)
645
-
646
- try:
647
- # Phase 1
648
- status_text.markdown("**Phase 1/4: Extracting Audio...**")
649
- with open("temp_input", "wb") as f:
650
- f.write(uploaded_file.getbuffer())
651
-
652
- subprocess.run(["ffmpeg", "-i", "temp_input", "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", "temp_audio.wav", "-y"])
653
- progress_bar.progress(25)
654
-
655
- device = "cuda" if torch.cuda.is_available() else "cpu"
656
- if device == "cpu": st.warning("⚠️ No GPU detected.")
657
-
658
- # Phase 2
659
- status_text.markdown("**Phase 2/4: Transcribing (Whisper)... This is the longest step.**")
660
- compute_type = "float16" if device == "cuda" else "int8"
661
- model = whisperx.load_model("large-v2", device, compute_type=compute_type)
662
- audio = whisperx.load_audio("temp_audio.wav")
663
- result = model.transcribe(audio, batch_size=4, language=target_language)
664
- del model
665
- gc.collect()
666
- torch.cuda.empty_cache()
667
- progress_bar.progress(50)
668
-
669
- # Phase 3
670
- status_text.markdown("**Phase 3/4: Aligning Text...**")
671
- model_a, metadata = whisperx.load_align_model(language_code=result["language"], device=device)
672
- result = whisperx.align(result["segments"], model_a, metadata, audio, device, return_char_alignments=False)
673
- del model_a
674
- gc.collect()
675
- torch.cuda.empty_cache()
676
- progress_bar.progress(75)
677
-
678
- # Phase 4
679
- status_text.markdown("**Phase 4/4: Identifying Speakers...**")
680
- diarize_model = whisperx.DiarizationPipeline(use_auth_token=ACTIVE_HF_TOKEN, device=device)
681
- diarize_kwargs = {"min_speakers": num_speakers, "max_speakers": num_speakers} if num_speakers > 0 else {}
682
- diarize_segments = diarize_model(audio, **diarize_kwargs)
683
-
684
- # Final Merge
685
- status_text.markdown("**Finalizing...**")
686
- final_result = whisperx.assign_word_speakers(diarize_segments, result)
687
-
688
- processed_segments = []
689
- for segment in final_result["segments"]:
690
- processed_segments.append({
691
- "speaker": segment.get("speaker", "Unknown"),
692
- "text": segment["text"].strip(),
693
- "start": segment["start"],
694
- "end": segment["end"]
695
- })
696
-
697
- st.session_state.transcript = processed_segments
698
- if os.path.exists("temp_input"): os.remove("temp_input")
699
- if os.path.exists("temp_audio.wav"): os.remove("temp_audio.wav")
700
- progress_bar.progress(100)
701
- status_text.success(f"Done! Processed {len(processed_segments)} segments.")
702
-
703
- except Exception as e:
704
- status_text.error(f"Error: {e}")
705
- if os.path.exists("temp_input"): os.remove("temp_input")
706
- st.stop()
707
-
708
- if "transcript" in st.session_state:
709
- st.divider()
710
- with st.expander("Transcript Preview", expanded=False):
711
- for seg in st.session_state.transcript:
712
- st.markdown(f"**{seg['speaker']}:** {seg['text']}")
713
 
714
- st.subheader("Your Instruction")
715
- 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.")
716
 
717
- if st.button("Generate Edit"):
718
- if not ACTIVE_GEMINI_KEY:
719
- st.error("Gemini API Key required.")
720
- else:
721
- with st.spinner("Junior Editor is thinking..."):
722
- final_source_name = custom_reel_name.strip() if custom_reel_name.strip() else uploaded_file.name
723
- offset_frames = timecode_to_frames(source_start_tc, fps)
724
-
725
- edl_segments, used_model, used_endpoint = call_gemini_for_edl(st.session_state.transcript, brief, ACTIVE_GEMINI_KEY)
726
-
727
- if edl_segments:
728
- safe_filename = "".join([c for c in final_source_name if c.isalnum() or c in (' ', '_', '-')]).strip()
729
- safe_filename = safe_filename.replace(' ', '_')
730
- if not safe_filename:
731
- safe_filename = "junior_editor_cut"
732
-
733
- # Generate the selected edit format (EDL/XML)
734
- if export_format == "EDL":
735
- final_output = generate_cmx_edl(final_source_name, edl_segments, final_source_name, fps, offset_frames)
736
- ext = "edl"
737
- else:
738
- final_output = generate_xml(final_source_name, edl_segments, final_source_name, fps, offset_frames)
739
- ext = "xml"
740
-
741
- # Generate the companion human-readable transcript
742
- transcript_txt = generate_transcript_txt(final_source_name, edl_segments, fps, offset_frames, st.session_state.transcript)
743
-
744
- # Save to session state so buttons don't disappear on click
745
- st.session_state.edit_generated = True
746
- st.session_state.revision_count = 1
747
- st.session_state.base_filename = safe_filename
748
- st.session_state.final_output = final_output
749
- st.session_state.ext = ext
750
- st.session_state.transcript_txt = transcript_txt
751
- st.session_state.safe_filename = safe_filename
752
- st.session_state.used_model = used_model
753
- st.session_state.used_endpoint = used_endpoint
754
- st.session_state.edl_segments = edl_segments
755
-
756
- # Render the download buttons outside the Generate block using session state
757
- if st.session_state.get("edit_generated"):
758
- st.subheader("Ready for Import")
759
- st.success(f"✅ Edit generated successfully using **{st.session_state.used_model}** (via {st.session_state.used_endpoint}).")
760
 
761
- # Display downloads in a neat row
762
- col1, col2 = st.columns(2)
763
- with col1:
764
- 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}")
765
- with col2:
766
- st.download_button(f"Download Reference Transcript (.TXT)", data=st.session_state.transcript_txt, file_name=f"{st.session_state.safe_filename}-Transcript.txt")
767
 
768
- 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.")
 
769
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
770
  st.divider()
771
- st.subheader("Revise Edit")
772
- 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.")
 
 
 
 
 
773
 
774
- if st.button("Apply Revisions"):
775
- with st.spinner("Junior Editor is revising the edit..."):
776
- final_source_name = custom_reel_name.strip() if custom_reel_name.strip() else uploaded_file.name
777
- offset_frames = timecode_to_frames(source_start_tc, fps)
778
-
779
- new_edl_segments, used_model, used_endpoint = call_gemini_for_edl(
780
- st.session_state.transcript,
781
- revision_brief,
782
- ACTIVE_GEMINI_KEY,
783
- previous_edit=st.session_state.edl_segments
784
- )
785
 
786
- if new_edl_segments:
787
- # Increment version count
788
- st.session_state.revision_count = st.session_state.get("revision_count", 1) + 1
789
- rev_seq_name = f"{final_source_name} V{st.session_state.revision_count}"
790
-
791
- if export_format == "EDL":
792
- final_output = generate_cmx_edl(rev_seq_name, new_edl_segments, final_source_name, fps, offset_frames)
793
- ext = "edl"
794
- else:
795
- final_output = generate_xml(rev_seq_name, new_edl_segments, final_source_name, fps, offset_frames)
796
- ext = "xml"
797
-
798
- transcript_txt = generate_transcript_txt(rev_seq_name, new_edl_segments, fps, offset_frames, st.session_state.transcript)
799
-
800
- st.session_state.final_output = final_output
801
- st.session_state.ext = ext
802
- st.session_state.transcript_txt = transcript_txt
803
- st.session_state.used_model = used_model
804
- st.session_state.used_endpoint = used_endpoint
805
- st.session_state.edl_segments = new_edl_segments
806
-
807
- # Update safe filename for the download buttons to show _V2, _V3, etc.
808
- st.session_state.safe_filename = f"{st.session_state.base_filename}_V{st.session_state.revision_count}"
809
-
810
- st.rerun()
811
-
812
-
813
-
814
-
815
-
816
-
 
8
  import json
9
  import requests
10
  import gc
 
 
11
  import re
 
12
  from urllib.parse import quote
 
13
 
14
  # --- CRITICAL ENVIRONMENT FIXES ---
15
  if os.environ.get("OMP_NUM_THREADS", "").endswith("m"):
 
76
  # --- HELPER FUNCTIONS ---
77
 
78
  def clean_json_response(text):
 
79
  try:
80
  pattern = r"`{3}(?:json)?\s*(.*?)`{3}"
81
  match = re.search(pattern, text, re.DOTALL)
 
86
  return text
87
 
88
  def escape_xml(text):
89
+ if not text: return ""
 
 
90
  text = str(text)
91
+ return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;").replace("'", "&apos;")
 
 
 
 
 
92
 
93
  def timecode_to_frames(tc, fps):
94
+ if not tc or not re.match(r"\d{2}:\d{2}:\d{2}[:\.]\d{2}", tc): return 0
 
 
95
  parts = re.split(r'[:\.]', tc)
96
  h, m, s, f = map(int, parts)
97
+ timebase = int(round(fps))
98
  return (h * 3600 * timebase) + (m * 60 * timebase) + (s * timebase) + f
99
 
100
  def frames_to_timecode(frames, fps):
 
101
  timebase = int(round(fps))
102
  hours = frames // (3600 * timebase)
103
  minutes = (frames // (60 * timebase)) % 60
 
106
  return f"{hours:02}:{minutes:02}:{secs:02}:{f:02}"
107
 
108
  def seconds_to_frames(seconds, fps):
 
109
  return int(round(seconds * fps))
110
 
111
+ # --- MULTI-CLIP EXPORT GENERATORS ---
112
+
113
+ def generate_cmx_edl(edl_title, segments, clip_metadata, fps=25):
114
+ """Constructs a CMX 3600 formatted EDL handling multiple source clips."""
115
  edl_lines = [f"TITLE: {edl_title}", "FCM: NON-DROP FRAME\n"]
116
  rec_start_frames = 0
 
117
 
118
  for i, seg in enumerate(segments, 1):
119
  seg_type = seg.get('type', 'clip')
 
129
  src_out_frames = duration_frames
130
  current_source = "GEN_VO" if is_vo else "GEN_GFX"
131
  note_text = f"{'VO SCRIPT' if is_vo else 'GRAPHIC'}: {text_content}"
132
+ source_name = "Generated"
133
  else:
134
+ source_clip = seg.get('source_clip', 'UNKNOWN')
135
+ meta = clip_metadata.get(source_clip, {'offset_frames': 0})
136
+ start_offset_frames = meta['offset_frames']
137
+
138
  src_start_val = float(seg.get('src_start', seg.get('start', 0.0)))
139
  src_end_val = float(seg.get('src_end', seg.get('end', 0.0)))
140
  src_in_frames = seconds_to_frames(src_start_val, fps) + start_offset_frames
141
  src_out_frames = seconds_to_frames(src_end_val, fps) + start_offset_frames
142
  duration_frames = src_out_frames - src_in_frames
143
+
144
+ current_source = source_clip.replace(" ", "_")[:8] # Short EDL reel ID
145
  note_text = seg.get('note', 'Clip')
146
+ source_name = source_clip
147
 
148
  src_in = frames_to_timecode(src_in_frames, fps)
149
  src_out = frames_to_timecode(src_out_frames, fps)
 
150
  rec_in = frames_to_timecode(rec_start_frames, fps)
151
  rec_out = frames_to_timecode(rec_start_frames + duration_frames, fps)
152
 
 
160
 
161
  return "\n".join(edl_lines)
162
 
163
+ def generate_xml(sequence_name, segments, clip_metadata, fps=25):
164
+ """Constructs a Robust XML referencing multiple source files dynamically."""
 
165
  timebase = int(round(fps))
166
  is_ntsc = "TRUE" if fps % 1 != 0 else "FALSE"
 
 
 
 
 
167
 
168
  lines = []
169
  lines.append('<?xml version="1.0" encoding="UTF-8"?>')
 
190
  lines.append('\t\t\t</format>')
191
  lines.append('\t\t\t<track>')
192
 
193
+ defined_files = set()
 
 
194
  timeline_head_frames = 0
195
 
196
  for i, seg in enumerate(segments, 1):
 
205
  duration_frames = seconds_to_frames(duration_sec, fps)
206
  src_in_frames = 0
207
  src_out_frames = duration_frames
208
+
209
+ source_clip = "Generated_VO_Placeholder" if is_vo else "Generated_Graphic_Placeholder"
210
+ master_id_to_use = f"masterfile-{source_clip.lower().replace('_', '')}"
211
+ start_offset_frames = 0
212
+ start_tc_string = "00:00:00:00"
213
+ path_url = f"file://localhost/{escape_xml(source_clip)}.{'wav' if is_vo else 'mov'}"
214
+
215
  prefix = "VO SCRIPT" if is_vo else "GRAPHIC CARD"
216
  clip_note = f"{prefix}: {text_content}"
217
  clip_name_disp = (clip_note[:75] + '..') if len(clip_note) > 75 else clip_note
218
  else:
219
+ source_clip = seg.get('source_clip', 'UNKNOWN')
220
+ meta = clip_metadata.get(source_clip, {'offset_frames': 0, 'start_tc': '00:00:00:00'})
221
+ start_offset_frames = meta['offset_frames']
222
+ start_tc_string = meta['start_tc']
223
+
224
  src_start_val = float(seg.get('src_start', seg.get('start', 0.0)))
225
  src_end_val = float(seg.get('src_end', seg.get('end', 0.0)))
 
 
226
  src_in_frames = seconds_to_frames(src_start_val, fps)
227
  src_out_frames = seconds_to_frames(src_end_val, fps)
228
 
229
  duration_frames = src_out_frames - src_in_frames
230
+
231
+ safe_id = "".join([c for c in source_clip if c.isalnum()]).lower()
232
+ master_id_to_use = f"masterfile-{safe_id}"
233
+ path_url = f"file://localhost/{quote(source_clip)}"
234
+
235
+ clip_note = seg.get('note', 'Junior Editor Selection')
236
+ clip_name_disp = source_clip
237
 
238
  tl_start = timeline_head_frames
239
  tl_end = tl_start + duration_frames
 
248
  lines.append(f'\t\t\t\t\t<out>{src_out_frames}</out>')
249
  lines.append(f'\t\t\t\t\t<masterclipid>{master_id_to_use}</masterclipid>')
250
 
251
+ # Define file only once per unique source
252
+ if master_id_to_use not in defined_files:
253
+ lines.append(f'\t\t\t\t\t<file id="{master_id_to_use}">')
254
+ lines.append(f'\t\t\t\t\t\t<name>{escape_xml(source_clip)}</name>')
255
+ lines.append(f'\t\t\t\t\t\t<pathurl>{path_url}</pathurl>')
256
+ lines.append(f'\t\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>')
257
+ lines.append(f'\t\t\t\t\t\t<duration>8640000</duration>')
258
+ lines.append(f'\t\t\t\t\t\t<timecode>')
259
+ lines.append(f'\t\t\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>')
260
+ lines.append(f'\t\t\t\t\t\t\t<string>{start_tc_string}</string>')
261
+ lines.append(f'\t\t\t\t\t\t\t<frame>{start_offset_frames}</frame>')
262
+ lines.append(f'\t\t\t\t\t\t\t<displayformat>NDF</displayformat>')
263
+ lines.append(f'\t\t\t\t\t\t</timecode>')
264
+ lines.append('\t\t\t\t\t\t<media>')
265
+ lines.append('\t\t\t\t\t\t\t<video><samplecharacteristics><width>1920</width><height>1080</height></samplecharacteristics></video>')
266
+ if not is_graphic: # Graphics get no audio
267
  lines.append('\t\t\t\t\t\t\t<audio><samplecharacteristics><depth>16</depth><samplerate>48000</samplerate></samplecharacteristics><channelcount>2</channelcount></audio>')
268
+ lines.append('\t\t\t\t\t\t</media>')
269
+ lines.append('\t\t\t\t\t</file>')
270
+ defined_files.add(master_id_to_use)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  else:
272
+ lines.append(f'\t\t\t\t\t<file id="{master_id_to_use}"/>')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
274
  lines.append('\t\t\t\t\t<marker>')
275
  lines.append(f'\t\t\t\t\t\t<name>{escape_xml(clip_note)}</name>')
276
  lines.append(f'\t\t\t\t\t\t<in>{src_in_frames}</in>')
 
 
277
  if is_vo or is_graphic:
278
  lines.append(f'\t\t\t\t\t\t<out>{src_out_frames}</out>')
279
  else:
280
  lines.append(f'\t\t\t\t\t\t<out>{src_in_frames}</out>')
 
281
  lines.append('\t\t\t\t\t</marker>')
 
282
  lines.append('\t\t\t\t</clipitem>')
283
 
284
  gap_frames = seconds_to_frames(float(seg.get('gap', 0.0)), fps)
 
298
  is_graphic = seg_type == 'graphic'
299
 
300
  if is_graphic:
 
301
  duration_sec = float(seg.get('duration', 4.0))
302
+ timeline_head_frames += seconds_to_frames(duration_sec, fps) + seconds_to_frames(float(seg.get('gap', 0.0)), fps)
 
 
303
  continue
304
 
305
  if is_vo:
 
308
  duration_frames = seconds_to_frames(duration_sec, fps)
309
  src_in_frames = 0
310
  src_out_frames = duration_frames
311
+ source_clip = "Generated_VO_Placeholder"
 
312
  else:
313
+ source_clip = seg.get('source_clip', 'UNKNOWN')
314
  src_start_val = float(seg.get('src_start', seg.get('start', 0.0)))
315
  src_end_val = float(seg.get('src_end', seg.get('end', 0.0)))
316
  src_in_frames = seconds_to_frames(src_start_val, fps)
317
  src_out_frames = seconds_to_frames(src_end_val, fps)
318
  duration_frames = src_out_frames - src_in_frames
319
+
320
+ safe_id = "".join([c for c in source_clip if c.isalnum()]).lower()
321
+ master_id_to_use = f"masterfile-{safe_id}"
322
 
323
  tl_start = timeline_head_frames
324
  tl_end = tl_start + duration_frames
325
 
326
  lines.append(f'\t\t\t\t<clipitem id="clipitem-a-{i}">')
327
+ lines.append(f'\t\t\t\t\t<name>{escape_xml(source_clip)}</name>')
328
  lines.append(f'\t\t\t\t\t<masterclipid>{master_id_to_use}</masterclipid>')
329
  lines.append(f'\t\t\t\t\t<duration>8640000</duration>')
330
  lines.append(f'\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>')
 
345
 
346
  return "\n".join(lines)
347
 
348
+ def generate_transcript_txt(sequence_name, segments, clip_metadata, fps=25, all_transcripts_data=None):
 
349
  lines = [f"Sequence Transcript: {sequence_name}", "=" * 50, ""]
350
 
351
  for i, seg in enumerate(segments, 1):
 
364
  lines.append(f"Clip {i:02} | [GRAPHIC CARD] | Est. Duration: {duration:.1f}s")
365
  lines.append(f"Text: \"{gfx_text}\"")
366
  else:
367
+ source_clip = seg.get('source_clip', 'UNKNOWN')
368
+ meta = clip_metadata.get(source_clip, {'offset_frames': 0})
369
+
370
  src_start_val = float(seg.get('src_start', seg.get('start', 0.0)))
371
  src_end_val = float(seg.get('src_end', seg.get('end', 0.0)))
372
+ src_in_frames = seconds_to_frames(src_start_val, fps) + meta['offset_frames']
373
+ src_out_frames = seconds_to_frames(src_end_val, fps) + meta['offset_frames']
374
 
375
  src_in = frames_to_timecode(src_in_frames, fps)
376
  src_out = frames_to_timecode(src_out_frames, fps)
377
  note = seg.get('note', 'No note provided.')
378
 
379
  actual_text = ""
380
+ if all_transcripts_data and source_clip in all_transcripts_data:
381
  clip_words = []
382
+ for t_seg in all_transcripts_data[source_clip]:
383
  if max(src_start_val, t_seg['start']) < min(src_end_val, t_seg['end']):
384
  clip_words.append(t_seg['text'])
385
  actual_text = " ".join(clip_words).strip()
386
 
387
+ lines.append(f"Clip {i:02} | SOURCE: {source_clip} | IN: {src_in} --> OUT: {src_out}")
388
  lines.append(f"AI Summary: {note}")
389
  if actual_text:
390
  lines.append(f"Exact Audio Text: \"{actual_text}\"")
391
 
392
  lines.append("")
 
393
  return "\n".join(lines)
394
 
395
 
396
+ def call_gemini_for_edl(transcripts_dict, story_prompt, api_key, previous_edit=None):
397
  if not api_key:
398
  st.error("Gemini API Key is missing.")
399
+ return None, None, None
400
 
401
  system_prompt = (
402
+ "You are an expert Documentary Senior Editor. Use the provided JSON object containing transcripts "
403
+ "from ONE OR MORE source clips to create a condensed story.\n\n"
404
  "NEW CAPABILITIES:\n"
405
  "1. You can generate Voice-Over (VO) to bridge gaps, introduce topics, or summarize.\n"
406
+ "2. You can generate Graphic Cards (Title Cards) to display text on screen.\n"
407
+ "CRITICAL: ONLY generate 'vo' or 'graphic' segments if explicitly requested. Otherwise, use 'clip' segments.\n\n"
408
  "Output ONLY a valid JSON array of segments. Every segment MUST be a 'clip', a 'vo', or a 'graphic'.\n\n"
409
  "For 'clip' segments (extracting from the subject):\n"
410
+ "{\"type\": \"clip\", \"source_clip\": \"Cam_A_Interview\", \"src_start\": 12.5, \"src_end\": 25.0, \"note\": \"Subject talks about X\", \"gap\": 0.0}\n"
411
+ "- MULTI-CAM CRITICAL: You MUST include the exact 'source_clip' key representing which file the transcript came from.\n"
412
  "- IGNORE ALL INTERVIEWER COMMENTS.\n"
413
+ "- REMOVE FLUFF: Delete 'um', 'ah', repeats.\n"
414
+ "- TIMESTAMP INTEGRITY: Use exact word-level start/end times.\n\n"
415
+ "For 'vo' segments:\n"
416
+ "{\"type\": \"vo\", \"text\": \"The journey began years ago...\", \"duration\": 3.5, \"gap\": 0.0}\n\n"
417
+ "For 'graphic' segments:\n"
418
+ "{\"type\": \"graphic\", \"text\": \"Chapter 1\", \"duration\": 4.0, \"gap\": 0.0}\n\n"
419
+ "PACING: Add a 'gap' (in seconds) between distinct ideas."
 
 
420
  )
421
 
422
  if previous_edit:
423
  prompt_text = (
424
+ f"Transcripts Data (By Source):\n{json.dumps(transcripts_dict)}\n\n"
425
  f"Previous Edit You Generated:\n{json.dumps(previous_edit)}\n\n"
426
  f"Director's Note for Revision: {story_prompt}\n\n"
427
+ "Please output a NEW JSON array applying these requested changes."
428
  )
429
  else:
430
+ prompt_text = f"Creative Brief: {story_prompt}\n\nTranscripts Data (By Source):\n{json.dumps(transcripts_dict)}"
431
 
432
  payload = {
433
+ "contents": [{"role": "user", "parts": [{"text": prompt_text}]}],
434
+ "systemInstruction": {"parts": [{"text": system_prompt}]},
435
+ "generationConfig": {"responseMimeType": "application/json"}
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  }
437
 
438
+ headers = {"Content-Type": "application/json"}
 
 
 
 
 
 
 
439
 
440
+ models_to_try = ["gemini-3.1-pro-preview", "gemini-3-flash-preview", "gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash"]
441
  endpoints_to_try = ["v1beta", "v1"]
442
  last_error = ""
443
 
444
  for model_name in models_to_try:
445
  for api_version in endpoints_to_try:
446
  url = f"https://generativelanguage.googleapis.com/{api_version}/models/{model_name}:generateContent?key={api_key}"
 
447
  try:
448
  res = requests.post(url, json=payload, headers=headers)
 
 
449
  if res.status_code == 200:
450
  result_json = res.json()
451
  raw_text = result_json['candidates'][0]['content']['parts'][0]['text']
452
+ return json.loads(clean_json_response(raw_text)), model_name, api_version
 
 
 
453
  elif res.status_code in [503, 429]:
454
  last_error = f"Model {model_name} busy ({res.status_code}) on {api_version}."
455
+ continue
 
 
 
456
  else:
457
+ last_error = f"API rejected ({res.status_code}) on {api_version}."
 
 
 
 
 
458
  continue
 
 
 
 
 
 
 
 
 
459
  except Exception as e:
460
  last_error = f"Error with {model_name} on {api_version}: {e}"
 
461
  continue
462
 
463
+ st.error(f"All Gemini models failed. Last error: {last_error}")
464
  return None, None, None
465
 
466
  # --- Streamlit UI ---
467
+ st.set_page_config(page_title="Junior Editor Pro", layout="wide")
468
+ st.title("Junior Editor (Multi-Clip Edition)")
469
 
470
  st.markdown("""
471
  **Instructions**
472
+ * Upload all your source files (Cameras, separate interviews, multicam sequences).
473
+ * Assign a specific Reel/Clip name and Start Timecode for each file below.
474
+ * Click 'Transcribe All Clips'.
475
+ * Instruct the Junior Editor to assemble a narrative pulling from *any* of your sources.
 
 
 
 
476
  """)
477
 
478
  st.divider()
479
 
480
+ # -- Global Settings Sidebar --
481
  with st.sidebar:
482
+ st.header("Project Settings")
483
+ sequence_title = st.text_input("Sequence / Project Title", value="Junior_Editor_Cut")
484
+ export_format = st.radio("Output Format", ["XML (Premiere/Resolve)", "EDL"], index=0)
485
+
486
+ fps_options = [23.98, 24, 25, 29.97, 30, 50, 59.94, 60]
487
+ fps = st.selectbox("Timeline FPS", fps_options, index=2)
488
+
489
+ st.header("Transcription Settings")
490
+ language_map = {"Auto-Detect": None, "English": "en", "Spanish": "es"}
491
+ target_language = language_map[st.selectbox("Audio Language", list(language_map.keys()), index=1)]
492
+ num_speakers = st.number_input("Speakers per clip (0=Auto)", min_value=0, value=0)
493
+
494
+ # -- Main Multi-Clip Bin area --
495
+ if "transcripts" not in st.session_state:
496
+ st.session_state.transcripts = {}
497
+ if "clip_metadata" not in st.session_state:
498
+ st.session_state.clip_metadata = {}
499
+
500
+ uploaded_files = st.file_uploader("Upload Clip(s)", type=["mp4", "m4a", "wav", "mp3", "mov"], accept_multiple_files=True)
501
+
502
+ if uploaded_files and len(st.session_state.transcripts) == 0:
503
+ st.subheader("Clip Settings Bin")
504
+ clip_configs = {}
505
+
506
+ for idx, f in enumerate(uploaded_files):
507
+ with st.expander(f"⚙️ Settings: {f.name}", expanded=True):
508
+ col1, col2 = st.columns(2)
509
+ with col1:
510
+ custom_name = st.text_input(
511
+ "Clip / Multicam Name",
512
+ value=f.name.rsplit('.', 1)[0],
513
+ key=f"name_{idx}",
514
+ help="The EXACT name of the clip or Multicam sequence as it appears in your NLE Bin."
515
+ )
516
+ with col2:
517
+ start_tc = st.text_input(
518
+ "Source Start Timecode",
519
+ value="00:00:00:00",
520
+ key=f"tc_{idx}"
521
+ )
522
+ clip_configs[f.name] = {"file": f, "custom_name": custom_name, "start_tc": start_tc}
523
+
524
+ if st.button("Transcribe All Clips", type="primary"):
525
+ if not ACTIVE_HF_TOKEN or "PASTE_YOUR_HF_TOKEN" in ACTIVE_HF_TOKEN:
526
+ st.error("Please provide a valid Hugging Face Token.")
527
+ st.stop()
528
+
529
+ progress_bar = st.progress(0)
530
+ status_text = st.empty()
531
 
532
+ all_transcripts = {}
533
+ all_metadata = {}
 
 
 
534
 
535
+ device = "cuda" if torch.cuda.is_available() else "cpu"
536
+ compute_type = "float16" if device == "cuda" else "int8"
 
 
 
 
 
 
 
 
 
537
 
538
+ # Load heavy models ONCE outside the loop to save massive VRAM and time
539
+ status_text.markdown("**Loading AI Models into memory...**")
540
+ whisper_model = whisperx.load_model("large-v2", device, compute_type=compute_type)
541
+ diarize_model = whisperx.DiarizationPipeline(use_auth_token=ACTIVE_HF_TOKEN, device=device)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
542
 
543
+ total_clips = len(clip_configs)
 
544
 
545
+ for i, (original_filename, config) in enumerate(clip_configs.items()):
546
+ c_name = config["custom_name"]
547
+ c_tc = config["start_tc"]
548
+ c_file = config["file"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
549
 
550
+ status_text.markdown(f"**Processing Clip {i+1}/{total_clips}: {c_name}...**")
 
 
 
 
 
551
 
552
+ with open("temp_input", "wb") as f: f.write(c_file.getbuffer())
553
+ subprocess.run(["ffmpeg", "-i", "temp_input", "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", "temp_audio.wav", "-y"], capture_output=True)
554
 
555
+ audio = whisperx.load_audio("temp_audio.wav")
556
+ result = whisper_model.transcribe(audio, batch_size=4, language=target_language)
557
+
558
+ model_a, metadata = whisperx.load_align_model(language_code=result["language"], device=device)
559
+ result = whisperx.align(result["segments"], model_a, metadata, audio, device, return_char_alignments=False)
560
+ del model_a
561
+ gc.collect()
562
+ torch.cuda.empty_cache()
563
+
564
+ diarize_kwargs = {"min_speakers": num_speakers, "max_speakers": num_speakers} if num_speakers > 0 else {}
565
+ diarize_segments = diarize_model(audio, **diarize_kwargs)
566
+ final_result = whisperx.assign_word_speakers(diarize_segments, result)
567
+
568
+ processed_segments = []
569
+ for segment in final_result["segments"]:
570
+ processed_segments.append({
571
+ "speaker": segment.get("speaker", "Unknown"),
572
+ "text": segment["text"].strip(),
573
+ "start": segment["start"],
574
+ "end": segment["end"]
575
+ })
576
+
577
+ all_transcripts[c_name] = processed_segments
578
+ all_metadata[c_name] = {
579
+ "start_tc": c_tc,
580
+ "offset_frames": timecode_to_frames(c_tc, fps)
581
+ }
582
+
583
+ progress_bar.progress((i + 1) / total_clips)
584
+
585
+ # Cleanup
586
+ del whisper_model
587
+ del diarize_model
588
+ gc.collect()
589
+ torch.cuda.empty_cache()
590
+ if os.path.exists("temp_input"): os.remove("temp_input")
591
+ if os.path.exists("temp_audio.wav"): os.remove("temp_audio.wav")
592
+
593
+ st.session_state.transcripts = all_transcripts
594
+ st.session_state.clip_metadata = all_metadata
595
+ status_text.success("All clips transcribed successfully!")
596
+ st.rerun()
597
+
598
+ # -- Chat / Generation Area --
599
+ if len(st.session_state.transcripts) > 0:
600
+ st.divider()
601
+
602
+ with st.expander("View Source Transcripts", expanded=False):
603
+ for clip_name, segments in st.session_state.transcripts.items():
604
+ st.markdown(f"### {clip_name}")
605
+ for seg in segments[:10]: # Preview first 10
606
+ st.markdown(f"**{seg['speaker']}**: {seg['text']}")
607
+ if len(segments) > 10: st.markdown("*... (truncated)*")
608
  st.divider()
609
+
610
+ st.subheader("Your Instruction")
611
+ brief = st.text_area("What should the Junior Editor do?", placeholder="e.g. Build a 1-minute intro cutting between Cam_A and Cam_B. Write VO to bridge them.")
612
+
613
+ if st.button("Generate Multi-Clip Edit", type="primary"):
614
+ with st.spinner("Analyzing all transcripts and building sequence..."):
615
+ edl_segments, used_model, used_endpoint = call_gemini_for_edl(st.session_state.transcripts, brief, ACTIVE_GEMINI_KEY)
616
 
617
+ if edl_segments:
618
+ safe_title = "".join([c for c in sequence_title if c.isalnum() or c in (' ', '_', '-')]).strip().replace(' ', '_')
619
+
620
+ if "XML" in export_format:
621
+ final_output = generate_xml(safe_title, edl_segments, st.session_state.clip_metadata, fps)
622
+ ext = "xml"
623
+ else:
624
+ final_output = generate_cmx_edl(safe_title, edl_segments, st.session_state.clip_metadata, fps)
625
+ ext = "edl"
 
 
626
 
627
+ transcript_txt = generate_transcript_txt(safe_title, edl_segments, st.session_state.clip_metadata, fps, st.session_state.transcripts)
628
+
629
+ st.session_state.edit_generated = True
630
+ st.session_state.revision_count = 1
631
+ st.session_state.safe_title = safe_title
632
+ st.session_state.final_output = final_output
633
+ st.session_state.ext = ext
634
+ st.session_state.transcript_txt = transcript_txt
635
+ st.session_state.used_model = used_model
636
+ st.session_state.edl_segments = edl_segments
637
+
638
+ if st.session_state.get("edit_generated"):
639
+ st.success(f"✅ Multi-clip sequence generated successfully using **{st.session_state.used_model}**.")
640
+
641
+ col1, col2 = st.columns(2)
642
+ with col1:
643
+ st.download_button(f"Download .{st.session_state.ext.upper()}", data=st.session_state.final_output, file_name=f"{st.session_state.safe_title}_V{st.session_state.revision_count}.{st.session_state.ext}")
644
+ with col2:
645
+ st.download_button(f"Download Transcript (.TXT)", data=st.session_state.transcript_txt, file_name=f"{st.session_state.safe_title}_V{st.session_state.revision_count}_Transcript.txt")
646
+
647
+ if st.button("Start Over / Clear Bin"):
648
+ for key in ["transcripts", "clip_metadata", "edit_generated"]:
649
+ if key in st.session_state: del st.session_state[key]
650
+ st.rerun()