|
|
import streamlit as st |
|
|
import os |
|
|
from PIL import Image |
|
|
|
|
|
from config import INDUSTRIES, CAMPAIGN_GOALS, CATEGORY_COLORS, MAX_VIDEO_LENGTH_SECONDS |
|
|
from video_loader import VideoLoader |
|
|
from frame_extractor import FrameExtractor |
|
|
from audio_extractor import AudioExtractor |
|
|
from vision_analyzer import VisionAnalyzer |
|
|
from segment_synchronizer import SegmentSynchronizer |
|
|
from narrative_classifier import NarrativeClassifier |
|
|
from report_generator import ReportGenerator |
|
|
|
|
|
|
|
|
st.set_page_config( |
|
|
page_title="StoryLens - Ad Narrative Analyzer", |
|
|
page_icon="🎬", |
|
|
layout="wide" |
|
|
) |
|
|
|
|
|
|
|
|
if 'analysis_result' not in st.session_state: |
|
|
st.session_state.analysis_result = None |
|
|
if 'transcript' not in st.session_state: |
|
|
st.session_state.transcript = None |
|
|
|
|
|
|
|
|
with st.sidebar: |
|
|
st.header("Configuration") |
|
|
|
|
|
|
|
|
with st.expander("API Settings", expanded=True): |
|
|
st.subheader("MiniMax (Vision & LLM)") |
|
|
api_key = st.text_input( |
|
|
"MiniMax API Key", |
|
|
type="password", |
|
|
value=os.getenv("MINIMAX_API_KEY", ""), |
|
|
help="Get your API key from MiniMax platform" |
|
|
) |
|
|
group_id = st.text_input( |
|
|
"MiniMax Group ID", |
|
|
value=os.getenv("MINIMAX_GROUP_ID", "") |
|
|
) |
|
|
|
|
|
if api_key and group_id: |
|
|
st.session_state.api_key = api_key |
|
|
st.session_state.group_id = group_id |
|
|
st.success("MiniMax configured") |
|
|
|
|
|
st.divider() |
|
|
|
|
|
st.subheader("OpenAI (Whisper)") |
|
|
openai_key = st.text_input( |
|
|
"OpenAI API Key", |
|
|
type="password", |
|
|
value=os.getenv("OPENAI_API_KEY", ""), |
|
|
help="For audio transcription (Whisper)" |
|
|
) |
|
|
|
|
|
if openai_key: |
|
|
st.session_state.openai_key = openai_key |
|
|
st.success("OpenAI configured") |
|
|
|
|
|
st.divider() |
|
|
|
|
|
|
|
|
st.subheader("Campaign Settings") |
|
|
|
|
|
industry = st.selectbox("Industry", INDUSTRIES) |
|
|
campaign_goal = st.selectbox("Campaign Goal", CAMPAIGN_GOALS) |
|
|
|
|
|
|
|
|
st.title("StoryLens") |
|
|
st.markdown("*Diagnose your video ad's narrative structure*") |
|
|
|
|
|
|
|
|
st.header("Video Input") |
|
|
|
|
|
col1, col2 = st.columns(2) |
|
|
|
|
|
with col1: |
|
|
st.subheader("Upload File") |
|
|
uploaded_file = st.file_uploader( |
|
|
"Choose video file", |
|
|
type=["mp4", "mov", "avi", "webm"], |
|
|
help="Max 120 seconds" |
|
|
) |
|
|
|
|
|
with col2: |
|
|
st.subheader("YouTube URL") |
|
|
youtube_url = st.text_input( |
|
|
"Paste URL", |
|
|
placeholder="https://youtube.com/watch?v=..." |
|
|
) |
|
|
|
|
|
|
|
|
video_source = uploaded_file or youtube_url |
|
|
minimax_ready = hasattr(st.session_state, 'api_key') and st.session_state.api_key |
|
|
openai_ready = hasattr(st.session_state, 'openai_key') and st.session_state.openai_key |
|
|
api_ready = minimax_ready and openai_ready |
|
|
|
|
|
if video_source and api_ready: |
|
|
if st.button("Analyze", type="primary", use_container_width=True): |
|
|
|
|
|
|
|
|
progress_container = st.container() |
|
|
|
|
|
with progress_container: |
|
|
progress_bar = st.progress(0) |
|
|
status_text = st.empty() |
|
|
|
|
|
try: |
|
|
|
|
|
api_key = st.session_state.api_key |
|
|
group_id = st.session_state.group_id |
|
|
openai_key = st.session_state.openai_key |
|
|
|
|
|
video_loader = VideoLoader() |
|
|
frame_extractor = FrameExtractor() |
|
|
audio_extractor = AudioExtractor(openai_api_key=openai_key) |
|
|
vision_analyzer = VisionAnalyzer(api_key, group_id) |
|
|
synchronizer = SegmentSynchronizer() |
|
|
classifier = NarrativeClassifier(api_key, group_id) |
|
|
report_generator = ReportGenerator() |
|
|
|
|
|
|
|
|
status_text.text("Loading video...") |
|
|
progress_bar.progress(10) |
|
|
|
|
|
if uploaded_file: |
|
|
video_path = video_loader.load_local(uploaded_file) |
|
|
else: |
|
|
video_path = video_loader.load_youtube(youtube_url) |
|
|
|
|
|
if not video_path: |
|
|
st.error("Failed to load video") |
|
|
st.stop() |
|
|
|
|
|
|
|
|
duration = video_loader.get_video_duration(video_path) |
|
|
if duration > MAX_VIDEO_LENGTH_SECONDS: |
|
|
st.error(f"Video too long ({duration:.0f}s). Max allowed: {MAX_VIDEO_LENGTH_SECONDS}s") |
|
|
st.stop() |
|
|
|
|
|
|
|
|
status_text.text("Extracting frames...") |
|
|
progress_bar.progress(20) |
|
|
|
|
|
frames = frame_extractor.extract_frames(video_path) |
|
|
|
|
|
|
|
|
status_text.text("Transcribing audio...") |
|
|
progress_bar.progress(35) |
|
|
|
|
|
audio_path = audio_extractor.extract_audio(video_path) |
|
|
transcript = audio_extractor.transcribe(audio_path) |
|
|
|
|
|
|
|
|
status_text.text("Analyzing frames...") |
|
|
progress_bar.progress(50) |
|
|
|
|
|
frame_descriptions = vision_analyzer.describe_frames_batch(frames) |
|
|
|
|
|
|
|
|
status_text.text("Synchronizing segments...") |
|
|
progress_bar.progress(70) |
|
|
|
|
|
segments = synchronizer.synchronize(frame_descriptions, transcript) |
|
|
|
|
|
|
|
|
status_text.text("Classifying narrative structure...") |
|
|
progress_bar.progress(85) |
|
|
|
|
|
analysis = classifier.classify(segments) |
|
|
|
|
|
|
|
|
status_text.text("Generating report...") |
|
|
progress_bar.progress(95) |
|
|
|
|
|
report = report_generator.generate(analysis, industry, campaign_goal) |
|
|
|
|
|
progress_bar.progress(100) |
|
|
status_text.text("Analysis complete!") |
|
|
|
|
|
|
|
|
st.session_state.analysis_result = report |
|
|
st.session_state.transcript = transcript |
|
|
|
|
|
except Exception as e: |
|
|
st.error(f"Analysis failed: {str(e)}") |
|
|
import traceback |
|
|
st.code(traceback.format_exc()) |
|
|
|
|
|
elif not api_ready: |
|
|
missing = [] |
|
|
if not minimax_ready: |
|
|
missing.append("MiniMax API Key + Group ID") |
|
|
if not openai_ready: |
|
|
missing.append("OpenAI API Key") |
|
|
st.warning(f"Please configure API settings in the sidebar: {', '.join(missing)}") |
|
|
elif not video_source: |
|
|
st.info("Upload a video file or paste a YouTube URL to begin") |
|
|
|
|
|
|
|
|
if st.session_state.analysis_result: |
|
|
result = st.session_state.analysis_result |
|
|
|
|
|
st.divider() |
|
|
|
|
|
|
|
|
st.header("Analysis Results") |
|
|
|
|
|
col1, col2, col3, col4 = st.columns(4) |
|
|
|
|
|
with col1: |
|
|
story_status = "YES" if result['summary']['has_story'] else "NO" |
|
|
st.metric("Story Detected", story_status) |
|
|
|
|
|
with col2: |
|
|
st.metric("Detected Arc", result['summary']['detected_arc']) |
|
|
|
|
|
with col3: |
|
|
st.metric("Optimal Arc", result['summary']['optimal_arc_for_goal']) |
|
|
|
|
|
with col4: |
|
|
st.metric("Potential Uplift", result['summary']['potential_uplift']) |
|
|
|
|
|
|
|
|
if result['summary']['story_explanation']: |
|
|
st.info(f"**Story Analysis:** {result['summary']['story_explanation']}") |
|
|
|
|
|
st.divider() |
|
|
|
|
|
|
|
|
st.subheader("Narrative Timeline") |
|
|
|
|
|
for seg in result['segments']: |
|
|
col1, col2, col3, col4 = st.columns([1, 1, 2, 3]) |
|
|
|
|
|
with col1: |
|
|
|
|
|
if seg.get('frame_path') and os.path.exists(seg['frame_path']): |
|
|
img = Image.open(seg['frame_path']) |
|
|
st.image(img, width=120) |
|
|
else: |
|
|
st.write("[Frame]") |
|
|
|
|
|
with col2: |
|
|
st.caption(f"**{seg['start']:.1f}s - {seg['end']:.1f}s**") |
|
|
|
|
|
|
|
|
category = seg.get('role_category', 'OTHER') |
|
|
color = CATEGORY_COLORS.get(category, '#9E9E9E') |
|
|
role = seg.get('functional_role', 'Unknown') |
|
|
|
|
|
st.markdown( |
|
|
f'<span style="background-color: {color}; color: white; ' |
|
|
f'padding: 4px 8px; border-radius: 4px; font-size: 12px;">' |
|
|
f'{role}</span>', |
|
|
unsafe_allow_html=True |
|
|
) |
|
|
|
|
|
with col3: |
|
|
visual_text = seg.get('visual', 'N/A') |
|
|
st.write(f"**Visual:** {visual_text}") |
|
|
|
|
|
with col4: |
|
|
if seg.get('speech'): |
|
|
st.write(f"**Speech:** \"{seg['speech']}\"") |
|
|
if seg.get('reasoning'): |
|
|
st.caption(f"*{seg['reasoning']}*") |
|
|
|
|
|
st.divider() |
|
|
|
|
|
|
|
|
if result.get('detected_sequence'): |
|
|
st.subheader("Story Arc Flow") |
|
|
arc_flow = " -> ".join(result['detected_sequence']) |
|
|
st.markdown(f"**{arc_flow}**") |
|
|
|
|
|
|
|
|
if result.get('missing_elements'): |
|
|
st.subheader("Missing Elements") |
|
|
for element in result['missing_elements']: |
|
|
st.warning(f"- {element}") |
|
|
|
|
|
st.divider() |
|
|
|
|
|
|
|
|
st.subheader("Recommendations") |
|
|
|
|
|
for rec in result.get('recommendations', []): |
|
|
priority = rec.get('priority', 'LOW') |
|
|
icon = "[HIGH]" if priority == "HIGH" else "[MEDIUM]" if priority == "MEDIUM" else "[LOW]" |
|
|
|
|
|
with st.expander(f"{icon} {rec['action']}", expanded=(priority == "HIGH")): |
|
|
col1, col2 = st.columns(2) |
|
|
with col1: |
|
|
st.metric("Expected Impact", rec.get('expected_impact', 'N/A')) |
|
|
with col2: |
|
|
st.metric("Priority", priority) |
|
|
st.write(f"**Reasoning:** {rec.get('reasoning', '')}") |
|
|
|
|
|
|
|
|
with st.expander("Benchmark Details"): |
|
|
benchmark = result.get('benchmark', {}) |
|
|
st.write(f"**Best Arc for {campaign_goal}:** {benchmark.get('best_arc', 'N/A')}") |
|
|
st.write(f"**Average Uplift:** +{benchmark.get('uplift_percent', '?')}%") |
|
|
st.write(f"**Recommendation:** {benchmark.get('recommendation', 'N/A')}") |
|
|
|
|
|
|
|
|
if hasattr(st.session_state, 'transcript') and st.session_state.transcript: |
|
|
st.divider() |
|
|
st.subheader("Full Transcript") |
|
|
|
|
|
transcript = st.session_state.transcript |
|
|
|
|
|
|
|
|
for seg in transcript: |
|
|
start = seg.get('start', 0) |
|
|
end = seg.get('end', 0) |
|
|
text = seg.get('text', '') |
|
|
|
|
|
if text: |
|
|
if start > 0 or end > 0: |
|
|
st.markdown(f"**[{start:.1f}s - {end:.1f}s]** {text}") |
|
|
else: |
|
|
st.markdown(text) |
|
|
|
|
|
|
|
|
with st.expander("Plain Text"): |
|
|
full_text = " ".join([seg.get('text', '') for seg in transcript if seg.get('text')]) |
|
|
st.text_area("Full transcript", full_text, height=150, disabled=True) |
|
|
|