Spaces:
Sleeping
Sleeping
github-actions[bot]
commited on
Commit
·
8b7ae7a
1
Parent(s):
a9e0afd
Automated deployment from GitHub Actions
Browse files- .env +5 -5
- .huggingface.yml +1 -1
- Dockerfile +44 -44
- README.md +1 -10
- START_HERE.txt +28 -28
- app/__init__.py +0 -0
- app/pipeline/__init__.py +0 -0
- app/pipeline/audio_analysis.py +185 -0
- app/pipeline/frame_analysis.py +195 -0
- app/pipeline/frame_extract.py +99 -0
- app/pipeline/scene_detect.py +64 -0
- app/pipeline/scoring.py +202 -0
- app/utils/__init__.py +0 -0
- app/utils/logging.py +19 -0
- config.py +29 -29
- demo.txt +61 -0
- packages.txt +5 -5
- ui/__init__.py +0 -0
- ui/app.py +936 -0
.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()
|