NickVerri commited on
Commit
84035ab
·
verified ·
1 Parent(s): e2a5014

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +183 -164
app.py CHANGED
@@ -8,15 +8,20 @@ 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,172 +30,186 @@ def format_timecode(seconds, fps=25):
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
- )
 
 
 
 
 
 
8
  from datetime import timedelta
9
  from pyannote.audio import Pipeline
10
  from huggingface_hub import login
11
+ from pydub import AudioSegment
12
 
13
+ # --- Configuration & Tokens ---
14
+ HARDCODED_HF_TOKEN = "PASTE_YOUR_HF_TOKEN_HERE"
15
+ HARDCODED_GEMINI_KEY = ""
16
 
17
+ ENV_HF_TOKEN = os.environ.get("HF_TOKEN", "")
18
+ ENV_GEMINI_KEY = os.environ.get("GEMINI_API_KEY", "")
19
+
20
+ ACTIVE_HF_TOKEN = ENV_HF_TOKEN if ENV_HF_TOKEN else HARDCODED_HF_TOKEN
21
+ ACTIVE_GEMINI_KEY = ENV_GEMINI_KEY if ENV_GEMINI_KEY else HARDCODED_GEMINI_KEY
22
 
 
23
  def format_timecode(seconds, fps=25):
24
+ """Converts seconds to HH:MM:SS:FF."""
25
  td = timedelta(seconds=seconds)
26
  total_seconds = int(td.total_seconds())
27
  hours = total_seconds // 3600
 
30
  frames = int((seconds - total_seconds) * fps)
31
  return f"{hours:02}:{minutes:02}:{secs:02}:{frames:02}"
32
 
33
+ def generate_cmx_edl(edl_title, segments, source_name, fps=25):
34
+ """Constructs a CMX 3600 formatted EDL."""
35
+ edl_lines = [f"TITLE: {edl_title}", "FCM: NON-DROP FRAME\n"]
36
+ rec_start = 0.0
37
  for i, seg in enumerate(segments, 1):
38
+ src_in = format_timecode(seg['src_start'], fps)
39
+ src_out = format_timecode(seg['src_end'], fps)
40
+ duration = seg['src_end'] - seg['src_start']
41
+ rec_in = format_timecode(rec_start, fps)
42
+ rec_out = format_timecode(rec_start + duration, fps)
43
+
44
+ edl_lines.append(f"{i:03} AX V C {src_in} {src_out} {rec_in} {rec_out}")
45
+ edl_lines.append(f"* FROM CLIP NAME: {source_name}")
46
+ edl_lines.append(f"* {seg.get('note', 'Clip')}\n")
47
+ rec_start += duration
48
+ return "\n".join(edl_lines)
49
+
50
+ def call_gemini_for_edl(transcript_data, story_prompt, api_key):
51
+ """Sends diarized, word-level transcript to Gemini Senior Editor."""
 
52
  if not api_key:
53
+ st.error("Gemini API Key is missing. Set it in Space Secrets or app.py.")
54
  return None
55
+
56
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key={api_key}"
57
+
 
 
 
 
58
  system_prompt = (
59
+ "You are an expert Documentary Senior Editor. Use the provided transcript JSON "
60
+ "(which includes Speaker IDs and word-level timestamps) to create a condensed story. "
61
+ "Output ONLY a valid JSON array of segments with 'src_start', 'src_end', and 'note'. "
62
+ "CRITICAL RULES:\n"
63
+ "1. IGNORE ALL INTERVIEWER COMMENTS: Do not include any speech or segments where the interviewer is speaking.\n"
64
+ "2. REMOVE FLUFF: Delete 'um', 'ah', repeats, and irrelevant filler.\n"
65
+ "3. NARRATIVE FLOW: Focus on the subject's high-energy responses and narrative hooks.\n"
66
+ "4. TIMESTAMP INTEGRITY: Use only the exact word-level start and end times from the data."
67
  )
68
+
69
+ prompt_text = f"Creative Brief: {story_prompt}\n\nTranscript Data:\n{json.dumps(transcript_data)}"
70
+
71
  payload = {
72
+ "contents": [{"parts": [{"text": prompt_text}]}],
 
 
73
  "systemInstruction": {"parts": [{"text": system_prompt}]},
74
  "generationConfig": {"responseMimeType": "application/json"}
75
  }
76
+
77
+ try:
78
+ res = requests.post(url, json=payload)
79
+ res.raise_for_status()
80
+ result_json = res.json()
81
+ return json.loads(result_json['candidates'][0]['content']['parts'][0]['text'])
82
+ except Exception as e:
83
+ st.error(f"Senior Editor AI Error: {e}")
84
+ return None
85
 
