github-actions[bot] commited on
Commit
8b7ae7a
·
1 Parent(s): a9e0afd

Automated deployment from GitHub Actions

Browse files
.env CHANGED
@@ -1,5 +1,5 @@
1
- SIEVE_API_KEY="YOUR_KEY_HERE"
2
- OPENAI_API_KEY="YOUR_KEY_HERE"
3
- # GEMINI_API_KEY="YOUR_KEY_HERE"
4
- # GEMINI_API_KEY="YOUR_KEY_HERE"
5
- GEMINI_API_KEY="YOUR_KEY_HERE"
 
1
+ SIEVE_API_KEY="YOUR_KEY_HERE"
2
+ OPENAI_API_KEY="YOUR_KEY_HERE"
3
+ # GEMINI_API_KEY="YOUR_KEY_HERE"
4
+ # GEMINI_API_KEY="YOUR_KEY_HERE"
5
+ GEMINI_API_KEY="YOUR_KEY_HERE"
.huggingface.yml CHANGED
@@ -1,2 +1,2 @@
1
- sdk: docker
2
  python_version: 3.12
 
1
+ sdk: docker
2
  python_version: 3.12
Dockerfile CHANGED
@@ -1,44 +1,44 @@
1
- # syntax=docker/dockerfile:1
2
-
3
- FROM python:3.12-slim
4
-
5
- # Prevent Python from writing pyc files and buffer stdout/stderr
6
- ENV PYTHONDONTWRITEBYTECODE=1 \
7
- PYTHONUNBUFFERED=1 \
8
- PIP_NO_CACHE_DIR=off \
9
- POETRY_VIRTUALENVS_CREATE=false
10
-
11
- WORKDIR /app
12
-
13
- # system deps (add if you need ffmpeg, build-essential, libgl1 etc)
14
- RUN apt-get update && apt-get install -y --no-install-recommends \
15
- build-essential \
16
- ffmpeg \
17
- libgl1 \
18
- && rm -rf /var/lib/apt/lists/*
19
-
20
- # Copy requirements first for caching
21
- COPY ui/requirements.txt ./ui/requirements.txt
22
-
23
- # Install pip dependencies (use --no-cache-dir in production)
24
- RUN python -m pip install --upgrade pip setuptools wheel \
25
- && pip install --no-cache-dir -r ui/requirements.txt
26
-
27
- # Copy the UI code
28
- COPY ui/ ./ui/
29
-
30
- # Add entrypoint script
31
- COPY entrypoint.sh /entrypoint.sh
32
- RUN chmod +x /entrypoint.sh
33
-
34
- # Expose common ports (Streamlit default 8501, FastAPI/Flask default 8000)
35
- EXPOSE 8501 8000
36
-
37
- # Default env vars (can be overridden at runtime)
38
- ENV APP_MODULE="app:app" \
39
- APP_TYPE="streamlit" \
40
- PORT=8501 \
41
- PYTHONPATH="/app/ui"
42
-
43
- # Entrypoint handles which server to start
44
- ENTRYPOINT ["/entrypoint.sh"]
 
1
+ # syntax=docker/dockerfile:1
2
+
3
+ FROM python:3.12-slim
4
+
5
+ # Prevent Python from writing pyc files and buffer stdout/stderr
6
+ ENV PYTHONDONTWRITEBYTECODE=1 \
7
+ PYTHONUNBUFFERED=1 \
8
+ PIP_NO_CACHE_DIR=off \
9
+ POETRY_VIRTUALENVS_CREATE=false
10
+
11
+ WORKDIR /app
12
+
13
+ # system deps (add if you need ffmpeg, build-essential, libgl1 etc)
14
+ RUN apt-get update && apt-get install -y --no-install-recommends \
15
+ build-essential \
16
+ ffmpeg \
17
+ libgl1 \
18
+ && rm -rf /var/lib/apt/lists/*
19
+
20
+ # Copy requirements first for caching
21
+ COPY ui/requirements.txt ./ui/requirements.txt
22
+
23
+ # Install pip dependencies (use --no-cache-dir in production)
24
+ RUN python -m pip install --upgrade pip setuptools wheel \
25
+ && pip install --no-cache-dir -r ui/requirements.txt
26
+
27
+ # Copy the UI code
28
+ COPY ui/ ./ui/
29
+
30
+ # Add entrypoint script
31
+ COPY entrypoint.sh /entrypoint.sh
32
+ RUN chmod +x /entrypoint.sh
33
+
34
+ # Expose common ports (Streamlit default 8501, FastAPI/Flask default 8000)
35
+ EXPOSE 8501 8000
36
+
37
+ # Default env vars (can be overridden at runtime)
38
+ ENV APP_MODULE="app:app" \
39
+ APP_TYPE="streamlit" \
40
+ PORT=8501 \
41
+ PYTHONPATH="/app/ui"
42
+
43
+ # Entrypoint handles which server to start
44
+ ENTRYPOINT ["/entrypoint.sh"]
README.md CHANGED
@@ -1,10 +1 @@
1
- ---
2
- title: Video Virality Scoring
3
- emoji: 🎬
4
- colorFrom: indigo
5
- colorTo: blue
6
- sdk: streamlit
7
- sdk_version: "1.39.0"
8
- app_file: app.py
9
- pinned: false
10
- ---
 
1
+ # video-virality-scoring
 
 
 
 
 
 
 
 
 
START_HERE.txt CHANGED
@@ -1,28 +1,28 @@
1
- ==========================================
2
- VIRALITY COACH - DEPLOYMENT PACKAGE
3
- ==========================================
4
-
5
- This folder contains everything you need to deploy and run the Virality Coach application.
6
-
7
- QUICK START:
8
- 1. Install Python dependencies: pip install -r requirements.txt
9
- 2. Install FFmpeg (required for video processing)
10
- 3. Run: streamlit run ui/app_v2.py
11
- OR double-click: run_app.bat (Windows)
12
-
13
- FILES INCLUDED:
14
- ✓ All application code (app/, ui/)
15
- ✓ Configuration files (config.py)
16
- ✓ Dependencies (requirements.txt)
17
- ✓ Demo video (demo.mp4)
18
- ✓ Startup scripts (run_app.bat, run_app.sh)
19
- ✓ Documentation (README_DEPLOYMENT.md)
20
-
21
- IMPORTANT:
22
- - API Keys can be entered in the UI or set as environment variables
23
- - Data directories will be auto-created when you run analyses
24
- - Demo video is included for testing
25
-
26
- See README_DEPLOYMENT.md for detailed instructions.
27
-
28
- ==========================================
 
1
+ ==========================================
2
+ VIRALITY COACH - DEPLOYMENT PACKAGE
3
+ ==========================================
4
+
5
+ This folder contains everything you need to deploy and run the Virality Coach application.
6
+
7
+ QUICK START:
8
+ 1. Install Python dependencies: pip install -r requirements.txt
9
+ 2. Install FFmpeg (required for video processing)
10
+ 3. Run: streamlit run ui/app_v2.py
11
+ OR double-click: run_app.bat (Windows)
12
+
13
+ FILES INCLUDED:
14
+ ✓ All application code (app/, ui/)
15
+ ✓ Configuration files (config.py)
16
+ ✓ Dependencies (requirements.txt)
17
+ ✓ Demo video (demo.mp4)
18
+ ✓ Startup scripts (run_app.bat, run_app.sh)
19
+ ✓ Documentation (README_DEPLOYMENT.md)
20
+
21
+ IMPORTANT:
22
+ - API Keys can be entered in the UI or set as environment variables
23
+ - Data directories will be auto-created when you run analyses
24
+ - Demo video is included for testing
25
+
26
+ See README_DEPLOYMENT.md for detailed instructions.
27
+
28
+ ==========================================
app/__init__.py ADDED
File without changes
app/pipeline/__init__.py ADDED
File without changes
app/pipeline/audio_analysis.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import ffmpeg
4
+ import whisper
5
+ import subprocess
6
+ import base64
7
+ from pathlib import Path
8
+ from typing import Dict, List
9
+ import google.generativeai as genai
10
+
11
+ from config import make_path, GEMINI_API_KEY
12
+ from app.utils.logging import get_logger
13
+
14
+
15
+ class AudioAnalyzer:
16
+ def __init__(self, video_path: str, gemini_api_key: str = "", model_size: str = 'small'):
17
+ self.model_size = model_size
18
+ self.video_path = Path(video_path)
19
+ self.audio_path = make_path('interim/audio', video_path, 'audio', 'wav')
20
+ self.json_out = make_path('processed/audio-analysis', video_path, 'audio_analysis', 'json')
21
+ self.logger = get_logger('audio_analysis', f'{self.video_path.stem}_log.txt')
22
+
23
+ # ✅ Set Gemini key (explicit or from environment)
24
+ if gemini_api_key:
25
+ genai.configure(api_key=gemini_api_key)
26
+ else:
27
+ genai.configure(api_key=os.getenv("GEMINI_API_KEY", ""))
28
+ self.llm_model = genai.GenerativeModel('gemini-2.5-pro')
29
+
30
+ def _extract_audio(self) -> None:
31
+ self.audio_path.parent.mkdir(parents=True, exist_ok=True)
32
+ (
33
+ ffmpeg
34
+ .input(str(self.video_path))
35
+ .output(str(self.audio_path), ac=1, ar='16k', format='wav', loglevel='quiet')
36
+ .overwrite_output()
37
+ .run()
38
+ )
39
+ self.logger.info('Audio extracted to %s', self.audio_path)
40
+
41
+ def _transcribe(self) -> Dict:
42
+ model = whisper.load_model(self.model_size)
43
+ return model.transcribe(str(self.audio_path), fp16=False)
44
+
45
+ def _loudness_stats(self, audio_path: Path) -> Dict:
46
+ cmd = [
47
+ 'ffmpeg', '-i', str(audio_path),
48
+ '-af', 'volumedetect',
49
+ '-f', 'null', 'NUL' if os.name == 'nt' else '/dev/null'
50
+ ]
51
+ result = subprocess.run(cmd, capture_output=True, text=True)
52
+ mean = peak = None
53
+ for line in result.stderr.splitlines():
54
+ if 'mean_volume:' in line:
55
+ mean = float(line.split('mean_volume:')[1].split()[0])
56
+ if 'max_volume:' in line:
57
+ peak = float(line.split('max_volume:')[1].split()[0])
58
+ return {'loudness_mean': mean, 'loudness_peak': peak}
59
+
60
+ def _load_visual_context(self) -> Dict:
61
+ """Load nearby frames and brightness values from extracted frame data."""
62
+ frame_json_path = make_path('processed/scene-detection', self.video_path, 'scene', 'json')
63
+ frames_dir = make_path('interim/frames', self.video_path, '', '')
64
+
65
+ if not frame_json_path.exists():
66
+ self.logger.warning("Frame metadata not found: %s", frame_json_path)
67
+ return {}
68
+
69
+ with open(frame_json_path, 'r', encoding='utf-8') as f:
70
+ scene_data = json.load(f)
71
+
72
+ if not scene_data.get('scenes'):
73
+ return {}
74
+
75
+ scene = scene_data['scenes'][0]
76
+ mid_time = (float(scene['start_time']) + float(scene['end_time'])) / 2
77
+ scene_idx = 0
78
+
79
+ def get_frame_path(tag):
80
+ return frames_dir / f"{self.video_path.stem}_scene_{scene_idx:02}{tag}.jpg"
81
+
82
+ def encode_image(p: Path) -> str:
83
+ if p.exists():
84
+ with open(p, 'rb') as f:
85
+ return base64.b64encode(f.read()).decode('utf-8')
86
+ return ""
87
+
88
+ return {
89
+ 'mid_time': mid_time,
90
+ 'frame': encode_image(get_frame_path('')),
91
+ 'prev': encode_image(get_frame_path('_prev')),
92
+ 'next': encode_image(get_frame_path('_next')),
93
+ 'brightness': float(scene.get('brightness', -1.0))
94
+ }
95
+
96
+ def _gemini_audio_analysis(self, text: str, loudness: Dict, wps: float, visuals: Dict) -> Dict:
97
+ """LLM-enhanced audio analysis using audio + first scene frames + metadata"""
98
+ prompt = f"""
99
+ You are an expert video analyst. Based on the transcript, loudness, speaking pace,
100
+ and the first scene's frames (prev, current, next), analyze the audio tone.
101
+
102
+ Answer in JSON only:
103
+ {{
104
+ "tone": "calm|excited|angry|funny|sad|neutral",
105
+ "emotion": "joy|sadness|anger|surprise|neutral|mixed",
106
+ "pace": "fast|medium|slow",
107
+ "delivery_score": 0-100,
108
+ "is_hooking_start": true|false,
109
+ "comment": "brief summary of audio performance",
110
+ "is_dark_artistic": true|false,
111
+ "brightness": 0-100
112
+ }}
113
+
114
+ Transcript: {text}
115
+ Loudness: {json.dumps(loudness)}
116
+ Words/sec: {wps}
117
+ Frame brightness: {visuals.get('brightness')}
118
+ """
119
+
120
+ # ✅ Properly formatted parts for Gemini multimodal prompt
121
+ parts = [{"text": prompt}]
122
+ for tag in ['prev', 'frame', 'next']:
123
+ img_b64 = visuals.get(tag)
124
+ if img_b64:
125
+ parts.append({
126
+ "inline_data": {
127
+ "mime_type": "image/jpeg",
128
+ "data": base64.b64decode(img_b64),
129
+ }
130
+ })
131
+
132
+ try:
133
+ response = self.llm_model.generate_content(
134
+ contents=[{"role": "user", "parts": parts}],
135
+ generation_config={'temperature': 0.3}
136
+ )
137
+ text = getattr(response, 'text', '').strip()
138
+ cleaned = text.replace('```json', '').replace('```', '')
139
+ return json.loads(cleaned)
140
+ except Exception as e:
141
+ error_msg = str(e)
142
+ self.logger.error("LLM call failed: %s", e)
143
+
144
+ # Check if it's an API key error - if so, raise it to stop the pipeline
145
+ if any(keyword in error_msg.lower() for keyword in ["api_key", "invalid", "401", "403", "authentication", "unauthorized"]):
146
+ raise ValueError(f"Invalid Gemini API key: {error_msg}") from e
147
+
148
+ # For other errors, return defaults but log the issue
149
+ return {
150
+ "tone": "neutral",
151
+ "emotion": "neutral",
152
+ "pace": "medium",
153
+ "delivery_score": 50,
154
+ "is_hooking_start": False,
155
+ "comment": "LLM analysis failed, using defaults",
156
+ "is_dark_artistic": False,
157
+ "brightness": visuals.get("brightness", -1.0)
158
+ }
159
+
160
+ def analyze(self) -> Dict:
161
+ self._extract_audio()
162
+ whisper_res = self._transcribe()
163
+ full_text = whisper_res['text']
164
+ duration_s = whisper_res['segments'][-1]['end'] if whisper_res['segments'] else 0
165
+ wps = round(len(full_text.split()) / duration_s, 2) if duration_s else 0
166
+
167
+ loudness = self._loudness_stats(self.audio_path)
168
+ visual_context = self._load_visual_context()
169
+ gemini_analysis = self._gemini_audio_analysis(full_text, loudness, wps, visual_context)
170
+
171
+ result = {
172
+ 'full_transcript': full_text,
173
+ 'duration_seconds': duration_s,
174
+ 'word_count': len(full_text.split()),
175
+ 'words_per_second': wps,
176
+ **loudness,
177
+ **gemini_analysis
178
+ }
179
+
180
+ self.json_out.parent.mkdir(parents=True, exist_ok=True)
181
+ with open(self.json_out, 'w', encoding='utf-8') as f:
182
+ json.dump(result, f, indent=2)
183
+
184
+ self.logger.info('Audio + Visual LLM analysis saved to %s', self.json_out)
185
+ return result
app/pipeline/frame_analysis.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import json
4
+ import base64
5
+ import openai
6
+ from pathlib import Path
7
+ import google.generativeai as genai
8
+ from app.utils.logging import get_logger
9
+ from config import make_path, OPENAI_API_KEY, GEMINI_API_KEY, DATA_DIR
10
+
11
+
12
+ class FrameAnalyzer:
13
+ def __init__(self, video_path: str, openai_api_key: str = "", save_dir: str = 'processed/frame-analysis'):
14
+ # ✅ Set OpenAI key (explicit or from environment)
15
+
16
+ # print(openai_api_key)
17
+
18
+ if openai_api_key:
19
+ openai.api_key = openai_api_key
20
+ else:
21
+ import os
22
+ openai.api_key = os.getenv("OPENAI_API_KEY")
23
+
24
+ self.video_path = Path(video_path)
25
+ self.frames_dir = DATA_DIR / 'interim' / 'frames' / f'{self.video_path.stem}_'
26
+ self.save_path = make_path(save_dir, video_path, 'frame_analysis', 'json')
27
+ self.save_path.parent.mkdir(parents=True, exist_ok=True)
28
+
29
+ log_file = f'{self.video_path.stem}_log.txt'
30
+ self.logger = get_logger('frame_analysis', log_file)
31
+
32
+ @staticmethod
33
+ def encode_image(path: Path) -> str:
34
+ with open(path, 'rb') as f:
35
+ return base64.b64encode(f.read()).decode('utf-8')
36
+
37
+ @staticmethod
38
+ def extract_json(text: str) -> dict:
39
+ try:
40
+ return json.loads(text)
41
+ except json.JSONDecodeError:
42
+ pass
43
+
44
+ match = re.search(r'```json\s*(\{.*?\})\s*```', text, re.DOTALL)
45
+ if match:
46
+ return json.loads(match.group(1))
47
+
48
+ match = re.search(r'(\{.*?\})', text, re.DOTALL)
49
+ if match:
50
+ return json.loads(match.group(1))
51
+
52
+ raise ValueError('No valid JSON found in GPT response')
53
+
54
+ def gpt_analyze(self, frame_path: Path, prev_path: Path, next_path: Path) -> dict:
55
+ prompt = """
56
+ You are an expert video content strategist. Analyze this video frame and surrounding context.
57
+ Determine if the lighting is poor or intentionally low for creative reasons.
58
+
59
+ Output JSON only:
60
+ {
61
+ lighting: 0-100,
62
+ is_artistic_dark: true|false,
63
+ composition: 0-100,
64
+ has_text: true|false,
65
+ text: "string",
66
+ hook_strength: 0-100
67
+ }
68
+ """
69
+
70
+ images = [
71
+ {'type': 'image_url', 'image_url': {'url': f'data:image/jpeg;base64,{self.encode_image(p)}'}}
72
+ for p in [prev_path, frame_path, next_path] if p.exists()
73
+ ]
74
+
75
+ response = openai.chat.completions.create(
76
+ model='gpt-4o-mini',
77
+ messages=[
78
+ {'role': 'user', 'content': [{'type': 'text', 'text': prompt}] + images}
79
+ ],
80
+ temperature=0.2,
81
+ max_tokens=400,
82
+ )
83
+ return self.extract_json(response.choices[0].message.content)
84
+
85
+ def analyze(self) -> dict:
86
+ results = {}
87
+ all_frames = sorted(self.frames_dir.glob('*_scene_*.jpg'))
88
+ center_frames = [f for f in all_frames if '_prev' not in f.name and '_next' not in f.name]
89
+
90
+ for frame in center_frames:
91
+ prev = frame.with_name(frame.name.replace('.jpg', '_prev.jpg'))
92
+ next_ = frame.with_name(frame.name.replace('.jpg', '_next.jpg'))
93
+
94
+ self.logger.info('Analyzing frame: %s', frame.name)
95
+ try:
96
+ result = self.gpt_analyze(frame, prev, next_)
97
+ results[frame.name] = result
98
+ except Exception as e:
99
+ self.logger.error('LLM analysis failed on %s: %s', frame.name, e)
100
+ results[frame.name] = {'error': str(e)}
101
+
102
+ with open(self.save_path, 'w', encoding='utf-8') as f:
103
+ json.dump(results, f, indent=2)
104
+
105
+ self.logger.info('Frame analysis saved to %s', self.save_path)
106
+ return results
107
+
108
+ class HookAnalyzer:
109
+ def __init__(self, video_path: str, gemini_api_key: str = ""):
110
+ self.video_path = Path(video_path)
111
+ self.frames_dir = Path('data/interim/frames') / f'{self.video_path.stem}_'
112
+ self.audio_json = make_path('processed/audio-analysis', video_path, 'audio_analysis', 'json')
113
+ self.output_json = make_path('processed/hook-analysis', video_path, 'hook_analysis', 'json')
114
+ self.logger = get_logger('hook_analysis', f'{self.video_path.stem}_log.txt')
115
+
116
+ # ✅ Set Gemini key (explicit or from environment)
117
+ if gemini_api_key:
118
+ genai.configure(api_key=gemini_api_key)
119
+ else:
120
+ genai.configure(api_key=os.getenv("GEMINI_API_KEY", ""))
121
+ self.model = genai.GenerativeModel('gemini-2.5-pro')
122
+
123
+ def _encode_image(self, path: Path) -> bytes:
124
+ with open(path, 'rb') as f:
125
+ return f.read()
126
+
127
+ def _load_audio_summary(self) -> dict:
128
+ with open(self.audio_json, 'r', encoding='utf-8') as f:
129
+ return json.load(f)
130
+
131
+ def _gemini_hook_alignment(self, audio_summary: dict, frames: list[Path]) -> dict:
132
+ parts = [{'mime_type': 'image/jpeg', 'data': self._encode_image(f)} for f in frames if f.exists()]
133
+ text = f"""You are a virality analyst. Analyze the opening visuals and tone:
134
+ - Does the audio mood match the expressions and visuals?
135
+ - Are viewers likely to be hooked in the first few seconds?
136
+
137
+ Audio Summary: {json.dumps(audio_summary)}
138
+
139
+ Give JSON only:
140
+ {{
141
+ "hook_alignment_score": 0-100,
142
+ "facial_sync": "good|ok|poor|none",
143
+ "comment": "short summary"
144
+ }}"""
145
+
146
+ try:
147
+ response = self.model.generate_content([text] + parts)
148
+ raw_text = getattr(response, 'text', '').strip()
149
+ self.logger.debug("Gemini raw response: %s", raw_text)
150
+ if not raw_text:
151
+ raise ValueError("Gemini response was empty.")
152
+
153
+ raw_text = (
154
+ raw_text
155
+ .replace('```json\n', '')
156
+ .replace('\n```', '')
157
+ .replace('```json', '')
158
+ .replace('```', '')
159
+ )
160
+
161
+ return json.loads(raw_text)
162
+ except json.JSONDecodeError as e:
163
+ self.logger.error("❌ Failed to parse Gemini response as JSON: %s", e)
164
+ self.logger.debug("Gemini response was: %r", getattr(response, 'text', '<<NO TEXT>>'))
165
+ return {
166
+ "hook_alignment_score": -1,
167
+ "facial_sync": "none",
168
+ "comment": "Invalid JSON response from Gemini"
169
+ }
170
+ except Exception as e:
171
+ error_msg = str(e)
172
+ self.logger.error("❌ Gemini API call failed: %s", e)
173
+
174
+ # Check if it's an API key error - if so, raise it to stop the pipeline
175
+ if any(keyword in error_msg.lower() for keyword in ["api_key", "invalid", "401", "403", "authentication", "unauthorized"]):
176
+ raise ValueError(f"Invalid Gemini API key: {error_msg}") from e
177
+
178
+ # For other errors, return defaults
179
+ return {
180
+ "hook_alignment_score": -1,
181
+ "facial_sync": "none",
182
+ "comment": f"Gemini API error: {error_msg}"
183
+ }
184
+
185
+ def analyze(self) -> dict:
186
+ audio_summary = self._load_audio_summary()
187
+ frames = sorted(self.frames_dir.glob('*_scene_*.jpg'))[:3]
188
+ result = self._gemini_hook_alignment(audio_summary, frames)
189
+
190
+ self.output_json.parent.mkdir(parents=True, exist_ok=True)
191
+ with open(self.output_json, 'w', encoding='utf-8') as f:
192
+ json.dump(result, f, indent=2)
193
+
194
+ self.logger.info('Hook analysis saved to %s', self.output_json)
195
+ return result
app/pipeline/frame_extract.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import subprocess
3
+ from pathlib import Path
4
+ from config import make_path
5
+ from app.utils.logging import get_logger
6
+
7
+
8
+ class FrameExtractor:
9
+ def __init__(self, video_path: str, min_scene_len: float = 0.2):
10
+ self.min_scene_len = min_scene_len
11
+ self.video_path = Path(video_path)
12
+ self.scene_json_path = self.frame_json = make_path('processed/scene-detection', video_path, 'scene', 'json')
13
+ self.output_dir = make_path('interim/frames', video_path, '', '')
14
+ self.output_dir.mkdir(parents=True, exist_ok=True)
15
+
16
+ log_file = f'{self.video_path.stem}_log.txt'
17
+ self.logger = get_logger('frame_extract', log_file)
18
+
19
+ def _ffmpeg_extract(self, timestamp: float, out_path: Path):
20
+ cmd = [
21
+ 'ffmpeg',
22
+ '-loglevel', 'error',
23
+ '-y',
24
+ '-ss', f'{timestamp:.3f}',
25
+ '-t', '1',
26
+ '-i', str(self.video_path),
27
+ '-frames:v', '1',
28
+ '-q:v', '2',
29
+ '-pix_fmt', 'yuvj420p',
30
+ str(out_path)
31
+ ]
32
+ result = subprocess.run(cmd, capture_output=True)
33
+ if result.returncode != 0:
34
+ self.logger.error('ffmpeg failed: %s', result.stderr.decode('utf-8', 'ignore').strip())
35
+
36
+ def _get_brightness(self, timestamp: float) -> float:
37
+ cmd = [
38
+ 'ffprobe',
39
+ '-v', 'error',
40
+ '-read_intervals', f'%{timestamp}+1',
41
+ '-select_streams', 'v:0',
42
+ '-show_frames',
43
+ '-show_entries', 'frame_tags=lavfi.signalstats.YAVG',
44
+ '-of', 'default=noprint_wrappers=1:nokey=1',
45
+ str(self.video_path)
46
+ ]
47
+ result = subprocess.run(cmd, capture_output=True, text=True)
48
+ try:
49
+ yavg_values = [float(line.strip()) for line in result.stdout.strip().split('\n') if line.strip()]
50
+ if yavg_values:
51
+ return yavg_values[0]
52
+ except Exception:
53
+ pass
54
+ self.logger.warning('Could not get brightness at %.2fs', timestamp)
55
+ return -1.0
56
+
57
+ def extract(self) -> list[dict]:
58
+ with open(self.scene_json_path, encoding='utf-8') as f:
59
+ scenes = json.load(f).get('scenes', [])
60
+ if not scenes:
61
+ self.logger.warning('No scenes found in %s', self.scene_json_path)
62
+ return []
63
+
64
+ delta = 0.5
65
+ results = []
66
+
67
+ for i, sc in enumerate(scenes):
68
+ start = float(sc['start_time'])
69
+ end = float(sc['end_time'])
70
+ dur = end - start
71
+ if dur < self.min_scene_len:
72
+ self.logger.warning('Scene %s too short (%.2fs), skipping', i, dur)
73
+ continue
74
+
75
+ mid = (start + end) / 2
76
+
77
+ frame_path = self.output_dir / f'{self.video_path.stem}_scene_{i:02}.jpg'
78
+ prev_path = self.output_dir / f'{self.video_path.stem}_scene_{i:02}_prev.jpg'
79
+ next_path = self.output_dir / f'{self.video_path.stem}_scene_{i:02}_next.jpg'
80
+
81
+ self._ffmpeg_extract(mid, frame_path)
82
+ self._ffmpeg_extract(mid - delta, prev_path)
83
+ self._ffmpeg_extract(mid + delta, next_path)
84
+
85
+ brightness = self._get_brightness(mid)
86
+
87
+ self.logger.info('[Scene %s] %.2fs → %s | Brightness: %.2f', i, mid, frame_path.name, brightness)
88
+
89
+ results.append({
90
+ 'scene_index': i,
91
+ 'timestamp': mid,
92
+ 'frame_path': str(frame_path),
93
+ 'prev_frame_path': str(prev_path),
94
+ 'next_frame_path': str(next_path),
95
+ 'brightness': brightness
96
+ })
97
+
98
+ self.logger.info('%s frames (with context) extracted to %s', len(results), self.output_dir)
99
+ return results
app/pipeline/scene_detect.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ from pathlib import Path
4
+ from scenedetect import VideoManager, SceneManager
5
+ from scenedetect.detectors import ContentDetector
6
+ from app.utils.logging import get_logger
7
+ from config import make_path
8
+
9
+
10
+ class SceneDetector:
11
+ def __init__(self, video_path: str, backend='base', return_scenes=False,
12
+ min_scene_duration=0.1, threshold=30.0, transition_merge_gap=0.1):
13
+ self.video_path = video_path
14
+ self.backend = backend
15
+ self.return_scenes = return_scenes
16
+ self.min_scene_duration = min_scene_duration
17
+ self.threshold = threshold
18
+ self.transition_merge_gap = transition_merge_gap
19
+
20
+ log_filename = f'{Path(video_path).stem}_log.txt'
21
+ self.logger = get_logger(name='scene_detect', log_file=log_filename)
22
+
23
+ def detect(self, start_time: float = 0, end_time: float = -1) -> list:
24
+ try:
25
+ self.logger.info(f'Detecting scenes for: {self.video_path}')
26
+
27
+ video_manager = VideoManager([self.video_path])
28
+ scene_manager = SceneManager()
29
+ scene_manager.add_detector(ContentDetector(threshold=self.threshold))
30
+
31
+ video_manager.set_downscale_factor()
32
+ video_manager.start()
33
+ scene_manager.detect_scenes(frame_source=video_manager)
34
+ scene_list = scene_manager.get_scene_list()
35
+
36
+ # Format output to match Sieve style
37
+ scenes = []
38
+ for start, end in scene_list:
39
+ scenes.append({
40
+ "start": round(start.get_seconds(), 2),
41
+ "end": round(end.get_seconds(), 2)
42
+ })
43
+
44
+ self.logger.info(f"{len(scenes)} scenes detected.")
45
+ return [{"scenes": scenes}]
46
+
47
+ except Exception as e:
48
+ self.logger.error(f'Scene detection failed: {e}')
49
+ return []
50
+
51
+ def detect_and_save(self) -> list:
52
+ scenes = self.detect()
53
+ if not scenes:
54
+ self.logger.warning('No scenes detected. Skipping save.')
55
+ return []
56
+
57
+ out_path = make_path('processed/scene-detection', self.video_path, 'scene', 'json')
58
+ out_path.parent.mkdir(parents=True, exist_ok=True)
59
+
60
+ with open(out_path, 'w', encoding='utf-8') as f:
61
+ json.dump({'scenes': scenes[0]['scenes']}, f, indent=2)
62
+
63
+ self.logger.info(f'Scene data saved to: {out_path}')
64
+ return scenes
app/pipeline/scoring.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import openai
2
+ import json
3
+ from pathlib import Path
4
+ from app.utils.logging import get_logger
5
+ from config import make_path, OPENAI_API_KEY
6
+
7
+
8
+ class VideoReport:
9
+ def __init__(self, video_path: str, openai_api_key: str = ""):
10
+ # ✅ Set OpenAI key (explicit or from environment)
11
+ if openai_api_key:
12
+ openai.api_key = openai_api_key
13
+ else:
14
+ import os
15
+ openai.api_key = os.getenv("OPENAI_API_KEY", "")
16
+ self.video_path = Path(video_path)
17
+ self.audio_json = make_path('processed/audio-analysis', video_path, 'audio_analysis', 'json')
18
+ self.frame_json = make_path('processed/frame-analysis', video_path, 'frame_analysis', 'json')
19
+ self.hook_json = make_path('processed/hook-analysis', video_path, 'hook_analysis', 'json')
20
+ self.output_json = make_path('reports', video_path, 'final_report', 'json')
21
+
22
+ log_filename = f'{self.video_path.stem}_log.txt'
23
+ self.logger = get_logger(name='video_report', log_file=log_filename)
24
+
25
+ self.audio_analysis = self.load_json(self.audio_json)
26
+ self.frame_analysis = self.load_json(self.frame_json)
27
+ self.hook_analysis = self.load_json(self.hook_json)
28
+
29
+ def load_json(self, path: Path):
30
+ try:
31
+ with open(path, 'r', encoding='utf-8') as f:
32
+ return json.load(f)
33
+ except Exception:
34
+ return {}
35
+
36
+ def extract_matrices(self):
37
+ return {
38
+ "tone": self.audio_analysis.get("tone", "unknown"),
39
+ "emotion": self.audio_analysis.get("emotion", "unknown"),
40
+ "pace": self.audio_analysis.get("pace", "unknown"),
41
+ "facial_sync": self.hook_analysis.get("facial_sync", "unknown")
42
+ }
43
+
44
+ def prepare_prompt(self) -> str:
45
+ prompt_sections = []
46
+ prompt_sections.append(f"""
47
+ You are an expert evaluator trained to assess the **virality potential and content quality** of short-form video ads (e.g., TikToks, Reels). You are provided with:
48
+
49
+ - A sequence of scene-selected **frames**
50
+ - A full **audio transcription**
51
+ - Detailed **audio statistics**
52
+ - And other meta-data of videos
53
+
54
+ Your task is to analyze the video and assign the **five scores** with weighted importance. Follow the criteria and format strictly.
55
+
56
+ ---
57
+
58
+ ### 🎯 Scores to Judge (Each 0–100)
59
+
60
+ You must evaluate the following sub-categories:
61
+
62
+ - `hook`: Does the video grab attention in the first 3 seconds? A good hook is **surprising, emotional, funny, or visually intense**. A poor hook is **slow, random, or bland**.
63
+
64
+ - `visuals`: Are visuals high-resolution, diverse, and relevant to the message? Good visuals are **intentional and professionally framed**. Poor visuals are **static, noisy, or irrelevant**.
65
+
66
+ - `audio`: Is the audio clean, engaging, and well-synced? Quality audio has **clarity, proper levels, and supports the visuals**. Poor audio is **distracting, flat, or off-sync**.
67
+
68
+ - `engagement`: Does the video maintain interest? Strong pacing, emotional depth, or thought-provoking content improves this. Weak pacing or meaningless content hurts it.
69
+
70
+ - `visual_diversity`: Does the video use **multiple camera angles, transitions, or visual styles**? A lack of variation makes it feel stale.
71
+
72
+ ---
73
+
74
+ ### 📌 Scoring Enforcement Guidelines
75
+
76
+ - Be **strict**: Low-effort content should fall well below 50
77
+ - Be **realistic**: Reward polish, creativity, clarity, and emotional impact
78
+ - Only videos with **clear intent and great execution** should reach 80+
79
+ - Penalize poor hooks, bland visuals, unclear audio, or meaningless structure
80
+ - Ensure your scores reflect meaningful differences between videos — **don't cluster everything around 60**
81
+
82
+ ---
83
+ """)
84
+
85
+ if self.audio_analysis:
86
+ prompt_sections.append("Audio Analysis:\n" + json.dumps(self.audio_analysis, indent=2))
87
+ if self.frame_analysis:
88
+ prompt_sections.append("\nFrame Analysis:\n" + json.dumps(self.frame_analysis, indent=2))
89
+ if self.hook_analysis:
90
+ prompt_sections.append("\nHook Alignment Analysis:\n" + json.dumps(self.hook_analysis, indent=2))
91
+
92
+ matrices = self.extract_matrices()
93
+ prompt_sections.append("\nHere are extracted behavioral/performance matrices:\n" + json.dumps(matrices, indent=2))
94
+
95
+ prompt_sections.append(f"""
96
+ ### 📤 Output Format (JSON Only — No Comments or Explanations):
97
+ {{
98
+ "video_name": "{self.video_path.stem}",
99
+ "scores": {{
100
+ "hook": 0,
101
+ "visuals": 0,
102
+ "audio": 0,
103
+ "engagement": 0,
104
+ "visual_diversity": 0
105
+ }},
106
+ "matrices": {{
107
+ "tone": "",
108
+ "emotion": "",
109
+ "pace": "",
110
+ "facial_sync": ""
111
+ }},
112
+ "summary": "",
113
+ "suggestions": [
114
+ "Specific improvement 1",
115
+ "Specific improvement 2",
116
+ "Specific improvement 3",
117
+ ... more if required
118
+ ]
119
+ }}
120
+ """)
121
+ return "\n".join(prompt_sections)
122
+
123
+ def query_llm(self, prompt: str) -> dict:
124
+ try:
125
+ response = openai.chat.completions.create(
126
+ model='gpt-4o',
127
+ messages=[
128
+ {"role": "system", "content": "You are a professional short-video quality evaluator."},
129
+ {"role": "user", "content": prompt}
130
+ ],
131
+ temperature=0.4,
132
+ )
133
+ reply = response.choices[0].message.content.strip()
134
+ cleaned = reply.replace('```json', '').replace('```', '')
135
+ result = json.loads(cleaned)
136
+ return result
137
+ except Exception as e:
138
+ self.logger.error(f"LLM generation failed: {e}")
139
+ return {
140
+ "scores": {
141
+ "hook": 0,
142
+ "visuals": 0,
143
+ "audio": 0,
144
+ "engagement": 0,
145
+ "visual_diversity": 0
146
+ },
147
+ "matrices": self.extract_matrices(),
148
+ "summary": "Failed to generate report.",
149
+ "suggestions": ["Try again", "Check input files", "Verify OpenAI key"]
150
+ }
151
+
152
+ def compute_virality_score(self, result):
153
+ weights = {
154
+ 'hook': 0.18,
155
+ 'visuals': 0.20,
156
+ 'audio': 0.25,
157
+ 'engagement': 0.27,
158
+ 'visual_diversity': 0.10
159
+ }
160
+
161
+ sub_scores = result["scores"]
162
+ base_score = sum(sub_scores[key] * weights[key] for key in weights)
163
+
164
+ bonus = 0
165
+ matrices = result.get("matrices", {})
166
+
167
+ if matrices.get("emotion") in ["joy", "inspiration"]:
168
+ bonus += 6
169
+ if matrices.get("tone") in ["funny", "relatable"]:
170
+ bonus += 6
171
+ if matrices.get("facial_sync") in ["ok", "good"]:
172
+ bonus += 4
173
+
174
+ if sub_scores.get("hook", 0) <= 30:
175
+ bonus -= 6
176
+ if sub_scores.get("audio", 0) < 40:
177
+ bonus -= 5
178
+ if matrices.get("facial_sync") == "none":
179
+ bonus -= 5
180
+
181
+ final_score = max(0, min(100, int(base_score + bonus)))
182
+ return final_score
183
+
184
+ def generate(self) -> dict:
185
+ self.logger.info("Preparing prompt for LLM...")
186
+ prompt = self.prepare_prompt()
187
+
188
+ self.logger.info("Querying LLM for report generation...")
189
+ result = self.query_llm(prompt)
190
+ total_score = self.compute_virality_score(result)
191
+ final_output = {
192
+ "video_name": self.video_path.stem,
193
+ "total_score": total_score,
194
+ **result
195
+ }
196
+ self.logger.info("Saving final report...")
197
+ self.output_json.parent.mkdir(parents=True, exist_ok=True)
198
+ with open(self.output_json, 'w', encoding='utf-8') as f:
199
+ json.dump(final_output, f, indent=2)
200
+
201
+ self.logger.info("Report successfully generated at %s", self.output_json)
202
+ return final_output
app/utils/__init__.py ADDED
File without changes
app/utils/logging.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from pathlib import Path
3
+ from config import LOG_DIR
4
+
5
+
6
+ def get_logger(name='vc', log_file: str = 'latest.log', level='INFO'):
7
+ Path(LOG_DIR).mkdir(exist_ok=True)
8
+ log_path = LOG_DIR / log_file
9
+
10
+ logger = logging.getLogger(name)
11
+ logger.setLevel(level.upper())
12
+
13
+ if not logger.handlers:
14
+ handler = logging.FileHandler(log_path, encoding='utf-8')
15
+ formatter = logging.Formatter('%(asctime)s | %(levelname)-7s | %(name)s | %(message)s')
16
+ handler.setFormatter(formatter)
17
+ logger.addHandler(handler)
18
+
19
+ return logger
config.py CHANGED
@@ -1,29 +1,29 @@
1
- import os
2
- from pathlib import Path
3
-
4
-
5
- ROOT_DIR = Path(__file__).resolve().parent
6
-
7
- LOG_DIR = ROOT_DIR / 'logs'
8
- DATA_DIR = ROOT_DIR / 'data'
9
-
10
- OPENAI_API_KEY = os.getenv('OPENAI_API_KEY', '')
11
- GEMINI_API_KEY = os.getenv('GEMINI_API_KEY', '')
12
- SIEVE_API_KEY = os.getenv('SIEVE_API_KEY', '')
13
- WHISPER_MODEL = os.getenv('WHISPER_MODEL', 'base')
14
-
15
-
16
- def make_name(video_path: str, suffix: str, ext: str) -> str:
17
- """
18
- Returns: myvideo_transcript.json (etc.)
19
- """
20
- stem = Path(video_path).stem
21
- return f'{stem}_{suffix}.{ext}'
22
-
23
-
24
- def make_path(subdir: str, video_path: str, suffix: str, ext: str) -> Path:
25
- """
26
- Returns: full path inside subfolder (e.g. data/processed/myvideo_scene.json)
27
- """
28
- filename = make_name(video_path, suffix, ext)
29
- return DATA_DIR / subdir / filename
 
1
+ import os
2
+ from pathlib import Path
3
+
4
+
5
+ ROOT_DIR = Path(__file__).resolve().parent
6
+
7
+ LOG_DIR = ROOT_DIR / 'logs'
8
+ DATA_DIR = ROOT_DIR / 'data'
9
+
10
+ OPENAI_API_KEY = os.getenv('OPENAI_API_KEY', '')
11
+ GEMINI_API_KEY = os.getenv('GEMINI_API_KEY', '')
12
+ SIEVE_API_KEY = os.getenv('SIEVE_API_KEY', '')
13
+ WHISPER_MODEL = os.getenv('WHISPER_MODEL', 'base')
14
+
15
+
16
+ def make_name(video_path: str, suffix: str, ext: str) -> str:
17
+ """
18
+ Returns: myvideo_transcript.json (etc.)
19
+ """
20
+ stem = Path(video_path).stem
21
+ return f'{stem}_{suffix}.{ext}'
22
+
23
+
24
+ def make_path(subdir: str, video_path: str, suffix: str, ext: str) -> Path:
25
+ """
26
+ Returns: full path inside subfolder (e.g. data/processed/myvideo_scene.json)
27
+ """
28
+ filename = make_name(video_path, suffix, ext)
29
+ return DATA_DIR / subdir / filename
demo.txt ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Deploy to Hugging Face Spaces
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ jobs:
9
+ deploy:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - name: Checkout Repository
14
+ uses: actions/checkout@v3
15
+
16
+ - name: Set Up Python
17
+ uses: actions/setup-python@v4
18
+ with:
19
+ python-version: '3.8'
20
+
21
+ - name: Install Dependencies
22
+ run: |
23
+ python -m pip install --upgrade pip
24
+ pip install torch --index-url https://download.pytorch.org/whl/cpu
25
+ pip install -r requirements.txt
26
+ pip install huggingface_hub
27
+
28
+
29
+
30
+
31
+ - name: Configure Git User
32
+ run: |
33
+ git config --global user.name "github-actions[bot]"
34
+ git config --global user.email "github-actions[bot]@users.noreply.github.com"
35
+
36
+ - name: Deploy to Hugging Face Space
37
+ env:
38
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
39
+ HF_USERNAME: ${{ secrets.HF_USERNAME }}
40
+ HF_SPACE_NAME: ${{ secrets.HF_SPACE_NAME }}
41
+ run: |
42
+ # Store credentials
43
+ echo "https://$HF_USERNAME:$HF_TOKEN@huggingface.co" > ~/.git-credentials
44
+ git config --global credential.helper store
45
+
46
+ # Clone the space repository
47
+ git clone https://huggingface.co/spaces/$HF_USERNAME/$HF_SPACE_NAME repo
48
+
49
+ # Copy files to the repo directory
50
+ rsync -av --exclude={'.git','repo'} ./ repo/
51
+
52
+ # Push changes
53
+ cd repo
54
+ git add .
55
+ git commit -m "Automated deployment" || echo "No changes to commit"
56
+ git push https://huggingface.co/spaces/$HF_USERNAME/$HF_SPACE_NAME main
57
+
58
+ - name: Cleanup Credentials
59
+ if: always()
60
+ run: |
61
+ rm -f ~/.git-credentials
packages.txt CHANGED
@@ -1,5 +1,5 @@
1
- libgl1-mesa-glx
2
- libglib2.0-0
3
- libsm6
4
- libxext6
5
- libxrender1
 
1
+ libgl1-mesa-glx
2
+ libglib2.0-0
3
+ libsm6
4
+ libxext6
5
+ libxrender1
ui/__init__.py ADDED
File without changes
ui/app.py ADDED
@@ -0,0 +1,936 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import sys
3
+ import json
4
+ import time
5
+ import signal
6
+ import traceback
7
+ import threading
8
+ import unicodedata
9
+ import hashlib
10
+ from pathlib import Path
11
+ import plotly.express as px
12
+ import yt_dlp
13
+ import streamlit as st
14
+
15
+ # -----------------------------
16
+ # Safe signal handling for non-main thread environments (yt_dlp)
17
+ # -----------------------------
18
+ if threading.current_thread() is not threading.main_thread():
19
+ _orig_signal = signal.signal
20
+
21
+ def _safe(sig, handler):
22
+ if sig in (signal.SIGTERM, signal.SIGINT):
23
+ return
24
+ return _orig_signal(sig, handler)
25
+
26
+ signal.signal = _safe
27
+
28
+ # -----------------------------
29
+ # Project paths & imports
30
+ # -----------------------------
31
+ ROOT = Path(__file__).resolve().parents[1]
32
+ ROOT_STR = str(ROOT.resolve()) # Ensure absolute path
33
+ # Insert at beginning for higher priority
34
+ if ROOT_STR not in sys.path:
35
+ sys.path.insert(0, ROOT_STR)
36
+
37
+ # Verify app package exists
38
+ app_dir = ROOT / "app"
39
+ if not app_dir.exists():
40
+ raise ImportError(f"Cannot find 'app' package at {app_dir}. ROOT={ROOT}")
41
+
42
+ from config import make_path
43
+ from app.pipeline.scene_detect import SceneDetector
44
+ from app.pipeline.frame_extract import FrameExtractor
45
+
46
+ # -----------------------------
47
+ # Storage layout
48
+ # -----------------------------
49
+ DATA_DIR = ROOT / "data"
50
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
51
+
52
+ for name in ["raw", "interim", "processed", "reports"]:
53
+ (DATA_DIR / name).mkdir(parents=True, exist_ok=True)
54
+
55
+ RAW_DIR = DATA_DIR / "raw"
56
+ INTERIM_DIR = DATA_DIR / "interim"
57
+ PROCESSED_DIR = DATA_DIR / "processed"
58
+ REPORTS_DIR = DATA_DIR / "reports"
59
+
60
+ # -----------------------------
61
+ # Utilities
62
+ # -----------------------------
63
+ def sanitize_title(title: str, max_length: int = 150) -> str:
64
+ title = unicodedata.normalize("NFKD", title)
65
+ title = title.encode("ascii", "ignore").decode("ascii")
66
+ title = re.sub(r"#\w+", "", title)
67
+ title = re.sub(r"[^\w\s]", "", title)
68
+ title = re.sub(r"\s+", " ", title).strip()
69
+ title = title.lower()
70
+ return title[:max_length]
71
+
72
+
73
+ def sanitize_filename(filename: str) -> str:
74
+ filename = filename.lower().replace(" ", "_")
75
+ filename = unicodedata.normalize("NFKD", filename)
76
+ filename = filename.encode("ascii", "ignore").decode("ascii")
77
+ filename = re.sub(r"[^a-z0-9._-]", "", filename)
78
+ return filename.strip()
79
+
80
+
81
+ def create_short_path(video_path: Path) -> str:
82
+ """Create a short identifier for frame directories to avoid Windows path limits"""
83
+ path_str = str(video_path)
84
+ # Create a short hash of the full path
85
+ path_hash = hashlib.md5(path_str.encode()).hexdigest()[:12]
86
+ return f"frames_{path_hash}"
87
+
88
+
89
+ def get_frames_directory(video_path: Path) -> Path:
90
+ """Get the frames directory path using short naming to avoid Windows path limits"""
91
+ short_id = create_short_path(video_path)
92
+ return INTERIM_DIR / "frames" / short_id
93
+
94
+
95
+ def download_video(url: str) -> tuple[Path, str]:
96
+ with yt_dlp.YoutubeDL({"quiet": True}) as ydl:
97
+ info = ydl.extract_info(url, download=False)
98
+ original_title = info.get("title", "video")
99
+ ext = info.get("ext", "mp4")
100
+
101
+ clean_title = sanitize_title(original_title)
102
+ sanitized_name = sanitize_filename(clean_title) or "video"
103
+ filename = f"{sanitized_name}.{ext}"
104
+ file_path = RAW_DIR / filename
105
+
106
+ if not file_path.exists():
107
+ ydl_opts = {
108
+ "outtmpl": str(file_path),
109
+ "restrictfilenames": True,
110
+ "quiet": True,
111
+ "noplaylist": True,
112
+ "no_color": True,
113
+ "format": "bv*+ba/b",
114
+ }
115
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
116
+ ydl.download([url])
117
+
118
+ return file_path, clean_title
119
+
120
+
121
+ def get_paths(video_path: Path):
122
+ vp_str = str(video_path)
123
+ audio_json = make_path("processed/audio-analysis", vp_str, "audio_analysis", "json")
124
+ report_json = make_path("reports", vp_str, "final_report", "json")
125
+ scene_json = make_path("processed/scene-detection", vp_str, "scene", "json")
126
+ frame_json = make_path("processed/frame-analysis", vp_str, "frame_analysis", "json")
127
+ hook_json = make_path("processed/hook-analysis", vp_str, "hook_analysis", "json")
128
+ return scene_json, frame_json, audio_json, hook_json, report_json
129
+
130
+
131
+ def safe_load_json(path: Path | str):
132
+ p = Path(path)
133
+ if p.exists():
134
+ try:
135
+ with p.open(encoding="utf-8") as f:
136
+ return json.load(f)
137
+ except Exception:
138
+ return {}
139
+ return {}
140
+
141
+
142
+ def remove_artifacts(video_path: Path):
143
+ try:
144
+ if video_path and video_path.exists():
145
+ video_path.unlink(missing_ok=True)
146
+ except Exception:
147
+ pass
148
+
149
+ # -----------------------------
150
+ # Streamlit page config & styles
151
+ # -----------------------------
152
+ st.set_page_config(page_title="Virality Coach", layout="wide")
153
+
154
+ st.markdown(
155
+ """
156
+ <style>
157
+ footer{display:none}
158
+ .block-container{padding-top:1rem;padding-bottom:2rem;max-width:1100px}
159
+ .title-center{text-align:center;margin-bottom:0.2rem}
160
+ .desc-center{text-align:center;margin-bottom:1.2rem;color:#dbdbdb}
161
+ .metric-card{background:#1f2937;border-radius:12px;padding:1.25rem;text-align:center;box-shadow:0 2px 8px rgba(0,0,0,.08);height:100%}
162
+ .metric-card h4{margin:0;font-size:0.95rem;color:#d1d5db}
163
+ .metric-card p{margin:0;font-size:1.8rem;font-weight:700;color:#ffffff}
164
+ video{max-height:240px;border-radius:10px;margin-bottom:0.5rem}
165
+ .status-msg{font-size:0.9rem;margin:0}
166
+ </style>
167
+ """,
168
+ unsafe_allow_html=True,
169
+ )
170
+
171
+ st.markdown('<h1 class="title-center">Video Virality Coach</h1>', unsafe_allow_html=True)
172
+ st.markdown('<p class="desc-center">An AI-powered system that analyzes and scores the virality potential of short-form videos (TikTok, Reels, Shorts) and delivers clear, actionable feedback to creators and marketers.</p>', unsafe_allow_html=True)
173
+
174
+ # -----------------------------
175
+ # Session state
176
+ # -----------------------------
177
+ DEFAULT_STATE = {
178
+ "mode": None,
179
+ "url": "",
180
+ "uploaded_name": None,
181
+ "video_path": None,
182
+ "clean_title": None,
183
+ "stage": None,
184
+ "progress": 0,
185
+ "status": [],
186
+ "cancel": False,
187
+ "error_msg": None,
188
+ "_ready_to_run": False,
189
+ }
190
+
191
+ for k, v in DEFAULT_STATE.items():
192
+ if k not in st.session_state:
193
+ st.session_state[k] = v
194
+
195
+
196
+ def reset_state(clear_video: bool = True):
197
+ keep = st.session_state.get("video_path") if not clear_video else None
198
+ st.session_state.update(DEFAULT_STATE | {"video_path": keep})
199
+
200
+
201
+ def push_status(msg: str):
202
+ st.session_state.status.append(msg)
203
+
204
+
205
+ # -----------------------------
206
+ # Pipeline step executor
207
+ # -----------------------------
208
+ STAGES = ["download video", "scene detection", "frames extraction", "frame analysis", "audio analysis", "hook analysis", "report"]
209
+
210
+ PROGRESS_MAP = {
211
+ "download video": 10,
212
+ "scene detection": 25,
213
+ "frames extraction": 40,
214
+ "frame analysis": 55,
215
+ "audio analysis": 70,
216
+ "hook analysis": 85,
217
+ "report": 100,
218
+ }
219
+
220
+
221
+ def _run_current_stage():
222
+ """
223
+ Run the heavy work for the current stage.
224
+ This is called only when _ready_to_run is True,
225
+ so the UI has already rendered progress/cancel.
226
+ """
227
+ stage = st.session_state.stage
228
+ if not stage or stage in ("done", "error"):
229
+ return
230
+
231
+ if st.session_state.cancel:
232
+ push_status("⚠️ Process canceled by user.")
233
+ print("[INFO] Processing canceled by user.")
234
+ st.session_state.stage = None
235
+ st.session_state.progress = 0
236
+ try:
237
+ vp = st.session_state.video_path
238
+ if vp:
239
+ remove_artifacts(Path(vp))
240
+ except Exception:
241
+ pass
242
+ st.session_state._ready_to_run = False
243
+ st.rerun()
244
+
245
+ try:
246
+ vp = Path(st.session_state.video_path) if st.session_state.video_path else None
247
+
248
+ if stage == "download video":
249
+ push_status("Starting download…")
250
+ print(f"[INFO] Stage: Downloading video from {st.session_state.url}")
251
+ path, title = download_video(st.session_state.url)
252
+ st.session_state.video_path = str(path)
253
+ st.session_state.clean_title = title
254
+
255
+ # Skip full pipeline if a report already exists
256
+ _, _, _, _, report_json = get_paths(path)
257
+ if Path(report_json).exists():
258
+ push_status("📄 Report already exists. Skipping analysis.")
259
+ print("[INFO] Report already exists, skipping pipeline.")
260
+ st.session_state.progress = 100
261
+ st.session_state.stage = "done"
262
+ st.session_state._ready_to_run = False
263
+ st.rerun()
264
+
265
+ st.session_state.progress = PROGRESS_MAP[stage]
266
+ push_status("✅ Download complete.")
267
+ print("[INFO] Download complete.")
268
+ st.session_state.stage = "scene detection"
269
+ st.session_state._ready_to_run = False
270
+ st.rerun()
271
+
272
+ elif stage == "scene detection":
273
+ push_status("Detecting scenes…")
274
+ print("[INFO] Stage: Scene detection started.")
275
+ try:
276
+ scene_detector = SceneDetector(str(vp))
277
+ scene_detector.detect_and_save()
278
+
279
+ # Verify the scene detection file was created
280
+ scene_json, _, _, _, _ = get_paths(vp)
281
+ if not Path(scene_json).exists():
282
+ raise FileNotFoundError("Scene detection failed - no output file")
283
+
284
+ # Verify the scene file has the expected structure
285
+ scene_data = safe_load_json(scene_json)
286
+ if not scene_data or 'scenes' not in scene_data:
287
+ raise ValueError("Scene detection produced invalid results - no 'scenes' key")
288
+
289
+ # Check if scenes have the required 'start_time' field
290
+ if scene_data['scenes'] and 'start_time' not in scene_data['scenes'][0]:
291
+ print("[WARNING] Scene data missing 'start_time' field, adding compatible structure")
292
+ # Convert the scene data to the expected format
293
+ fixed_scenes = []
294
+ for i, scene in enumerate(scene_data['scenes']):
295
+ fixed_scene = {
296
+ 'start_time': scene.get('start', 0), # Use 'start' if available, else 0
297
+ 'end_time': scene.get('end', 0), # Use 'end' if available, else 0
298
+ 'duration': scene.get('duration', 0), # Use 'duration' if available, else 0
299
+ 'scene_number': i
300
+ }
301
+ fixed_scenes.append(fixed_scene)
302
+
303
+ scene_data['scenes'] = fixed_scenes
304
+
305
+ # Save the fixed scene data
306
+ with open(scene_json, 'w', encoding='utf-8') as f:
307
+ json.dump(scene_data, f, indent=2)
308
+
309
+ st.session_state.progress = PROGRESS_MAP[stage]
310
+ push_status("✅ Scene detection done.")
311
+ print("[INFO] Scene detection complete.")
312
+ st.session_state.stage = "frames extraction"
313
+ st.session_state._ready_to_run = False
314
+ st.rerun()
315
+
316
+ except Exception as e:
317
+ # If scene detection fails, create a compatible scene file
318
+ print(f"[WARNING] Scene detection failed: {e}. Creating fallback scene data.")
319
+ push_status("⚠️ Scene detection failed. Using fallback scene data.")
320
+
321
+ scene_json, _, _, _, _ = get_paths(vp)
322
+
323
+ # Create compatible scene data with required 'start_time' field
324
+ fallback_scene_data = {
325
+ "scenes": [{
326
+ "start_time": 0,
327
+ "end_time": 30, # Assume 30 second scenes
328
+ "duration": 30,
329
+ "scene_number": 0
330
+ }]
331
+ }
332
+
333
+ # Ensure directory exists
334
+ Path(scene_json).parent.mkdir(parents=True, exist_ok=True)
335
+
336
+ with open(scene_json, 'w', encoding='utf-8') as f:
337
+ json.dump(fallback_scene_data, f, indent=2)
338
+
339
+ st.session_state.progress = PROGRESS_MAP[stage]
340
+ push_status("✅ Using fallback scene detection.")
341
+ st.session_state.stage = "frames extraction"
342
+ st.session_state._ready_to_run = False
343
+ st.rerun()
344
+
345
+ elif stage == "frames extraction":
346
+ push_status("Extracting frames…")
347
+ print("[INFO] Stage: Frame extraction started.")
348
+ # Use the original FrameExtractor without modification
349
+ FrameExtractor(str(vp)).extract()
350
+ st.session_state.progress = PROGRESS_MAP[stage]
351
+ push_status("✅ Frame extraction done.")
352
+ print("[INFO] Frame extraction complete.")
353
+ st.session_state.stage = "frame analysis"
354
+ st.session_state._ready_to_run = False
355
+ st.rerun()
356
+
357
+ elif stage == "frame analysis":
358
+ push_status("Analyzing frames…")
359
+ if st.session_state.openai_key and st.session_state.openai_key.strip():
360
+ from app.pipeline.frame_analysis import FrameAnalyzer
361
+ try:
362
+ FrameAnalyzer(str(vp), openai_api_key=st.session_state.openai_key.strip()).analyze()
363
+ except Exception as api_error:
364
+ error_msg = str(api_error)
365
+ if "invalid" in error_msg.lower() or "401" in error_msg or "authentication" in error_msg.lower():
366
+ st.session_state.stage = "error"
367
+ st.session_state.error_msg = f"OPENAI API KEY FAILED: Invalid OpenAI API Key provided. Please verify your API key is correct."
368
+ st.session_state._ready_to_run = False
369
+ st.rerun()
370
+ else:
371
+ raise
372
+ else:
373
+ st.session_state.stage = "error"
374
+ st.session_state.error_msg = "OPENAI API KEY FAILED: OpenAI API Key is required for frame analysis but was not provided."
375
+ st.session_state._ready_to_run = False
376
+ st.rerun()
377
+ st.session_state.progress = PROGRESS_MAP[stage]
378
+ push_status("✅ Frame analysis done.")
379
+ st.session_state.stage = "audio analysis"
380
+ st.session_state._ready_to_run = False
381
+ st.rerun()
382
+
383
+ elif stage == "audio analysis":
384
+ push_status("Analyzing audio…")
385
+ if st.session_state.gemini_key and st.session_state.gemini_key.strip():
386
+ from app.pipeline.audio_analysis import AudioAnalyzer
387
+ try:
388
+ AudioAnalyzer(str(vp), gemini_api_key=st.session_state.gemini_key.strip()).analyze()
389
+ except (ValueError, Exception) as api_error:
390
+ error_msg = str(api_error)
391
+ if "invalid" in error_msg.lower() or "401" in error_msg or "403" in error_msg or "api_key" in error_msg.lower() or "authentication" in error_msg.lower():
392
+ st.session_state.stage = "error"
393
+ st.session_state.error_msg = f"GEMINI API KEY FAILED: Invalid Gemini API Key provided. Error: {error_msg}"
394
+ st.session_state._ready_to_run = False
395
+ st.rerun()
396
+ else:
397
+ raise
398
+ else:
399
+ st.session_state.stage = "error"
400
+ st.session_state.error_msg = "GEMINI API KEY FAILED: Gemini API Key is required for audio analysis but was not provided."
401
+ st.session_state._ready_to_run = False
402
+ st.rerun()
403
+ st.session_state.progress = PROGRESS_MAP[stage]
404
+ push_status("✅ Audio analysis done.")
405
+ st.session_state.stage = "hook analysis"
406
+ st.session_state._ready_to_run = False
407
+ st.rerun()
408
+
409
+ elif stage == "hook analysis":
410
+ push_status("Evaluating hook…")
411
+ if st.session_state.gemini_key and st.session_state.gemini_key.strip():
412
+ from app.pipeline.frame_analysis import HookAnalyzer
413
+ try:
414
+ HookAnalyzer(str(vp), gemini_api_key=st.session_state.gemini_key.strip()).analyze()
415
+ except (ValueError, Exception) as api_error:
416
+ error_msg = str(api_error)
417
+ if "invalid" in error_msg.lower() or "401" in error_msg or "403" in error_msg or "api_key" in error_msg.lower() or "authentication" in error_msg.lower():
418
+ st.session_state.stage = "error"
419
+ st.session_state.error_msg = f"GEMINI API KEY FAILED: Invalid Gemini API Key provided. Error: {error_msg}"
420
+ st.session_state._ready_to_run = False
421
+ st.rerun()
422
+ else:
423
+ raise
424
+ else:
425
+ st.session_state.stage = "error"
426
+ st.session_state.error_msg = "GEMINI API KEY FAILED: Gemini API Key is required for hook analysis but was not provided."
427
+ st.session_state._ready_to_run = False
428
+ st.rerun()
429
+ st.session_state.progress = PROGRESS_MAP[stage]
430
+ push_status("✅ Hook analysis done.")
431
+ st.session_state.stage = "report"
432
+ st.session_state._ready_to_run = False
433
+ st.rerun()
434
+
435
+ elif stage == "report":
436
+ push_status("Generating final report…")
437
+ if st.session_state.openai_key and st.session_state.openai_key.strip():
438
+ from app.pipeline.scoring import VideoReport
439
+ try:
440
+ VideoReport(str(vp), openai_api_key=st.session_state.openai_key.strip()).generate()
441
+ except Exception as api_error:
442
+ error_msg = str(api_error)
443
+ if "invalid" in error_msg.lower() or "401" in error_msg or "authentication" in error_msg.lower():
444
+ st.session_state.stage = "error"
445
+ st.session_state.error_msg = f"OPENAI API KEY FAILED: Invalid OpenAI API Key provided. Error: {error_msg}"
446
+ st.session_state._ready_to_run = False
447
+ st.rerun()
448
+ else:
449
+ raise
450
+ else:
451
+ st.session_state.stage = "error"
452
+ st.session_state.error_msg = "OPENAI API KEY FAILED: OpenAI API Key is required for report generation but was not provided."
453
+ st.session_state._ready_to_run = False
454
+ st.rerun()
455
+ st.session_state.progress = PROGRESS_MAP[stage]
456
+ push_status("🎉 Video report ready!")
457
+ st.session_state.stage = "done"
458
+ st.session_state._ready_to_run = False
459
+ st.rerun()
460
+
461
+ except Exception as e:
462
+ err_type = type(e).__name__
463
+ err_msg = str(e).strip()
464
+ tb_last = traceback.format_exc(limit=1).strip()
465
+ st.session_state.stage = "error"
466
+ st.session_state.error_msg = f"{err_type}: {err_msg}\n➡️ {tb_last}"
467
+ st.session_state.progress = 0
468
+ st.session_state._ready_to_run = False
469
+ push_status(f"❌ {err_type}: {err_msg}")
470
+ st.rerun()
471
+
472
+
473
+ def run_next_stage_if_needed():
474
+ if not st.session_state.stage or st.session_state.stage in ("done", "error"):
475
+ return
476
+ if not st.session_state._ready_to_run:
477
+ st.session_state._ready_to_run = True
478
+ time.sleep(0.01)
479
+ st.rerun()
480
+ else:
481
+ _run_current_stage()
482
+
483
+
484
+ # -----------------------------
485
+ # Input section
486
+ # -----------------------------
487
+
488
+ report, api_tab = st.tabs(["Upload Video", "🔑 API Configuration"])
489
+
490
+ with api_tab:
491
+ st.markdown("### Configure Your API Keys")
492
+ st.markdown("Enter your API keys below. Keys will be validated during analysis. If a key is invalid, you'll see an error message during the analysis stage.")
493
+
494
+ col1, col2 = st.columns(2)
495
+
496
+ with col1:
497
+ st.session_state.openai_key = st.text_input(
498
+ "OpenAI API Key",
499
+ type="password",
500
+ placeholder="sk-...",
501
+ help="Required for frame analysis and report generation",
502
+ value=st.session_state.get("openai_key", "")
503
+ )
504
+
505
+ with col2:
506
+ st.session_state.gemini_key = st.text_input(
507
+ "Gemini API Key",
508
+ type="password",
509
+ placeholder="AIza...",
510
+ help="Required for audio analysis and hook analysis",
511
+ value=st.session_state.get("gemini_key", "")
512
+ )
513
+
514
+ st.markdown("---")
515
+ st.info("💡 Add your API keys and return to the Upload Video tab to start analysis. Invalid keys will show error messages during the analysis process.")
516
+
517
+ if 'openai_key' not in st.session_state:
518
+ st.session_state.openai_key = ""
519
+ if 'gemini_key' not in st.session_state:
520
+ st.session_state.gemini_key = ""
521
+
522
+ with report:
523
+ method = st.radio("Choose Upload Method", ["Paste Video URL", "Upload MP4 File"], horizontal=True)
524
+
525
+ col_in_1, col_in_2 = st.columns([1, 1])
526
+
527
+ if method == "Paste Video URL":
528
+ st.session_state.mode = "url"
529
+ url = st.text_input(
530
+ "Paste direct video URL [insta / tiktok / yt-shorts]",
531
+ placeholder="https://example.com/@username/video/123",
532
+ value=st.session_state.url,
533
+ )
534
+ st.session_state.url = url
535
+ run_from_url = col_in_1.button("Run Analysis", key="run_url")
536
+
537
+ if run_from_url:
538
+ if not url:
539
+ st.error("❌ Please enter a video URL.")
540
+ else:
541
+ st.session_state.cancel = False
542
+ st.session_state.stage = "download video"
543
+ st.session_state.status = []
544
+ st.session_state.progress = 0
545
+ st.session_state._ready_to_run = False
546
+ st.rerun()
547
+
548
+ else:
549
+ st.session_state.mode = "file"
550
+ uploaded = st.file_uploader("Upload MP4 File", type=["mp4"])
551
+ run_from_file = col_in_1.button("Run Analysis", key="run_file")
552
+
553
+ if uploaded and run_from_file:
554
+ clean_name = sanitize_filename(Path(uploaded.name).stem) + ".mp4"
555
+ dest = RAW_DIR / clean_name
556
+ with dest.open("wb") as f:
557
+ f.write(uploaded.getbuffer())
558
+ st.session_state.video_path = str(dest)
559
+ st.session_state.clean_title = Path(clean_name).stem
560
+
561
+ # Skip if a report is already present
562
+ _, _, _, _, report_json = get_paths(dest)
563
+ if Path(report_json).exists():
564
+ st.session_state.stage = "done"
565
+ st.session_state.status = ["📄 Report already exists. Skipping analysis."]
566
+ st.session_state.progress = 100
567
+ st.rerun()
568
+
569
+ st.session_state.cancel = False
570
+ st.session_state.status = ["✅ Upload complete."]
571
+ st.session_state.progress = 0
572
+ st.session_state.stage = "scene detection"
573
+ st.session_state._ready_to_run = False
574
+ st.rerun()
575
+
576
+ # -----------------------------
577
+ # Progress & Status
578
+ # -----------------------------
579
+ if st.session_state.stage and st.session_state.stage not in ("done", "error"):
580
+ percent = st.session_state.progress
581
+ stage = st.session_state.stage.replace("_", " ").title()
582
+
583
+ st.markdown(f"##### {stage}: {percent}%")
584
+ st.progress(percent)
585
+
586
+ if st.button("Cancel Processing"):
587
+ st.session_state.cancel = True
588
+ st.rerun()
589
+
590
+ run_next_stage_if_needed()
591
+
592
+ # -----------------------------
593
+ # Error state
594
+ # -----------------------------
595
+ if st.session_state.stage == "error":
596
+ error_msg = st.session_state.error_msg or "An unknown error occurred."
597
+
598
+ # Detect which API key failed and display prominently
599
+ if "openai" in error_msg.lower() or "openai" in str(st.session_state.error_msg).lower():
600
+ st.error("🚨 **API KEY ERROR: OpenAI Key Failed**")
601
+ st.markdown("""
602
+ <div style='background-color: #fee2e2; border-left: 4px solid #ef4444; padding: 1rem; margin: 1rem 0; border-radius: 4px;'>
603
+ <h4 style='color: #991b1b; margin-top: 0;'>❌ OpenAI API Key Invalid or Missing</h4>
604
+ <p style='color: #7f1d1d; margin-bottom: 0;'><strong>Error Details:</strong> {}</p>
605
+ <p style='color: #7f1d1d; margin-top: 0.5rem;'>Please go to the <strong>🔑 API Configuration</strong> tab and update your OpenAI API key.</p>
606
+ </div>
607
+ """.format(error_msg), unsafe_allow_html=True)
608
+ elif "gemini" in error_msg.lower() or "gemini" in str(st.session_state.error_msg).lower():
609
+ st.error("🚨 **API KEY ERROR: Gemini Key Failed**")
610
+ st.markdown("""
611
+ <div style='background-color: #fee2e2; border-left: 4px solid #ef4444; padding: 1rem; margin: 1rem 0; border-radius: 4px;'>
612
+ <h4 style='color: #991b1b; margin-top: 0;'>❌ Gemini API Key Invalid or Missing</h4>
613
+ <p style='color: #7f1d1d; margin-bottom: 0;'><strong>Error Details:</strong> {}</p>
614
+ <p style='color: #7f1d1d; margin-top: 0.5rem;'>Please go to the <strong>🔑 API Configuration</strong> tab and update your Gemini API key.</p>
615
+ </div>
616
+ """.format(error_msg), unsafe_allow_html=True)
617
+ else:
618
+ # Generic error display
619
+ st.error("🚨 **ANALYSIS FAILED**")
620
+ st.markdown(f"""
621
+ <div style='background-color: #fee2e2; border-left: 4px solid #ef4444; padding: 1rem; margin: 1rem 0; border-radius: 4px;'>
622
+ <h4 style='color: #991b1b; margin-top: 0;'>❌ Error Occurred</h4>
623
+ <p style='color: #7f1d1d; margin-bottom: 0;'><strong>Error Details:</strong> {error_msg}</p>
624
+ </div>
625
+ """, unsafe_allow_html=True)
626
+
627
+ st.warning("⚠️ **Report Not Generated**: The analysis pipeline stopped due to the error above. No report was created.")
628
+
629
+ if st.button("🔄 Reset & Try Again", type="primary", use_container_width=True):
630
+ reset_state(clear_video=True)
631
+ st.rerun()
632
+
633
+ # -----------------------------
634
+ # Results section
635
+ # -----------------------------
636
+ if st.session_state.stage == "done" and st.session_state.video_path:
637
+ vp = Path(st.session_state.video_path)
638
+ scene_json, frame_json, audio_json, hook_json, report_json = get_paths(vp)
639
+
640
+ st.success("Analysis complete.")
641
+
642
+ with st.expander("Preview Video", expanded=False):
643
+ if vp.exists():
644
+ st.video(str(vp), format="video/mp4")
645
+
646
+ report = safe_load_json(report_json)
647
+ audio_data = safe_load_json(audio_json)
648
+ hook_data = safe_load_json(hook_json)
649
+
650
+ if not report:
651
+ st.warning("No report found. You can rerun the analysis.")
652
+ else:
653
+ results_tab, json_tab = st.tabs(["Results", "JSON Reports"])
654
+
655
+ with results_tab:
656
+ st.markdown(
657
+ "<h2 style='text-align: center;'>📝 Video Virality Report</h2>",
658
+ unsafe_allow_html=True
659
+ )
660
+
661
+ # --- Main Score Cards ---
662
+ total = report.get("total_score", 0)
663
+ st.markdown(f"""
664
+ <div style="text-align:center; margin-bottom:1rem;">
665
+ <div style="font-size:2rem; font-weight:bold; color:#10b981;">Total Score: {total}</div>
666
+ <p style="color:#9ca3af;">Overall Virality Potential</p>
667
+ </div>
668
+ """, unsafe_allow_html=True)
669
+
670
+ scores = report.get("scores", {})
671
+ if scores:
672
+ cols = st.columns(len(scores))
673
+ for col, (cat, val) in zip(cols, scores.items()):
674
+ color = "#10b981" if val >= 70 else "#fbbf24" if val >= 50 else "#ef4444"
675
+ with col:
676
+ st.markdown(
677
+ f"""
678
+ <div style="background:{color}22;
679
+ border-radius:12px;
680
+ padding:1rem;
681
+ text-align:center;
682
+ box-shadow:0 2px 8px rgba(0,0,0,.08);height:100%">
683
+ <h4 style="margin:0; font-size:0.9rem; color:#d1d5db">{cat.title()}</h4>
684
+ <p style="margin:0; font-size:1.5rem; font-weight:700; color:{color}">{val}</p>
685
+ </div>
686
+ """,
687
+ unsafe_allow_html=True,
688
+ )
689
+
690
+ # --- Matrices (tone, emotion, pace, facial_sync) ---
691
+ st.markdown(
692
+ """
693
+ <div style="text-align:center; margin-bottom:1rem; margin-top:1rem;">
694
+ <p style="color:#9ca3af;">Video Attributes</p>
695
+ </div>
696
+ """,
697
+ unsafe_allow_html=True
698
+ )
699
+ matrices = report.get("matrices", {})
700
+ if matrices:
701
+ attr_cols = st.columns(len(matrices))
702
+ for col, (k, v) in zip(attr_cols, matrices.items()):
703
+ color = "#10b981" if str(v).lower() in ["high", "positive", "fast", "good", "funny", "joy"] else "#fbbf24" if str(v).lower() in ["medium", "neutral", "mixed"] else "#ef4444"
704
+
705
+ with col:
706
+ st.markdown(f"""
707
+ <div style="background:{color}22;
708
+ border-radius:12px;
709
+ padding:1rem;
710
+ text-align:center;
711
+ box-shadow:0 2px 6px rgba(0,0,0,0.1)">
712
+ <h4 style="margin:0; font-size:0.9rem; color:#d1d5db">{k.title()}</h4>
713
+ <p style="margin:0; font-size:1.3rem; font-weight:700; color:{color}">{v}</p>
714
+ </div>
715
+ """, unsafe_allow_html=True)
716
+
717
+ # --- Summary ---
718
+ if "summary" in report:
719
+ st.markdown(
720
+ """
721
+ <h2 style='text-align: center; font-size:1.4rem; margin-top:1.3rem;'>
722
+ Report Summary
723
+ </h2>
724
+ """,
725
+ unsafe_allow_html=True
726
+ )
727
+ st.markdown(
728
+ f"""
729
+ <div style='background-color:#1e3a8a20;
730
+ border-left: 0.25rem solid #3b82f6;
731
+ border-radius: 8px;
732
+ padding: 1rem;
733
+ text-align: center;
734
+ color: #d1d5db;'>
735
+ {report["summary"]}
736
+ </div>
737
+ """,
738
+ unsafe_allow_html=True
739
+ )
740
+
741
+ # --- Suggestions ---
742
+ st.markdown(
743
+ """
744
+ <h2 style='text-align: center; font-size:1.4rem; margin-top:1.3rem;'>
745
+ Suggestions
746
+ </h2>
747
+ """,
748
+ unsafe_allow_html=True
749
+ )
750
+
751
+ suggestions = report.get("suggestions", [])
752
+ if suggestions:
753
+ for i, s in enumerate(suggestions, start=1):
754
+ st.markdown(
755
+ f"<p style='text-align:center; font-size:1rem;'> {s}</p>",
756
+ unsafe_allow_html=True
757
+ )
758
+ else:
759
+ st.markdown(
760
+ "<p style='text-align:center; color:gray;'>No improvement suggestions provided.</p>",
761
+ unsafe_allow_html=True
762
+ )
763
+
764
+ # --- Audio Analysis ---
765
+ if audio_data:
766
+ st.markdown(
767
+ """
768
+ <h2 style='text-align: center; font-size:1.4rem; margin-top:1.5rem;'>
769
+ Audio Analysis
770
+ </h2>
771
+ """,
772
+ unsafe_allow_html=True
773
+ )
774
+
775
+ # --- Audio Score Cards ---
776
+ metrics = {
777
+ "Delivery Score": audio_data.get("delivery_score", ""),
778
+ "Duration (s)": round(audio_data.get("duration_seconds", 0), 2),
779
+ "Words/Sec": audio_data.get("words_per_second", 0),
780
+ "Tone": audio_data.get("tone", ""),
781
+ "Emotion": audio_data.get("emotion", ""),
782
+ "Pace": audio_data.get("pace", ""),
783
+ }
784
+
785
+ cols = st.columns(len(metrics))
786
+ for col, (title, value) in zip(cols, metrics.items()):
787
+ color = "#10b981"
788
+
789
+ if title in ["Delivery Score", "Tone", "Emotion", "Pace"]:
790
+ if title == "Delivery Score" and isinstance(value, (int, float)):
791
+ color = "#10b981" if value >= 70 else "#fbbf24" if value >= 50 else "#ef4444"
792
+ else:
793
+ val = str(value).lower()
794
+ if val in ["high", "positive", "fast", "good", "funny", "clear", "joy"]:
795
+ color = "#10b981"
796
+ elif val in ["medium", "neutral", "mixed", "average"]:
797
+ color = "#fbbf24"
798
+ elif val in ["low", "negative", "slow", "bad", "sad"]:
799
+ color = "#ef4444"
800
+ else:
801
+ color = "#d1d5db"
802
+
803
+ with col:
804
+ st.markdown(
805
+ f"""
806
+ <div style="background:{color}22;
807
+ border-radius:12px;
808
+ padding:1rem;
809
+ text-align:center;
810
+ box-shadow:0 2px 6px rgba(0,0,0,0.15);
811
+ margin-bottom:0.8rem;">
812
+ <h4 style="margin:0; font-size:0.85rem; color:#d1d5db">{title}</h4>
813
+ <p style="margin:0; font-size:1.3rem; font-weight:700; color:{color}">{value}</p>
814
+ </div>
815
+ """,
816
+ unsafe_allow_html=True,
817
+ )
818
+
819
+ # Transcript box
820
+ st.markdown(
821
+ f"""
822
+ <div style='background:#111827;
823
+ border-left: 4px solid #3b82f6;
824
+ padding:1rem;
825
+ margin-top:1rem;
826
+ border-radius:8px;
827
+ text-align:left;
828
+ color:#e5e7eb;'>
829
+ <b>Transcript:</b><br>
830
+ <i>{audio_data.get("full_transcript","")}</i>
831
+ </div>
832
+ """,
833
+ unsafe_allow_html=True
834
+ )
835
+
836
+ # Comment box
837
+ st.markdown(
838
+ f"""
839
+ <div style='background:#1e293b;
840
+ border-radius:8px;
841
+ padding:0.8rem;
842
+ margin-top:0.5rem;
843
+ text-align:center;
844
+ font-size:0.95rem;
845
+ color:#d1d5db;'>
846
+ {audio_data.get("comment","")}
847
+ </div>
848
+ """,
849
+ unsafe_allow_html=True
850
+ )
851
+
852
+
853
+ # --- Hook Analysis ---
854
+ if hook_data:
855
+ st.markdown(
856
+ """
857
+ <h2 style='text-align: center; font-size:1.4rem; margin-top:1.5rem;'>
858
+ Hook Analysis
859
+ </h2>
860
+ """,
861
+ unsafe_allow_html=True
862
+ )
863
+
864
+ # --- Hook Score Card ---
865
+ score = hook_data.get("hook_alignment_score", 0)
866
+ color = "#10b981" if score >= 70 else "#fbbf24" if score >= 50 else "#ef4444"
867
+
868
+ st.markdown(
869
+ f"""
870
+ <div style="background:{color}22;
871
+ border-radius:12px;
872
+ padding:1.2rem;
873
+ text-align:center;
874
+ box-shadow:0 2px 6px rgba(0,0,0,0.1);
875
+ margin:0 auto;
876
+ width:50%;">
877
+ <h4 style="margin:0; font-size:1rem; color:#d1d5db;">Hook Alignment Score</h4>
878
+ <p style="margin:0; font-size:2rem; font-weight:700; color:{color};">{score}</p>
879
+ </div>
880
+ """,
881
+ unsafe_allow_html=True
882
+ )
883
+
884
+ # --- Comment Box ---
885
+ st.markdown(
886
+ f"""
887
+ <div style='background:#1e293b;
888
+ border-radius:8px;
889
+ padding:0.8rem;
890
+ margin-top:0.5rem;
891
+ text-align:center;
892
+ font-size:0.95rem;
893
+ color:#d1d5db;'>
894
+ {audio_data.get("comment","")}
895
+ </div>
896
+ """,
897
+ unsafe_allow_html=True
898
+ )
899
+
900
+ # --- Download Report ---
901
+ st.markdown("<br>", unsafe_allow_html=True)
902
+ st.download_button(
903
+ "Download Final Report",
904
+ json.dumps(report, indent=2),
905
+ file_name="final_report.json",
906
+ )
907
+
908
+ with json_tab:
909
+ with st.expander("Scene Detection", expanded=False):
910
+ st.json(safe_load_json(scene_json))
911
+ with st.expander("Extracted Frames", expanded=False):
912
+ frames_dir = INTERIM_DIR / "frames" / f"{vp.stem}_"
913
+ if frames_dir.exists():
914
+ imgs = sorted(frames_dir.glob("*.jpg"))
915
+ if imgs:
916
+ cols = st.columns(4)
917
+ for i, img in enumerate(imgs):
918
+ with cols[i % 4]:
919
+ st.image(str(img), use_container_width=True)
920
+ else:
921
+ st.info("No frames found.")
922
+ else:
923
+ st.info("No frames directory found.")
924
+ with st.expander("Frame Analysis", expanded=False):
925
+ st.json(safe_load_json(frame_json))
926
+ with st.expander("Audio Analysis", expanded=False):
927
+ st.json(audio_data)
928
+ with st.expander("Hook Analysis", expanded=False):
929
+ st.json(hook_data)
930
+ with st.expander("Final Report", expanded=False):
931
+ st.json(report)
932
+
933
+ # Reset button only after analysis is done
934
+ if st.button("Reset Session"):
935
+ reset_state(clear_video=True)
936
+ st.rerun()