Spaces:
Sleeping
Sleeping
Deployment Setup
Browse files- multimodal-engine/.env.example +7 -0
- multimodal-engine/.gitignore +3 -1
- multimodal-engine/Dockerfile +28 -0
- multimodal-engine/README.md +350 -116
- multimodal-engine/app/agent_optimizer.py +21 -11
- multimodal-engine/app/app.py +45 -25
- multimodal-engine/app/audio_processor.py +15 -4
- multimodal-engine/app/clip_extractor.py +13 -15
- multimodal-engine/app/models.py +0 -1
- multimodal-engine/app/reel_generator.py +31 -16
- multimodal-engine/app/video_processor.py +36 -10
- multimodal-engine/app/workflow_engine.py +52 -29
- multimodal-engine/assets/dashboard.png +3 -0
- multimodal-engine/assets/mdpage.png +3 -0
- multimodal-engine/assets/reelpage.png +3 -0
- multimodal-engine/output/how_multimodals_work_blog.md +17 -131
- multimodal-engine/pytest.ini +4 -0
- multimodal-engine/requirements.txt +5 -5
- multimodal-engine/test/__init__.py +0 -0
- multimodal-engine/test/test_agent_optimizer.py +192 -0
- multimodal-engine/test/test_models.py +162 -0
- multimodal-engine/test/test_pipeline_integration.py +449 -0
- multimodal-engine/test/test_utils.py +129 -0
- multimodal-engine/test/test_video_processor.py +189 -0
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 |
-
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
---
|
| 6 |
|
| 7 |
-
##
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
|
| 15 |
---
|
| 16 |
|
| 17 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
```
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
β
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
β
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
```
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
---
|
| 40 |
|
| 41 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
### 1. Prerequisites
|
| 44 |
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
```bash
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
pip install -r requirements.txt
|
| 56 |
```
|
| 57 |
|
| 58 |
-
### 3.
|
| 59 |
|
| 60 |
-
|
| 61 |
|
| 62 |
-
```
|
| 63 |
-
|
| 64 |
-
OPENAI_API_KEY=your_openai_key
|
| 65 |
```
|
| 66 |
|
| 67 |
-
|
| 68 |
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
```bash
|
| 74 |
-
|
| 75 |
-
streamlit run app.py
|
| 76 |
```
|
| 77 |
|
| 78 |
-
|
| 79 |
|
| 80 |
-
|
| 81 |
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
---
|
| 91 |
|
| 92 |
-
##
|
| 93 |
|
| 94 |
-
|
| 95 |
|
| 96 |
```bash
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
-
|
| 104 |
-
|
|
|
|
| 105 |
|
| 106 |
-
|
| 107 |
-
python app/reel_generator.py
|
| 108 |
|
| 109 |
-
#
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
```
|
| 112 |
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
---
|
| 116 |
|
| 117 |
-
##
|
| 118 |
|
| 119 |
```
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
```
|
| 139 |
|
| 140 |
---
|
| 141 |
|
| 142 |
-
##
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 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 |
-
##
|
| 159 |
|
| 160 |
-
-
|
| 161 |
-
-
|
| 162 |
-
-
|
| 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 |
-
##
|
| 178 |
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
|
| 181 |
---
|
| 182 |
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
-
|
| 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 |
+
[](https://www.python.org/)
|
| 10 |
+
[](https://streamlit.io/)
|
| 11 |
+
[](https://ai.google.dev/)
|
| 12 |
+
[](multimodal-engine/test/)
|
| 13 |
+
[](Dockerfile)
|
| 14 |
+
[](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
|
| 78 |
return client.models.generate_content(
|
| 79 |
model="gemini-2.5-flash",
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
)
|
| 88 |
-
response = retry_with_backoff(
|
| 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 =
|
| 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 |
-
#
|
| 17 |
-
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
| 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 |
-
|
| 106 |
-
st.session_state.transcript_obj,
|
| 107 |
-
st.session_state.visual_breakdown_obj
|
|
|
|
| 108 |
)
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 64 |
),
|
| 65 |
)
|
| 66 |
try:
|
| 67 |
-
response = retry_with_backoff(
|
| 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 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 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 |
-
#
|
| 26 |
-
|
| 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(
|
| 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 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 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 |
-
|
| 78 |
-
|
| 79 |
-
candidate.
|
|
|
|
| 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 |
-
|
| 16 |
-
|
| 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 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 12 |
-
from video_processor import extract_keyframes, analyze_scene_with_gemini, generate_vertical_reel_clip
|
| 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
|
| 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 |
-
|
| 33 |
-
-
|
| 34 |
-
-
|
| 35 |
-
-
|
| 36 |
-
- Blend visual timeline
|
| 37 |
-
- Highlight
|
| 38 |
-
- End with a summary conclusion.
|
| 39 |
|
| 40 |
-
Do not add conversational commentaryβreturn ONLY the markdown
|
| 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 =
|
| 59 |
-
frames_dir =
|
| 60 |
-
blogs_output_path =
|
| 61 |
-
transcript_json_path =
|
| 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 =
|
| 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
|
multimodal-engine/assets/mdpage.png
ADDED
|
Git LFS Details
|
multimodal-engine/assets/reelpage.png
ADDED
|
Git LFS Details
|
multimodal-engine/output/how_multimodals_work_blog.md
CHANGED
|
@@ -1,147 +1,33 @@
|
|
| 1 |
-
#
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
|
| 6 |
|
| 7 |
-
|
| 8 |
|
| 9 |
-
|
| 10 |
|
| 11 |
-
|
| 12 |
|
| 13 |
-
|
| 14 |
|
| 15 |
-
|
| 16 |
|
| 17 |
-
|
| 18 |
-
1. Apple
|
| 19 |
-
2. Ball
|
| 20 |
-
3. Cat
|
| 21 |
-
4. Dog
|
| 22 |
|
| 23 |
-
|
| 24 |
|
| 25 |
-
|
| 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 |
-
|
| 30 |
|
| 31 |
-
|
| 32 |
|
| 33 |
-
|
| 34 |
|
| 35 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 2 |
-
google-genai
|
| 3 |
-
openai
|
| 4 |
-
python-dotenv
|
| 5 |
-
pydantic
|
|
|
|
| 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"))
|