NickVerri commited on
Commit
6e44088
·
verified ·
1 Parent(s): 34ef38e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +184 -85
app.py CHANGED
@@ -119,20 +119,31 @@ def generate_cmx_edl(edl_title, segments, source_name, fps=25, start_offset=0.0)
119
  reel_id = source_name.replace(" ", "_")[:8] # EDL reel IDs are traditionally short max 8 chars
120
 
121
  for i, seg in enumerate(segments, 1):
122
- src_start_val = float(seg.get('src_start', seg.get('start', 0.0)))
123
- src_end_val = float(seg.get('src_end', seg.get('end', 0.0)))
124
 
125
- # Add offset to source time
126
- src_in = format_timecode(src_start_val + start_offset, fps)
127
- src_out = format_timecode(src_end_val + start_offset, fps)
 
 
 
 
 
 
 
 
 
 
 
 
128
 
129
- duration = src_end_val - src_start_val
130
  rec_in = format_timecode(rec_start, fps)
131
  rec_out = format_timecode(rec_start + duration, fps)
132
 
133
- edl_lines.append(f"{i:03} {reel_id:8} V C {src_in} {src_out} {rec_in} {rec_out}")
134
- edl_lines.append(f"* FROM CLIP NAME: {source_name}")
135
- edl_lines.append(f"* {seg.get('note', 'Clip')}\n")
 
136
 
137
  gap = float(seg.get('gap', 0.0))
138
  rec_start += duration + gap
@@ -177,53 +188,90 @@ def generate_xml(sequence_name, segments, source_name, fps=25, start_offset=0.0)
177
  lines.append('\t\t\t<track>')
178
 
179
  file_defined = False
 
180
  timeline_head_frames = 0
 
181
  for i, seg in enumerate(segments, 1):
182
- src_start_val = float(seg.get('src_start', seg.get('start', 0.0)))
183
- src_end_val = float(seg.get('src_end', seg.get('end', 0.0)))
184
 
185
- # IN and OUT refer to the absolute zero-based frame of the media file.
186
- src_in_frames = seconds_to_frames(src_start_val, fps)
187
- src_out_frames = seconds_to_frames(src_end_val, fps)
188
- duration_frames = src_out_frames - src_in_frames
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
 
190
  tl_start = timeline_head_frames
191
  tl_end = tl_start + duration_frames
192
-
193
- raw_note = seg.get('note', 'Junior Editor Selection')
194
- clip_note = (raw_note[:75] + '..') if len(raw_note) > 75 else raw_note
195
 
196
  lines.append(f'\t\t\t\t<clipitem id="clipitem-v-{i}">')
197
- lines.append(f'\t\t\t\t\t<name>{source_name}</name>')
198
  lines.append(f'\t\t\t\t\t<duration>8640000</duration>')
199
  lines.append(f'\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>')
200
  lines.append(f'\t\t\t\t\t<start>{tl_start}</start>')
201
  lines.append(f'\t\t\t\t\t<end>{tl_end}</end>')
202
  lines.append(f'\t\t\t\t\t<in>{src_in_frames}</in>')
203
  lines.append(f'\t\t\t\t\t<out>{src_out_frames}</out>')
204
- lines.append(f'\t\t\t\t\t<masterclipid>{master_id}</masterclipid>')
205
 
206
- # --- ROBUST FILE DEFINITION ---
207
- if not file_defined:
208
- lines.append(f'\t\t\t\t\t<file id="{master_id}">')
209
- lines.append(f'\t\t\t\t\t\t<name>{source_name}</name>')
210
- lines.append(f'\t\t\t\t\t\t<pathurl>{path_url}</pathurl>')
211
- lines.append(f'\t\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>')
212
- lines.append(f'\t\t\t\t\t\t<duration>8640000</duration>')
213
- lines.append(f'\t\t\t\t\t\t<timecode>')
214
- lines.append(f'\t\t\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>')
215
- lines.append(f'\t\t\t\t\t\t\t<string>{start_tc_string}</string>')
216
- lines.append(f'\t\t\t\t\t\t\t<frame>{start_offset_frames}</frame>')
217
- lines.append(f'\t\t\t\t\t\t\t<displayformat>NDF</displayformat>')
218
- lines.append(f'\t\t\t\t\t\t</timecode>')
219
- lines.append('\t\t\t\t\t\t<media>')
220
- lines.append('\t\t\t\t\t\t\t<video><samplecharacteristics><width>1920</width><height>1080</height></samplecharacteristics></video>')
221
- lines.append('\t\t\t\t\t\t\t<audio><samplecharacteristics><depth>16</depth><samplerate>48000</samplerate></samplecharacteristics><channelcount>2</channelcount></audio>')
222
- lines.append('\t\t\t\t\t\t</media>')
223
- lines.append('\t\t\t\t\t</file>')
224
- file_defined = True
 
 
 
