Update app.py
Browse files
app.py
CHANGED
|
@@ -90,32 +90,32 @@ def clean_json_response(text):
|
|
| 90 |
except Exception:
|
| 91 |
return text
|
| 92 |
|
| 93 |
-
def
|
| 94 |
-
"""Converts HH:MM:SS:FF
|
| 95 |
if not tc or not re.match(r"\d{2}:\d{2}:\d{2}[:\.]\d{2}", tc):
|
| 96 |
-
return 0
|
| 97 |
-
|
| 98 |
parts = re.split(r'[:\.]', tc)
|
| 99 |
h, m, s, f = map(int, parts)
|
| 100 |
-
|
|
|
|
| 101 |
|
| 102 |
-
def
|
| 103 |
-
"""Converts
|
| 104 |
-
|
| 105 |
-
hours =
|
| 106 |
-
minutes = (
|
| 107 |
-
secs = (
|
| 108 |
-
|
| 109 |
-
return f"{hours:02}:{minutes:02}:{secs:02}:{
|
| 110 |
|
| 111 |
def seconds_to_frames(seconds, fps):
|
| 112 |
-
"""Converts seconds to integer frames
|
| 113 |
return int(round(seconds * fps))
|
| 114 |
|
| 115 |
-
def generate_cmx_edl(edl_title, segments, source_name, fps=25,
|
| 116 |
"""Constructs a CMX 3600 formatted EDL with source offset."""
|
| 117 |
edl_lines = [f"TITLE: {edl_title}", "FCM: NON-DROP FRAME\n"]
|
| 118 |
-
|
| 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):
|
|
@@ -123,41 +123,44 @@ def generate_cmx_edl(edl_title, segments, source_name, fps=25, start_offset=0.0)
|
|
| 123 |
|
| 124 |
if is_vo:
|
| 125 |
vo_text = seg.get('text', 'VO')
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
| 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 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
current_source = reel_id
|
| 138 |
note_text = seg.get('note', 'Clip')
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
-
rec_in =
|
| 141 |
-
rec_out =
|
| 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 |
-
|
| 149 |
-
|
| 150 |
|
| 151 |
return "\n".join(edl_lines)
|
| 152 |
|
| 153 |
-
def generate_xml(sequence_name, segments, source_name, fps=25,
|
| 154 |
"""Constructs a Robust XML with source timecode offset for Premiere AND Resolve."""
|
| 155 |
master_id = "masterfile-1"
|
| 156 |
timebase = int(round(fps))
|
| 157 |
is_ntsc = "TRUE" if fps % 1 != 0 else "FALSE"
|
| 158 |
|
| 159 |
-
|
| 160 |
-
start_tc_string = format_timecode(start_offset, fps)
|
| 161 |
|
| 162 |
encoded_name = quote(source_name)
|
| 163 |
path_url = f"file://localhost/{encoded_name}"
|
|
@@ -337,7 +340,7 @@ def generate_xml(sequence_name, segments, source_name, fps=25, start_offset=0.0)
|
|
| 337 |
|
| 338 |
return "\n".join(lines)
|
| 339 |
|
| 340 |
-
def generate_transcript_txt(sequence_name, segments, fps=25,
|
| 341 |
"""Generates a human-readable text document of the edit decisions and exact transcription."""
|
| 342 |
lines = [f"Sequence Transcript: {sequence_name}", "=" * 50, ""]
|
| 343 |
|
|
@@ -352,16 +355,17 @@ def generate_transcript_txt(sequence_name, segments, fps=25, start_offset=0.0, t
|
|
| 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 |
-
|
| 356 |
-
|
|
|
|
|
|
|
|
|
|
| 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()
|
|
@@ -371,12 +375,12 @@ def generate_transcript_txt(sequence_name, segments, fps=25, start_offset=0.0, t
|
|
| 371 |
if actual_text:
|
| 372 |
lines.append(f"Exact Audio Text: \"{actual_text}\"")
|
| 373 |
|
| 374 |
-
lines.append("")
|
| 375 |
|
| 376 |
return "\n".join(lines)
|
| 377 |
|
| 378 |
|
| 379 |
-
def call_gemini_for_edl(transcript_data, story_prompt, api_key):
|
| 380 |
if not api_key:
|
| 381 |
st.error("Gemini API Key is missing.")
|
| 382 |
return None
|
|
@@ -399,7 +403,15 @@ def call_gemini_for_edl(transcript_data, story_prompt, api_key):
|
|
| 399 |
"PACING: Group related clips. Add a 'gap' (in seconds) between distinct ideas."
|
| 400 |
)
|
| 401 |
|
| 402 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
|
| 404 |
payload = {
|
| 405 |
"contents": [
|
|
@@ -637,7 +649,7 @@ if uploaded_file:
|
|
| 637 |
else:
|
| 638 |
with st.spinner("Junior Editor is thinking..."):
|
| 639 |
final_source_name = custom_reel_name.strip() if custom_reel_name.strip() else uploaded_file.name
|
| 640 |
-
|
| 641 |
|
| 642 |
edl_segments, used_model, used_endpoint = call_gemini_for_edl(st.session_state.transcript, brief, ACTIVE_GEMINI_KEY)
|
| 643 |
|
|
@@ -649,14 +661,14 @@ if uploaded_file:
|
|
| 649 |
|
| 650 |
# Generate the selected edit format (EDL/XML)
|
| 651 |
if export_format == "EDL":
|
| 652 |
-
final_output = generate_cmx_edl(final_source_name, edl_segments, final_source_name, fps,
|
| 653 |
ext = "edl"
|
| 654 |
else:
|
| 655 |
-
final_output = generate_xml(final_source_name, edl_segments, final_source_name, fps,
|
| 656 |
ext = "xml"
|
| 657 |
|
| 658 |
-
# Generate the companion human-readable transcript
|
| 659 |
-
transcript_txt = generate_transcript_txt(final_source_name, edl_segments, fps,
|
| 660 |
|
| 661 |
# Save to session state so buttons don't disappear on click
|
| 662 |
st.session_state.edit_generated = True
|
|
@@ -666,6 +678,7 @@ if uploaded_file:
|
|
| 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"):
|
|
@@ -679,9 +692,38 @@ if uploaded_file:
|
|
| 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 |
-
|
| 683 |
-
|
|
|
|
| 684 |
|
| 685 |
-
|
| 686 |
-
st.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 687 |
|
|
|
|
| 90 |
except Exception:
|
| 91 |
return text
|
| 92 |
|
| 93 |
+
def timecode_to_frames(tc, fps):
|
| 94 |
+
"""Converts HH:MM:SS:FF to absolute integer frames based on timebase."""
|
| 95 |
if not tc or not re.match(r"\d{2}:\d{2}:\d{2}[:\.]\d{2}", tc):
|
| 96 |
+
return 0
|
|
|
|
| 97 |
parts = re.split(r'[:\.]', tc)
|
| 98 |
h, m, s, f = map(int, parts)
|
| 99 |
+
timebase = int(round(fps)) # 29.97 becomes 30, 23.98 becomes 24
|
| 100 |
+
return (h * 3600 * timebase) + (m * 60 * timebase) + (s * timebase) + f
|
| 101 |
|
| 102 |
+
def frames_to_timecode(frames, fps):
|
| 103 |
+
"""Converts absolute integer frames back to HH:MM:SS:FF (NDF)."""
|
| 104 |
+
timebase = int(round(fps))
|
| 105 |
+
hours = frames // (3600 * timebase)
|
| 106 |
+
minutes = (frames // (60 * timebase)) % 60
|
| 107 |
+
secs = (frames // timebase) % 60
|
| 108 |
+
f = frames % timebase
|
| 109 |
+
return f"{hours:02}:{minutes:02}:{secs:02}:{f:02}"
|
| 110 |
|
| 111 |
def seconds_to_frames(seconds, fps):
|
| 112 |
+
"""Converts real seconds to integer frames."""
|
| 113 |
return int(round(seconds * fps))
|
| 114 |
|
| 115 |
+
def generate_cmx_edl(edl_title, segments, source_name, fps=25, start_offset_frames=0):
|
| 116 |
"""Constructs a CMX 3600 formatted EDL with source offset."""
|
| 117 |
edl_lines = [f"TITLE: {edl_title}", "FCM: NON-DROP FRAME\n"]
|
| 118 |
+
rec_start_frames = 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):
|
|
|
|
| 123 |
|
| 124 |
if is_vo:
|
| 125 |
vo_text = seg.get('text', 'VO')
|
| 126 |
+
duration_sec = float(seg.get('duration', max(1.0, len(vo_text.split()) / 2.5)))
|
| 127 |
+
duration_frames = seconds_to_frames(duration_sec, fps)
|
| 128 |
+
src_in_frames = 0
|
| 129 |
+
src_out_frames = duration_frames
|
| 130 |
current_source = "GEN_VO"
|
| 131 |
note_text = f"VO SCRIPT: {vo_text}"
|
| 132 |
else:
|
| 133 |
src_start_val = float(seg.get('src_start', seg.get('start', 0.0)))
|
| 134 |
src_end_val = float(seg.get('src_end', seg.get('end', 0.0)))
|
| 135 |
+
src_in_frames = seconds_to_frames(src_start_val, fps) + start_offset_frames
|
| 136 |
+
src_out_frames = seconds_to_frames(src_end_val, fps) + start_offset_frames
|
| 137 |
+
duration_frames = src_out_frames - src_in_frames
|
| 138 |
current_source = reel_id
|
| 139 |
note_text = seg.get('note', 'Clip')
|
| 140 |
+
|
| 141 |
+
src_in = frames_to_timecode(src_in_frames, fps)
|
| 142 |
+
src_out = frames_to_timecode(src_out_frames, fps)
|
| 143 |
|
| 144 |
+
rec_in = frames_to_timecode(rec_start_frames, fps)
|
| 145 |
+
rec_out = frames_to_timecode(rec_start_frames + duration_frames, fps)
|
| 146 |
|
| 147 |
edl_lines.append(f"{i:03} {current_source:8} V C {src_in} {src_out} {rec_in} {rec_out}")
|
| 148 |
if not is_vo:
|
| 149 |
edl_lines.append(f"* FROM CLIP NAME: {source_name}")
|
| 150 |
edl_lines.append(f"* {note_text}\n")
|
| 151 |
|
| 152 |
+
gap_frames = seconds_to_frames(float(seg.get('gap', 0.0)), fps)
|
| 153 |
+
rec_start_frames += duration_frames + gap_frames
|
| 154 |
|
| 155 |
return "\n".join(edl_lines)
|
| 156 |
|
| 157 |
+
def generate_xml(sequence_name, segments, source_name, fps=25, start_offset_frames=0):
|
| 158 |
"""Constructs a Robust XML with source timecode offset for Premiere AND Resolve."""
|
| 159 |
master_id = "masterfile-1"
|
| 160 |
timebase = int(round(fps))
|
| 161 |
is_ntsc = "TRUE" if fps % 1 != 0 else "FALSE"
|
| 162 |
|
| 163 |
+
start_tc_string = frames_to_timecode(start_offset_frames, fps)
|
|
|
|
| 164 |
|
| 165 |
encoded_name = quote(source_name)
|
| 166 |
path_url = f"file://localhost/{encoded_name}"
|
|
|
|
| 340 |
|
| 341 |
return "\n".join(lines)
|
| 342 |
|
| 343 |
+
def generate_transcript_txt(sequence_name, segments, fps=25, start_offset_frames=0, transcript_data=None):
|
| 344 |
"""Generates a human-readable text document of the edit decisions and exact transcription."""
|
| 345 |
lines = [f"Sequence Transcript: {sequence_name}", "=" * 50, ""]
|
| 346 |
|
|
|
|
| 355 |
else:
|
| 356 |
src_start_val = float(seg.get('src_start', seg.get('start', 0.0)))
|
| 357 |
src_end_val = float(seg.get('src_end', seg.get('end', 0.0)))
|
| 358 |
+
src_in_frames = seconds_to_frames(src_start_val, fps) + start_offset_frames
|
| 359 |
+
src_out_frames = seconds_to_frames(src_end_val, fps) + start_offset_frames
|
| 360 |
+
|
| 361 |
+
src_in = frames_to_timecode(src_in_frames, fps)
|
| 362 |
+
src_out = frames_to_timecode(src_out_frames, fps)
|
| 363 |
note = seg.get('note', 'No note provided.')
|
| 364 |
|
|
|
|
| 365 |
actual_text = ""
|
| 366 |
if transcript_data:
|
| 367 |
clip_words = []
|
| 368 |
for t_seg in transcript_data:
|
|
|
|
| 369 |
if max(src_start_val, t_seg['start']) < min(src_end_val, t_seg['end']):
|
| 370 |
clip_words.append(t_seg['text'])
|
| 371 |
actual_text = " ".join(clip_words).strip()
|
|
|
|
| 375 |
if actual_text:
|
| 376 |
lines.append(f"Exact Audio Text: \"{actual_text}\"")
|
| 377 |
|
| 378 |
+
lines.append("")
|
| 379 |
|
| 380 |
return "\n".join(lines)
|
| 381 |
|
| 382 |
|
| 383 |
+
def call_gemini_for_edl(transcript_data, story_prompt, api_key, previous_edit=None):
|
| 384 |
if not api_key:
|
| 385 |
st.error("Gemini API Key is missing.")
|
| 386 |
return None
|
|
|
|
| 403 |
"PACING: Group related clips. Add a 'gap' (in seconds) between distinct ideas."
|
| 404 |
)
|
| 405 |
|
| 406 |
+
if previous_edit:
|
| 407 |
+
prompt_text = (
|
| 408 |
+
f"Transcript Data:\n{json.dumps(transcript_data)}\n\n"
|
| 409 |
+
f"Previous Edit You Generated:\n{json.dumps(previous_edit)}\n\n"
|
| 410 |
+
f"Director's Note for Revision: {story_prompt}\n\n"
|
| 411 |
+
"Please output a NEW JSON array of segments applying these requested changes to the previous edit."
|
| 412 |
+
)
|
| 413 |
+
else:
|
| 414 |
+
prompt_text = f"Creative Brief: {story_prompt}\n\nTranscript Data:\n{json.dumps(transcript_data)}"
|
| 415 |
|
| 416 |
payload = {
|
| 417 |
"contents": [
|
|
|
|
| 649 |
else:
|
| 650 |
with st.spinner("Junior Editor is thinking..."):
|
| 651 |
final_source_name = custom_reel_name.strip() if custom_reel_name.strip() else uploaded_file.name
|
| 652 |
+
offset_frames = timecode_to_frames(source_start_tc, fps)
|
| 653 |
|
| 654 |
edl_segments, used_model, used_endpoint = call_gemini_for_edl(st.session_state.transcript, brief, ACTIVE_GEMINI_KEY)
|
| 655 |
|
|
|
|
| 661 |
|
| 662 |
# Generate the selected edit format (EDL/XML)
|
| 663 |
if export_format == "EDL":
|
| 664 |
+
final_output = generate_cmx_edl(final_source_name, edl_segments, final_source_name, fps, offset_frames)
|
| 665 |
ext = "edl"
|
| 666 |
else:
|
| 667 |
+
final_output = generate_xml(final_source_name, edl_segments, final_source_name, fps, offset_frames)
|
| 668 |
ext = "xml"
|
| 669 |
|
| 670 |
+
# Generate the companion human-readable transcript
|
| 671 |
+
transcript_txt = generate_transcript_txt(final_source_name, edl_segments, fps, offset_frames, st.session_state.transcript)
|
| 672 |
|
| 673 |
# Save to session state so buttons don't disappear on click
|
| 674 |
st.session_state.edit_generated = True
|
|
|
|
| 678 |
st.session_state.safe_filename = safe_filename
|
| 679 |
st.session_state.used_model = used_model
|
| 680 |
st.session_state.used_endpoint = used_endpoint
|
| 681 |
+
st.session_state.edl_segments = edl_segments
|
| 682 |
|
| 683 |
# Render the download buttons outside the Generate block using session state
|
| 684 |
if st.session_state.get("edit_generated"):
|
|
|
|
| 692 |
with col2:
|
| 693 |
st.download_button(f"Download Reference Transcript (.TXT)", data=st.session_state.transcript_txt, file_name=f"{st.session_state.safe_filename}-Transcript.txt")
|
| 694 |
|
| 695 |
+
st.divider()
|
| 696 |
+
st.subheader("Revise Edit")
|
| 697 |
+
revision_brief = st.text_area("Want changes? Tell Junior Editor:", placeholder="e.g. Make it twice as long, add more details about X, or write a VO intro.")
|
| 698 |
|
| 699 |
+
if st.button("Apply Revisions"):
|
| 700 |
+
with st.spinner("Junior Editor is revising the edit..."):
|
| 701 |
+
final_source_name = custom_reel_name.strip() if custom_reel_name.strip() else uploaded_file.name
|
| 702 |
+
offset_frames = timecode_to_frames(source_start_tc, fps)
|
| 703 |
+
|
| 704 |
+
new_edl_segments, used_model, used_endpoint = call_gemini_for_edl(
|
| 705 |
+
st.session_state.transcript,
|
| 706 |
+
revision_brief,
|
| 707 |
+
ACTIVE_GEMINI_KEY,
|
| 708 |
+
previous_edit=st.session_state.edl_segments
|
| 709 |
+
)
|
| 710 |
+
|
| 711 |
+
if new_edl_segments:
|
| 712 |
+
if export_format == "EDL":
|
| 713 |
+
final_output = generate_cmx_edl(final_source_name, new_edl_segments, final_source_name, fps, offset_frames)
|
| 714 |
+
ext = "edl"
|
| 715 |
+
else:
|
| 716 |
+
final_output = generate_xml(final_source_name, new_edl_segments, final_source_name, fps, offset_frames)
|
| 717 |
+
ext = "xml"
|
| 718 |
+
|
| 719 |
+
transcript_txt = generate_transcript_txt(final_source_name, new_edl_segments, fps, offset_frames, st.session_state.transcript)
|
| 720 |
+
|
| 721 |
+
st.session_state.final_output = final_output
|
| 722 |
+
st.session_state.ext = ext
|
| 723 |
+
st.session_state.transcript_txt = transcript_txt
|
| 724 |
+
st.session_state.used_model = used_model
|
| 725 |
+
st.session_state.used_endpoint = used_endpoint
|
| 726 |
+
st.session_state.edl_segments = new_edl_segments
|
| 727 |
+
st.rerun()
|
| 728 |
+
|
| 729 |
|