NickVerri commited on
Commit
efc7ad0
·
verified ·
1 Parent(s): b4c2174

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +320 -4
app.py CHANGED
@@ -115,7 +115,323 @@ def generate_cmx_edl(edl_title, segments, source_name, fps=25):
115
  def generate_xml(sequence_name, segments, source_name, fps=25):
116
  """Constructs a Final Cut Pro 7 XML with Video AND Audio Track 1."""
117
 
118
- xml_output = [
119
- '<?xml version="1.0" encoding="UTF-8"?>',
120
- '<!DOCTYPE xmeml>',
121
- '<x
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  def generate_xml(sequence_name, segments, source_name, fps=25):
116
  """Constructs a Final Cut Pro 7 XML with Video AND Audio Track 1."""
117
 
118
+ # Use append mode to avoid copy-paste line break errors
119
+ lines = []
120
+ lines.append('<?xml version="1.0" encoding="UTF-8"?>')
121
+ lines.append('<!DOCTYPE xmeml>')
122
+ lines.append('<xmeml version="4">')
123
+ lines.append('<sequence>')
124
+ lines.append(f'\t<name>{sequence_name}</name>')
125
+ lines.append('\t<rate>')
126
+ lines.append(f'\t\t<timebase>{fps}</timebase>')
127
+ lines.append('\t</rate>')
128
+ lines.append('\t<media>')
129
+
130
+ # --- VIDEO TRACK ---
131
+ lines.append('\t\t<video>')
132
+ lines.append('\t\t\t<format>')
133
+ lines.append('\t\t\t\t<samplecharacteristics>')
134
+ lines.append(f'\t\t\t\t\t<rate><timebase>{fps}</timebase></rate>')
135
+ lines.append('\t\t\t\t\t<width>1920</width>')
136
+ lines.append('\t\t\t\t\t<height>1080</height>')
137
+ lines.append('\t\t\t\t\t<pixelaspectratio>square</pixelaspectratio>')
138
+ lines.append('\t\t\t\t</samplecharacteristics>')
139
+ lines.append('\t\t\t</format>')
140
+ lines.append('\t\t\t<track>')
141
+
142
+ # --- VIDEO LOOP ---
143
+ timeline_head_frames = 0
144
+ for i, seg in enumerate(segments, 1):
145
+ src_in_frames = seconds_to_frames(seg['src_start'], fps)
146
+ src_out_frames = seconds_to_frames(seg['src_end'], fps)
147
+ duration_frames = src_out_frames - src_in_frames
148
+
149
+ tl_start = timeline_head_frames
150
+ tl_end = tl_start + duration_frames
151
+ clip_note = seg.get('note', 'Junior Editor Selection')
152
+
153
+ lines.append(f'\t\t\t\t<clipitem id="clipitem-v-{i}">')
154
+ lines.append(f'\t\t\t\t\t<name>{clip_note}</name>')
155
+ lines.append(f'\t\t\t\t\t<duration>{duration_frames}</duration>')
156
+ lines.append(f'\t\t\t\t\t<rate><timebase>{fps}</timebase></rate>')
157
+ lines.append(f'\t\t\t\t\t<start>{tl_start}</start>')
158
+ lines.append(f'\t\t\t\t\t<end>{tl_end}</end>')
159
+ lines.append(f'\t\t\t\t\t<in>{src_in_frames}</in>')
160
+ lines.append(f'\t\t\t\t\t<out>{src_out_frames}</out>')
161
+
162
+ # File Reference
163
+ lines.append(f'\t\t\t\t\t<file id="multicam_file">')
164
+ lines.append(f'\t\t\t\t\t\t<name>{source_name}</name>')
165
+ lines.append(f'\t\t\t\t\t\t<pathurl>file://localhost/placeholder/{source_name}</pathurl>')
166
+ lines.append(f'\t\t\t\t\t\t<rate><timebase>{fps}</timebase></rate>')
167
+ lines.append(f'\t\t\t\t\t\t<timecode><string>00:00:00:00</string></timecode>')
168
+ lines.append(f'\t\t\t\t\t</file>')
169
+ lines.append(f'\t\t\t\t</clipitem>')
170
+
171
+ gap_seconds = seg.get('gap', 0.0)
172
+ gap_frames = seconds_to_frames(gap_seconds, fps)
173
+ timeline_head_frames = tl_end + gap_frames
174
+
175
+ lines.append('\t\t\t</track>')
176
+ lines.append('\t\t</video>')
177
+
178
+ # --- AUDIO TRACK ---
179
+ lines.append('\t\t<audio>')
180
+ lines.append('\t\t\t<numOutputChannels>2</numOutputChannels>')
181
+ lines.append('\t\t\t<format>')
182
+ lines.append('\t\t\t\t<samplecharacteristics>')
183
+ lines.append('\t\t\t\t\t<depth>16</depth>')
184
+ lines.append('\t\t\t\t\t<samplerate>48000</samplerate>')
185
+ lines.append('\t\t\t\t</samplecharacteristics>')
186
+ lines.append('\t\t\t</format>')
187
+ lines.append('\t\t\t<track>') # Audio Track 1
188
+
189
+ # --- AUDIO LOOP (Identical Timing to Video) ---
190
+ timeline_head_frames = 0
191
+ for i, seg in enumerate(segments, 1):
192
+ src_in_frames = seconds_to_frames(seg['src_start'], fps)
193
+ src_out_frames = seconds_to_frames(seg['src_end'], fps)
194
+ duration_frames = src_out_frames - src_in_frames
195
+
196
+ tl_start = timeline_head_frames
197
+ tl_end = tl_start + duration_frames
198
+ clip_note = seg.get('note', 'Junior Editor Selection')
199
+
200
+ lines.append(f'\t\t\t\t<clipitem id="clipitem-a-{i}">')
201
+ lines.append(f'\t\t\t\t\t<name>{clip_note}</name>')
202
+ lines.append(f'\t\t\t\t\t<duration>{duration_frames}</duration>')
203
+ lines.append(f'\t\t\t\t\t<rate><timebase>{fps}</timebase></rate>')
204
+ lines.append(f'\t\t\t\t\t<start>{tl_start}</start>')
205
+ lines.append(f'\t\t\t\t\t<end>{tl_end}</end>')
206
+ lines.append(f'\t\t\t\t\t<in>{src_in_frames}</in>')
207
+ lines.append(f'\t\t\t\t\t<out>{src_out_frames}</out>')
208
+
209
+ # File Reference (Same ID as video)
210
+ lines.append(f'\t\t\t\t\t<file id="multicam_file"/>')
211
+
212
+ # SOURCE TRACK MAPPING (Use Source Track 1)
213
+ lines.append('\t\t\t\t\t<sourcetrack>')
214
+ lines.append('\t\t\t\t\t\t<mediatype>audio</mediatype>')
215
+ lines.append('\t\t\t\t\t\t<trackindex>1</trackindex>')
216
+ lines.append('\t\t\t\t\t</sourcetrack>')
217
+
218
+ lines.append(f'\t\t\t\t</clipitem>')
219
+
220
+ gap_seconds = seg.get('gap', 0.0)
221
+ gap_frames = seconds_to_frames(gap_seconds, fps)
222
+ timeline_head_frames = tl_end + gap_frames
223
+
224
+ lines.append('\t\t\t</track>')
225
+ lines.append('\t\t</audio>')
226
+ lines.append('\t</media>')
227
+ lines.append('</sequence>')
228
+ lines.append('</xmeml>')
229
+
230
+ return "\n".join(lines)
231
+
232
+ def call_gemini_for_edl(transcript_data, story_prompt, api_key):
233
+ if not api_key:
234
+ st.error("Gemini API Key is missing.")
235
+ return None
236
+
237
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key={api_key}"
238
+
239
+ system_prompt = (
240
+ "You are an expert Documentary Senior Editor. Use the provided transcript JSON "
241
+ "(which includes Speaker IDs and word-level timestamps) to create a condensed story. "
242
+ "Output ONLY a valid JSON array of segments with 'src_start', 'src_end', 'note', and optionally 'gap'. "
243
+ "CRITICAL RULES:\n"
244
+ "1. IGNORE ALL INTERVIEWER COMMENTS: Do not include any speech or segments where the interviewer is speaking.\n"
245
+ "2. REMOVE FLUFF: Delete 'um', 'ah', repeats, and irrelevant filler.\n"
246
+ "3. NARRATIVE FLOW: Focus on the subject's high-energy responses and narrative hooks.\n"
247
+ "4. TIMESTAMP INTEGRITY: Use only the exact word-level start and end times from the data.\n"
248
+ "5. PACING: Group related clips together. Between distinct ideas, add a 'gap': 1.0 (float seconds) "
249
+ "to the segment preceding the break."
250
+ )
251
+
252
+ prompt_text = f"Creative Brief: {story_prompt}\n\nTranscript Data:\n{json.dumps(transcript_data)}"
253
+
254
+ payload = {
255
+ "contents": [{"parts": [{"text": prompt_text}]}],
256
+ "systemInstruction": {"parts": [{"text": system_prompt}]},
257
+ "generationConfig": {"responseMimeType": "application/json"}
258
+ }
259
+
260
+ try:
261
+ res = requests.post(url, json=payload)
262
+ res.raise_for_status()
263
+ result_json = res.json()
264
+ return json.loads(result_json['candidates'][0]['content']['parts'][0]['text'])
265
+ except Exception as e:
266
+ st.error(f"Senior Editor AI Error: {e}")
267
+ return None
268
+
269
+ # --- Streamlit UI ---
270
+ st.set_page_config(page_title="Junior Editor", layout="wide")
271
+ st.title("Junior Editor")
272
+
273
+ st.markdown("""
274
+ **Instructions**
275
+ * Upload your file here (video or audio).
276
+ * Set your timeline FPS and transcription quality.
277
+ * Junior Editor will transcribe and separate speakers. You can then instruct it to find engaging bits or construct a narrative.
278
+ * It will create an EDL or XML to import back into your editing software (Resolve, Premiere).
279
+ * **For Multicam Workflows:** Use the **XML** option in the sidebar and enter the **exact name** of your Multicam Sequence.
280
+ """)
281
+
282
+ st.divider()
283
+
284
+ with st.sidebar:
285
+ st.header("Project Settings")
286
+ fps = st.number_input("Timeline FPS", value=25)
287
+
288
+ st.header("Export Settings")
289
+ export_format = st.radio("Output Format", ["EDL", "XML (Multicam)"], index=0)
290
+
291
+ input_label = "EDL Reel Name"
292
+ input_help = "Leave empty to use the uploaded file name."
293
+
294
+ if export_format == "XML (Multicam)":
295
+ input_label = "Multicam Sequence Name"
296
+ input_help = "EXACT name of your Multicam Clip in Resolve."
297
+
298
+ st.info("💡 **Conform Helper**")
299
+ custom_reel_name = st.text_input(
300
+ input_label,
301
+ placeholder="e.g. Interview_Day1_Multi",
302
+ help=input_help
303
+ )
304
+
305
+ st.header("Model Settings")
306
+ model_size = st.selectbox("Whisper Model", ["large-v2", "medium", "base"], index=0)
307
+
308
+ language_map = {
309
+ "Auto-Detect": None,
310
+ "English": "en",
311
+ "Spanish": "es",
312
+ "French": "fr",
313
+ "German": "de",
314
+ "Italian": "it",
315
+ "Portuguese": "pt"
316
+ }
317
+ selected_lang_label = st.selectbox("Audio Language", list(language_map.keys()), index=1)
318
+ target_language = language_map[selected_lang_label]
319
+
320
+ num_speakers = st.number_input("Speakers (0=Auto)", min_value=0, value=0)
321
+
322
+ st.divider()
323
+ if ACTIVE_HF_TOKEN == "PASTE_YOUR_HF_TOKEN_HERE":
324
+ st.warning("⚠️ HF_TOKEN not set in Secrets!")
325
+ else:
326
+ st.success("✅ HF_TOKEN Loaded")
327
+
328
+ uploaded_file = st.file_uploader("Upload Video/Audio Clip", type=["mp4", "m4a", "wav", "mp3", "mov"])
329
+
330
+ if uploaded_file:
331
+ # --- Auto-Reset Logic ---
332
+ if "last_processed_file" not in st.session_state or st.session_state.last_processed_file != uploaded_file.name:
333
+ if "transcript" in st.session_state:
334
+ del st.session_state.transcript
335
+ st.session_state.last_processed_file = uploaded_file.name
336
+
337
+ # --- Auto-Process Logic ---
338
+ if "transcript" not in st.session_state:
339
+ if not ACTIVE_HF_TOKEN or "PASTE_YOUR_HF_TOKEN" in ACTIVE_HF_TOKEN:
340
+ st.error("Please provide a valid Hugging Face Token in the Sidebar/Secrets.")
341
+ else:
342
+ progress_container = st.container()
343
+ with progress_container:
344
+ st.info("🤖 **Junior Editor is processing your file...**")
345
+ status_text = st.empty()
346
+ progress_bar = st.progress(0)
347
+
348
+ try:
349
+ # Phase 1
350
+ status_text.markdown("**Phase 1/4: Extracting Audio...**")
351
+ with open("temp_input", "wb") as f:
352
+ f.write(uploaded_file.getbuffer())
353
+
354
+ subprocess.run(["ffmpeg", "-i", "temp_input", "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", "temp_audio.wav", "-y"])
355
+ progress_bar.progress(25)
356
+
357
+ device = "cuda" if torch.cuda.is_available() else "cpu"
358
+ if device == "cpu": st.warning("⚠️ No GPU detected.")
359
+
360
+ # Phase 2
361
+ status_text.markdown(f"**Phase 2/4: Transcribing (Whisper {model_size})... This is the longest step.**")
362
+ compute_type = "float16" if device == "cuda" else "int8"
363
+ model = whisperx.load_model(model_size, device, compute_type=compute_type)
364
+ audio = whisperx.load_audio("temp_audio.wav")
365
+ result = model.transcribe(audio, batch_size=16, language=target_language)
366
+ del model
367
+ gc.collect()
368
+ torch.cuda.empty_cache()
369
+ progress_bar.progress(50)
370
+
371
+ # Phase 3
372
+ status_text.markdown("**Phase 3/4: Aligning Text...**")
373
+ model_a, metadata = whisperx.load_align_model(language_code=result["language"], device=device)
374
+ result = whisperx.align(result["segments"], model_a, metadata, audio, device, return_char_alignments=False)
375
+ del model_a
376
+ gc.collect()
377
+ torch.cuda.empty_cache()
378
+ progress_bar.progress(75)
379
+
380
+ # Phase 4
381
+ status_text.markdown("**Phase 4/4: Identifying Speakers...**")
382
+ diarize_model = whisperx.DiarizationPipeline(use_auth_token=ACTIVE_HF_TOKEN, device=device)
383
+ diarize_kwargs = {"min_speakers": num_speakers, "max_speakers": num_speakers} if num_speakers > 0 else {}
384
+ diarize_segments = diarize_model(audio, **diarize_kwargs)
385
+
386
+ # Final Merge
387
+ status_text.markdown("**Finalizing...**")
388
+ final_result = whisperx.assign_word_speakers(diarize_segments, result)
389
+
390
+ processed_segments = []
391
+ for segment in final_result["segments"]:
392
+ processed_segments.append({
393
+ "speaker": segment.get("speaker", "Unknown"),
394
+ "text": segment["text"].strip(),
395
+ "start": segment["start"],
396
+ "end": segment["end"]
397
+ })
398
+
399
+ st.session_state.transcript = processed_segments
400
+ if os.path.exists("temp_input"): os.remove("temp_input")
401
+ if os.path.exists("temp_audio.wav"): os.remove("temp_audio.wav")
402
+ progress_bar.progress(100)
403
+ status_text.success(f"Done! Processed {len(processed_segments)} segments.")
404
+
405
+ except Exception as e:
406
+ status_text.error(f"Error: {e}")
407
+ if os.path.exists("temp_input"): os.remove("temp_input")
408
+ st.stop()
409
+
410
+ if "transcript" in st.session_state:
411
+ st.divider()
412
+ with st.expander("Transcript Preview", expanded=True):
413
+ for seg in st.session_state.transcript:
414
+ st.markdown(f"**{seg['speaker']}:** {seg['text']}")
415
+
416
+ st.subheader("Your Instruction")
417
+ 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.")
418
+
419
+ if st.button("Generate Edit"):
420
+ if not ACTIVE_GEMINI_KEY:
421
+ st.error("Gemini API Key required.")
422
+ else:
423
+ with st.spinner("Junior Editor is thinking..."):
424
+ final_source_name = custom_reel_name.strip() if custom_reel_name.strip() else uploaded_file.name
425
+
426
+ edl_segments = call_gemini_for_edl(st.session_state.transcript, brief, ACTIVE_GEMINI_KEY)
427
+ if edl_segments:
428
+ if export_format == "EDL":
429
+ final_output = generate_cmx_edl("Junior_Editor_Cut", edl_segments, final_source_name, fps)
430
+ ext = "edl"
431
+ else:
432
+ final_output = generate_xml("Junior_Editor_Cut", edl_segments, final_source_name, fps)
433
+ ext = "xml"
434
+
435
+ st.subheader("Ready for Import")
436
+ st.code(final_output, language="xml" if ext == "xml" else "text")
437
+ st.download_button(f"Download .{ext.upper()}", data=final_output, file_name=f"junior_editor_cut.{ext}")