NickVerri commited on
Commit
091b119
·
verified ·
1 Parent(s): 5b70c11

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +167 -233
app.py CHANGED
@@ -5,44 +5,18 @@ import json
5
  import os
6
  import requests
7
  import torch
8
- import numpy as np
9
- import librosa
10
  from datetime import timedelta
11
  from pyannote.audio import Pipeline
12
- from huggingface_hub import login, hf_hub_download
13
-
14
- # --- Safe Globals for PyTorch 2.6+ ---
15
- try:
16
- from pyannote.audio.core.task import Specifications, Problem, Resolution
17
- from pyannote.audio.core.model import Model
18
- from pyannote.audio.pipelines.speaker_diarization import SpeakerDiarization
19
-
20
- torch.serialization.add_safe_globals([
21
- torch.torch_version.TorchVersion,
22
- Specifications,
23
- Problem,
24
- Resolution,
25
- Model,
26
- SpeakerDiarization,
27
- np.dtype,
28
- torch.nn.modules.container.ModuleList,
29
- np.core.multiarray.scalar
30
- ])
31
- except Exception as e:
32
- print(f"Safe Globals Warning: {e}")
33
-
34
- # --- Configuration & Tokens ---
35
- HARDCODED_HF_TOKEN = "PASTE_YOUR_HF_TOKEN_HERE"
36
- HARDCODED_GEMINI_KEY = ""
37
-
38
- ENV_HF_TOKEN = os.environ.get("HF_TOKEN", "")
39
- ENV_GEMINI_KEY = os.environ.get("GEMINI_API_KEY", "")
40
-
41
- ACTIVE_HF_TOKEN = ENV_HF_TOKEN if ENV_HF_TOKEN else HARDCODED_HF_TOKEN
42
- ACTIVE_GEMINI_KEY = ENV_GEMINI_KEY if ENV_GEMINI_KEY else HARDCODED_GEMINI_KEY
43
 
 
 
 
 
 
 
 
44
  def format_timecode(seconds, fps=25):
45
- """Converts seconds to HH:MM:SS:FF."""
46
  td = timedelta(seconds=seconds)
47
  total_seconds = int(td.total_seconds())
48
  hours = total_seconds // 3600
@@ -51,212 +25,172 @@ def format_timecode(seconds, fps=25):
51
  frames = int((seconds - total_seconds) * fps)
52
  return f"{hours:02}:{minutes:02}:{secs:02}:{frames:02}"
53
 
54
- def generate_cmx_edl(edl_title, segments, source_name, fps=25):
55
- """Constructs a CMX 3600 formatted EDL."""
56
- edl_lines = [f"TITLE: {edl_title}", "FCM: NON-DROP FRAME\n"]
57
- rec_start = 0.0
58
  for i, seg in enumerate(segments, 1):
59
- src_in = format_timecode(seg['src_start'], fps)
60
- src_out = format_timecode(seg['src_end'], fps)
61
- duration = seg['src_end'] - seg['src_start']
62
- rec_in = format_timecode(rec_start, fps)
63
- rec_out = format_timecode(rec_start + duration, fps)
64
-
65
- edl_lines.append(f"{i:03} AX V C {src_in} {src_out} {rec_in} {rec_out}")
66
- edl_lines.append(f"* FROM CLIP NAME: {source_name}")
67
- edl_lines.append(f"* {seg.get('note', 'Clip')}\n")
68
- rec_start += duration
69
- return "\n".join(edl_lines)
70
-
71
- def call_gemini_for_edl(transcript_data, story_prompt, api_key):
72
- """Sends diarized, word-level transcript to Gemini Senior Editor."""
 
73
  if not api_key:
74
- st.error("Gemini API Key is missing. Set it in Space Secrets or app.py.")
75
  return None
76
-
77
- url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key={api_key}"
78
-
 
 
 
 
79
  system_prompt = (
80
- "You are an expert Documentary Senior Editor. Use the provided transcript JSON "
81
- "(which includes Speaker IDs and word-level timestamps) to create a condensed story. "
82
- "Output ONLY a valid JSON array of segments with 'src_start', 'src_end', and 'note'. "
83
- "CRITICAL RULES:\n"
84
- "1. IGNORE ALL INTERVIEWER COMMENTS: Do not include any speech or segments where the interviewer is speaking.\n"
85
- "2. REMOVE FLUFF: Delete 'um', 'ah', repeats, and irrelevant filler.\n"
86
- "3. NARRATIVE FLOW: Focus on the subject's high-energy responses and narrative hooks.\n"
87
- "4. TIMESTAMP INTEGRITY: Use only the exact word-level start and end times from the data."
88
  )