225
  else:
226
- lines.append(f'\t\t\t\t\t<file id="{master_id}"/>')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
 
228
  lines.append('\t\t\t\t\t<marker>')
229
  lines.append(f'\t\t\t\t\t\t<name>{clip_note}</name>')
@@ -245,25 +293,38 @@ def generate_xml(sequence_name, segments, source_name, fps=25, start_offset=0.0)
245
 
246
  timeline_head_frames = 0
247
  for i, seg in enumerate(segments, 1):
248
- src_start_val = float(seg.get('src_start', seg.get('start', 0.0)))
249
- src_end_val = float(seg.get('src_end', seg.get('end', 0.0)))
250
 
251
- src_in_frames = seconds_to_frames(src_start_val, fps)
252
- src_out_frames = seconds_to_frames(src_end_val, fps)
253
- duration_frames = src_out_frames - src_in_frames
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  tl_start = timeline_head_frames
255
  tl_end = tl_start + duration_frames
256
 
257
  lines.append(f'\t\t\t\t<clipitem id="clipitem-a-{i}">')
258
- lines.append(f'\t\t\t\t\t<name>{source_name}</name>')
259
- lines.append(f'\t\t\t\t\t<masterclipid>{master_id}</masterclipid>')
260
  lines.append(f'\t\t\t\t\t<duration>8640000</duration>')
261
  lines.append(f'\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>')
262
  lines.append(f'\t\t\t\t\t<start>{tl_start}</start>')
263
  lines.append(f'\t\t\t\t\t<end>{tl_end}</end>')
264
  lines.append(f'\t\t\t\t\t<in>{src_in_frames}</in>')
265
  lines.append(f'\t\t\t\t\t<out>{src_out_frames}</out>')
266
- lines.append(f'\t\t\t\t\t<file id="{master_id}"/>')
267
  lines.append('\t\t\t\t\t<sourcetrack><mediatype>audio</mediatype><trackindex>1</trackindex></sourcetrack>')
268
  lines.append('\t\t\t\t</clipitem>')
269
  timeline_head_frames = tl_end + seconds_to_frames(float(seg.get('gap', 0.0)), fps)
@@ -276,21 +337,40 @@ def generate_xml(sequence_name, segments, source_name, fps=25, start_offset=0.0)
276
 
277
  return "\n".join(lines)
278
 
279
- def generate_transcript_txt(sequence_name, segments, fps=25, start_offset=0.0):
280
- """Generates a human-readable text document of the edit decisions and transcription."""
281
  lines = [f"Sequence Transcript: {sequence_name}", "=" * 50, ""]
282
 
283
  for i, seg in enumerate(segments, 1):
284
- src_start_val = float(seg.get('src_start', seg.get('start', 0.0)))
285
- src_end_val = float(seg.get('src_end', seg.get('end', 0.0)))
286
-
287
- src_in = format_timecode(src_start_val + start_offset, fps)
288
- src_out = format_timecode(src_end_val + start_offset, fps)
289
 
