ajmel commited on
Commit
2d74db3
Β·
1 Parent(s): ef1d40f

Deployment Setup

Browse files
multimodal-engine/.env.example ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Google Gemini API key β€” required for transcription, scene analysis, blog synthesis, and clip verification
2
+ # Get yours at: https://aistudio.google.com/app/apikey
3
+ GEMINI_API_KEY="your_google_gemini_api_key_here"
4
+
5
+ # OpenAI API key β€” used as a fallback if Gemini scene analysis fails (optional but recommended)
6
+ # Get yours at: https://platform.openai.com/api-keys
7
+ OPENAI_API_KEY="your_openai_api_key_here"
multimodal-engine/.gitignore CHANGED
@@ -5,4 +5,6 @@ data/*.avi
5
  data/extracted_frames
6
  data/extracted_clips
7
  data/extracted_real
8
- output/clips/*.mp4
 
 
 
5
  data/extracted_frames
6
  data/extracted_clips
7
  data/extracted_real
8
+ output/clips/*.mp4
9
+ assets/video.mp4
10
+ multimodal-engine/assets/video.mp4
multimodal-engine/Dockerfile ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ # Install system utilities and FFmpeg video processing binaries
4
+ RUN apt-get update && apt-get install -y \
5
+ ffmpeg \
6
+ build-essential \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ # Set up a non-root user to satisfy Hugging Face security protocols
10
+ RUN useradd -m -u 1000 user
11
+ USER user
12
+ ENV HOME=/home/user \
13
+ PATH=/home/user/.local/bin:$PATH
14
+
15
+ WORKDIR $HOME/app
16
+
17
+ # Copy local dependency maps into virtual container layers
18
+ COPY --chown=user requirements.txt .
19
+ RUN pip install --no-cache-dir -r requirements.txt
20
+
21
+ # Copy the rest of the application codebase
22
+ COPY --chown=user . .
23
+
24
+ # Hugging Face Spaces strictly requires port 7860
25
+ EXPOSE 7860
26
+
27
+ # Run Streamlit on Hugging Face's required port configuration layout
28
+ ENTRYPOINT ["streamlit", "run", "multimodal-engine/app/app.py", "--server.port=7860", "--server.address=0.0.0.0"]
multimodal-engine/README.md CHANGED
@@ -1,186 +1,420 @@
1
- # 🎬 SynapseMedia: Multimodal Content Engine
2
 
3
- An AI-powered pipeline that takes a raw video file and automatically produces a full content package β€” audio transcript, visual scene breakdown, a generated technical blog post, a 16:9 highlight clip, and a 9:16 vertical short reel.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  ---
6
 
7
- ## 🧠 What It Does
 
 
8
 
9
- Upload any tutorial or walkthrough video. The engine runs three sequential AI phases:
10
 
11
- - **Phase 1 β€” Audio:** Demuxes the audio stream via FFmpeg and transcribes it using Gemini 2.5 Flash.
12
- - **Phase 2 β€” Vision:** Extracts keyframes at a configurable interval and runs chronological scene analysis via Gemini Vision (with GPT-4o-mini as fallback).
13
- - **Phase 3 β€” Synthesis:** Fuses the transcript and visual breakdown into a structured Markdown blog post, then cuts a horizontal highlight clip and a vertical mobile short reel.
14
 
15
  ---
16
 
17
- ## πŸ“‚ Project Structure
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  ```
20
- multimodal-engine/
21
- β”œβ”€β”€ app/
22
- β”‚ β”œβ”€β”€ app.py # Streamlit UI β€” main entry point
23
- β”‚ β”œβ”€β”€ workflow_engine.py # Orchestrates the full pipeline & blog synthesis
24
- β”‚ β”œβ”€β”€ audio_processor.py # FFmpeg audio extraction + Gemini transcription
25
- β”‚ β”œβ”€β”€ video_processor.py # FFmpeg keyframe extraction + Gemini/OpenAI scene analysis
26
- β”‚ β”œβ”€β”€ clip_extractor.py # Cuts a 16:9 horizontal highlight clip
27
- β”‚ └── reel_generator.py # Crops and compiles a 9:16 vertical short reel
28
- β”œβ”€β”€ data/
29
- β”‚ β”œβ”€β”€ sample.mp4 # Test video input
30
- β”‚ β”œβ”€β”€ extracted_audio.mp3 # Audio demux output
31
- β”‚ β”œβ”€β”€ extracted_frames/ # Keyframe JPGs
32
- β”‚ β”œβ”€β”€ extracted_clips/ # Output clips
33
- β”‚ └── extracted_real/ # Output vertical reels
34
- β”œβ”€β”€ output/ # Generated blog posts (.md)
35
- β”œβ”€β”€ requirements.txt
36
- └── README.md
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  ```
38
 
 
 
 
 
 
 
 
 
 
39
  ---
40
 
41
- ## βš™οΈ Setup
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
  ### 1. Prerequisites
44
 
45
- - Python 3.10+
46
- - **FFmpeg** must be installed and available in your system PATH
47
- - Windows: [ffmpeg.org/download](https://ffmpeg.org/download.html) or `winget install ffmpeg`
48
- - macOS: `brew install ffmpeg`
49
- - Linux: `sudo apt install ffmpeg`
 
 
 
50
 
51
- ### 2. Install dependencies
 
 
 
 
 
 
 
52
 
53
  ```bash
54
- cd multimodal-engine
 
 
 
 
 
 
 
55
  pip install -r requirements.txt
56
  ```
57
 
58
- ### 3. Environment variables
59
 
60
- Create a `.env` file in the root of the repository (or in `multimodal-engine/`):
61
 
62
- ```env
63
- GOOGLE_API_KEY=your_google_gemini_key
64
- OPENAI_API_KEY=your_openai_key
65
  ```
66
 
67
- Both keys are used β€” Gemini is the primary model, OpenAI GPT-4o-mini is the visual analysis fallback.
68
 
69
- ---
 
 
 
70
 
71
- ## πŸš€ Running the App
 
 
 
72
 
73
  ```bash
74
- cd multimodal-engine/app
75
- streamlit run app.py
76
  ```
77
 
78
- Then open [http://localhost:8501](http://localhost:8501) in your browser.
79
 
80
- ### UI Walkthrough
81
 
82
- 1. Drag and drop an `.mp4` file into the sidebar uploader
83
- 2. Set the **keyframe sampling interval** (seconds between captured frames)
84
- 3. Click **πŸš€ Process Complete AI Workflow**
85
- 4. View results across three tabs:
86
- - **πŸ“„ Generated Blog Post** β€” download the Markdown article
87
- - **🎬 Automated Video Assets** β€” preview the 16:9 clip and 9:16 reel side by side
88
- - **πŸŽ™οΈ Raw Data Tracks** β€” inspect the raw transcript and scene analysis text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
  ---
91
 
92
- ## πŸ”§ Running Modules Standalone
93
 
94
- Each module has its own `__main__` block for isolated testing:
95
 
96
  ```bash
97
- # Test audio extraction + transcription only
98
- python app/audio_processor.py
 
 
 
 
99
 
100
- # Test keyframe extraction + scene analysis only
101
- python app/video_processor.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
 
103
- # Test clip cutting only
104
- python app/clip_extractor.py
 
105
 
106
- # Test vertical reel generation only
107
- python app/reel_generator.py
108
 
109
- # Run the full pipeline headlessly (no UI)
110
- python app/workflow_engine.py
 
 
 
 
 
111
  ```
112
 
113
- All standalone runners expect `../data/sample.mp4` as the input file.
 
 
 
 
 
 
 
 
114
 
115
  ---
116
 
117
- ## πŸ—οΈ Architecture
118
 
119
  ```
120
- Video Input (.mp4)
121
- β”‚
122
- β”œβ”€β”€[FFmpeg]──► extracted_audio.mp3
123
- β”‚ β”‚
124
- β”‚ [Gemini 2.5 Flash]
125
- β”‚ β”‚
126
- β”‚ Audio Transcript
127
- β”‚
128
- β”œβ”€β”€[FFmpeg]──► keyframe_XXXX.jpg (every N seconds)
129
- β”‚ β”‚
130
- β”‚ [Gemini Vision / GPT-4o-mini fallback]
131
- β”‚ β”‚
132
- β”‚ Visual Scene Breakdown
133
- β”‚
134
- └──[Gemini 2.5 Flash]──► Technical Blog Post (.md)
135
- β”‚
136
- β”œβ”€β”€[FFmpeg]──► 16:9 Highlight Clip (.mp4)
137
- └──[FFmpeg]──► 9:16 Vertical Short Reel (.mp4)
 
 
 
 
 
 
 
 
 
 
138
  ```
139
 
140
  ---
141
 
142
- ## πŸ› οΈ Tech Stack
143
 
144
- | Component | Technology |
145
- |-------------------|-------------------------------------|
146
- | UI | Streamlit |
147
- | Audio extraction | FFmpeg (`libmp3lame`) |
148
- | Transcription | Google Gemini 2.5 Flash |
149
- | Keyframe slicing | FFmpeg (`fps` filter) |
150
- | Scene analysis | Google Gemini 2.5 Flash Vision |
151
- | Vision fallback | OpenAI GPT-4o-mini |
152
- | Blog synthesis | Google Gemini 2.5 Flash |
153
- | Clip/reel cutting | FFmpeg (`libx264`, crop filter) |
154
- | Environment | python-dotenv |
155
 
156
  ---
157
 
158
- ## πŸ“ˆ Roadmap
159
 
160
- - [x] FFmpeg audio demux
161
- - [x] Gemini audio transcription
162
- - [x] FFmpeg keyframe extraction
163
- - [x] Gemini visual scene analysis
164
- - [x] OpenAI vision fallback
165
- - [x] Blog post synthesis (transcript + visuals)
166
- - [x] 16:9 highlight clip cutter
167
- - [x] 9:16 vertical reel generator
168
- - [x] Streamlit UI with 3-phase progress tracking
169
- - [ ] Smart clip detection (auto-find highlight moments from transcript)
170
- - [ ] Subtitle/caption burn-in on reels
171
- - [ ] Cross-modal search (natural language β†’ video timestamp)
172
- - [ ] Batch processing (queue multiple videos)
173
- - [ ] FastAPI backend
174
 
175
  ---
176
 
177
- ## πŸ–ΌοΈ Screenshots
178
 
179
- > Screenshots will be added once the UI is deployed.
 
 
 
 
180
 
181
  ---
182
 
183
- ## πŸ‘€ Author
 
 
 
 
184
 
185
- Built as Phase 2 of a structured AI engineering roadmap.
186
- See the [root README](../README.md) for the full portfolio overview.
 
1
+ <div align="center">
2
 
3
+ <img src="assets/dashboard.png" alt="SynapseMedia Dashboard" width="100%"/>
4
+
5
+ # 🎬 SynapseMedia β€” Multimodal Content Engine
6
+
7
+ **Turn any long-form video into a vertical reel and a CMS-ready blog post β€” fully automated.**
8
+
9
+ [![Python](https://img.shields.io/badge/Python-3.12-blue?logo=python&logoColor=white)](https://www.python.org/)
10
+ [![Streamlit](https://img.shields.io/badge/Streamlit-1.57-FF4B4B?logo=streamlit&logoColor=white)](https://streamlit.io/)
11
+ [![Gemini](https://img.shields.io/badge/Gemini_2.5-Flash_%26_Pro-4285F4?logo=google&logoColor=white)](https://ai.google.dev/)
12
+ [![Tests](https://img.shields.io/badge/Tests-72_passing-brightgreen?logo=pytest)](multimodal-engine/test/)
13
+ [![Docker](https://img.shields.io/badge/Docker-Ready-2496ED?logo=docker&logoColor=white)](Dockerfile)
14
+ [![License](https://img.shields.io/badge/License-MIT-yellow)](LICENSE)
15
+
16
+ [**Live Demo**](#-demo) Β· [**Quick Start**](#-quick-start) Β· [**Architecture**](#-architecture) Β· [**Docker**](#-docker-deployment)
17
+
18
+ </div>
19
+
20
+ ---
21
+
22
+ ## ✨ What It Does
23
+
24
+ Upload an MP4 β€” a tutorial, podcast, or product walkthrough. SynapseMedia runs it through a 4-phase AI pipeline and delivers two production-ready assets:
25
+
26
+ | Output | Description |
27
+ |---|---|
28
+ | πŸ“ **Blog Post** | Full Markdown article with YAML front matter, headers, and tables β€” ready for WordPress, Dev.to, or Hugo |
29
+ | πŸ“± **Vertical Reels** | 1080Γ—1920 MP4 clips at 9:16 β€” verified for energy score, auto-cropped, ready for TikTok, Reels, or Shorts |
30
+
31
+ Everything runs with **zero manual editing**. No timeline scrubbing, no copy-pasting transcripts, no cropping by hand.
32
 
33
  ---
34
 
35
+ ## πŸŽ₯ Demo
36
+
37
+ <div align="center">
38
 
39
+ https://github.com/ajme-abes/RAG-Multimodal-SafeAI/tree/main/multimodal-engine/assets/video.mp4
40
 
41
+ </div>
42
+
43
+ > Upload a video β†’ click one button β†’ get a blog post and vertical reels in minutes.
44
 
45
  ---
46
 
47
+ ## πŸ“Έ Screenshots
48
+
49
+ <table>
50
+ <tr>
51
+ <td align="center" width="33%">
52
+ <img src="assets/dashboard.png" alt="Main Dashboard" width="100%"/>
53
+ <br/><b>Main Dashboard</b>
54
+ <br/><sub>Upload panel, interval slider, layout mode selector</sub>
55
+ </td>
56
+ <td align="center" width="33%">
57
+ <img src="assets/mdpage.png" alt="Blog Post Output" width="100%"/>
58
+ <br/><b>Generated Blog Post</b>
59
+ <br/><sub>YAML front matter + full Markdown rendered in-app</sub>
60
+ </td>
61
+ <td align="center" width="33%">
62
+ <img src="assets/reelpage.png" alt="Reels Output" width="100%"/>
63
+ <br/><b>Vertical Reels</b>
64
+ <br/><sub>9:16 clips displayed side-by-side with inline playback</sub>
65
+ </td>
66
+ </tr>
67
+ </table>
68
+
69
+ ---
70
+
71
+ ## πŸ—οΈ Architecture
72
+
73
+ The pipeline runs two tracks in parallel β€” one for audio, one for video β€” then fuses them.
74
 
75
  ```
76
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
77
+ β”‚ Uploaded MP4 Video β”‚
78
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
79
+ β”‚
80
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
81
+ β–Ό β–Ό
82
+ TRACK A TRACK B
83
+ audio_processor video_processor
84
+ ───────────── ───────────────
85
+ FFmpeg demux FFmpeg keyframe
86
+ 16kHz PCM WAV extraction (JPEGs)
87
+ β”‚ β”‚
88
+ β–Ό β–Ό
89
+ Gemini 2.5 Flash Gemini 2.5 Flash
90
+ StructuredTranscript ChronologicalVisual
91
+ (float timestamps) Timeline (float ts)
92
+ β”‚ β”‚
93
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
94
+ β”‚
95
+ β–Ό
96
+ workflow_engine.py
97
+ ───────────────────
98
+ Injects both tracks
99
+ into Gemini prompt
100
+ β†’ Markdown Blog Post
101
+ (YAML front matter)
102
+ β”‚
103
+ β–Ό
104
+ agent_optimizer.py
105
+ ──────────────────
106
+ Stage 1: Text filter
107
+ (Gemini 2.5 Flash)
108
+ Top clip candidates
109
+ β”‚
110
+ β–Ό
111
+ reel_generator.py
112
+ ─────────────────
113
+ Stage 2: Video verify
114
+ (Gemini 2.5 Pro)
115
+ Energy score > 60
116
+ Timestamp fine-tune
117
+ β”‚
118
+ β–Ό
119
+ video_processor.py
120
+ ──────────────────
121
+ FFmpeg 9:16 render
122
+ Blurred Stack or
123
+ AI Smart Face Crop
124
+ β”‚
125
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”
126
+ β–Ό β–Ό
127
+ πŸ“ Blog Post πŸ“± Vertical Reels
128
+ (.md + YAML) (1080Γ—1920 MP4)
129
  ```
130
 
131
+ ### The 4 Phases
132
+
133
+ | Phase | Module | What Happens |
134
+ |---|---|---|
135
+ | **1 β€” Hear** | `audio_processor.py` | FFmpeg extracts 16kHz mono WAV β†’ Gemini 2.5 Flash transcribes into `StructuredTranscript` with per-word float timestamps |
136
+ | **2 β€” See** | `video_processor.py` | FFmpeg samples keyframes at configurable intervals β†’ Gemini 2.5 Flash maps each frame to a float timestamp in `ChronologicalVisualTimeline` |
137
+ | **3 β€” Write** | `workflow_engine.py` | Both data streams are serialised and injected into a Gemini prompt β†’ structured Markdown blog post with YAML front matter |
138
+ | **4 β€” Cut** | `agent_optimizer.py` β†’ `reel_generator.py` | Stage 1 text filter finds hook candidates β†’ Stage 2 uploads raw clips to Gemini 2.5 Pro for energy scoring β†’ FFmpeg renders verified clips |
139
+
140
  ---
141
 
142
+ ## ⚑ Key Features
143
+
144
+ **🎯 Zero-Hallucination Timestamps**
145
+ Pydantic models enforce `float` types at every pipeline boundary. No `HH:MM:SS` strings, no off-by-one second errors, no silent type mismatches.
146
+
147
+ **πŸ”„ Dual Render Modes**
148
+ - **Blurred Stack** β€” Scales the full 16:9 frame to fit 9:16, fills margins with a blurred duplicate. Keeps code and UI text fully readable.
149
+ - **AI Smart Face Crop** β€” Gemini detects speaker position (`left` / `center` / `right`) and centers the crop frame around them.
150
+
151
+ **πŸ›‘οΈ Two-Stage Clip Verification**
152
+ Every candidate clip is scored by Gemini 2.5 Pro before rendering. Only clips with `audio_energy_score > 60` and `final_relevance_decision = true` make it to FFmpeg.
153
+
154
+ **♻️ Automatic Fallback Chain**
155
+ If Gemini scene analysis fails β†’ OpenAI GPT-4o-mini takes over β†’ if that fails too β†’ a placeholder timeline keeps the pipeline alive.
156
+
157
+ **⏳ Exponential Backoff with Jitter**
158
+ Every Gemini call is wrapped in `retry_with_backoff` β€” handles 429 / 503 errors automatically with randomised delay to prevent thundering herd.
159
+
160
+ **☁️ Cloud Asset Cleanup**
161
+ All files uploaded to the Gemini Files API are deleted inside `finally` blocks β€” no leaked cloud storage, even on exception.
162
+
163
+ **πŸ“ CMS-Ready Output**
164
+ Blog posts include complete YAML front matter: `title`, `slug`, `date`, `tags`, `category`, `description`. Date-prefixed filenames prevent overwrites.
165
+
166
+ ---
167
+
168
+ ## πŸ› οΈ Tech Stack
169
+
170
+ | Layer | Tool | Version |
171
+ |---|---|---|
172
+ | **UI** | Streamlit | 1.57.0 |
173
+ | **Audio extraction** | FFmpeg (`pcm_s16le`) | system |
174
+ | **Transcription** | Google Gemini 2.5 Flash | via `google-genai` 1.68.0 |
175
+ | **Scene analysis** | Google Gemini 2.5 Flash | via `google-genai` 1.68.0 |
176
+ | **Vision fallback** | OpenAI GPT-4o-mini | via `openai` 2.37.0 |
177
+ | **Clip verification** | Google Gemini 2.5 Pro | via `google-genai` 1.68.0 |
178
+ | **Blog synthesis** | Google Gemini 2.5 Flash | via `google-genai` 1.68.0 |
179
+ | **Video rendering** | FFmpeg (`libx264` + `aac`) | system |
180
+ | **Schema validation** | Pydantic v2 | 2.12.5 |
181
+ | **Testing** | pytest | 72 tests, 0 failures |
182
+
183
+ ---
184
+
185
+ ## πŸš€ Quick Start
186
 
187
  ### 1. Prerequisites
188
 
189
+ Install **FFmpeg** and make sure it's on your system PATH:
190
+
191
+ ```bash
192
+ # macOS
193
+ brew install ffmpeg
194
+
195
+ # Ubuntu / Debian
196
+ sudo apt install ffmpeg
197
 
198
+ # Windows
199
+ winget install ffmpeg
200
+
201
+ # Verify
202
+ ffmpeg -version
203
+ ```
204
+
205
+ ### 2. Clone & Install
206
 
207
  ```bash
208
+ git clone https://github.com/ajme-abes/RAG-Multimodal-SafeAI
209
+ cd synapsemedia/multimodal-engine
210
+
211
+ # Create a virtual environment
212
+ python -m venv venv
213
+ source venv/bin/activate # Windows: venv\Scripts\activate
214
+
215
+ # Install pinned dependencies
216
  pip install -r requirements.txt
217
  ```
218
 
219
+ ### 3. Configure API Keys
220
 
221
+ Copy the example env file and fill in your keys:
222
 
223
+ ```bash
224
+ cp .env.example .env
 
225
  ```
226
 
227
+ Edit `.env`:
228
 
229
+ ```env
230
+ GEMINI_API_KEY="your_google_gemini_api_key"
231
+ OPENAI_API_KEY="your_openai_api_key"
232
+ ```
233
 
234
+ > Get your Gemini key at [aistudio.google.com](https://aistudio.google.com/app/apikey)
235
+ > The OpenAI key is optional β€” used only as a fallback if Gemini scene analysis fails.
236
+
237
+ ### 4. Run
238
 
239
  ```bash
240
+ streamlit run app/app.py
 
241
  ```
242
 
243
+ Open **http://localhost:8501** in your browser.
244
 
245
+ ---
246
 
247
+ ## πŸŽ›οΈ Usage Guide
248
+
249
+ <table>
250
+ <tr>
251
+ <th>Step</th>
252
+ <th>Action</th>
253
+ <th>Tip</th>
254
+ </tr>
255
+ <tr>
256
+ <td>1</td>
257
+ <td>Upload an MP4 in the left sidebar</td>
258
+ <td>Works best with videos 3–60 minutes long</td>
259
+ </tr>
260
+ <tr>
261
+ <td>2</td>
262
+ <td>Set the <b>Downsampling Interval</b> slider</td>
263
+ <td>Use <b>5s</b> for code tutorials, <b>10–15s</b> for interviews</td>
264
+ </tr>
265
+ <tr>
266
+ <td>3</td>
267
+ <td>Choose a <b>Reel Layout</b></td>
268
+ <td><b>Blurred Stack</b> for screen recordings, <b>AI Smart Crop</b> for talking-head</td>
269
+ </tr>
270
+ <tr>
271
+ <td>4</td>
272
+ <td>Click <b>πŸš€ Process Complete AI Workflow</b></td>
273
+ <td>A 10-min video takes roughly 2–4 minutes to process</td>
274
+ </tr>
275
+ <tr>
276
+ <td>5</td>
277
+ <td>Download from the output tabs</td>
278
+ <td><b>Blog Post</b> tab β†’ Markdown file Β· <b>Reels</b> tab β†’ MP4 files</td>
279
+ </tr>
280
+ </table>
281
+
282
+ ### Output Tabs
283
+
284
+ | Tab | Content |
285
+ |---|---|
286
+ | πŸ“„ **Blog Post** | Rendered Markdown with download button |
287
+ | πŸ“± **Mobile Reels** | Inline video player for each 9:16 clip |
288
+ | πŸŽ™οΈ **System Logs** | Raw JSON for the transcript and visual timeline |
289
 
290
  ---
291
 
292
+ ## 🐳 Docker Deployment
293
 
294
+ ### Build & Run Locally
295
 
296
  ```bash
297
+ docker build -t synapsemedia .
298
+ docker run -p 8501:8501 \
299
+ -e GEMINI_API_KEY="your_key" \
300
+ -e OPENAI_API_KEY="your_key" \
301
+ synapsemedia
302
+ ```
303
 
304
+ Open **http://localhost:8501**.
305
+
306
+ ### Docker Compose (Recommended)
307
+
308
+ ```yaml
309
+ version: "3.9"
310
+ services:
311
+ synapsemedia:
312
+ build: .
313
+ ports:
314
+ - "8501:8501"
315
+ environment:
316
+ - GEMINI_API_KEY=${GEMINI_API_KEY}
317
+ - OPENAI_API_KEY=${OPENAI_API_KEY}
318
+ volumes:
319
+ - ./data:/workspace/data
320
+ - ./output:/workspace/output
321
+ ```
322
 
323
+ ```bash
324
+ docker compose up --build
325
+ ```
326
 
327
+ ---
 
328
 
329
+ ## πŸ§ͺ Tests
330
+
331
+ The test suite runs without any API keys or video files.
332
+
333
+ ```bash
334
+ # From the multimodal-engine/ directory
335
+ pytest
336
  ```
337
 
338
+ **72 tests Β· 0 failures Β· ~30s runtime**
339
+
340
+ | File | What It Covers |
341
+ |---|---|
342
+ | `test_models.py` | Pydantic schema construction, type coercion, JSON roundtrip |
343
+ | `test_utils.py` | Retry on 429/503, immediate raise on 400/401, backoff delay doubling |
344
+ | `test_video_processor.py` | All 4 FFmpeg render modes, directory cleanup, base64 encoding, input validation |
345
+ | `test_agent_optimizer.py` | Slug sanitisation, layout routing, per-clip render calls, error isolation |
346
+ | `test_pipeline_integration.py` | All 4 phases mocked end-to-end, fallback chains, full pipeline smoke test |
347
 
348
  ---
349
 
350
+ ## πŸ“ Project Structure
351
 
352
  ```
353
+ multimodal-engine/
354
+ β”œβ”€β”€ app/
355
+ β”‚ β”œβ”€β”€ app.py # Streamlit dashboard β€” pipeline orchestration & UI
356
+ β”‚ β”œβ”€β”€ models.py # Shared Pydantic schemas (single source of truth)
357
+ β”‚ β”œβ”€β”€ utils.py # Exponential backoff with jitter
358
+ β”‚ β”œβ”€β”€ audio_processor.py # Phase 1 β€” FFmpeg WAV demux + Gemini transcription
359
+ β”‚ β”œβ”€β”€ video_processor.py # Phase 2 β€” Keyframe extraction + VLM scene analysis + FFmpeg render
360
+ β”‚ β”œβ”€β”€ clip_extractor.py # Phase 4a β€” Stage 1 text-based semantic filter
361
+ β”‚ β”œβ”€β”€ reel_generator.py # Phase 4b β€” Stage 2 multi-modal video verification
362
+ β”‚ └── workflow_engine.py # Phase 3 β€” Blog synthesis + pipeline orchestration
363
+ β”œβ”€β”€ assets/
364
+ β”‚ β”œβ”€β”€ dashboard.png # UI screenshot β€” main dashboard
365
+ β”‚ β”œβ”€β”€ mdpage.png # UI screenshot β€” blog output tab
366
+ β”‚ β”œβ”€β”€ reelpage.png # UI screenshot β€” reels output tab
367
+ β”‚ └── video.mp4 # Demo walkthrough video
368
+ β”œβ”€β”€ data/ # Runtime: uploaded videos, audio, keyframes (gitignored)
369
+ β”œβ”€β”€ output/ # Runtime: blog posts and rendered reels (gitignored)
370
+ β”œβ”€β”€ test/
371
+ β”‚ β”œβ”€β”€ __init__.py
372
+ β”‚ β”œβ”€β”€ test_models.py
373
+ β”‚ β”œβ”€β”€ test_utils.py
374
+ β”‚ β”œβ”€β”€ test_video_processor.py
375
+ β”‚ β”œβ”€β”€ test_agent_optimizer.py
376
+ β”‚ └── test_pipeline_integration.py
377
+ β”œβ”€β”€ .env.example # API key template
378
+ β”œβ”€β”€ Dockerfile # Production container
379
+ β”œβ”€β”€ pytest.ini # Test runner config
380
+ └── requirements.txt # Pinned Python dependencies
381
  ```
382
 
383
  ---
384
 
385
+ ## πŸ“ˆ Roadmap
386
 
387
+ - [ ] **Word-level animated subtitles** β€” Burn styled captions into frames using ASS/SSA overlay filters
388
+ - [ ] **Frame-by-frame face tracking** β€” Replace static `left/center/right` with a landmark model for smooth pan-and-scan
389
+ - [ ] **Social publishing webhooks** β€” Direct push to YouTube Shorts, Instagram Reels, and TikTok APIs
390
+ - [ ] **Natural language video search** β€” Query timestamps using the `StructuredTranscript` as a semantic index
391
+ - [ ] **Configurable resolutions** β€” 1080Γ—1920 (TikTok/Reels), 1080Γ—1080 (square), custom aspect ratios
392
+ - [ ] **Progress persistence** β€” Save pipeline state to disk so a page refresh doesn't lose results
 
 
 
 
 
393
 
394
  ---
395
 
396
+ ## ⚠️ Known Limitations
397
 
398
+ - **Gemini file processing delay** β€” Uploaded video files enter a `PROCESSING` state before they can be queried. Large clips can take 30–60 seconds to process server-side.
399
+ - **No persistent storage** β€” Streamlit session state resets on page refresh. Results are saved to `output/` on disk but not re-loaded automatically.
400
+ - **Rate limits** β€” Gemini 2.5 Pro (used in Stage 2) has lower rate limits than Flash. If you process many clips back-to-back, the backoff utility will kick in.
 
 
 
 
 
 
 
 
 
 
 
401
 
402
  ---
403
 
404
+ ## 🀝 Contributing
405
 
406
+ 1. Fork the repo
407
+ 2. Create a feature branch: `git checkout -b feature/my-feature`
408
+ 3. Make your changes and add tests
409
+ 4. Run `pytest` β€” all tests must pass
410
+ 5. Open a pull request
411
 
412
  ---
413
 
414
+ <div align="center">
415
+
416
+ Built with ❀️ using **Gemini 2.5**, **FFmpeg**, and **Streamlit**
417
+
418
+ ⭐ Star this repo if it saved you hours of manual editing
419
 
420
+ </div>
 
multimodal-engine/app/agent_optimizer.py CHANGED
@@ -10,6 +10,18 @@ from audio_processor import StructuredTranscript
10
  from video_processor import ChronologicalVisualTimeline, generate_vertical_reel_clip
11
  from utils import retry_with_backoff
12
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  load_dotenv()
14
 
15
  # =====================================================================
@@ -74,18 +86,17 @@ def discover_highlights_autonomously(
74
  try:
75
  print("πŸ€– Requesting Gemini processing with zero-hallucination Pydantic constraint filters...")
76
 
77
- def excute_call():
78
  return client.models.generate_content(
79
  model="gemini-2.5-flash",
80
- contents=[macro_prompt],
81
- config=types.GenerateContentConfig(
82
- response_mime_type="application/json",
83
- response_schema=HighlightAnalysisResult,
84
- temperature=0.1, # Keep temperature low to prevent numeric drift
85
- )
86
-
87
  )
88
- response = retry_with_backoff(excute_call)
89
 
90
  # Access the typed object directly using response.parsed to avoid raw json parsing errors
91
  validated_data: HighlightAnalysisResult = response.parsed
@@ -114,12 +125,11 @@ def run_autonomous_editing_pipeline(
114
  if not discovered_highlights:
115
  return False
116
 
117
- os.makedirs("../output/clips", exist_ok=True)
118
 
119
  for index, highlight in enumerate(discovered_highlights):
120
  print(f"🎬 Processing Clip #{index + 1}: [{highlight.hook_title}]")
121
  safe_title = "".join(c for c in highlight.hook_title if c.isalnum() or c in (' ', '_')).rstrip().replace(' ', '_').lower()
122
- output_reel = f"../output/clips/auto_reel_{index + 1}_{safe_title}.mp4"
123
 
124
  # Determine the string instruction parameter for FFmpeg
125
  # If user picked Blurred Stack, override position and pass "blurred"
 
10
  from video_processor import ChronologicalVisualTimeline, generate_vertical_reel_clip
11
  from utils import retry_with_backoff
12
 
13
+ APP_DIR = os.path.dirname(os.path.abspath(__file__))
14
+ PROJECT_ROOT = os.path.abspath(os.path.join(APP_DIR, ".."))
15
+
16
+ DATA_DIR = os.path.join(PROJECT_ROOT, "data")
17
+ OUTPUT_DIR = os.path.join(PROJECT_ROOT, "output")
18
+ CLIPS_DIR = os.path.join(OUTPUT_DIR, "clips")
19
+ TEMP_DIR = os.path.join(DATA_DIR, "temp_verification_slices")
20
+
21
+ # Guarantee that folder path hierarchies exist on disk before invoking downstream tools
22
+ os.makedirs(DATA_DIR, exist_ok=True)
23
+ os.makedirs(CLIPS_DIR, exist_ok=True)
24
+ os.makedirs(TEMP_DIR, exist_ok=True)
25
  load_dotenv()
26
 
27
  # =====================================================================
 
86
  try:
87
  print("πŸ€– Requesting Gemini processing with zero-hallucination Pydantic constraint filters...")
88
 
89
+ def execute_call():
90
  return client.models.generate_content(
91
  model="gemini-2.5-flash",
92
+ contents=[macro_prompt],
93
+ config=types.GenerateContentConfig(
94
+ response_mime_type="application/json",
95
+ response_schema=HighlightAnalysisResult,
96
+ temperature=0.1,
97
+ ),
 
98
  )
99
+ response = retry_with_backoff(execute_call)
100
 
101
  # Access the typed object directly using response.parsed to avoid raw json parsing errors
102
  validated_data: HighlightAnalysisResult = response.parsed
 
125
  if not discovered_highlights:
126
  return False
127
 
 
128
 
129
  for index, highlight in enumerate(discovered_highlights):
130
  print(f"🎬 Processing Clip #{index + 1}: [{highlight.hook_title}]")
131
  safe_title = "".join(c for c in highlight.hook_title if c.isalnum() or c in (' ', '_')).rstrip().replace(' ', '_').lower()
132
+ output_reel = os.path.join(CLIPS_DIR, f'auto_reel_{index + 1}_{safe_title}.mp4')
133
 
134
  # Determine the string instruction parameter for FFmpeg
135
  # If user picked Blurred Stack, override position and pass "blurred"
multimodal-engine/app/app.py CHANGED
@@ -1,30 +1,37 @@
1
- import os
2
  import sys
3
  import glob
 
4
  import streamlit as st
5
-
6
- # Setup system environment alignment routes
7
- sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
8
-
9
  from workflow_engine import generate_production_blog
10
  from audio_processor import extract_audio_from_video, transcribe_audio
11
  from video_processor import extract_keyframes, analyze_scene_with_gemini
12
  from agent_optimizer import run_autonomous_editing_pipeline
13
-
14
  from models import StructuredTranscript, ChronologicalVisualTimeline
15
 
16
- # Configure clean, production-grade page layout parameters
17
- st.set_page_config(page_title="Multimodal Content Engine", page_icon="🎬", layout="wide")
 
 
18
 
19
- # Sync runtime directory configurations with core backends
20
- DATA_DIR = "../data"
21
- FRAMES_DIR = "../data/extracted_frames"
22
- OUTPUT_CLIPS_DIR = "../output/clips"
23
- BLOG_OUTPUT_PATH = "../output/how_multimodals_work_blog.md"
 
24
 
 
25
  os.makedirs(DATA_DIR, exist_ok=True)
 
26
  os.makedirs(FRAMES_DIR, exist_ok=True)
27
  os.makedirs(OUTPUT_CLIPS_DIR, exist_ok=True)
 
 
 
 
 
28
 
29
  st.title("🎬 Multimodal AI Content Engine")
30
  st.caption("Convert long horizontal streams into clear technical blog posts and vertical mobile shorts using a type-safe pipeline.")
@@ -50,7 +57,6 @@ with st.sidebar:
50
  help="Capture 1 image frame every X seconds of timeline playback."
51
  )
52
 
53
- # πŸ†• UPGRADE: User choices for dynamic reel layout style
54
  reel_style = st.selectbox(
55
  "πŸ“± Reel Visual Layout Style",
56
  options=["Blurred Stack Mode (Presentation/Code)", "AI Smart Face Crop (Podcast/Vlog)"],
@@ -89,27 +95,41 @@ if uploaded_video is not None:
89
  extract_audio_from_video(video_input_path, audio_output_path)
90
  st.write("Transcribing audio tracks into structured Pydantic time models...")
91
  st.session_state.transcript_obj = transcribe_audio(audio_output_path)
 
 
 
 
92
  status.update(label="Phase 1 Complete: Audio Transcript Secured!", state="complete")
93
-
94
  # Phase 2 Step: Seeing
95
  with st.status("🎬 Phase 2: Extracting Timelines & Scene Layouts...", expanded=True) as status:
96
  st.write("Slicing uniform visual image arrays from video timeline...")
97
  extract_keyframes(video_input_path, FRAMES_DIR, interval_seconds=sampling_interval)
98
  st.write("Analyzing chronological frame sequences via Vision-VLM array...")
99
  st.session_state.visual_breakdown_obj = analyze_scene_with_gemini(FRAMES_DIR, interval_seconds=sampling_interval)
 
 
 
 
100
  status.update(label="Phase 2 Complete: Visual Timeline Extracted!", state="complete")
101
-
102
  # Phase 3 Step: Fusing and Clipping
103
  with st.status("🧠 Phase 3: Synthesizing Content & Cutting Highlights...", expanded=True) as status:
104
  st.write("Fusing text arrays and structural visual timelines together into markdown entries...")
105
- st.session_state.final_blog = generate_production_blog(
106
- st.session_state.transcript_obj,
107
- st.session_state.visual_breakdown_obj
 
108
  )
109
-
110
- # Save the final blog post straight to the output directory
111
- with open(BLOG_OUTPUT_PATH, "w", encoding="utf-8") as b_file:
112
- b_file.write(st.session_state.final_blog)
 
 
 
 
 
113
 
114
  st.write("Running Two-Stage Filtering Highlight Detection Engine...")
115
  # Automatically scans, validates, and runs 9:16 vertical center crops completely hands-free
@@ -119,9 +139,9 @@ if uploaded_video is not None:
119
  visual_breakdown=st.session_state.visual_breakdown_obj,
120
  layout_style=reel_style
121
  )
122
-
123
  status.update(label="Phase 3 Complete: Content Generated & Reels Rendered!", state="complete")
124
-
125
  st.session_state.pipeline_executed = True
126
  st.success("πŸŽ‰ Multimodal Content Engine Processed All Layers Successfully with Zero Time Hallucinations!")
127
 
 
1
+ import os, re
2
  import sys
3
  import glob
4
+ import time
5
  import streamlit as st
6
+ from datetime import datetime
 
 
 
7
  from workflow_engine import generate_production_blog
8
  from audio_processor import extract_audio_from_video, transcribe_audio
9
  from video_processor import extract_keyframes, analyze_scene_with_gemini
10
  from agent_optimizer import run_autonomous_editing_pipeline
 
11
  from models import StructuredTranscript, ChronologicalVisualTimeline
12
 
13
+ # Setup system environment alignment routes
14
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
15
+ APP_DIR = os.path.dirname(os.path.abspath(__file__))
16
+ PROJECT_ROOT = os.path.abspath(os.path.join(APP_DIR, ".."))
17
 
18
+ DATA_DIR = os.path.join(PROJECT_ROOT, "data")
19
+ OUTPUT_DIR = os.path.join(PROJECT_ROOT, "output")
20
+ CLIPS_DIR = os.path.join(OUTPUT_DIR, "clips")
21
+ FRAMES_DIR = os.path.join(DATA_DIR, "extracted_frames")
22
+ OUTPUT_CLIPS_DIR = os.path.join(OUTPUT_DIR, "clips")
23
+ TEMP_DIR = os.path.join(DATA_DIR, "temp_verification_slices")
24
 
25
+ # Guarantee that folder path hierarchies exist on disk before invoking downstream tools
26
  os.makedirs(DATA_DIR, exist_ok=True)
27
+ os.makedirs(CLIPS_DIR, exist_ok=True)
28
  os.makedirs(FRAMES_DIR, exist_ok=True)
29
  os.makedirs(OUTPUT_CLIPS_DIR, exist_ok=True)
30
+ os.makedirs(TEMP_DIR, exist_ok=True)
31
+
32
+ # Configure clean, production-grade page layout parameters
33
+ st.set_page_config(page_title="Multimodal Content Engine", page_icon="🎬", layout="wide")
34
+
35
 
36
  st.title("🎬 Multimodal AI Content Engine")
37
  st.caption("Convert long horizontal streams into clear technical blog posts and vertical mobile shorts using a type-safe pipeline.")
 
57
  help="Capture 1 image frame every X seconds of timeline playback."
58
  )
59
 
 
60
  reel_style = st.selectbox(
61
  "πŸ“± Reel Visual Layout Style",
62
  options=["Blurred Stack Mode (Presentation/Code)", "AI Smart Face Crop (Podcast/Vlog)"],
 
95
  extract_audio_from_video(video_input_path, audio_output_path)
96
  st.write("Transcribing audio tracks into structured Pydantic time models...")
97
  st.session_state.transcript_obj = transcribe_audio(audio_output_path)
98
+ if st.session_state.transcript_obj is None:
99
+ status.update(label="Phase 1 Failed: Transcription returned no data.", state="error")
100
+ st.error("❌ Audio transcription failed β€” Gemini returned no structured output. Check your GEMINI_API_KEY and try again.")
101
+ st.stop()
102
  status.update(label="Phase 1 Complete: Audio Transcript Secured!", state="complete")
103
+
104
  # Phase 2 Step: Seeing
105
  with st.status("🎬 Phase 2: Extracting Timelines & Scene Layouts...", expanded=True) as status:
106
  st.write("Slicing uniform visual image arrays from video timeline...")
107
  extract_keyframes(video_input_path, FRAMES_DIR, interval_seconds=sampling_interval)
108
  st.write("Analyzing chronological frame sequences via Vision-VLM array...")
109
  st.session_state.visual_breakdown_obj = analyze_scene_with_gemini(FRAMES_DIR, interval_seconds=sampling_interval)
110
+ if st.session_state.visual_breakdown_obj is None:
111
+ status.update(label="Phase 2 Failed: Scene analysis returned no data.", state="error")
112
+ st.error("❌ Visual timeline extraction failed β€” no frames were found or both Gemini and OpenAI fallback failed.")
113
+ st.stop()
114
  status.update(label="Phase 2 Complete: Visual Timeline Extracted!", state="complete")
115
+
116
  # Phase 3 Step: Fusing and Clipping
117
  with st.status("🧠 Phase 3: Synthesizing Content & Cutting Highlights...", expanded=True) as status:
118
  st.write("Fusing text arrays and structural visual timelines together into markdown entries...")
119
+ raw_blog_text = generate_production_blog(
120
+ st.session_state.transcript_obj,
121
+ st.session_state.visual_breakdown_obj,
122
+ uploaded_video.name
123
  )
124
+ st.session_state.final_blog = raw_blog_text
125
+
126
+ slug_match = re.search(r'slug:\s*"(.*?)"', raw_blog_text)
127
+ file_slug = slug_match.group(1) if slug_match else f"tutorial_{int(time.time())}"
128
+ unique_blog_filename = f"{datetime.now().strftime('%Y-%m-%d')}-{file_slug}.md"
129
+
130
+ final_blog_path = os.path.join(OUTPUT_DIR, unique_blog_filename)
131
+ with open(final_blog_path, "w", encoding="utf-8") as b_file:
132
+ b_file.write(raw_blog_text)
133
 
134
  st.write("Running Two-Stage Filtering Highlight Detection Engine...")
135
  # Automatically scans, validates, and runs 9:16 vertical center crops completely hands-free
 
139
  visual_breakdown=st.session_state.visual_breakdown_obj,
140
  layout_style=reel_style
141
  )
142
+
143
  status.update(label="Phase 3 Complete: Content Generated & Reels Rendered!", state="complete")
144
+
145
  st.session_state.pipeline_executed = True
146
  st.success("πŸŽ‰ Multimodal Content Engine Processed All Layers Successfully with Zero Time Hallucinations!")
147
 
multimodal-engine/app/audio_processor.py CHANGED
@@ -4,10 +4,21 @@ from dotenv import load_dotenv
4
  from google import genai
5
  from google.genai import types
6
  from utils import retry_with_backoff
7
-
8
  # Import the shared structural schema to fix your Streamlit ImportError
9
  from models import StructuredTranscript
10
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  load_dotenv()
12
 
13
  def extract_audio_from_video(video_path, audio_output_path):
@@ -53,18 +64,18 @@ def transcribe_audio(audio_file_path) -> StructuredTranscript:
53
 
54
  print("πŸ€– Processing Speech-to-Text structured inference (Waiting for engine response)...")
55
 
56
- def excute_call():
57
  return client.models.generate_content(
58
  model="gemini-2.5-flash",
59
  contents=[upload_audio, prompt],
60
  config=types.GenerateContentConfig(
61
  response_mime_type="application/json",
62
  response_schema=StructuredTranscript,
63
- temperature=0.0 # Zero out creativity to force strict transcription accuracy
64
  ),
65
  )
66
  try:
67
- response = retry_with_backoff(excute_call)
68
  return response.parsed
69
 
70
  except Exception as e:
 
4
  from google import genai
5
  from google.genai import types
6
  from utils import retry_with_backoff
 
7
  # Import the shared structural schema to fix your Streamlit ImportError
8
  from models import StructuredTranscript
9
 
10
+ APP_DIR = os.path.dirname(os.path.abspath(__file__))
11
+ PROJECT_ROOT = os.path.abspath(os.path.join(APP_DIR, ".."))
12
+
13
+ DATA_DIR = os.path.join(PROJECT_ROOT, "data")
14
+ OUTPUT_DIR = os.path.join(PROJECT_ROOT, "output")
15
+ CLIPS_DIR = os.path.join(OUTPUT_DIR, "clips")
16
+ TEMP_DIR = os.path.join(DATA_DIR, "temp_verification_slices")
17
+
18
+ # Guarantee that folder path hierarchies exist on disk before invoking downstream tools
19
+ os.makedirs(DATA_DIR, exist_ok=True)
20
+ os.makedirs(CLIPS_DIR, exist_ok=True)
21
+ os.makedirs(TEMP_DIR, exist_ok=True)
22
  load_dotenv()
23
 
24
  def extract_audio_from_video(video_path, audio_output_path):
 
64
 
65
  print("πŸ€– Processing Speech-to-Text structured inference (Waiting for engine response)...")
66
 
67
+ def execute_call():
68
  return client.models.generate_content(
69
  model="gemini-2.5-flash",
70
  contents=[upload_audio, prompt],
71
  config=types.GenerateContentConfig(
72
  response_mime_type="application/json",
73
  response_schema=StructuredTranscript,
74
+ temperature=0.0
75
  ),
76
  )
77
  try:
78
+ response = retry_with_backoff(execute_call)
79
  return response.parsed
80
 
81
  except Exception as e:
multimodal-engine/app/clip_extractor.py CHANGED
@@ -1,11 +1,10 @@
1
- import os
2
  from typing import List
3
  from pydantic import BaseModel, Field
4
  from google import genai
5
  from google.genai import types
6
  from audio_processor import StructuredTranscript
7
  from video_processor import ChronologicalVisualTimeline
8
-
9
  # Define a clean structural format for Stage 1 Candidate Output
10
  class CandidateHighlight(BaseModel):
11
  title: str = Field(description="A hook-driven viral title candidate.")
@@ -36,19 +35,18 @@ def stage_1_semantic_filter(audio_transcript: StructuredTranscript, visual_break
36
  """
37
 
38
  try:
39
- response = client.models.generate_content(
40
- model="gemini-2.5-flash",
41
- contents=[prompt],
42
- config=types.GenerateContentConfig(
43
- response_mime_type="application/json",
44
- response_schema=CandidateHighlightBatch,
45
- temperature=0.2, # Low temperature to ensure time accuracy
46
- ),
47
- )
48
-
49
- parsed_response: CandidateHighlightBatch = response.parsed
50
- print(f" Found {len(parsed_response.candidates)} candidate highlights for Stage 2 verification.")
51
- return parsed_response.candidates
52
 
53
  except Exception as e:
54
  print(f"🚨 Stage 1 Semantic Extraction Error: {str(e)}")
 
 
1
  from typing import List
2
  from pydantic import BaseModel, Field
3
  from google import genai
4
  from google.genai import types
5
  from audio_processor import StructuredTranscript
6
  from video_processor import ChronologicalVisualTimeline
7
+ from utils import retry_with_backoff
8
  # Define a clean structural format for Stage 1 Candidate Output
9
  class CandidateHighlight(BaseModel):
10
  title: str = Field(description="A hook-driven viral title candidate.")
 
35
  """
36
 
37
  try:
38
+ def execute_call():
39
+ return client.models.generate_content(
40
+ model="gemini-2.5-flash",
41
+ contents=[prompt],
42
+ config=types.GenerateContentConfig(
43
+ response_mime_type="application/json",
44
+ response_schema=CandidateHighlightBatch,
45
+ temperature=0.2,
46
+ ),
47
+ )
48
+ response = retry_with_backoff(execute_call)
49
+ return response.parsed.candidates
 
50
 
51
  except Exception as e:
52
  print(f"🚨 Stage 1 Semantic Extraction Error: {str(e)}")
multimodal-engine/app/models.py CHANGED
@@ -1,4 +1,3 @@
1
- # app/models.py
2
  from pydantic import BaseModel, Field
3
  from typing import List
4
 
 
 
1
  from pydantic import BaseModel, Field
2
  from typing import List
3
 
multimodal-engine/app/reel_generator.py CHANGED
@@ -7,7 +7,20 @@ from google import genai
7
  from google.genai import types
8
  from clip_extractor import CandidateHighlight
9
  from video_processor import generate_vertical_reel_clip
 
10
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  # Define validation checks for our multi-modal confirmation layer
12
  class VisualVerificationReport(BaseModel):
13
  is_visually_engaging: bool = Field(description="True if the visual pacing, facial cues, or action sequences are high quality.")
@@ -22,14 +35,13 @@ def stage_2_visual_verification(video_path: str, candidates: List[CandidateHighl
22
  client = genai.Client()
23
  verified_production_clips = []
24
 
25
- # Create a temporary directory to store raw clip slices
26
- temp_dir = "../data/temp_verification_slices"
27
- os.makedirs(temp_dir, exist_ok=True)
28
 
29
  for idx, candidate in enumerate(candidates):
30
  print(f" Processing verification step for candidate clip #{idx+1}: {candidate.title}")
31
 
32
- temp_clip_path = os.path.join(temp_dir, f"candidate_slice_{idx}.mp4")
33
  duration = candidate.end_time - candidate.start_time
34
 
35
  # Quick FFmpeg slice to create a small evaluation file
@@ -60,23 +72,26 @@ def stage_2_visual_verification(video_path: str, candidates: List[CandidateHighl
60
  """
61
 
62
  try:
63
- response = client.models.generate_content(
64
- model="gemini-2.5-pro", # Multi-modal execution requires the Pro engine
65
- contents=[uploaded_video, prompt],
66
- config=types.GenerateContentConfig(
67
- response_mime_type="application/json",
68
- response_schema=VisualVerificationReport,
69
- temperature=0.1
70
- ),
71
- )
 
 
72
 
73
  report: VisualVerificationReport = response.parsed
74
 
75
  if report.final_relevance_decision and report.audio_energy_score > 60:
76
  print(f" Passed Verification! Audio Energy Score: {report.audio_energy_score}/100")
77
- # Update the target with our verified timestamps
78
- candidate.start_time = report.adjusted_start_time + candidate.start_time
79
- candidate.end_time = report.adjusted_end_time + candidate.start_time
 
80
  verified_production_clips.append(candidate)
81
  else:
82
  print(" Failed Verification: Rejected due to flat visual energy levels.")
 
7
  from google.genai import types
8
  from clip_extractor import CandidateHighlight
9
  from video_processor import generate_vertical_reel_clip
10
+ from utils import retry_with_backoff
11
 
12
+ APP_DIR = os.path.dirname(os.path.abspath(__file__))
13
+ PROJECT_ROOT = os.path.abspath(os.path.join(APP_DIR, ".."))
14
+
15
+ DATA_DIR = os.path.join(PROJECT_ROOT, "data")
16
+ OUTPUT_DIR = os.path.join(PROJECT_ROOT, "output")
17
+ CLIPS_DIR = os.path.join(OUTPUT_DIR, "clips")
18
+ TEMP_DIR = os.path.join(DATA_DIR, "temp_verification_slices")
19
+
20
+ # Guarantee that folder path hierarchies exist on disk before invoking downstream tools
21
+ os.makedirs(DATA_DIR, exist_ok=True)
22
+ os.makedirs(CLIPS_DIR, exist_ok=True)
23
+ os.makedirs(TEMP_DIR, exist_ok=True)
24
  # Define validation checks for our multi-modal confirmation layer
25
  class VisualVerificationReport(BaseModel):
26
  is_visually_engaging: bool = Field(description="True if the visual pacing, facial cues, or action sequences are high quality.")
 
35
  client = genai.Client()
36
  verified_production_clips = []
37
 
38
+ # Use the module-level resolved TEMP_DIR constant β€” no self-referencing os.path.join
39
+ os.makedirs(TEMP_DIR, exist_ok=True)
 
40
 
41
  for idx, candidate in enumerate(candidates):
42
  print(f" Processing verification step for candidate clip #{idx+1}: {candidate.title}")
43
 
44
+ temp_clip_path = os.path.join(TEMP_DIR, f"candidate_slice_{idx}.mp4")
45
  duration = candidate.end_time - candidate.start_time
46
 
47
  # Quick FFmpeg slice to create a small evaluation file
 
72
  """
73
 
74
  try:
75
+ def execute_call():
76
+ return client.models.generate_content(
77
+ model="gemini-2.5-pro",
78
+ contents=[uploaded_video, prompt],
79
+ config=types.GenerateContentConfig(
80
+ response_mime_type="application/json",
81
+ response_schema=VisualVerificationReport,
82
+ temperature=0.1,
83
+ ),
84
+ )
85
+ response = retry_with_backoff(execute_call)
86
 
87
  report: VisualVerificationReport = response.parsed
88
 
89
  if report.final_relevance_decision and report.audio_energy_score > 60:
90
  print(f" Passed Verification! Audio Energy Score: {report.audio_energy_score}/100")
91
+
92
+ original_baseline_start = candidate.start_time
93
+ candidate.start_time = report.adjusted_start_time + original_baseline_start
94
+ candidate.end_time = report.adjusted_end_time + original_baseline_start
95
  verified_production_clips.append(candidate)
96
  else:
97
  print(" Failed Verification: Rejected due to flat visual energy levels.")
multimodal-engine/app/video_processor.py CHANGED
@@ -9,16 +9,22 @@ from google.genai import types
9
  from dotenv import load_dotenv
10
  from openai import OpenAI
11
  from utils import retry_with_backoff
 
12
 
13
  load_dotenv()
14
 
15
- # Strict structures to link visual changes with audio timestamps cleanly
16
- class VideoFrameMoment(BaseModel):
17
- timestamp_seconds: float = Field(description="The timestamp of this keyframe based on its position.")
18
- visual_description: str = Field(description="Detailed summary of visual activity, slide content, text onscreen, or facial cues.")
19
 
20
- class ChronologicalVisualTimeline(BaseModel):
21
- timeline: List[VideoFrameMoment]
 
 
 
 
 
 
 
22
 
23
  def extract_keyframes(video_path: str, keyframe_output_dir: str, interval_seconds: int = 5) -> int:
24
  """Extracts high-quality keyframes at precise uniform chronological markers."""
@@ -109,7 +115,7 @@ def analyze_scene_with_gemini(frame_dir: str, interval_seconds: int = 5) -> Opti
109
  Describe any visual changes, on-screen slide text, speaker facial actions, or object tracking details.
110
  """
111
 
112
- def excute_call():
113
  return client.models.generate_content(
114
  model="gemini-2.5-flash",
115
  contents=frame_uploaded + [prompt],
@@ -120,14 +126,23 @@ def analyze_scene_with_gemini(frame_dir: str, interval_seconds: int = 5) -> Opti
120
  ),
121
  )
122
 
123
- response = retry_with_backoff(excute_call)
124
  print(" Gemini visual analysis execution step succeeded.")
125
  return response.parsed
126
  except Exception as gemini_error:
127
  print(f"🚨 Primary Gemini Cluster failed. Reason: {gemini_error}")
128
- # Build prompt string for fallback handler matching our logic mapping structure
129
  fallback_prompt = f"Analyze these frames in sequential order. Provide a visual summary every {interval_seconds} seconds."
130
- return run_openai_fallback(frame_paths, fallback_prompt)
 
 
 
 
 
 
 
 
 
 
131
  finally:
132
  # Guarantee cleanup loops execute under all load behaviors
133
  if frame_uploaded:
@@ -142,6 +157,17 @@ def generate_vertical_reel_clip(video_path: str, start_time: float, end_time: fl
142
  """Cuts and crops horizontal 16:9 video source files directly into a 9:16 vertical workspace canvas."""
143
  print(f"[3] Slicing and re-centering vertical layout from {start_time}s to {end_time}s")
144
 
 
 
 
 
 
 
 
 
 
 
 
145
  if os.path.exists(output_path):
146
  os.remove(output_path)
147
 
 
9
  from dotenv import load_dotenv
10
  from openai import OpenAI
11
  from utils import retry_with_backoff
12
+ from models import ChronologicalVisualTimeline, VideoFrameMoment
13
 
14
  load_dotenv()
15
 
16
+ APP_DIR = os.path.dirname(os.path.abspath(__file__))
17
+ PROJECT_ROOT = os.path.abspath(os.path.join(APP_DIR, ".."))
 
 
18
 
19
+ DATA_DIR = os.path.join(PROJECT_ROOT, "data")
20
+ OUTPUT_DIR = os.path.join(PROJECT_ROOT, "output")
21
+ CLIPS_DIR = os.path.join(OUTPUT_DIR, "clips")
22
+ TEMP_DIR = os.path.join(DATA_DIR, "temp_verification_slices")
23
+
24
+ # Guarantee that folder path hierarchies exist on disk before invoking downstream tools
25
+ os.makedirs(DATA_DIR, exist_ok=True)
26
+ os.makedirs(CLIPS_DIR, exist_ok=True)
27
+ os.makedirs(TEMP_DIR, exist_ok=True)
28
 
29
  def extract_keyframes(video_path: str, keyframe_output_dir: str, interval_seconds: int = 5) -> int:
30
  """Extracts high-quality keyframes at precise uniform chronological markers."""
 
115
  Describe any visual changes, on-screen slide text, speaker facial actions, or object tracking details.
116
  """
117
 
118
+ def execute_call():
119
  return client.models.generate_content(
120
  model="gemini-2.5-flash",
121
  contents=frame_uploaded + [prompt],
 
126
  ),
127
  )
128
 
129
+ response = retry_with_backoff(execute_call)
130
  print(" Gemini visual analysis execution step succeeded.")
131
  return response.parsed
132
  except Exception as gemini_error:
133
  print(f"🚨 Primary Gemini Cluster failed. Reason: {gemini_error}")
 
134
  fallback_prompt = f"Analyze these frames in sequential order. Provide a visual summary every {interval_seconds} seconds."
135
+ raw_text_fallback = run_openai_fallback(frame_paths, fallback_prompt)
136
+
137
+ from models import ChronologicalVisualTimeline, VideoFrameMoment
138
+
139
+ fallback_desc = raw_text_fallback if raw_text_fallback else "Visual capture processing failure."
140
+ return ChronologicalVisualTimeline(timeline=[
141
+ VideoFrameMoment(
142
+ timestamp_seconds=float(i * interval_seconds),
143
+ visual_description=f"[OpenAI Fallback Data]: {fallback_desc}"
144
+ ) for i in range(1, len(frame_paths) + 1)
145
+ ])
146
  finally:
147
  # Guarantee cleanup loops execute under all load behaviors
148
  if frame_uploaded:
 
157
  """Cuts and crops horizontal 16:9 video source files directly into a 9:16 vertical workspace canvas."""
158
  print(f"[3] Slicing and re-centering vertical layout from {start_time}s to {end_time}s")
159
 
160
+ if start_time < 0:
161
+ raise ValueError(f"❌ Pipeline Range Violation: start_time ({start_time}s) cannot be negative.")
162
+ if end_time <= start_time:
163
+ raise ValueError(f"❌ Pipeline Range Violation: end_time ({end_time}s) must occur after start_time ({start_time}s).")
164
+ if not os.path.exists(video_path):
165
+ raise FileNotFoundError(f"❌ Pipeline Resource Missing: Source target not located at {video_path}")
166
+
167
+ output_directory = os.path.dirname(output_path)
168
+ if output_directory:
169
+ os.makedirs(output_directory, exist_ok=True)
170
+
171
  if os.path.exists(output_path):
172
  os.remove(output_path)
173
 
multimodal-engine/app/workflow_engine.py CHANGED
@@ -1,43 +1,74 @@
1
  import os
2
  import sys
 
3
  from dotenv import load_dotenv
4
  from google import genai
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  # Setup system environment routing parameters
7
  sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
8
  load_dotenv()
9
 
10
  # High-precision production component imports
11
- from audio_processor import extract_audio_from_video, transcribe_audio, save_transcript_todisk, StructuredTranscript
12
- from video_processor import extract_keyframes, analyze_scene_with_gemini, generate_vertical_reel_clip, ChronologicalVisualTimeline
13
-
14
 
15
- def generate_production_blog(audio_transcript: StructuredTranscript, visual_breakdown: ChronologicalVisualTimeline) -> str:
16
  """Synthesizes structured visual mappings and text arrays into a markdown blog post."""
17
  print(" [3/4] Orchestrating final multimodal content synthesis via Gemini...")
18
  client = genai.Client()
 
19
 
20
  # Pass the serialized clean string models to preserve structural hierarchy inside the token prompt space
21
  prompt = f"""
22
- You are an expert technical content writer and developer documentation engineer.
23
-
24
- Synthesize these two multimodal timelines into a clear, detailed, step-by-step Technical Blog Post in Markdown:
25
 
 
26
  1. TIMESTAMPED AUDIO TRANSCRIPT (JSON Mapping):
 
27
  {audio_transcript.model_dump_json(indent=2)}
 
28
 
29
  2. CHRONOLOGICAL VISUAL TIMELINE (JSON Mapping):
 
30
  {visual_breakdown.model_dump_json(indent=2)}
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
- STRUCTURE RULES:
33
- - Add a catchy title at the top (#).
34
- - Write a short introduction explaining what software/concept is being demonstrated.
35
- - Break the content down into logical step-by-step sections using clear headings (##).
36
- - Blend visual timeline actions smoothly with the spoken words so it reads like a cohesive tutorial.
37
- - Highlight specific keyboard shortcuts, timestamps, or interface menus using code blocks or bold text.
38
- - End with a summary conclusion.
39
 
40
- Do not add conversational commentaryβ€”return ONLY the markdown text blocks.
41
  """
42
 
43
  try:
@@ -55,15 +86,10 @@ def run_integrated_pipeline(video_path: str):
55
  print("πŸš€ ----- Starting Integrated Multimodal Content Generation Pipeline -----")
56
 
57
  # Hardcoded configurations converted to strict, upgraded extensions
58
- audio_output_path = "../data/extracted_audio.wav"
59
- frames_dir = "../data/extracted_frames"
60
- blogs_output_path = "../output/how_multimodals_work_blog.md"
61
- transcript_json_path = "../data/transcript.json"
62
-
63
- if not os.path.exists("../output"):
64
- os.makedirs("../output")
65
- if not os.path.exists("../data"):
66
- os.makedirs("../data")
67
 
68
  # -------------------------------------------------------------
69
  # PHASE 1: Speech-To-Text Timeline Assembly
@@ -90,7 +116,7 @@ def run_integrated_pipeline(video_path: str):
90
  # -------------------------------------------------------------
91
  # PHASE 3: Content Marketing Synthesis (Long-Form Blog Asset)
92
  # -------------------------------------------------------------
93
- final_blog_content = generate_production_blog(audio_transcript, visual_breakdown)
94
 
95
  if final_blog_content:
96
  with open(blogs_output_path, "w", encoding="utf-8") as f:
@@ -101,9 +127,6 @@ def run_integrated_pipeline(video_path: str):
101
  # PHASE 4: Short-Form Reel Extractor (Two-Stage Verification)
102
  # -------------------------------------------------------------
103
  print(" 🎬 [4/4] Activating Two-Stage Filtering Highlight Detection Routine...")
104
-
105
- from clip_extractor import stage_1_semantic_filter
106
- from reel_generator import stage_2_visual_verification
107
 
108
  # 1. Run the Stage 1 text-based check
109
  candidate_clips = stage_1_semantic_filter(audio_transcript, visual_breakdown)
@@ -113,7 +136,7 @@ def run_integrated_pipeline(video_path: str):
113
 
114
  # 3. Render the verified shorts using our 9:16 vertical crop filter
115
  for idx, clip in enumerate(verified_clips):
116
- reel_path = f"../output/viral_reel_{idx + 1}.mp4"
117
  generate_vertical_reel_clip(video_path, clip.start_time, clip.end_time, reel_path)
118
 
119
  print("🏁 Pipeline run completed successfully.")
 
1
  import os
2
  import sys
3
+ from datetime import datetime
4
  from dotenv import load_dotenv
5
  from google import genai
6
+ from clip_extractor import stage_1_semantic_filter
7
+ from reel_generator import stage_2_visual_verification
8
+
9
+ APP_DIR = os.path.dirname(os.path.abspath(__file__))
10
+ PROJECT_ROOT = os.path.abspath(os.path.join(APP_DIR, ".."))
11
+
12
+ DATA_DIR = os.path.join(PROJECT_ROOT, "data")
13
+ OUTPUT_DIR = os.path.join(PROJECT_ROOT, "output")
14
+ CLIPS_DIR = os.path.join(OUTPUT_DIR, "clips")
15
+ TEMP_DIR = os.path.join(DATA_DIR, "temp_verification_slices")
16
+
17
+ # Guarantee that folder path hierarchies exist on disk before invoking downstream tools
18
+ os.makedirs(DATA_DIR, exist_ok=True)
19
+ os.makedirs(CLIPS_DIR, exist_ok=True)
20
+ os.makedirs(TEMP_DIR, exist_ok=True)
21
 
22
  # Setup system environment routing parameters
23
  sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
24
  load_dotenv()
25
 
26
  # High-precision production component imports
27
+ from audio_processor import extract_audio_from_video, transcribe_audio, save_transcript_todisk
28
+ from video_processor import extract_keyframes, analyze_scene_with_gemini, generate_vertical_reel_clip
29
+ from models import StructuredTranscript, ChronologicalVisualTimeline
30
 
31
+ def generate_production_blog(audio_transcript: StructuredTranscript, visual_breakdown: ChronologicalVisualTimeline, video_name: str) -> str:
32
  """Synthesizes structured visual mappings and text arrays into a markdown blog post."""
33
  print(" [3/4] Orchestrating final multimodal content synthesis via Gemini...")
34
  client = genai.Client()
35
+ current_date = datetime.now().strftime("%Y-%m-%d")
36
 
37
  # Pass the serialized clean string models to preserve structural hierarchy inside the token prompt space
38
  prompt = f"""
39
+ You are an expert technical content writer and SEO documentation engineer.
 
 
40
 
41
+ I am providing you with two distinct data inputs extracted from the video file '{video_name}':
42
  1. TIMESTAMPED AUDIO TRANSCRIPT (JSON Mapping):
43
+ ---
44
  {audio_transcript.model_dump_json(indent=2)}
45
+ ---
46
 
47
  2. CHRONOLOGICAL VISUAL TIMELINE (JSON Mapping):
48
+ ---
49
  {visual_breakdown.model_dump_json(indent=2)}
50
+ ---
51
+
52
+ CRITICAL STRUCTURE REQUIREMENT:
53
+ You MUST begin your response with exactly this standard YAML Front Matter block (do not add backticks, trailing syntax, or markdown wrappers around it):
54
+ ---
55
+ title: "Generate a catchy, high-impact, SEO-optimized title here"
56
+ date: "{current_date}"
57
+ tags: ["Artificial Intelligence", "Tutorial", "Tech Guide"]
58
+ category: "Technology"
59
+ author: "Multimodal AI Engine"
60
+ description: "Write a short, engaging 150-character meta description summary here for search previews."
61
+ slug: "generate-a-clean-url-friendly-lowercase-slug-here"
62
+ ---
63
 
64
+ Following the front-matter block, write the blog post document using these strict rules:
65
+ - Write a short introduction explaining the core software/concept demonstrated in the clip.
66
+ - Break the content down into logical sections using clear H2 (##) and H3 (###) headers.
67
+ - Incorporate structural elements like bullet points, summary tables, and bold code blocks cleanly.
68
+ - Blend the visual timeline shifts smoothly with the spoken words so it reads like a comprehensive, standalone web tutorial.
69
+ - Highlight keyboard shortcuts, timestamps, or interface menus mentioned on screen using bold text.
 
70
 
71
+ Do not add conversational commentaryβ€”return ONLY the markdown content.
72
  """
73
 
74
  try:
 
86
  print("πŸš€ ----- Starting Integrated Multimodal Content Generation Pipeline -----")
87
 
88
  # Hardcoded configurations converted to strict, upgraded extensions
89
+ audio_output_path = os.path.join(DATA_DIR, "extracted_audio.wav")
90
+ frames_dir = os.path.join(DATA_DIR, "extracted_frames")
91
+ blogs_output_path = os.path.join(OUTPUT_DIR, "how_multimodals_work_blog.md")
92
+ transcript_json_path = os.path.join(DATA_DIR, "transcript.json")
 
 
 
 
 
93
 
94
  # -------------------------------------------------------------
95
  # PHASE 1: Speech-To-Text Timeline Assembly
 
116
  # -------------------------------------------------------------
117
  # PHASE 3: Content Marketing Synthesis (Long-Form Blog Asset)
118
  # -------------------------------------------------------------
119
+ final_blog_content = generate_production_blog(audio_transcript, visual_breakdown, os.path.basename(video_path))
120
 
121
  if final_blog_content:
122
  with open(blogs_output_path, "w", encoding="utf-8") as f:
 
127
  # PHASE 4: Short-Form Reel Extractor (Two-Stage Verification)
128
  # -------------------------------------------------------------
129
  print(" 🎬 [4/4] Activating Two-Stage Filtering Highlight Detection Routine...")
 
 
 
130
 
131
  # 1. Run the Stage 1 text-based check
132
  candidate_clips = stage_1_semantic_filter(audio_transcript, visual_breakdown)
 
136
 
137
  # 3. Render the verified shorts using our 9:16 vertical crop filter
138
  for idx, clip in enumerate(verified_clips):
139
+ reel_path = os.path.join(CLIPS_DIR, f"viral_reel_{idx + 1}.mp4")
140
  generate_vertical_reel_clip(video_path, clip.start_time, clip.end_time, reel_path)
141
 
142
  print("🏁 Pipeline run completed successfully.")
multimodal-engine/assets/dashboard.png ADDED

Git LFS Details

  • SHA256: 7d585c35427191adfa0e042a9c6ef2459ed0d240c98e2dbdbc381a0d671c3304
  • Pointer size: 130 Bytes
  • Size of remote file: 89.8 kB
multimodal-engine/assets/mdpage.png ADDED

Git LFS Details

  • SHA256: e2284e1c55f7a5c0bcc88b414b79e4ba0b39cc6f43aa3fc6f28ea3c76640fe08
  • Pointer size: 131 Bytes
  • Size of remote file: 155 kB
multimodal-engine/assets/reelpage.png ADDED

Git LFS Details

  • SHA256: fc9afc5399bea9d19f41169bbb1c806d3a1f04bb69b8f083959347bcf3c12a25
  • Pointer size: 131 Bytes
  • Size of remote file: 285 kB
multimodal-engine/output/how_multimodals_work_blog.md CHANGED
@@ -1,147 +1,33 @@
1
- # Demystifying Vector Embeddings: How AI Understands Meaning
2
 
3
- Welcome to the world of modern Artificial Intelligence! Today, we're going to unravel one of its most fundamental concepts: **vector embeddings**. Have you ever wondered how a computer, a machine that only comprehends numbers, can grasp the nuanced meaning of a word like "king"? Or how it knows that "king" is closely related to "queen" but not to "apple"? The answer lies in a clever technique of transforming words into special lists of numbers, known as **vectors**.
4
 
5
- ## The Problem: Representing Words Digitally
6
 
7
- Before diving into the modern solution, let's understand the challenge.
8
 
9
- ### The "Old Way": One-Hot Encoding
10
 
11
- **(Visual at 40.0s: 'Part one: The Old Way' title)**
12
 
13
- The simplest approach to represent words numerically is **one-hot encoding**. Imagine a small dictionary (our 'vocabulary') containing just a few words, each assigned a unique index.
14
 
15
- **(Visual at 45.0s: 'One-Hot Encoding' slide with 'Vocabulary' and 'cat' mapping to '0 0 1 0')**
16
 
17
- For example, if our vocabulary is:
18
- 1. Apple
19
- 2. Ball
20
- 3. Cat
21
- 4. Dog
22
 
23
- To represent the word "cat", we'd create a vector with a length equal to our vocabulary size (in this case, four). The vector would have a '1' in the position corresponding to "cat"'s index, and '0's everywhere else. So, "cat" would become `[0, 0, 1, 0]`.
24
 
25
- **(Visual at 50.0s: 'Vocabulary Mapping' showing King=0, Queen=1, Man=2, Woman=3. King=0 highlighted.)**
26
- **(Visual at 55.0s: 'Vocabulary' list (dog, cat, fish, bird) and 'cat' pointing to '[ zero, zero, one ,')**
27
- **(Visual at 65.0s: 'cat' mapping to '[1, 0, 0, 0]' and 'dog' mapping to '[0, 0, 0, 1]' highlighted.)**
28
 
29
- Similarly, if "dog" was at the fourth position, its vector would be `[0, 0, 0, 1]`.
30
 
31
- ### Why One-Hot Encoding Falls Short
32
 
33
- This method, while simple, presents two significant problems:
34
 
35
- 1. **Inefficiency and Scalability (Visual at 70.0s: 'Problem one: HUGE & Inefficient')**
36
- Real-world vocabularies contain tens of thousands of words, often **50,000 words or more**. This means each word would require a vector of 50,000 dimensions, with only one '1' and 49,999 '0's. This is incredibly inefficient in terms of storage and computation.
37
 
38
- 2. **Lack of Semantic Meaning (Visual at 75.0s: 'Problem two: NO MEANING')**
39
- More critically, one-hot vectors offer no inherent sense of meaning or relationship between words. Every word is equally "different" from every other word because their vectors are orthogonal (perpendicular). There's no mathematical way to discern that "cat" and "dog" are both animals, while "cat" and "bird" are also animals, but "cat" and "rock" are not. They're all just distinct points in a high-dimensional space.
40
 
41
- **(Visual at 80.0s: Words 'cat', 'dog', 'bird' shown as isolated points, illustrating no spatial relationship.)**
42
-
43
- ## The Modern Solution: The Embedding Matrix
44
-
45
- **(Visual at 85.0s: 'Part two: The En' - beginning of 'The New Way')**
46
-
47
- Modern AI models overcome these limitations using an **embedding matrix**β€”a core component for representing words with meaning.
48
-
49
- ### Defining the Embedding Space
50
-
51
- First, we define:
52
- * **Vocabulary Size**: The total number of unique words (or "tokens") our model will understand. This could be 50,000 or more.
53
- * **Embedding Dimension**: A much smaller, fixed number representing the length of each word's vector. Common dimensions are **300**, 768, or 1024. This dimension determines how much information each word vector can encode.
54
-
55
- **(Visual at 90.0s & 95.0s: 'Vocabulary' list (Size = 50,000) pointing to 'Embedding Dimension 300'.)**
56
-
57
- Our embedding matrix (often called a "lookup table") will therefore have:
58
- * **Rows**: Equal to the vocabulary size (e.g., 50,000 rows, one for each word).
59
- * **Columns**: Equal to the embedding dimension (e.g., 300 columns).
60
-
61
- **(Visual at 100.0s: 'Embedding Matrix' slide showing a large blue grid with 'fifty thousand rows' indicated by a yellow bracket.)**
62
-
63
- ### Initialization
64
-
65
- At the very beginning of training an AI model, we fill this giant table with small, random numbers. These numbers, initially meaningless, will be adjusted during the training process.
66
-
67
- **(Visual at 105.0s: 'Embedding Matrix' grid with numerical values, some highlighted in red.)**
68
-
69
- ### Assigning a Vector: A Simple Lookup
70
-
71
- So, how does a model get a vector for a specific word (or "token")? It's surprisingly simple: it's a direct lookup operation.
72
-
73
- **(Visual at 110.0s: 'queen' in a gray rectangle on the left, and a 'Vocabulary' list showing 'king ID: 41', 'queen ID: 42', 'prince ID: 43'.)**
74
- **(Visual at 115.0s: A gray rounded rectangle with 'ID: 42' next to a partially visible 'Embedding Matrix'.)**
75
-
76
- The model performs these steps:
77
- 1. It finds the unique **ID** corresponding to the word it needs to represent (e.g., "queen" might have ID: **42**).
78
- 2. It then goes to the embedding matrix and simply fetches the entire row of numbers associated with that ID.
79
-
80
- **(Visual at 120.0s: 'ID: 42' pointing to a highlighted yellow row in the 'Embedding Matrix', labeled 'Vector Embedding' below.)**
81
-
82
- This retrieved row of numbers is the word's vector embedding.
83
-
84
- ## How the Vectors Get Smart: Learning Meaning Through Training
85
-
86
- **(Visual at 125.0s: 'Part Three: How The Vectors Get Smart' title.)**
87
-
88
- The magic happens during the model's training phase. These numbers aren't random forever; they are continuously refined.
89
-
90
- Consider a model learning to predict the next word in a sentence: "The queen wore a ___".
91
-
92
- **(Visual at 130.0s: Text 'The queen wore a ___' with a 'MODEL' button.)**
93
-
94
- 1. **Initial Prediction**: Based on its initial (random) embedding for "queen" and the other words, the model might incorrectly predict the next word is "shoe".
95
-
96
- **(Visual at 135.0s: 'queen' vector pointing to 'MODEL' button, which points to a crossed-out 'shoe' with a red 'x'.)**
97
-
98
- 2. **Error Calculation and Adjustment**: The model calculates an error because "shoe" is not the correct next word (perhaps the correct word was "crown"). Using this error, the model then goes back and makes tiny adjustments to the numbers within the vector for "queen" (and potentially other words in the context).
99
-
100
- **(Visual at 140.0s: 'Embedding Matrix' showing 'queen' highlighted, and 'Model Feedback' with a red 'x' and 'Correct: crown' with a green checkmark.)**
101
-
102
- 3. **Repetitive Learning**: This process is repeated millions, even billions, of times with vast amounts of text data from the internet. The model is constantly predicting, calculating errors, and adjusting the numerical values in its embedding matrix.
103
-
104
- **(Visual at 145.0s: Dark background with faint grid lines and axis markers.)**
105
-
106
- ### Semantic Closeness
107
-
108
- Over countless iterations, a remarkable phenomenon occurs:
109
- * Words that frequently appear in similar contexts (like "king" and "queen", or "cat" and "kitten") will have their vectors nudged in similar directions.
110
- * Words with dissimilar meanings (like "king" and "apple") will have their vectors pushed further apart.
111
-
112
- **(Visual at 150.0s: Scatter plot showing 'prince', 'queen', 'king' clustered together in yellow, and 'apple', 'orange' clustered in red.)**
113
- **(Visual at 155.0s: Scatter plot showing 'king', 'queen', 'prince' clustered in blue, and 'apple', 'orange' clustered in orange.)**
114
-
115
- Eventually, the vectors for related words end up mathematically "close" to each other in the high-dimensional space, effectively filling those random numbers with rich, semantic meaning. This allows AI models to understand relationships and context in a way that one-hot encoding never could.
116
-
117
- **(Visual at 160.0s: Various words like 'King', 'Queen', 'Prince', 'Throne', 'Crown' clustered, while fruits are elsewhere.)**
118
-
119
- ## Recap: How an Embedding Model Works
120
-
121
- **(Visual at 165.0s: 'Token to Vector: A Recap' slide with four icons: 'Vocabulary & IDs', 'Embedding Matrix', 'Lookup', and 'Learn & Adjust'.)**
122
-
123
- Let's quickly recap the entire process:
124
-
125
- 1. **Vocabulary & IDs**: Every unique word is assigned a specific numerical ID.
126
- **(Visual at 175.0s: Table with 'Token' and 'ID' columns, listing 'The' (0), 'quick' (1), 'brown' (2), 'fox' (3).)**
127
-
128
- 2. **Embedding Matrix Initialization**: A large embedding matrix is created, where each row corresponds to a word ID and contains a vector of random numbers. The number of columns determines the embedding dimension.
129
- **(Visual at 180.0s: Table with 'Token' and 'ID' next to an 'Embedding Matrix' with values; 'quick' (ID 1) row highlighted.)**
130
-
131
- 3. **Vector Lookup**: To get a word's vector, the model simply uses the word's ID to fetch the corresponding row from the embedding matrix. This is a direct, efficient lookup.
132
- **(Visual at 190.0s: 'Input Token ID: 3' pointing to a highlighted yellow row in the 'Embedding Matrix', labeled 'Vector Embedding'.)**
133
-
134
- 4. **Learning & Adjustment**: Through extensive training, the model constantly adjusts these numerical vectors based on predictions and errors. This process mathematically nudges related words closer together in the vector space, imbuing the numbers with semantic meaning.
135
- **(Visual at 195.0s: Coordinate plane showing a red vector 'Cat' and a blue vector 'Kitten', with a circular arrow icon.)**
136
-
137
- ## Conclusion
138
-
139
- **(Visual at 200.0s: 'Embedding Models: A Smart Lookup Table' title.)**
140
-
141
- In essence, an embedding model is a lookup table that starts off with random numbers and progressively gets smarter through experience. It learns to represent words as meaningful numerical vectors, allowing AI to understand language, perform complex tasks like translation and sentiment analysis, and interact with us in increasingly sophisticated ways.
142
-
143
- **(Visual at 205.0s: 'blackboard AI' logo displayed.)**
144
-
145
- If this explanation helped you demystify vector embeddings, please consider liking this blog post and subscribing for more straightforward breakdowns of complex AI topics.
146
-
147
- Thanks for reading!
 
1
+ # Mending the Heart: Five Steps to Kintsugi Your Way Through Heartbreak
2
 
3
+ In the ancient Japanese art of Kintsugi, skilled artisans repair broken pottery using dust from gold or other precious metals. The beauty of Kintsugi lies not in hiding the damage, but in emphasizing the breaks and repairs, transforming them into a beautiful, integral part of the pottery's history. This philosophy teaches us that the breaks in an object's life can be highlighted to make it even more valuable and unique.
4
 
5
+ Similarly, when we experience heartbreak, we face a profound choice: will we forever view it as a wound, or will we transform it into art? Just as Kintsugi celebrates imperfection, we too can choose to see our emotional fractures as opportunities for growth and renewed strength. If you're navigating the pain of heartbreak, here are five practical steps inspired by the spirit of Kintsugi, designed to help you grow through your grief and emerge stronger.
6
 
7
+ ## 1. Jot in a Journal
8
 
9
+ Instead of replaying your heartbreak over and over in your head like a broken record, take the powerful step of recording your feelings in a journal. This act of transferring your thoughts and emotions to paper helps to create a crucial distance and perspective between you and your pain. As you capture your feelings, you'll begin to process them in a healthier way, preventing them from overwhelming your mind.
10
 
11
+ ## 2. Get Out of Your Head and Into Your Body
12
 
13
+ Heartbreak often manifests as a heavy weight on our chest, trapping us in a cycle of sadness and depression. Exercise is a potent remedy to lighten this load by encouraging movement and engagement with the physical world. It's more than just a physical activity; it's a mood enhancer, triggering your brain to release endorphinsβ€”natural feel-good chemicals. Beyond mood improvement, exercise promotes better sleep, which in turn boosts energy and vitality. This renewed energy can significantly shift your present reality, making it feel less bleak. Starting is often the hardest part, so make it easy: just put on your shoes and go for a walk in a beautiful place.
14
 
15
+ ## 3. Shift Your Thoughts to Thankfulness
16
 
17
+ When heartbroken, our minds tend to fixate on the hurt, making it challenging to focus on anything else. One of the most accessible and impactful ways to change this mental pattern is by cultivating a gratitude list. Begin by identifying just three things you are genuinely grateful for. Don't just list them; elaborate with specific details. Think about the people, experiences, and small joys that have brought real meaning into your life. By consciously focusing on these positive elements, day by day, you will gradually feel ready to embrace a new beginning.
 
 
 
 
18
 
19
+ ## 4. Change Your Tune
20
 
21
+ In moments of sorrow, it's common to seek solace in music that echoes our angst. However, music that matches a melancholic mood often amplifies our sadness and drains our energy. To truly heal, we need to lift our spirits, get energized, and find inspiration. Instead of dwelling in despair, consciously choose tunes that empower you and make you feel alive. Curate a playlist of uplifting songs that invigorate your spirit and remind you of your inner strength.
 
 
22
 
23
+ ## 5. Let Them Go
24
 
25
+ The most challenging aspect of a breakup isn't merely releasing the person; it's releasing all the possibilities, hopes, and dreams we projected onto that relationship. These projections, however, were never reality. It is crucial to acknowledge this distinction. Now is the time to release those idealized possibilities in favor of what can genuinely be your future. Holding onto the past will inevitably cause your present to slip by. By letting go, you create space for your real, authentic future to arrive.
26
 
27
+ ## Finding Purpose and Embracing Wellness with Calm
28
 
29
+ Sometimes, underlying our emotional pain is a struggle with a lack of purpose. If you find yourself feeling immense pressure to know your life's direction, have lost interest and feel disconnected from your own life, or doubt your skills and abilities, hope is not lost. Having a sense of purpose provides vital guidance through life's inevitable ups and downs, lending structure to your daily existence.
 
30
 
31
+ To support your journey towards healing and purpose, I've partnered with **Calm**, the leading app for mental health and wellness. The Calm app offers an extensive library of thousands of meditations, soothing songs designed to help you relax and focus, and immersive sleep stories to ensure a restful night's sleep. Additionally, you can find "The Daily Jay," a dedicated series where I share proven tools and techniques to improve your mindset and mental health in just a few minutes each day.
 
32
 
33
+ Embrace these steps, find your purpose, and remember that your journey through heartbreak can be a transformative process, akin to the golden repairs of Kintsugi, leaving you not broken, but beautifully restored.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
multimodal-engine/pytest.ini ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ [pytest]
2
+ testpaths = test
3
+ pythonpath = app
4
+ addopts = -v --tb=short
multimodal-engine/requirements.txt CHANGED
@@ -1,5 +1,5 @@
1
- streamlit>=1.35.0
2
- google-genai>=1.0.0
3
- openai>=1.30.0
4
- python-dotenv>=1.0.0
5
- pydantic>=2.0.0
 
1
+ streamlit==1.57.0
2
+ google-genai==1.68.0
3
+ openai==2.37.0
4
+ python-dotenv==1.0.1
5
+ pydantic==2.12.5
multimodal-engine/test/__init__.py ADDED
File without changes
multimodal-engine/test/test_agent_optimizer.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for agent_optimizer.py
3
+ Covers:
4
+ - Layout style β†’ render_mode routing logic (Blurred Stack vs AI Smart Crop)
5
+ - Slug sanitisation logic used for output filenames
6
+ - run_autonomous_editing_pipeline returns False when no highlights found
7
+ - run_autonomous_editing_pipeline iterates and calls generate_vertical_reel_clip per highlight
8
+ All Gemini API calls and FFmpeg calls are fully mocked.
9
+ """
10
+ import os
11
+ import sys
12
+ import pytest
13
+ from unittest.mock import patch, MagicMock, call
14
+
15
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../app")))
16
+
17
+ from models import (
18
+ StructuredTranscript,
19
+ AudioSegment,
20
+ WordTimestamp,
21
+ ChronologicalVisualTimeline,
22
+ VideoFrameMoment,
23
+ )
24
+
25
+
26
+ # ─── Helpers ──────────────────────────────────────────────────────────────────
27
+
28
+ def _make_transcript():
29
+ return StructuredTranscript(
30
+ segments=[
31
+ AudioSegment(
32
+ start_time=0.0,
33
+ end_time=10.0,
34
+ text="Hello world",
35
+ words=[WordTimestamp(word="Hello", start=0.0, end=0.5)],
36
+ )
37
+ ]
38
+ )
39
+
40
+ def _make_timeline():
41
+ return ChronologicalVisualTimeline(
42
+ timeline=[VideoFrameMoment(timestamp_seconds=5.0, visual_description="Slide visible")]
43
+ )
44
+
45
+ def _make_highlight(title="Great Hook", start=10.0, end=30.0, position="center"):
46
+ """Return a mock ProductionHighlight object."""
47
+ h = MagicMock()
48
+ h.hook_title = title
49
+ h.start_time = start
50
+ h.end_time = end
51
+ h.speaker_position = position
52
+ return h
53
+
54
+
55
+ # ─── Slug sanitisation (inline logic extracted for testing) ───────────────────
56
+
57
+ def _sanitise_slug(title: str) -> str:
58
+ """Mirrors the slug logic inside run_autonomous_editing_pipeline."""
59
+ return (
60
+ "".join(c for c in title if c.isalnum() or c in (" ", "_"))
61
+ .rstrip()
62
+ .replace(" ", "_")
63
+ .lower()
64
+ )
65
+
66
+ class TestSlugSanitisation:
67
+ def test_spaces_become_underscores(self):
68
+ assert _sanitise_slug("Hello World") == "hello_world"
69
+
70
+ def test_special_chars_removed(self):
71
+ assert _sanitise_slug("Top 5 Tips! #viral") == "top_5_tips_viral"
72
+
73
+ def test_already_clean_title(self):
74
+ assert _sanitise_slug("clean_title") == "clean_title"
75
+
76
+ def test_empty_title(self):
77
+ assert _sanitise_slug("") == ""
78
+
79
+ def test_only_special_chars_becomes_empty(self):
80
+ assert _sanitise_slug("!!!") == ""
81
+
82
+
83
+ # ─── Layout style β†’ render mode routing ──────────────────────────────────────
84
+
85
+ def _resolve_render_mode(layout_style: str, speaker_position: str) -> str:
86
+ """Mirrors the if/else in run_autonomous_editing_pipeline."""
87
+ if "Blurred Stack" in layout_style:
88
+ return "blurred"
89
+ return speaker_position
90
+
91
+ class TestRenderModeRouting:
92
+ def test_blurred_stack_overrides_speaker_position(self):
93
+ assert _resolve_render_mode("Blurred Stack Mode (Presentation/Code)", "left") == "blurred"
94
+
95
+ def test_ai_smart_crop_uses_speaker_position_center(self):
96
+ assert _resolve_render_mode("AI Smart Face Crop (Podcast/Vlog)", "center") == "center"
97
+
98
+ def test_ai_smart_crop_uses_speaker_position_left(self):
99
+ assert _resolve_render_mode("AI Smart Face Crop (Podcast/Vlog)", "left") == "left"
100
+
101
+ def test_ai_smart_crop_uses_speaker_position_right(self):
102
+ assert _resolve_render_mode("AI Smart Face Crop (Podcast/Vlog)", "right") == "right"
103
+
104
+ def test_blurred_partial_match_still_resolves(self):
105
+ # Ensures substring matching works as coded
106
+ assert _resolve_render_mode("Blurred Stack", "right") == "blurred"
107
+
108
+
109
+ # ─── run_autonomous_editing_pipeline ─────────────────────────────────────────
110
+
111
+ class TestRunAutonomousEditingPipeline:
112
+
113
+ @patch("agent_optimizer.generate_vertical_reel_clip")
114
+ @patch("agent_optimizer.discover_highlights_autonomously", return_value=[])
115
+ def test_returns_false_when_no_highlights(self, mock_discover, mock_render):
116
+ from agent_optimizer import run_autonomous_editing_pipeline
117
+ result = run_autonomous_editing_pipeline(
118
+ video_path="fake.mp4",
119
+ audio_transcript=_make_transcript(),
120
+ visual_breakdown=_make_timeline(),
121
+ layout_style="AI Smart Face Crop",
122
+ )
123
+ assert result is False
124
+ mock_render.assert_not_called()
125
+
126
+ @patch("agent_optimizer.os.makedirs")
127
+ @patch("agent_optimizer.generate_vertical_reel_clip")
128
+ @patch("agent_optimizer.discover_highlights_autonomously")
129
+ def test_returns_true_when_highlights_found(self, mock_discover, mock_render, mock_makedirs):
130
+ mock_discover.return_value = [_make_highlight("Good Clip", 5.0, 25.0, "center")]
131
+ from agent_optimizer import run_autonomous_editing_pipeline
132
+ result = run_autonomous_editing_pipeline(
133
+ video_path="fake.mp4",
134
+ audio_transcript=_make_transcript(),
135
+ visual_breakdown=_make_timeline(),
136
+ layout_style="AI Smart Face Crop",
137
+ )
138
+ assert result is True
139
+
140
+ @patch("agent_optimizer.os.makedirs")
141
+ @patch("agent_optimizer.generate_vertical_reel_clip")
142
+ @patch("agent_optimizer.discover_highlights_autonomously")
143
+ def test_calls_render_once_per_highlight(self, mock_discover, mock_render, mock_makedirs):
144
+ highlights = [
145
+ _make_highlight("Clip One", 5.0, 25.0, "center"),
146
+ _make_highlight("Clip Two", 40.0, 60.0, "left"),
147
+ ]
148
+ mock_discover.return_value = highlights
149
+ from agent_optimizer import run_autonomous_editing_pipeline
150
+ run_autonomous_editing_pipeline(
151
+ video_path="fake.mp4",
152
+ audio_transcript=_make_transcript(),
153
+ visual_breakdown=_make_timeline(),
154
+ layout_style="AI Smart Face Crop",
155
+ )
156
+ assert mock_render.call_count == 2
157
+
158
+ @patch("agent_optimizer.os.makedirs")
159
+ @patch("agent_optimizer.generate_vertical_reel_clip")
160
+ @patch("agent_optimizer.discover_highlights_autonomously")
161
+ def test_blurred_mode_overrides_render_mode(self, mock_discover, mock_render, mock_makedirs):
162
+ mock_discover.return_value = [_make_highlight("Demo", 0.0, 20.0, "right")]
163
+ from agent_optimizer import run_autonomous_editing_pipeline
164
+ run_autonomous_editing_pipeline(
165
+ video_path="fake.mp4",
166
+ audio_transcript=_make_transcript(),
167
+ visual_breakdown=_make_timeline(),
168
+ layout_style="Blurred Stack Mode",
169
+ )
170
+ _, kwargs = mock_render.call_args
171
+ assert kwargs["render_mode"] == "blurred"
172
+
173
+ @patch("agent_optimizer.os.makedirs")
174
+ @patch("agent_optimizer.generate_vertical_reel_clip")
175
+ @patch("agent_optimizer.discover_highlights_autonomously")
176
+ def test_ffmpeg_error_does_not_crash_pipeline(self, mock_discover, mock_render, mock_makedirs):
177
+ """A single clip render failure should not stop remaining clips."""
178
+ mock_discover.return_value = [
179
+ _make_highlight("Bad Clip", 0.0, 20.0, "center"),
180
+ _make_highlight("Good Clip", 30.0, 50.0, "center"),
181
+ ]
182
+ mock_render.side_effect = [Exception("ffmpeg failed"), None]
183
+ from agent_optimizer import run_autonomous_editing_pipeline
184
+ # Should not raise β€” the exception is caught internally
185
+ result = run_autonomous_editing_pipeline(
186
+ video_path="fake.mp4",
187
+ audio_transcript=_make_transcript(),
188
+ visual_breakdown=_make_timeline(),
189
+ layout_style="AI Smart Face Crop",
190
+ )
191
+ assert result is True
192
+ assert mock_render.call_count == 2
multimodal-engine/test/test_models.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for Pydantic data models (models.py)
3
+ These are pure unit tests β€” no API calls, no FFmpeg, no files needed.
4
+ """
5
+ import pytest
6
+ import sys
7
+ import os
8
+
9
+ # Allow imports from app/ without installing as a package
10
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../app")))
11
+
12
+ from models import (
13
+ WordTimestamp,
14
+ AudioSegment,
15
+ StructuredTranscript,
16
+ VideoFrameMoment,
17
+ ChronologicalVisualTimeline,
18
+ )
19
+
20
+
21
+ # ─── WordTimestamp ────────────────────────────────────────────────────────────
22
+
23
+ class TestWordTimestamp:
24
+ def test_valid_construction(self):
25
+ w = WordTimestamp(word="hello", start=0.0, end=0.45)
26
+ assert w.word == "hello"
27
+ assert w.start == 0.0
28
+ assert w.end == 0.45
29
+
30
+ def test_float_types_are_enforced(self):
31
+ # Pydantic coerces int β†’ float automatically
32
+ w = WordTimestamp(word="hi", start=1, end=2)
33
+ assert isinstance(w.start, float)
34
+ assert isinstance(w.end, float)
35
+
36
+ def test_missing_field_raises(self):
37
+ with pytest.raises(Exception):
38
+ WordTimestamp(word="oops") # start and end missing
39
+
40
+
41
+ # ─── AudioSegment ─────────────────────────────────────────────────────────────
42
+
43
+ class TestAudioSegment:
44
+ def _make_word(self, word="test", start=0.0, end=0.5):
45
+ return WordTimestamp(word=word, start=start, end=end)
46
+
47
+ def test_valid_construction(self):
48
+ seg = AudioSegment(
49
+ start_time=0.0,
50
+ end_time=5.0,
51
+ text="Hello world",
52
+ words=[self._make_word()],
53
+ )
54
+ assert seg.text == "Hello world"
55
+ assert len(seg.words) == 1
56
+
57
+ def test_empty_words_list_is_valid(self):
58
+ seg = AudioSegment(start_time=0.0, end_time=1.0, text="ok", words=[])
59
+ assert seg.words == []
60
+
61
+ def test_multiple_words(self):
62
+ words = [
63
+ self._make_word("Hi", 0.0, 0.3),
64
+ self._make_word("there", 0.3, 0.7),
65
+ ]
66
+ seg = AudioSegment(start_time=0.0, end_time=1.0, text="Hi there", words=words)
67
+ assert len(seg.words) == 2
68
+ assert seg.words[1].word == "there"
69
+
70
+
71
+ # ─── StructuredTranscript ─────────────────────────────────────────────────────
72
+
73
+ class TestStructuredTranscript:
74
+ def _make_segment(self, start=0.0, end=5.0, text="sample"):
75
+ return AudioSegment(
76
+ start_time=start,
77
+ end_time=end,
78
+ text=text,
79
+ words=[WordTimestamp(word=text, start=start, end=end)],
80
+ )
81
+
82
+ def test_single_segment(self):
83
+ t = StructuredTranscript(segments=[self._make_segment()])
84
+ assert len(t.segments) == 1
85
+
86
+ def test_multiple_segments(self):
87
+ t = StructuredTranscript(
88
+ segments=[
89
+ self._make_segment(0.0, 5.0, "first"),
90
+ self._make_segment(5.0, 10.0, "second"),
91
+ ]
92
+ )
93
+ assert len(t.segments) == 2
94
+ assert t.segments[0].text == "first"
95
+ assert t.segments[1].text == "second"
96
+
97
+ def test_empty_segments_is_valid(self):
98
+ t = StructuredTranscript(segments=[])
99
+ assert t.segments == []
100
+
101
+ def test_serialise_to_json_and_back(self):
102
+ original = StructuredTranscript(segments=[self._make_segment()])
103
+ json_str = original.model_dump_json()
104
+ restored = StructuredTranscript.model_validate_json(json_str)
105
+ assert restored.segments[0].text == original.segments[0].text
106
+
107
+ def test_float_timestamps_preserved_in_json(self):
108
+ seg = self._make_segment(start=12.5, end=20.75)
109
+ t = StructuredTranscript(segments=[seg])
110
+ data = t.model_dump()
111
+ assert data["segments"][0]["start_time"] == 12.5
112
+ assert data["segments"][0]["end_time"] == 20.75
113
+
114
+
115
+ # ─── VideoFrameMoment ─────────────────────────────────────────────────────────
116
+
117
+ class TestVideoFrameMoment:
118
+ def test_valid_construction(self):
119
+ frame = VideoFrameMoment(
120
+ timestamp_seconds=10.0,
121
+ visual_description="Speaker gestures at whiteboard.",
122
+ )
123
+ assert frame.timestamp_seconds == 10.0
124
+
125
+ def test_zero_timestamp_is_valid(self):
126
+ frame = VideoFrameMoment(timestamp_seconds=0.0, visual_description="Intro slide.")
127
+ assert frame.timestamp_seconds == 0.0
128
+
129
+ def test_missing_description_raises(self):
130
+ with pytest.raises(Exception):
131
+ VideoFrameMoment(timestamp_seconds=5.0)
132
+
133
+
134
+ # ─── ChronologicalVisualTimeline ──────────────────────────────────────────────
135
+
136
+ class TestChronologicalVisualTimeline:
137
+ def _make_frame(self, ts=5.0, desc="Frame description"):
138
+ return VideoFrameMoment(timestamp_seconds=ts, visual_description=desc)
139
+
140
+ def test_single_frame(self):
141
+ tl = ChronologicalVisualTimeline(timeline=[self._make_frame()])
142
+ assert len(tl.timeline) == 1
143
+
144
+ def test_ordered_timestamps(self):
145
+ tl = ChronologicalVisualTimeline(
146
+ timeline=[
147
+ self._make_frame(5.0, "frame 1"),
148
+ self._make_frame(10.0, "frame 2"),
149
+ self._make_frame(15.0, "frame 3"),
150
+ ]
151
+ )
152
+ timestamps = [f.timestamp_seconds for f in tl.timeline]
153
+ assert timestamps == sorted(timestamps), "Timeline should be in ascending order"
154
+
155
+ def test_serialise_roundtrip(self):
156
+ original = ChronologicalVisualTimeline(timeline=[self._make_frame(25.0, "desk shot")])
157
+ restored = ChronologicalVisualTimeline.model_validate_json(original.model_dump_json())
158
+ assert restored.timeline[0].timestamp_seconds == 25.0
159
+
160
+ def test_empty_timeline_is_valid(self):
161
+ tl = ChronologicalVisualTimeline(timeline=[])
162
+ assert tl.timeline == []
multimodal-engine/test/test_pipeline_integration.py ADDED
@@ -0,0 +1,449 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Integration tests for the full 4-phase pipeline.
3
+
4
+ All external boundaries (Gemini, OpenAI, FFmpeg, disk I/O) are mocked so these
5
+ tests run without API keys, video files, or FFmpeg installed.
6
+
7
+ They verify that the phases wire together correctly:
8
+ Phase 1 β†’ audio extraction + transcription β†’ StructuredTranscript
9
+ Phase 2 β†’ keyframe extraction + VLM analysis β†’ ChronologicalVisualTimeline
10
+ Phase 3 β†’ blog synthesis β†’ Markdown string saved to disk
11
+ Phase 4 β†’ highlight detection + reel render β†’ MP4 files written to output/clips
12
+ """
13
+
14
+ import os
15
+ import sys
16
+ import json
17
+ import pytest
18
+ from unittest.mock import patch, MagicMock, call
19
+
20
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../app")))
21
+
22
+ from models import (
23
+ StructuredTranscript,
24
+ AudioSegment,
25
+ WordTimestamp,
26
+ ChronologicalVisualTimeline,
27
+ VideoFrameMoment,
28
+ )
29
+
30
+
31
+ # ─── Reusable fixtures ────────────────────────────────────────────────────────
32
+
33
+ @pytest.fixture
34
+ def sample_transcript():
35
+ return StructuredTranscript(
36
+ segments=[
37
+ AudioSegment(
38
+ start_time=0.0,
39
+ end_time=15.0,
40
+ text="Welcome to the tutorial. Today we cover embeddings.",
41
+ words=[
42
+ WordTimestamp(word="Welcome", start=0.0, end=0.5),
43
+ WordTimestamp(word="embeddings", start=14.0, end=14.8),
44
+ ],
45
+ ),
46
+ AudioSegment(
47
+ start_time=15.0,
48
+ end_time=45.0,
49
+ text="Vectors allow semantic similarity search across large datasets.",
50
+ words=[
51
+ WordTimestamp(word="Vectors", start=15.0, end=15.6),
52
+ WordTimestamp(word="datasets", start=44.0, end=44.9),
53
+ ],
54
+ ),
55
+ ]
56
+ )
57
+
58
+
59
+ @pytest.fixture
60
+ def sample_timeline():
61
+ return ChronologicalVisualTimeline(
62
+ timeline=[
63
+ VideoFrameMoment(timestamp_seconds=5.0, visual_description="Title slide: AI Embeddings"),
64
+ VideoFrameMoment(timestamp_seconds=10.0, visual_description="Speaker at whiteboard"),
65
+ VideoFrameMoment(timestamp_seconds=15.0, visual_description="Code editor visible"),
66
+ VideoFrameMoment(timestamp_seconds=20.0, visual_description="Vector diagram on screen"),
67
+ ]
68
+ )
69
+
70
+
71
+ # ─── Phase 1 integration: audio extraction β†’ transcription ───────────────────
72
+
73
+ class TestPhase1AudioTranscription:
74
+
75
+ @patch("audio_processor.genai.Client")
76
+ @patch("audio_processor.subprocess.run")
77
+ def test_extract_then_transcribe_returns_structured_transcript(
78
+ self, mock_run, mock_client_cls, tmp_path, sample_transcript
79
+ ):
80
+ """FFmpeg succeeds β†’ Gemini returns parsed StructuredTranscript."""
81
+ mock_run.return_value = MagicMock(returncode=0)
82
+
83
+ # Build a mock Gemini client that returns sample_transcript via .parsed
84
+ mock_client = MagicMock()
85
+ mock_client_cls.return_value = mock_client
86
+ mock_upload = MagicMock()
87
+ mock_upload.name = "files/test-audio-001"
88
+ mock_client.files.upload.return_value = mock_upload
89
+ mock_response = MagicMock()
90
+ mock_response.parsed = sample_transcript
91
+ mock_client.models.generate_content.return_value = mock_response
92
+
93
+ from audio_processor import extract_audio_from_video, transcribe_audio
94
+
95
+ video_path = str(tmp_path / "video.mp4")
96
+ audio_path = str(tmp_path / "audio.wav")
97
+
98
+ # Create a dummy video file so FFmpeg mock has a real path
99
+ open(video_path, "wb").close()
100
+
101
+ extract_audio_from_video(video_path, audio_path)
102
+
103
+ # Create the audio file so transcribe_audio can upload it
104
+ open(audio_path, "wb").close()
105
+ result = transcribe_audio(audio_path)
106
+
107
+ assert isinstance(result, StructuredTranscript)
108
+ assert len(result.segments) == 2
109
+ assert result.segments[0].text == "Welcome to the tutorial. Today we cover embeddings."
110
+
111
+ @patch("audio_processor.genai.Client")
112
+ @patch("audio_processor.subprocess.run")
113
+ def test_transcription_none_parsed_raises(self, mock_run, mock_client_cls, tmp_path):
114
+ """If Gemini returns None for .parsed the exception propagates cleanly."""
115
+ mock_run.return_value = MagicMock(returncode=0)
116
+
117
+ mock_client = MagicMock()
118
+ mock_client_cls.return_value = mock_client
119
+ mock_upload = MagicMock()
120
+ mock_upload.name = "files/test-audio-002"
121
+ mock_client.files.upload.return_value = mock_upload
122
+ mock_response = MagicMock()
123
+ mock_response.parsed = None
124
+ mock_client.models.generate_content.return_value = mock_response
125
+
126
+ from audio_processor import transcribe_audio
127
+
128
+ audio_path = str(tmp_path / "audio.wav")
129
+ open(audio_path, "wb").close()
130
+
131
+ # Should not crash silently β€” caller must handle None
132
+ result = transcribe_audio(audio_path)
133
+ assert result is None # pipeline callers are responsible for the guard
134
+
135
+
136
+ # ─── Phase 2 integration: keyframe extraction β†’ scene analysis ───────────────
137
+
138
+ class TestPhase2VisualTimeline:
139
+
140
+ @patch("video_processor.genai.Client")
141
+ @patch("video_processor.subprocess.run")
142
+ def test_extract_then_analyze_returns_timeline(
143
+ self, mock_run, mock_client_cls, tmp_path, sample_timeline
144
+ ):
145
+ """FFmpeg succeeds β†’ Gemini returns a ChronologicalVisualTimeline."""
146
+ mock_run.return_value = MagicMock(returncode=0)
147
+
148
+ # Create fake JPEG frames so glob finds them
149
+ frames_dir = tmp_path / "frames"
150
+ frames_dir.mkdir()
151
+ for i in range(1, 5):
152
+ (frames_dir / f"keyframe_{i:04d}.jpg").write_bytes(b"\xff\xd8\xff")
153
+
154
+ mock_client = MagicMock()
155
+ mock_client_cls.return_value = mock_client
156
+ mock_upload = MagicMock()
157
+ mock_upload.name = "files/frame-001"
158
+ mock_client.files.upload.return_value = mock_upload
159
+ mock_response = MagicMock()
160
+ mock_response.parsed = sample_timeline
161
+ mock_client.models.generate_content.return_value = mock_response
162
+
163
+ from video_processor import analyze_scene_with_gemini
164
+
165
+ result = analyze_scene_with_gemini(str(frames_dir), interval_seconds=5)
166
+
167
+ assert isinstance(result, ChronologicalVisualTimeline)
168
+ assert len(result.timeline) == 4
169
+ assert result.timeline[0].timestamp_seconds == 5.0
170
+
171
+ @patch("video_processor.run_openai_fallback")
172
+ @patch("video_processor.genai.Client")
173
+ def test_gemini_failure_falls_back_to_openai_timeline(
174
+ self, mock_client_cls, mock_fallback, tmp_path
175
+ ):
176
+ """When Gemini raises, the OpenAI fallback returns a valid timeline object."""
177
+ frames_dir = tmp_path / "frames"
178
+ frames_dir.mkdir()
179
+ for i in range(1, 3):
180
+ (frames_dir / f"keyframe_{i:04d}.jpg").write_bytes(b"\xff\xd8\xff")
181
+
182
+ mock_client = MagicMock()
183
+ mock_client_cls.return_value = mock_client
184
+ mock_client.files.upload.side_effect = RuntimeError("Gemini upload failed")
185
+ mock_fallback.return_value = "Whiteboard with diagrams visible on screen."
186
+
187
+ from video_processor import analyze_scene_with_gemini
188
+
189
+ result = analyze_scene_with_gemini(str(frames_dir), interval_seconds=5)
190
+
191
+ # Must return a typed ChronologicalVisualTimeline β€” not a raw string
192
+ assert isinstance(result, ChronologicalVisualTimeline)
193
+ assert len(result.timeline) == 2
194
+ assert "[OpenAI Fallback Data]" in result.timeline[0].visual_description
195
+
196
+ @patch("video_processor.run_openai_fallback")
197
+ @patch("video_processor.genai.Client")
198
+ def test_both_providers_fail_returns_placeholder_timeline(
199
+ self, mock_client_cls, mock_fallback, tmp_path
200
+ ):
201
+ """If both Gemini and OpenAI fail, a placeholder timeline is still returned."""
202
+ frames_dir = tmp_path / "frames"
203
+ frames_dir.mkdir()
204
+ (frames_dir / "keyframe_0001.jpg").write_bytes(b"\xff\xd8\xff")
205
+
206
+ mock_client = MagicMock()
207
+ mock_client_cls.return_value = mock_client
208
+ mock_client.files.upload.side_effect = RuntimeError("Gemini down")
209
+ mock_fallback.return_value = None # OpenAI also fails
210
+
211
+ from video_processor import analyze_scene_with_gemini
212
+
213
+ result = analyze_scene_with_gemini(str(frames_dir), interval_seconds=5)
214
+
215
+ assert isinstance(result, ChronologicalVisualTimeline)
216
+ assert result.timeline[0].visual_description == "[OpenAI Fallback Data]: Visual capture processing failure."
217
+
218
+
219
+ # ─── Phase 3 integration: blog synthesis ─────────────────────────────────────
220
+
221
+ class TestPhase3BlogSynthesis:
222
+
223
+ @patch("workflow_engine.genai.Client")
224
+ def test_generate_blog_embeds_transcript_in_prompt(
225
+ self, mock_client_cls, sample_transcript, sample_timeline, tmp_path
226
+ ):
227
+ """Verify the transcript JSON is actually injected into the Gemini prompt."""
228
+ mock_client = MagicMock()
229
+ mock_client_cls.return_value = mock_client
230
+ mock_response = MagicMock()
231
+ mock_response.text = (
232
+ '---\ntitle: "Test"\nslug: "test-slug"\n---\n\n## Intro\nSome content.'
233
+ )
234
+ mock_client.models.generate_content.return_value = mock_response
235
+
236
+ from workflow_engine import generate_production_blog
237
+
238
+ result = generate_production_blog(sample_transcript, sample_timeline, "tutorial.mp4")
239
+
240
+ # The prompt sent to Gemini must contain the serialised transcript data
241
+ call_args = mock_client.models.generate_content.call_args
242
+ prompt_sent = call_args[1]["contents"][0] if call_args[1] else call_args[0][1][0]
243
+ assert "Welcome to the tutorial" in prompt_sent or "segments" in prompt_sent
244
+
245
+ @patch("workflow_engine.genai.Client")
246
+ def test_generate_blog_returns_string(
247
+ self, mock_client_cls, sample_transcript, sample_timeline
248
+ ):
249
+ mock_client = MagicMock()
250
+ mock_client_cls.return_value = mock_client
251
+ mock_client.models.generate_content.return_value.text = "# Blog\n\nContent here."
252
+
253
+ from workflow_engine import generate_production_blog
254
+
255
+ result = generate_production_blog(sample_transcript, sample_timeline, "video.mp4")
256
+
257
+ assert isinstance(result, str)
258
+ assert len(result) > 0
259
+
260
+
261
+ # ─── Phase 4 integration: highlight detection β†’ reel rendering ───────────────
262
+
263
+ class TestPhase4ReelPipeline:
264
+
265
+ @patch("agent_optimizer.generate_vertical_reel_clip")
266
+ @patch("agent_optimizer.discover_highlights_autonomously")
267
+ def test_pipeline_calls_render_for_each_highlight(
268
+ self, mock_discover, mock_render, sample_transcript, sample_timeline
269
+ ):
270
+ """Each discovered highlight must produce exactly one render call."""
271
+ h1 = MagicMock()
272
+ h1.hook_title = "Why Embeddings Matter"
273
+ h1.start_time = 10.0
274
+ h1.end_time = 35.0
275
+ h1.speaker_position = "center"
276
+
277
+ h2 = MagicMock()
278
+ h2.hook_title = "Vector Search Demo"
279
+ h2.start_time = 40.0
280
+ h2.end_time = 65.0
281
+ h2.speaker_position = "left"
282
+
283
+ mock_discover.return_value = [h1, h2]
284
+
285
+ from agent_optimizer import run_autonomous_editing_pipeline
286
+
287
+ result = run_autonomous_editing_pipeline(
288
+ video_path="fake_video.mp4",
289
+ audio_transcript=sample_transcript,
290
+ visual_breakdown=sample_timeline,
291
+ layout_style="AI Smart Face Crop (Podcast/Vlog)",
292
+ )
293
+
294
+ assert result is True
295
+ assert mock_render.call_count == 2
296
+
297
+ @patch("agent_optimizer.generate_vertical_reel_clip")
298
+ @patch("agent_optimizer.discover_highlights_autonomously")
299
+ def test_blurred_mode_overrides_all_speaker_positions(
300
+ self, mock_discover, mock_render, sample_transcript, sample_timeline
301
+ ):
302
+ """Blurred Stack layout must ignore speaker_position for every clip."""
303
+ for pos in ["left", "center", "right"]:
304
+ mock_render.reset_mock()
305
+ h = MagicMock()
306
+ h.hook_title = "Test Clip"
307
+ h.start_time = 5.0
308
+ h.end_time = 25.0
309
+ h.speaker_position = pos
310
+ mock_discover.return_value = [h]
311
+
312
+ from agent_optimizer import run_autonomous_editing_pipeline
313
+
314
+ run_autonomous_editing_pipeline(
315
+ video_path="fake_video.mp4",
316
+ audio_transcript=sample_transcript,
317
+ visual_breakdown=sample_timeline,
318
+ layout_style="Blurred Stack Mode (Presentation/Code)",
319
+ )
320
+
321
+ _, kwargs = mock_render.call_args
322
+ assert kwargs["render_mode"] == "blurred", (
323
+ f"Expected 'blurred' for speaker_position='{pos}', got '{kwargs['render_mode']}'"
324
+ )
325
+
326
+ @patch("agent_optimizer.generate_vertical_reel_clip")
327
+ @patch("agent_optimizer.discover_highlights_autonomously", return_value=[])
328
+ def test_empty_highlights_returns_false_no_render(
329
+ self, mock_discover, mock_render, sample_transcript, sample_timeline
330
+ ):
331
+ from agent_optimizer import run_autonomous_editing_pipeline
332
+
333
+ result = run_autonomous_editing_pipeline(
334
+ video_path="fake_video.mp4",
335
+ audio_transcript=sample_transcript,
336
+ visual_breakdown=sample_timeline,
337
+ layout_style="AI Smart Face Crop",
338
+ )
339
+
340
+ assert result is False
341
+ mock_render.assert_not_called()
342
+
343
+
344
+ # ─── Full pipeline smoke test ─────────────────────────────────────────────────
345
+
346
+ class TestFullPipelineSmoke:
347
+
348
+ @patch("agent_optimizer.generate_vertical_reel_clip")
349
+ @patch("agent_optimizer.discover_highlights_autonomously")
350
+ @patch("workflow_engine.genai.Client")
351
+ @patch("video_processor.genai.Client")
352
+ @patch("audio_processor.genai.Client")
353
+ @patch("video_processor.subprocess.run")
354
+ @patch("audio_processor.subprocess.run")
355
+ def test_all_4_phases_complete_without_error(
356
+ self,
357
+ mock_audio_run,
358
+ mock_video_run,
359
+ mock_audio_client_cls,
360
+ mock_video_client_cls,
361
+ mock_blog_client_cls,
362
+ mock_discover,
363
+ mock_render,
364
+ tmp_path,
365
+ sample_transcript,
366
+ sample_timeline,
367
+ ):
368
+ """
369
+ Smoke test: mock every external call and confirm all 4 phases
370
+ run end-to-end without raising any exception.
371
+ """
372
+ # --- FFmpeg mocks ---
373
+ mock_audio_run.return_value = MagicMock(returncode=0)
374
+ mock_video_run.return_value = MagicMock(returncode=0)
375
+
376
+ # --- Phase 1: transcription ---
377
+ audio_client = MagicMock()
378
+ mock_audio_client_cls.return_value = audio_client
379
+ mock_audio_upload = MagicMock()
380
+ mock_audio_upload.name = "files/audio-smoke"
381
+ audio_client.files.upload.return_value = mock_audio_upload
382
+ audio_response = MagicMock()
383
+ audio_response.parsed = sample_transcript
384
+ audio_client.models.generate_content.return_value = audio_response
385
+
386
+ # --- Phase 2: scene analysis (use pre-existing frames dir) ---
387
+ frames_dir = tmp_path / "frames"
388
+ frames_dir.mkdir()
389
+ for i in range(1, 4):
390
+ (frames_dir / f"keyframe_{i:04d}.jpg").write_bytes(b"\xff\xd8\xff")
391
+
392
+ video_client = MagicMock()
393
+ mock_video_client_cls.return_value = video_client
394
+ mock_frame_upload = MagicMock()
395
+ mock_frame_upload.name = "files/frame-smoke"
396
+ video_client.files.upload.return_value = mock_frame_upload
397
+ video_response = MagicMock()
398
+ video_response.parsed = sample_timeline
399
+ video_client.models.generate_content.return_value = video_response
400
+
401
+ # --- Phase 3: blog synthesis ---
402
+ blog_client = MagicMock()
403
+ mock_blog_client_cls.return_value = blog_client
404
+ blog_response = MagicMock()
405
+ blog_response.text = '---\ntitle: "Smoke Test"\nslug: "smoke-test"\n---\n\n## Content\nOK.'
406
+ blog_client.models.generate_content.return_value = blog_response
407
+
408
+ # --- Phase 4: highlight detection + render ---
409
+ highlight = MagicMock()
410
+ highlight.hook_title = "Key Insight"
411
+ highlight.start_time = 5.0
412
+ highlight.end_time = 25.0
413
+ highlight.speaker_position = "center"
414
+ mock_discover.return_value = [highlight]
415
+
416
+ # --- Run the full pipeline ---
417
+ audio_path = str(tmp_path / "audio.wav")
418
+ open(audio_path, "wb").close()
419
+
420
+ video_path = str(tmp_path / "video.mp4")
421
+ open(video_path, "wb").close()
422
+
423
+ from audio_processor import extract_audio_from_video, transcribe_audio
424
+ from video_processor import analyze_scene_with_gemini
425
+ from workflow_engine import generate_production_blog
426
+ from agent_optimizer import run_autonomous_editing_pipeline
427
+
428
+ # Phase 1
429
+ extract_audio_from_video(video_path, audio_path)
430
+ transcript = transcribe_audio(audio_path)
431
+ assert transcript is not None
432
+
433
+ # Phase 2
434
+ timeline = analyze_scene_with_gemini(str(frames_dir), interval_seconds=5)
435
+ assert timeline is not None
436
+
437
+ # Phase 3
438
+ blog = generate_production_blog(transcript, timeline, "video.mp4")
439
+ assert isinstance(blog, str) and len(blog) > 0
440
+
441
+ # Phase 4
442
+ result = run_autonomous_editing_pipeline(
443
+ video_path=video_path,
444
+ audio_transcript=transcript,
445
+ visual_breakdown=timeline,
446
+ layout_style="AI Smart Face Crop",
447
+ )
448
+ assert result is True
449
+ assert mock_render.call_count == 1
multimodal-engine/test/test_utils.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for retry_with_backoff (utils.py)
3
+ All tests are pure unit tests β€” no real API calls are made.
4
+ APIError is faked via a real subclass so isinstance() checks inside utils.py pass.
5
+ """
6
+ import pytest
7
+ import sys
8
+ import os
9
+ from unittest.mock import patch, MagicMock
10
+
11
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../app")))
12
+
13
+ from google.genai.errors import APIError
14
+ from utils import retry_with_backoff
15
+
16
+
17
+ class FakeAPIError(APIError):
18
+ """
19
+ A real subclass of APIError so isinstance(err, APIError) returns True.
20
+ APIError.__init__ requires a specific signature we bypass here.
21
+ """
22
+ def __init__(self, code: int):
23
+ # Skip the parent __init__ to avoid needing a full Response object
24
+ self.code = code
25
+ self.message = f"Fake API error {code}"
26
+
27
+ def __str__(self):
28
+ return self.message
29
+
30
+
31
+ def _make_api_error(code: int) -> FakeAPIError:
32
+ """Return a real APIError subclass instance with the given HTTP status code."""
33
+ return FakeAPIError(code)
34
+
35
+
36
+ class TestRetryWithBackoff:
37
+
38
+ # ── Success path ──────────────────────────────────────────────────────────
39
+
40
+ def test_returns_value_on_first_success(self):
41
+ func = MagicMock(return_value="ok")
42
+ result = retry_with_backoff(func, max_retries=3, initial_delay=0)
43
+ assert result == "ok"
44
+ func.assert_called_once()
45
+
46
+ # ── Retry on retriable codes ───────────────────────────────────────────────
47
+
48
+ @patch("utils.time.sleep")
49
+ def test_retries_on_429_then_succeeds(self, mock_sleep):
50
+ err = _make_api_error(429)
51
+ func = MagicMock(side_effect=[err, err, "recovered"])
52
+ result = retry_with_backoff(func, max_retries=5, initial_delay=0, backoff_factor=2)
53
+ assert result == "recovered"
54
+ assert func.call_count == 3
55
+
56
+ @patch("utils.time.sleep")
57
+ def test_retries_on_503_then_succeeds(self, mock_sleep):
58
+ err = _make_api_error(503)
59
+ func = MagicMock(side_effect=[err, "ok"])
60
+ result = retry_with_backoff(func, max_retries=3, initial_delay=0)
61
+ assert result == "ok"
62
+ assert func.call_count == 2
63
+
64
+ # ── Exhausted retries ─────────────────────────────────────────────────────
65
+
66
+ @patch("utils.time.sleep")
67
+ def test_raises_after_max_retries_exhausted(self, mock_sleep):
68
+ err = _make_api_error(429)
69
+ func = MagicMock(side_effect=err)
70
+ with pytest.raises(FakeAPIError):
71
+ retry_with_backoff(func, max_retries=3, initial_delay=0)
72
+ assert func.call_count == 3
73
+
74
+ # ── Non-retriable codes raise immediately ─────────────────────────────────
75
+
76
+ @patch("utils.time.sleep")
77
+ def test_raises_immediately_on_non_retriable_code(self, mock_sleep):
78
+ err = _make_api_error(400) # Bad request β€” not retriable
79
+ func = MagicMock(side_effect=err)
80
+ with pytest.raises(FakeAPIError):
81
+ retry_with_backoff(func, max_retries=5, initial_delay=0)
82
+ # Should not retry β€” called exactly once
83
+ assert func.call_count == 1
84
+
85
+ @patch("utils.time.sleep")
86
+ def test_raises_immediately_on_401(self, mock_sleep):
87
+ err = _make_api_error(401)
88
+ func = MagicMock(side_effect=err)
89
+ with pytest.raises(FakeAPIError):
90
+ retry_with_backoff(func, max_retries=5, initial_delay=0)
91
+ assert func.call_count == 1
92
+
93
+ # ── Non-API exceptions propagate immediately ──────────────────────────────
94
+
95
+ def test_non_api_exception_propagates_immediately(self):
96
+ func = MagicMock(side_effect=ValueError("bad input"))
97
+ with pytest.raises(ValueError, match="bad input"):
98
+ retry_with_backoff(func, max_retries=5, initial_delay=0)
99
+ func.assert_called_once()
100
+
101
+ def test_runtime_error_propagates_immediately(self):
102
+ func = MagicMock(side_effect=RuntimeError("crash"))
103
+ with pytest.raises(RuntimeError):
104
+ retry_with_backoff(func, max_retries=5, initial_delay=0)
105
+ func.assert_called_once()
106
+
107
+ # ── Sleep is actually called between retries ───────────────────────────────
108
+
109
+ @patch("utils.time.sleep")
110
+ def test_sleep_is_called_between_retries(self, mock_sleep):
111
+ err = _make_api_error(429)
112
+ func = MagicMock(side_effect=[err, err, "ok"])
113
+ retry_with_backoff(func, max_retries=5, initial_delay=1, backoff_factor=2)
114
+ # Should sleep twice (after attempt 1 and attempt 2)
115
+ assert mock_sleep.call_count == 2
116
+
117
+ # ── Backoff delay grows with each retry ───────────────────────────────────
118
+
119
+ @patch("utils.random.uniform", return_value=0.0) # Remove jitter noise
120
+ @patch("utils.time.sleep")
121
+ def test_delay_doubles_with_backoff_factor(self, mock_sleep, mock_random):
122
+ err = _make_api_error(429)
123
+ func = MagicMock(side_effect=[err, err, err, "ok"])
124
+ retry_with_backoff(func, max_retries=5, initial_delay=1, backoff_factor=2)
125
+ sleep_calls = [c.args[0] for c in mock_sleep.call_args_list]
126
+ # Delays should be 1, 2, 4 (doubling each time, jitter zeroed out)
127
+ assert sleep_calls[0] == pytest.approx(1.0, abs=0.01)
128
+ assert sleep_calls[1] == pytest.approx(2.0, abs=0.01)
129
+ assert sleep_calls[2] == pytest.approx(4.0, abs=0.01)
multimodal-engine/test/test_video_processor.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for video_processor.py
3
+ - extract_keyframes: tested with a real temp directory + mocked subprocess
4
+ - encode_image_to_base64: tested with a real temp file
5
+ - generate_vertical_reel_clip: filter-chain logic tested without running FFmpeg
6
+ - analyze_scene_with_gemini / run_openai_fallback: patched at the boundary
7
+ """
8
+ import os
9
+ import sys
10
+ import glob
11
+ import base64
12
+ import tempfile
13
+ import subprocess
14
+ import pytest
15
+ from unittest.mock import patch, MagicMock, call
16
+
17
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../app")))
18
+
19
+ import video_processor
20
+ from video_processor import (
21
+ encode_image_to_base64,
22
+ extract_keyframes,
23
+ generate_vertical_reel_clip,
24
+ )
25
+
26
+
27
+ # ─── encode_image_to_base64 ───────────────────────────────────────────────────
28
+
29
+ class TestEncodeImageToBase64:
30
+ def test_encodes_real_file(self, tmp_path):
31
+ img = tmp_path / "frame.jpg"
32
+ img.write_bytes(b"\xff\xd8\xff") # minimal JPEG header bytes
33
+ result = encode_image_to_base64(str(img))
34
+ # Result must be a valid base64 string that decodes back to our bytes
35
+ assert base64.b64decode(result) == b"\xff\xd8\xff"
36
+
37
+ def test_returns_string(self, tmp_path):
38
+ img = tmp_path / "frame.jpg"
39
+ img.write_bytes(b"abc")
40
+ assert isinstance(encode_image_to_base64(str(img)), str)
41
+
42
+ def test_missing_file_raises(self):
43
+ with pytest.raises(FileNotFoundError):
44
+ encode_image_to_base64("/nonexistent/path/frame.jpg")
45
+
46
+
47
+ # ─── extract_keyframes ────────────────────────────────────────────────────────
48
+
49
+ class TestExtractKeyframes:
50
+
51
+ @patch("video_processor.subprocess.run")
52
+ def test_creates_output_dir_if_missing(self, mock_run, tmp_path):
53
+ mock_run.return_value = MagicMock(returncode=0)
54
+ output_dir = tmp_path / "frames"
55
+ extract_keyframes("fake.mp4", str(output_dir), interval_seconds=5)
56
+ assert output_dir.exists()
57
+
58
+ @patch("video_processor.subprocess.run")
59
+ def test_clears_existing_jpgs_before_extraction(self, mock_run, tmp_path):
60
+ mock_run.return_value = MagicMock(returncode=0)
61
+ output_dir = tmp_path / "frames"
62
+ output_dir.mkdir()
63
+ # Pre-populate with stale files
64
+ (output_dir / "keyframe_0001.jpg").write_bytes(b"old")
65
+ (output_dir / "keyframe_0002.jpg").write_bytes(b"old")
66
+ extract_keyframes("fake.mp4", str(output_dir), interval_seconds=5)
67
+ # After the call (before ffmpeg actually writes) the old files are gone
68
+ assert len(list(output_dir.glob("*.jpg"))) == 0
69
+
70
+ @patch("video_processor.subprocess.run")
71
+ def test_ffmpeg_command_uses_correct_fps_filter(self, mock_run, tmp_path):
72
+ mock_run.return_value = MagicMock(returncode=0)
73
+ output_dir = tmp_path / "frames"
74
+ extract_keyframes("fake.mp4", str(output_dir), interval_seconds=10)
75
+ cmd = mock_run.call_args[0][0]
76
+ assert "fps=1/10" in cmd
77
+
78
+ @patch("video_processor.subprocess.run")
79
+ def test_ffmpeg_uses_high_quality_jpeg(self, mock_run, tmp_path):
80
+ mock_run.return_value = MagicMock(returncode=0)
81
+ output_dir = tmp_path / "frames"
82
+ extract_keyframes("fake.mp4", str(output_dir), interval_seconds=5)
83
+ cmd = mock_run.call_args[0][0]
84
+ assert "-q:v" in cmd and "2" in cmd
85
+
86
+ @patch("video_processor.subprocess.run")
87
+ def test_raises_on_ffmpeg_failure(self, mock_run, tmp_path):
88
+ mock_run.side_effect = subprocess.CalledProcessError(1, "ffmpeg")
89
+ output_dir = tmp_path / "frames"
90
+ with pytest.raises(subprocess.CalledProcessError):
91
+ extract_keyframes("bad.mp4", str(output_dir))
92
+
93
+
94
+ # ─── generate_vertical_reel_clip ─────────────────────────────────────────────
95
+
96
+ class TestGenerateVerticalReelClip:
97
+
98
+ @patch("video_processor.subprocess.run")
99
+ def test_blurred_mode_uses_boxblur_filter(self, mock_run, tmp_path):
100
+ mock_run.return_value = MagicMock(returncode=0)
101
+ src = tmp_path / "src.mp4"
102
+ src.write_bytes(b"fake") # must exist β€” validation guard checks os.path.exists
103
+ out = str(tmp_path / "reel.mp4")
104
+ generate_vertical_reel_clip(str(src), 0.0, 30.0, out, render_mode="blurred")
105
+ cmd = mock_run.call_args[0][0]
106
+ vf_value = cmd[cmd.index("-vf") + 1]
107
+ assert "boxblur" in vf_value
108
+ assert "split" in vf_value
109
+
110
+ @patch("video_processor.subprocess.run")
111
+ def test_center_mode_uses_center_crop(self, mock_run, tmp_path):
112
+ mock_run.return_value = MagicMock(returncode=0)
113
+ src = tmp_path / "src.mp4"
114
+ src.write_bytes(b"fake")
115
+ out = str(tmp_path / "reel.mp4")
116
+ generate_vertical_reel_clip(str(src), 0.0, 30.0, out, render_mode="center")
117
+ cmd = mock_run.call_args[0][0]
118
+ vf_value = cmd[cmd.index("-vf") + 1]
119
+ assert "(iw-ow)/2" in vf_value
120
+
121
+ @patch("video_processor.subprocess.run")
122
+ def test_left_mode_uses_zero_x_offset(self, mock_run, tmp_path):
123
+ mock_run.return_value = MagicMock(returncode=0)
124
+ src = tmp_path / "src.mp4"
125
+ src.write_bytes(b"fake")
126
+ out = str(tmp_path / "reel.mp4")
127
+ generate_vertical_reel_clip(str(src), 10.0, 40.0, out, render_mode="left")
128
+ cmd = mock_run.call_args[0][0]
129
+ vf_value = cmd[cmd.index("-vf") + 1]
130
+ assert "crop" in vf_value
131
+ assert ":0," in vf_value or vf_value.endswith(":0")
132
+
133
+ @patch("video_processor.subprocess.run")
134
+ def test_right_mode_uses_iw_ow_offset(self, mock_run, tmp_path):
135
+ mock_run.return_value = MagicMock(returncode=0)
136
+ src = tmp_path / "src.mp4"
137
+ src.write_bytes(b"fake")
138
+ out = str(tmp_path / "reel.mp4")
139
+ generate_vertical_reel_clip(str(src), 5.0, 25.0, out, render_mode="right")
140
+ cmd = mock_run.call_args[0][0]
141
+ vf_value = cmd[cmd.index("-vf") + 1]
142
+ assert "iw-ow" in vf_value
143
+
144
+ @patch("video_processor.subprocess.run")
145
+ def test_output_uses_libx264_and_aac(self, mock_run, tmp_path):
146
+ mock_run.return_value = MagicMock(returncode=0)
147
+ src = tmp_path / "src.mp4"
148
+ src.write_bytes(b"fake")
149
+ out = str(tmp_path / "reel.mp4")
150
+ generate_vertical_reel_clip(str(src), 0.0, 30.0, out)
151
+ cmd = mock_run.call_args[0][0]
152
+ assert "libx264" in cmd
153
+ assert "aac" in cmd
154
+
155
+ @patch("video_processor.subprocess.run")
156
+ def test_removes_existing_output_before_render(self, mock_run, tmp_path):
157
+ mock_run.return_value = MagicMock(returncode=0)
158
+ src = tmp_path / "src.mp4"
159
+ src.write_bytes(b"fake")
160
+ out = tmp_path / "reel.mp4"
161
+ out.write_bytes(b"old content")
162
+ generate_vertical_reel_clip(str(src), 0.0, 10.0, str(out))
163
+ assert not out.exists()
164
+
165
+ @patch("video_processor.subprocess.run")
166
+ def test_raises_on_ffmpeg_failure(self, mock_run, tmp_path):
167
+ mock_run.side_effect = subprocess.CalledProcessError(
168
+ 1, "ffmpeg", stderr=b"encoding error"
169
+ )
170
+ src = tmp_path / "src.mp4"
171
+ src.write_bytes(b"fake")
172
+ with pytest.raises(subprocess.CalledProcessError):
173
+ generate_vertical_reel_clip(str(src), 0.0, 30.0, str(tmp_path / "out.mp4"))
174
+
175
+ def test_raises_when_end_before_start(self, tmp_path):
176
+ src = tmp_path / "src.mp4"
177
+ src.write_bytes(b"fake")
178
+ with pytest.raises(ValueError, match="end_time"):
179
+ generate_vertical_reel_clip(str(src), 30.0, 10.0, str(tmp_path / "out.mp4"))
180
+
181
+ def test_raises_when_negative_start(self, tmp_path):
182
+ src = tmp_path / "src.mp4"
183
+ src.write_bytes(b"fake")
184
+ with pytest.raises(ValueError, match="negative"):
185
+ generate_vertical_reel_clip(str(src), -5.0, 10.0, str(tmp_path / "out.mp4"))
186
+
187
+ def test_raises_when_source_missing(self, tmp_path):
188
+ with pytest.raises(FileNotFoundError):
189
+ generate_vertical_reel_clip("nonexistent.mp4", 0.0, 10.0, str(tmp_path / "out.mp4"))