89
-
90
- prompt_text = f"Creative Brief: {story_prompt}\n\nTranscript Data:\n{json.dumps(transcript_data)}"
91
-
92
  payload = {
93
- "contents": [{"parts": [{"text": prompt_text}]}],
 
 
94
  "systemInstruction": {"parts": [{"text": system_prompt}]},
95
  "generationConfig": {"responseMimeType": "application/json"}
96
  }
97
-
98
- try:
99
- res = requests.post(url, json=payload)
100
- res.raise_for_status()
101
- result_json = res.json()
102
- return json.loads(result_json['candidates'][0]['content']['parts'][0]['text'])
103
- except Exception as e:
104
- st.error(f"Senior Editor AI Error: {e}")
105
- return None
106
 
107
- # --- Streamlit UI ---
108
- st.set_page_config(page_title="DocAI Editor", layout="wide")
109
- st.title("Documentary AI: Pipeline")
110
-
111
- with st.sidebar:
112
- st.header("Project Settings")
113
- fps = st.number_input("Timeline FPS", value=25)
114
-
115
- st.divider()
116
- st.info("API Keys are managed via Environment Secrets.")
117
- if not ACTIVE_GEMINI_KEY:
118
- st.error("⚠️ Gemini API Key not found!")
119
- if not ACTIVE_HF_TOKEN:
120
- st.error("⚠️ HF Token not found!")
121
-
122
- uploaded_file = st.file_uploader("Upload Video/Audio Clip", type=["mp4", "m4a", "wav", "mp3", "mov"])
123
-
124
- if uploaded_file:
125
- # --- Step 1: Technical Processing ---
126
- if "transcript" not in st.session_state:
127
- if st.button("Step 1: Transcribe & Diarize"):
128
- if not ACTIVE_HF_TOKEN or "PASTE_YOUR_HF_TOKEN" in ACTIVE_HF_TOKEN:
129
- st.error("Please provide a valid Hugging Face Token.")
130
- else:
131
- with st.spinner("Processing... This may take a moment."):
132
- # Authenticate Globally
133
- login(token=ACTIVE_HF_TOKEN)
134
-
135
- with open("temp_input", "wb") as f:
136
- f.write(uploaded_file.getbuffer())
137
-
138
- # Convert to strict WAV using FFmpeg
139
- subprocess.run([
140
- "ffmpeg", "-i", "temp_input",
141
- "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1",
142
- "temp_audio.wav", "-y"
143
- ])
144
-
145
- # 1. Diarization with Librosa (Robust Audio Loading)
146
- st.write("🗣️ **Running Speaker Diarization...**")
147
- diarization = None
148
- try:
149
- # Load Config Manually
150
- config_path = hf_hub_download(
151
- repo_id="pyannote/speaker-diarization-3.1",
152
- filename="config.yaml",
153
- token=ACTIVE_HF_TOKEN
154
- )
155
- pipeline = Pipeline.from_pretrained(config_path)
156
-
157
- # Load Audio with Librosa (More robust than torchaudio in some containers)
158
- y, sr = librosa.load("temp_audio.wav", sr=16000)
159
- waveform = torch.tensor(y).unsqueeze(0) # Add channel dim
160
-
161
- # Move to GPU if available
162
- if torch.cuda.is_available():
163
- st.write("🚀 Using GPU for Diarization")
164
- pipeline.to(torch.device("cuda"))
165
- waveform = waveform.to(torch.device("cuda"))
166
-
167
- # Run Pipeline on Tensor directly
168
- diarization_output = pipeline({"waveform": waveform, "sample_rate": 16000})
169
-
170
- # Handle Output Wrapper
171
- if isinstance(diarization_output, tuple):
172
- diarization = diarization_output[0]
173
- else:
174
- diarization = diarization_output
175
-
176
- # Extract Annotation
177
- if not hasattr(diarization, "itertracks"):
178
- if hasattr(diarization_output, "annotation"):
179
- diarization = diarization_output.annotation
180
- elif hasattr(diarization_output, "get"):
181
- diarization = diarization_output.get("annotation", diarization_output)
182
-
183
- except Exception as e:
184
- st.error(f"Diarization Error: {e}")
185
- diarization = None
186
-
187
- # 2. Whisper Transcription
188
- st.write("📝 **Transcribing with Whisper...**")
189
- device = "cuda" if torch.cuda.is_available() else "cpu"
190
- model = whisper.load_model("medium", device=device)
191
- result = model.transcribe("temp_audio.wav", word_timestamps=True)
192
-
193
- # 3. Alignment
194
- st.write("🔗 **Aligning Speakers...**")
195
- final_segments = []
196
- speaker_turns = []
197
-
198
- if diarization:
199
- try:
200
- iterator = None
201
- if hasattr(diarization, 'itertracks'):
202
- iterator = diarization.itertracks(yield_label=True)
203
-
204
- if iterator:
205
- for turn, _, speaker_id in iterator:
206
- speaker_turns.append({"start": turn.start, "end": turn.end, "speaker": speaker_id})
207
- st.write(f" Found {len(speaker_turns)} speaker turns.")
208
- else:
209
- st.warning("⚠️ Pipeline ran but returned no iterable tracks.")
210
- except Exception as e:
211
- st.error(f"Error iterating tracks: {e}")
212
-
213
- for segment in result['segments']:
214
- mid_time = (segment['start'] + segment['end']) / 2
215
- speaker = "Unknown"
216
-
217
- if speaker_turns:
218
- # Match speaker
219
- for turn in speaker_turns:
220
- if turn["start"] <= mid_time <= turn["end"]:
221
- speaker = turn["speaker"]
222
- break
223
-
224
- # Fallback distance matching
225
- if speaker == "Unknown":
226
- best_dist = 1.0
227
- for turn in speaker_turns:
228
- dist = min(abs(turn["start"] - mid_time), abs(turn["end"] - mid_time))
229
- if dist < best_dist:
230
- best_dist = dist
231
- speaker = turn["speaker"]
232
-
233
- final_segments.append({
234
- "speaker": speaker,
235
- "text": segment['text'],
236
- "start": segment['start'],
237
- "end": segment['end'],
238
- "words": segment.get('words', [])
239
- })
240
-
241
- st.session_state.transcript = final_segments
242
- st.success("Complete!")
243
-
244
- if "transcript" in st.session_state:
245
- st.divider()
246
- with st.expander("Transcript Preview (Diarized)"):
247
- for seg in st.session_state.transcript[:20]:
248
- st.markdown(f"**{seg['speaker']}:** {seg['text']}")
249
-
250
- brief = st.text_area("Creative Brief", placeholder="e.g. Focus on the yeast story.")
251
-
252
- if st.button("Step 2: Create EDL"):
253
- if not ACTIVE_GEMINI_KEY:
254
- st.error("Gemini API Key required.")
255
- else:
256
- with st.spinner("Analyzing..."):
257
- edl_segments = call_gemini_for_edl(st.session_state.transcript, brief, ACTIVE_GEMINI_KEY)
258
- if edl_segments:
259
- final_edl = generate_cmx_edl("AI_Senior_Editor_Cut", edl_segments, uploaded_file.name, fps)
260
- st.subheader("EDL Preview")
261
- st.code(final_edl, language="text")
262
- st.download_button("Download EDL", data=final_edl, file_name="edit.edl")
 