290
- note = seg.get('note', 'No note provided.')
291
-
292
- lines.append(f"Clip {i:02} | IN: {src_in} --> OUT: {src_out}")
293
- lines.append(f"Text: {note}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  lines.append("") # Blank line to separate clips visually
295
 
296
  return "\n".join(lines)
@@ -303,15 +383,20 @@ def call_gemini_for_edl(transcript_data, story_prompt, api_key):
303
 
304
  system_prompt = (
305
  "You are an expert Documentary Senior Editor. Use the provided transcript JSON "
306
- "(which includes Speaker IDs and word-level timestamps) to create a condensed story. "
307
- "Output ONLY a valid JSON array of segments with 'src_start', 'src_end', 'note', and optionally 'gap'. "
308
- "CRITICAL RULES:\n"
309
- "1. IGNORE ALL INTERVIEWER COMMENTS: Do not include any speech or segments where the interviewer is speaking.\n"
310
- "2. REMOVE FLUFF: Delete 'um', 'ah', repeats, and irrelevant filler.\n"
311
- "3. NARRATIVE FLOW: Focus on the subject's high-energy responses and narrative hooks.\n"
312
- "4. TIMESTAMP INTEGRITY: Use only the exact word-level start and end times from the data.\n"
313
- "5. PACING: Group related clips together. Between distinct ideas, add a 'gap': 1.0 (float seconds) "
314
- "to the segment preceding the break."
 
 
 
 
 
315
  )
316
 
317
  prompt_text = f"Creative Brief: {story_prompt}\n\nTranscript Data:\n{json.dumps(transcript_data)}"
@@ -402,6 +487,7 @@ st.markdown("""
402
  * Upload your file here (video or audio).
403
  * Set your timeline FPS, Source Timecode, and transcription quality in the sidebar.
404
  * Junior Editor will transcribe and separate speakers. You can then instruct it to find engaging bits or construct a narrative.
 
405
  * It will create an EDL or XML to import back into your editing software (Resolve, Premiere).
406
  * **For Multicam Workflows:** Use the **XML** option in the sidebar and enter the **exact name** of your Multicam Sequence.
407
  * **Resolve Users:** Uncheck "Automatically import source clips into media pool" during import.
@@ -459,6 +545,8 @@ if uploaded_file:
459
  if "last_processed_file" not in st.session_state or st.session_state.last_processed_file != uploaded_file.name:
460
  if "transcript" in st.session_state:
461
  del st.session_state.transcript
 
 
462
  st.session_state.last_processed_file = uploaded_file.name
463
 
464
  # --- Auto-Process Logic ---
@@ -541,7 +629,7 @@ if uploaded_file:
541
  st.markdown(f"**{seg['speaker']}:** {seg['text']}")
542
 
543
  st.subheader("Your Instruction")
544
- 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.")
545
 
546
  if st.button("Generate Edit"):
547
  if not ACTIVE_GEMINI_KEY:
@@ -567,22 +655,33 @@ if uploaded_file:
567
  final_output = generate_xml(final_source_name, edl_segments, final_source_name, fps, offset_sec)
568
  ext = "xml"
569
 
570
- # Generate the companion human-readable transcript
571
- transcript_txt = generate_transcript_txt(final_source_name, edl_segments, fps, offset_sec)
572
-
573
- st.subheader("Ready for Import")
574
- st.success(f"✅ Edit generated successfully using **{used_model}** (via {used_endpoint}).")
575
 
576
- # Display downloads in a neat row
577
- col1, col2 = st.columns(2)
578
- with col1:
579
- st.download_button(f"Download .{ext.upper()} Sequence", data=final_output, file_name=f"{safe_filename}.{ext}")
580
- with col2:
581
- st.download_button(f"Download Reference Transcript (.TXT)", data=transcript_txt, file_name=f"{safe_filename}-Transcript.txt")
 
 
582
 
583
- with st.expander(f"Show {ext.upper()} Code", expanded=False):
584
- st.code(final_output, language="xml" if ext == "xml" else "text")
585
-
586
- with st.expander("Show Transcript Reference", expanded=False):
587
- st.text(transcript_txt)
 
 
 
 
 
 
 
 
 
 
 
 
588
 
 
119
  reel_id = source_name.replace(" ", "_")[:8] # EDL reel IDs are traditionally short max 8 chars
120
 
121
  for i, seg in enumerate(segments, 1):
122
+ is_vo = seg.get('type') == 'vo' or 'text' in seg
 
123
 
124
+ if is_vo:
125
+ vo_text = seg.get('text', 'VO')
126
+ duration = float(seg.get('duration', max(1.0, len(vo_text.split()) / 2.5)))
127
+ src_in = format_timecode(0, fps)
128
+ src_out = format_timecode(duration, fps)
129
+ current_source = "GEN_VO"
130
+ note_text = f"VO SCRIPT: {vo_text}"
131
+ else:
132
+ src_start_val = float(seg.get('src_start', seg.get('start', 0.0)))
133
+ src_end_val = float(seg.get('src_end', seg.get('end', 0.0)))
134
+ src_in = format_timecode(src_start_val + start_offset, fps)
135
+ src_out = format_timecode(src_end_val + start_offset, fps)
136
+ duration = src_end_val - src_start_val
137
+ current_source = reel_id
138
+ note_text = seg.get('note', 'Clip')
139
 
 
140
  rec_in = format_timecode(rec_start, fps)
141
  rec_out = format_timecode(rec_start + duration, fps)
142
 
143
+ edl_lines.append(f"{i:03} {current_source:8} V C {src_in} {src_out} {rec_in} {rec_out}")
144
+ if not is_vo:
145
+ edl_lines.append(f"* FROM CLIP NAME: {source_name}")
146
+ edl_lines.append(f"* {note_text}\n")
147
 
148
  gap = float(seg.get('gap', 0.0))
149
  rec_start += duration + gap
 
188
  lines.append('\t\t\t<track>')
189
 
190
  file_defined = False
191
+ vo_file_defined = False
192
  timeline_head_frames = 0
193
+
194
  for i, seg in enumerate(segments, 1):
195
+ is_vo = seg.get('type') == 'vo' or 'text' in seg
 
196
 
197
+ if is_vo:
198
+ vo_text = seg.get('text', 'VO')
199
+ duration_sec = float(seg.get('duration', max(1.0, len(vo_text.split()) / 2.5)))
200
+ duration_frames = seconds_to_frames(duration_sec, fps)
201
+ src_in_frames = 0
202
+ src_out_frames = duration_frames
203
+ master_id_to_use = "masterfile-vo"
204
+ source_name_to_use = "Generated_VO_Placeholder"
205
+ clip_note = f"VO SCRIPT: {vo_text}"
206
+ clip_name_disp = (clip_note[:75] + '..') if len(clip_note) > 75 else clip_note
207
+ else:
208
+ src_start_val = float(seg.get('src_start', seg.get('start', 0.0)))
209
+ src_end_val = float(seg.get('src_end', seg.get('end', 0.0)))
210
+ src_in_frames = seconds_to_frames(src_start_val, fps)
211
+ src_out_frames = seconds_to_frames(src_end_val, fps)
212
+ duration_frames = src_out_frames - src_in_frames
213
+ master_id_to_use = master_id
214
+ source_name_to_use = source_name
215
+ raw_note = seg.get('note', 'Junior Editor Selection')
216
+ clip_note = raw_note
217
+ clip_name_disp = source_name
218
 
219
  tl_start = timeline_head_frames
220
  tl_end = tl_start + duration_frames
 
 
 
221
 
222
  lines.append(f'\t\t\t\t<clipitem id="clipitem-v-{i}">')
223
+ lines.append(f'\t\t\t\t\t<name>{clip_name_disp}</name>')
224
  lines.append(f'\t\t\t\t\t<duration>8640000</duration>')
225
  lines.append(f'\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>')
226
  lines.append(f'\t\t\t\t\t<start>{tl_start}</start>')
227
  lines.append(f'\t\t\t\t\t<end>{tl_end}</end>')
228
  lines.append(f'\t\t\t\t\t<in>{src_in_frames}</in>')
229
  lines.append(f'\t\t\t\t\t<out>{src_out_frames}</out>')
230
+ lines.append(f'\t\t\t\t\t<masterclipid>{master_id_to_use}</masterclipid>')
231
 
232
+ # --- ROBUST FILE DEFINITIONS ---
233
+ if is_vo:
234
+ if not vo_file_defined:
235
+ lines.append(f'\t\t\t\t\t<file id="{master_id_to_use}">')
236
+ lines.append(f'\t\t\t\t\t\t<name>{source_name_to_use}</name>')
237
+ lines.append(f'\t\t\t\t\t\t<pathurl>file://localhost/{source_name_to_use}.wav</pathurl>')
238
+ lines.append(f'\t\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>')
239
+ lines.append(f'\t\t\t\t\t\t<duration>8640000</duration>')
240
+ lines.append(f'\t\t\t\t\t\t<timecode>')
241
+ lines.append(f'\t\t\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>')
242
+ lines.append(f'\t\t\t\t\t\t\t<string>00:00:00:00</string>')
243
+ lines.append(f'\t\t\t\t\t\t\t<frame>0</frame>')
244
+ lines.append(f'\t\t\t\t\t\t\t<displayformat>NDF</displayformat>')
245
+ lines.append(f'\t\t\t\t\t\t</timecode>')
246
+ lines.append('\t\t\t\t\t\t<media>')
247
+ lines.append('\t\t\t\t\t\t\t<video><samplecharacteristics><width>1920</width><height>1080</height></samplecharacteristics></video>')
248
+ lines.append('\t\t\t\t\t\t\t<audio><samplecharacteristics><depth>16</depth><samplerate>48000</samplerate></samplecharacteristics><channelcount>2</channelcount></audio>')
249
+ lines.append('\t\t\t\t\t\t</media>')
250
+ lines.append('\t\t\t\t\t</file>')
251
+ vo_file_defined = True
252
+ else:
253
+ lines.append(f'\t\t\t\t\t<file id="{master_id_to_use}"/>')
254
  else:
255
+ if not file_defined:
256
+ lines.append(f'\t\t\t\t\t<file id="{master_id_to_use}">')
257
+ lines.append(f'\t\t\t\t\t\t<name>{source_name_to_use}</name>')
258
+ lines.append(f'\t\t\t\t\t\t<pathurl>{path_url}</pathurl>')
259
+ lines.append(f'\t\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>')
260
+ lines.append(f'\t\t\t\t\t\t<duration>8640000</duration>')
261
+ lines.append(f'\t\t\t\t\t\t<timecode>')
262
+ lines.append(f'\t\t\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>')
263
+ lines.append(f'\t\t\t\t\t\t\t<string>{start_tc_string}</string>')
264
+ lines.append(f'\t\t\t\t\t\t\t<frame>{start_offset_frames}</frame>')
265
+ lines.append(f'\t\t\t\t\t\t\t<displayformat>NDF</displayformat>')
266
+ lines.append(f'\t\t\t\t\t\t</timecode>')
267
+ lines.append('\t\t\t\t\t\t<media>')
268
+ lines.append('\t\t\t\t\t\t\t<video><samplecharacteristics><width>1920</width><height>1080</height></samplecharacteristics></video>')
269
+ lines.append('\t\t\t\t\t\t\t<audio><samplecharacteristics><depth>16</depth><samplerate>48000</samplerate></samplecharacteristics><channelcount>2</channelcount></audio>')
270
+ lines.append('\t\t\t\t\t\t</media>')
271
+ lines.append('\t\t\t\t\t</file>')
272
+ file_defined = True
273
+ else:
274
+ lines.append(f'\t\t\t\t\t<file id="{master_id_to_use}"/>')
275
 
276
  lines.append('\t\t\t\t\t<marker>')
277
  lines.append(f'\t\t\t\t\t\t<name>{clip_note}</name>')
 
293
 
294
  timeline_head_frames = 0
295
  for i, seg in enumerate(segments, 1):
296
+ is_vo = seg.get('type') == 'vo' or 'text' in seg
 
297
 
298
+ if is_vo:
299
+ vo_text = seg.get('text', 'VO')
300
+ duration_sec = float(seg.get('duration', max(1.0, len(vo_text.split()) / 2.5)))
301
+ duration_frames = seconds_to_frames(duration_sec, fps)
302
+ src_in_frames = 0
303
+ src_out_frames = duration_frames
304
+ master_id_to_use = "masterfile-vo"
305
+ source_name_to_use = "Generated_VO_Placeholder"
306
+ else:
307
+ src_start_val = float(seg.get('src_start', seg.get('start', 0.0)))
308
+ src_end_val = float(seg.get('src_end', seg.get('end', 0.0)))
309
+ src_in_frames = seconds_to_frames(src_start_val, fps)
310
+ src_out_frames = seconds_to_frames(src_end_val, fps)
311
+ duration_frames = src_out_frames - src_in_frames
312
+ master_id_to_use = master_id
313
+ source_name_to_use = source_name
314
+
315
  tl_start = timeline_head_frames
316
  tl_end = tl_start + duration_frames
317
 
318
  lines.append(f'\t\t\t\t<clipitem id="clipitem-a-{i}">')
319
+ lines.append(f'\t\t\t\t\t<name>{source_name_to_use}</name>')
320
+ lines.append(f'\t\t\t\t\t<masterclipid>{master_id_to_use}</masterclipid>')
321
  lines.append(f'\t\t\t\t\t<duration>8640000</duration>')
322
  lines.append(f'\t\t\t\t\t<rate><timebase>{timebase}</timebase></rate>')
323
  lines.append(f'\t\t\t\t\t<start>{tl_start}</start>')
324
  lines.append(f'\t\t\t\t\t<end>{tl_end}</end>')
325
  lines.append(f'\t\t\t\t\t<in>{src_in_frames}</in>')
326
  lines.append(f'\t\t\t\t\t<out>{src_out_frames}</out>')
327
+ lines.append(f'\t\t\t\t\t<file id="{master_id_to_use}"/>')
328
  lines.append('\t\t\t\t\t<sourcetrack><mediatype>audio</mediatype><trackindex>1</trackindex></sourcetrack>')
329
  lines.append('\t\t\t\t</clipitem>')
330
  timeline_head_frames = tl_end + seconds_to_frames(float(seg.get('gap', 0.0)), fps)
 
337
 
338
  return "\n".join(lines)
339
 
340
+ def generate_transcript_txt(sequence_name, segments, fps=25, start_offset=0.0, transcript_data=None):
341
+ """Generates a human-readable text document of the edit decisions and exact transcription."""
342
  lines = [f"Sequence Transcript: {sequence_name}", "=" * 50, ""]
343
 
344
  for i, seg in enumerate(segments, 1):
345
+ is_vo = seg.get('type') == 'vo' or 'text' in seg
 
 
 
 
346
 
347
+ if is_vo:
348
+ vo_text = seg.get('text', 'VO')
349
+ duration = float(seg.get('duration', max(1.0, len(vo_text.split()) / 2.5)))
350
+ lines.append(f"Clip {i:02} | [GENERATED VOICE-OVER] | Est. Duration: {duration:.1f}s")
351
+ lines.append(f"Script: \"{vo_text}\"")
352
+ else:
353
+ src_start_val = float(seg.get('src_start', seg.get('start', 0.0)))
354
+ src_end_val = float(seg.get('src_end', seg.get('end', 0.0)))
355
+ src_in = format_timecode(src_start_val + start_offset, fps)
356
+ src_out = format_timecode(src_end_val + start_offset, fps)
357
+ note = seg.get('note', 'No note provided.')
358
+
359
+ # Cross-reference the exact times with the original Whisper transcript to get the exact words
360
+ actual_text = ""
361
+ if transcript_data:
362
+ clip_words = []
363
+ for t_seg in transcript_data:
364
+ # Check if the transcript segment overlaps with our clip's time window
365
+ if max(src_start_val, t_seg['start']) < min(src_end_val, t_seg['end']):
366
+ clip_words.append(t_seg['text'])
367
+ actual_text = " ".join(clip_words).strip()
368
+
369
+ lines.append(f"Clip {i:02} | IN: {src_in} --> OUT: {src_out}")
370
+ lines.append(f"AI Summary: {note}")
371
+ if actual_text:
372
+ lines.append(f"Exact Audio Text: \"{actual_text}\"")
373
+
374
  lines.append("") # Blank line to separate clips visually
375
 
376
  return "\n".join(lines)
 
383
 
384
  system_prompt = (
385
  "You are an expert Documentary Senior Editor. Use the provided transcript JSON "
386
+ "(which includes Speaker IDs and word-level timestamps) to create a condensed story.\n\n"
387
+ "NEW CAPABILITY: You can now act as a Writer/Director and generate Voice-Over (VO) to bridge gaps, introduce topics, or summarize. "
388
+ "CRITICAL: ONLY generate 'vo' segments if the user explicitly requests voice over suggestions or narration in their creative brief. Otherwise, ONLY use 'clip' segments.\n\n"
389
+ "Output ONLY a valid JSON array of segments. Every segment MUST be either a 'clip' or a 'vo'.\n\n"
390
+ "For 'clip' segments (extracting from the subject):\n"
391
+ "{\"type\": \"clip\", \"src_start\": 12.5, \"src_end\": 25.0, \"note\": \"Subject talks about X\", \"gap\": 0.0}\n"
392
+ "- IGNORE ALL INTERVIEWER COMMENTS.\n"
393
+ "- REMOVE FLUFF: Delete 'um', 'ah', repeats, and irrelevant filler.\n"
394
+ "- TIMESTAMP INTEGRITY: Use only the exact word-level start and end times from the data.\n\n"
395
+ "For 'vo' segments (generating new voice-over):\n"
396
+ "{\"type\": \"vo\", \"text\": \"The journey began years ago...\", \"duration\": 3.5, \"gap\": 0.0}\n"
397
+ "- DURATION: Estimate duration accurately based on reading speed (approx 2.5 words per second).\n"
398
+ "- Keep VO concise and in the style of a documentary narrator.\n\n"
399
+ "PACING: Group related clips. Add a 'gap' (in seconds) between distinct ideas."
400
  )
401
 
402
  prompt_text = f"Creative Brief: {story_prompt}\n\nTranscript Data:\n{json.dumps(transcript_data)}"
 
487
  * Upload your file here (video or audio).
488
  * Set your timeline FPS, Source Timecode, and transcription quality in the sidebar.
489
  * Junior Editor will transcribe and separate speakers. You can then instruct it to find engaging bits or construct a narrative.
490
+ * **Note: If you want me to write voice over suggestions, please request it.**
491
  * It will create an EDL or XML to import back into your editing software (Resolve, Premiere).
492
  * **For Multicam Workflows:** Use the **XML** option in the sidebar and enter the **exact name** of your Multicam Sequence.
493
  * **Resolve Users:** Uncheck "Automatically import source clips into media pool" during import.
 
545
  if "last_processed_file" not in st.session_state or st.session_state.last_processed_file != uploaded_file.name:
546
  if "transcript" in st.session_state:
547
  del st.session_state.transcript
548
+ if "edit_generated" in st.session_state:
549
+ del st.session_state.edit_generated
550
  st.session_state.last_processed_file = uploaded_file.name
551
 
552
  # --- Auto-Process Logic ---
 
629
  st.markdown(f"**{seg['speaker']}:** {seg['text']}")
630
 
631
  st.subheader("Your Instruction")
632
+ 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, please request it.")
633
 
634
  if st.button("Generate Edit"):
635
  if not ACTIVE_GEMINI_KEY:
 
655
  final_output = generate_xml(final_source_name, edl_segments, final_source_name, fps, offset_sec)
656
  ext = "xml"
657
 
658
+ # Generate the companion human-readable transcript, passing the raw transcript data for exact word matching
659
+ transcript_txt = generate_transcript_txt(final_source_name, edl_segments, fps, offset_sec, st.session_state.transcript)
 
 
 
660
 
661
+ # Save to session state so buttons don't disappear on click
662
+ st.session_state.edit_generated = True
663
+ st.session_state.final_output = final_output
664
+ st.session_state.ext = ext
665
+ st.session_state.transcript_txt = transcript_txt
666
+ st.session_state.safe_filename = safe_filename
667
+ st.session_state.used_model = used_model
668
+ st.session_state.used_endpoint = used_endpoint
669
 
670
+ # Render the download buttons outside the Generate block using session state
671
+ if st.session_state.get("edit_generated"):
672
+ st.subheader("Ready for Import")
673
+ st.success(f"✅ Edit generated successfully using **{st.session_state.used_model}** (via {st.session_state.used_endpoint}).")
674
+
675
+ # Display downloads in a neat row
676
+ col1, col2 = st.columns(2)
677
+ with col1:
678
+ 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}")
679
+ with col2:
680
+ st.download_button(f"Download Reference Transcript (.TXT)", data=st.session_state.transcript_txt, file_name=f"{st.session_state.safe_filename}-Transcript.txt")
681
+
682
+ with st.expander(f"Show {st.session_state.ext.upper()} Code", expanded=False):
683
+ st.code(st.session_state.final_output, language="xml" if st.session_state.ext == "xml" else "text")
684
+
685
+ with st.expander("Show Transcript Reference", expanded=False):
686
+ st.text(st.session_state.transcript_txt)
687