86
+ # --- Streamlit UI ---
87
+ st.set_page_config(page_title="DocAI Editor", layout="wide")
88
+ st.title("Documentary AI: Pipeline (Stable v2.1)")
89
+
90
+ with st.sidebar:
91
+ st.header("Project Settings")
92
+ fps = st.number_input("Timeline FPS", value=25)
93
+
94
+ st.divider()
95
+ st.info("API Keys are managed via Environment Secrets.")
96
+ if not ACTIVE_GEMINI_KEY:
97
+ st.error("⚠️ Gemini API Key not found!")
98
+ if not ACTIVE_HF_TOKEN:
99
+ st.error("⚠️ HF Token not found!")
100
+
101
+ uploaded_file = st.file_uploader("Upload Video/Audio Clip", type=["mp4", "m4a", "wav", "mp3", "mov"])
102
+
103
+ if uploaded_file:
104
+ if "transcript" not in st.session_state:
105
+ if st.button("Step 1: Transcribe & Diarize"):
106
+ if not ACTIVE_HF_TOKEN or "PASTE_YOUR_HF_TOKEN" in ACTIVE_HF_TOKEN:
107
+ st.error("Please provide a valid Hugging Face Token.")
108
+ else:
109
+ with st.spinner("Processing... This may take a moment."):
110
+ # Save local temp file
111
+ with open("temp_input", "wb") as f:
112
+ f.write(uploaded_file.getbuffer())
113
+
114
+ st.write("🎵 **Preprocessing Audio...**")
115
+ try:
116
+ # Use PyDub to convert to WAV (Mono, 16kHz)
117
+ # This is safer than raw ffmpeg for Pyannote 2.1
118
+ audio = AudioSegment.from_file("temp_input")
119
+ audio = audio.set_channels(1)
120
+ audio = audio.set_frame_rate(16000)
121
+ audio.export("temp_audio.wav", format="wav")
122
+ except Exception as e:
123
+ st.error(f"Audio processing failed: {e}")
124
+ st.stop()
125
+
126
+ # 1. Diarization (Pyannote 2.1.1)
127
+ st.write("🗣️ **Running Speaker Diarization...**")
128
+ diarization = None
129
+ try:
130
+ # Simple, legacy loading method
131
+ pipeline = Pipeline.from_pretrained(
132
+ "pyannote/speaker-diarization@2.1",
133
+ use_auth_token=ACTIVE_HF_TOKEN
134
+ )
135
+
136
+ if torch.cuda.is_available():
137
+ st.write("🚀 Using GPU for Diarization")
138
+ pipeline.to(torch.device("cuda"))
139
+
140
+ # Run pipeline directly on file path
141
+ diarization = pipeline("temp_audio.wav")
142
+
143
+ except Exception as e:
144
+ st.error(f"Diarization Error: {e}")
145
+ diarization = None
146
+
147
+ # 2. Whisper Transcription
148
+ st.write("📝 **Transcribing with Whisper...**")
149
+ device = "cuda" if torch.cuda.is_available() else "cpu"
150
+ model = whisper.load_model("medium", device=device)
151
+ result = model.transcribe("temp_audio.wav", word_timestamps=True)
152
+
153
+ # 3. Alignment
154
+ st.write("🔗 **Aligning Speakers...**")
155
+ final_segments = []
156
+ speaker_turns = []
157
+
158
+ if diarization:
159
+ # 2.1.1 returns a proper Annotation object directly
160
+ for turn, _, speaker_id in diarization.itertracks(yield_label=True):
161
+ speaker_turns.append({"start": turn.start, "end": turn.end, "speaker": speaker_id})
162
+
163
+ if len(speaker_turns) > 0:
164
+ st.write(f"✅ Found {len(speaker_turns)} speaker turns.")
165
+ else:
166
+ st.warning("⚠️ Pipeline ran but returned no tracks.")
167
+
168
+ for segment in result['segments']:
169
+ mid_time = (segment['start'] + segment['end']) / 2
170
+ speaker = "Unknown"
171
+
172
+ if speaker_turns:
173
+ for turn in speaker_turns:
174
+ if turn["start"] <= mid_time <= turn["end"]:
175
+ speaker = turn["speaker"]
176
+ break
177
+
178
+ if speaker == "Unknown":
179
+ best_dist = 1.0
180
+ for turn in speaker_turns:
181
+ dist = min(abs(turn["start"] - mid_time), abs(turn["end"] - mid_time))
182
+ if dist < best_dist:
183
+ best_dist = dist
184
+ speaker = turn["speaker"]
185
+
186
+ final_segments.append({
187
+ "speaker": speaker,
188
+ "text": segment['text'],
189
+ "start": segment['start'],
190
+ "end": segment['end'],
191
+ "words": segment.get('words', [])
192
+ })
193
+
194
+ st.session_state.transcript = final_segments
195
+ st.success("Complete!")
196
+
197
+ if "transcript" in st.session_state:
198
+ st.divider()
199
+ with st.expander("Transcript Preview (Diarized)"):
200
+ for seg in st.session_state.transcript[:20]:
201
+ st.markdown(f"**{seg['speaker']}:** {seg['text']}")
202
+
203
+ brief = st.text_area("Creative Brief", placeholder="e.g. Focus on the yeast story.")
204
+
205
+ if st.button("Step 2: Create EDL"):
206
+ if not ACTIVE_GEMINI_KEY:
207
+ st.error("Gemini API Key required.")
208
+ else:
209
+ with st.spinner("Analyzing..."):
210
+ edl_segments = call_gemini_for_edl(st.session_state.transcript, brief, ACTIVE_GEMINI_KEY)
211
+ if edl_segments:
212
+ final_edl = generate_cmx_edl("AI_Senior_Editor_Cut", edl_segments, uploaded_file.name, fps)
213
+ st.subheader("EDL Preview")
214
+ st.code(final_edl, language="text")
215
+ st.download_button("Download EDL", data=final_edl, file_name="edit.edl")