5
  import os
6
  import requests
7
  import torch
 
 
8
  from datetime import timedelta
9
  from pyannote.audio import Pipeline
10
+ from huggingface_hub import login
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
+ # ------------------ ENV SETUP ------------------
13
+ os.environ["OMP_NUM_THREADS"] = "1"
14
+
15
+ HF_TOKEN = os.environ.get("HF_TOKEN", "")
16
+ GEMINI_KEY = os.environ.get("GEMINI_API_KEY", "")
17
+
18
+ # ------------------ UTILS ------------------
19
  def format_timecode(seconds, fps=25):
 
20
  td = timedelta(seconds=seconds)
21
  total_seconds = int(td.total_seconds())
22
  hours = total_seconds // 3600
 
25
  frames = int((seconds - total_seconds) * fps)
26
  return f"{hours:02}:{minutes:02}:{secs:02}:{frames:02}"
27
 
28
+ def generate_cmx_edl(title, segments, source_name, fps=25):
29
+ edl = [f"TITLE: {title}", "FCM: NON-DROP FRAME\n"]
30
+ rec_time = 0.0
31
+
32
  for i, seg in enumerate(segments, 1):
33
+ dur = seg["src_end"] - seg["src_start"]
34
+ edl.append(
35
+ f"{i:03} AX V C "
36
+ f"{format_timecode(seg['src_start'], fps)} "
37
+ f"{format_timecode(seg['src_end'], fps)} "
38
+ f"{format_timecode(rec_time, fps)} "
39
+ f"{format_timecode(rec_time + dur, fps)}"
40
+ )
41
+ edl.append(f"* FROM CLIP NAME: {source_name}")
42
+ edl.append(f"* {seg.get('note', '')}\n")
43
+ rec_time += dur
44
+
45
+ return "\n".join(edl)
46
+
47
+ def call_gemini(transcript, brief, api_key):
48
  if not api_key:
 
49
  return None
50
+
51
+ url = (
52
+ "https://generativelanguage.googleapis.com/v1beta/"
53
+ "models/gemini-2.5-flash-preview-09-2025:generateContent"
54
+ f"?key={api_key}"
55
+ )
56
+
57
  system_prompt = (
58
+ "You are a documentary senior editor. "
59
+ "Using the diarized transcript JSON, produce a concise story cut. "
60
+ "Output ONLY a JSON array with src_start, src_end, note. "
61
+ "Ignore interviewer speech."
 
 
 
 
62
  )
63
+
 
 
64
  payload = {
65
+ "contents": [{
66
+ "parts": [{"text": f"Brief:\n{brief}\n\nTranscript:\n{json.dumps(transcript)}"}]
67
+ }],
68
  "systemInstruction": {"parts": [{"text": system_prompt}]},
69
  "generationConfig": {"responseMimeType": "application/json"}
70
  }
 
 
 
 
 
 
 
 
 
71
 
72
+ r = requests.post(url, json=payload)
73
+ r.raise_for_status()
74
+ data = r.json()
75
+ return json.loads(data["candidates"][0]["content"]["parts"][0]["text"])
76
+
77
+ # ------------------ UI ------------------
78
+ st.set_page_config("DocAI Editor", layout="wide")
79
+ st.title("🎬 Documentary AI Pipeline")
80
+
81
+ if not HF_TOKEN:
82
+ st.error("HF_TOKEN missing in Space secrets")
83
+ st.stop()
84
+
85
+ login(token=HF_TOKEN)
86
+
87
+ uploaded = st.file_uploader(
88
+ "Upload audio or video",
89
+ type=["wav", "mp3", "m4a", "mp4", "mov"]
90
+ )
91
+
92
+ if uploaded:
93
+ if st.button("Step 1: Transcribe & Diarize"):
94
+ with st.spinner("Processing audio…"):
95
+
96
+ with open("input_media", "wb") as f:
97
+ f.write(uploaded.getbuffer())
98
+
99
+ subprocess.run(
100
+ [
101
+ "ffmpeg", "-y",
102
+ "-i", "input_media",
103
+ "-vn",
104
+ "-ac", "1",
105
+ "-ar", "16000",
106
+ "-acodec", "pcm_s16le",
107
+ "audio.wav",
108
+ ],
109
+ check=True
110
+ )
111
+
112
+ # -------- DIARIZATION --------
113
+ st.write("🗣️ Running speaker diarization…")
114
+
115
+ pipeline = Pipeline.from_pretrained(
116
+ "pyannote/speaker-diarization-3.1",
117
+ use_auth_token=HF_TOKEN
118
+ )
119
+
120
+ diarization = pipeline("audio.wav")
121
+
122
+ speaker_turns = []
123
+ for turn, _, speaker in diarization.itertracks(yield_label=True):
124
+ speaker_turns.append({
125
+ "start": turn.start,
126
+ "end": turn.end,
127
+ "speaker": speaker
128
+ })
129
+
130
+ st.success(f"Detected {len(set(t['speaker'] for t in speaker_turns))} speakers")
131
+
132
+ # -------- TRANSCRIPTION --------
133
+ st.write("📝 Transcribing with Whisper…")
134
+ device = "cuda" if torch.cuda.is_available() else "cpu"
135
+ whisper_model = whisper.load_model("medium", device=device)
136
+
137
+ result = whisper_model.transcribe(
138
+ "audio.wav",
139
+ word_timestamps=True
140
+ )
141
+
142
+ # -------- ALIGNMENT --------
143
+ st.write("🔗 Aligning speakers…")
144
+ final_segments = []
145
+
146
+ for seg in result["segments"]:
147
+ mid = (seg["start"] + seg["end"]) / 2
148
+ speaker = "Unknown"
149
+
150
+ for t in speaker_turns:
151
+ if t["start"] <= mid <= t["end"]:
152
+ speaker = t["speaker"]
153
+ break
154
+
155
+ final_segments.append({
156
+ "speaker": speaker,
157
+ "text": seg["text"],
158
+ "start": seg["start"],
159
+ "end": seg["end"],
160
+ "words": seg.get("words", [])
161
+ })
162
+
163
+ st.session_state.transcript = final_segments
164
+ st.success("Pipeline complete!")
165
+
166
+ # ------------------ OUTPUT ------------------
167
+ if "transcript" in st.session_state:
168
+ st.subheader("Transcript Preview")
169
+ for seg in st.session_state.transcript[:15]:
170
+ st.markdown(f"**{seg['speaker']}**: {seg['text']}")
171
+
172
+ brief = st.text_area("Creative Brief")
173
+
174
+ if st.button("Step 2: Create EDL"):
175
+ if not GEMINI_KEY:
176
+ st.error("Missing GEMINI_API_KEY")
177
+ else:
178
+ with st.spinner("Creating edit…"):
179
+ edl_segments = call_gemini(
180
+ st.session_state.transcript,
181
+ brief,
182
+ GEMINI_KEY
183
+ )
184
+
185
+ edl = generate_cmx_edl(
186
+ "AI_EDIT",
187
+ edl_segments,
188
+ uploaded.name
189
+ )
190
+
191
+ st.code(edl)
192
+ st.download_button(
193
+ "Download EDL",
194
+ edl,
195
+ "edit.edl"
196
+ )