feat: sentiment-aware video pipeline with DeepSeek, metadata generation, and per-video folder structure
SENTIMENT DETECTION (utils/sentiment.py)
- Integrate DeepSeek API using OpenAI-compatible SDK to classify each Reddit post
- Detect sentiment from post title + body (first 500 chars) into 8 labels:
sad, happy, angry, mysterious, funny, dramatic, wholesome, scary
- Override in-memory config per post (background_video, background_audio, voice)
- Falls back to 'dramatic' label if DeepSeek API fails or is unavailable
- Can be enabled/disabled via config.toml [deepseek] enabled = true/false
SENTIMENT MAPS (utils/sentiment_map.py)
- BACKGROUND_MAP: maps each sentiment to optimal background video + audio pair
- OPENAI_VOICE_MAP: maps each sentiment to best-fit OpenAI TTS voice
- ELEVENLABS_VOICE_MAP: maps each sentiment to best-fit ElevenLabs voice
(fully mapped to real voices: Adam, George, Harry, Callum, Jessica, Brian, Laura, Matilda)
- All overrides are in-memory only — config.toml is never modified
METADATA GENERATION (utils/sentiment.py)
- Single DeepSeek API call generates both sentiment + social media metadata
- Generates per-platform content:
* YouTube: title (max 70 chars) + full description
* TikTok: caption (max 150 chars) with hashtags
* Instagram: caption with hashtags
* Facebook: caption
* Hashtags: list of relevant tags
- Falls back to basic title-based metadata if DeepSeek fails
- Saves metadata.json inside each video's output folder
RESULTS FOLDER RESTRUCTURE (video_creation/final_video.py)
- Changed output structure from results/{subreddit}/{filename}.mp4
- New structure: results/{actual_subreddit}/{thread_id}_{sentiment}/video.mp4
- Each video now has its own isolated folder containing:
* video.mp4
* metadata.json
* thumbnail.png (if thumbnail generation is enabled)
* OnlyTTS/video.mp4 (if enable_extra_audio is enabled)
SUBREDDIT TRACKING (reddit/subreddit.py)
- Added thread_subreddit field to reddit_object using submission.subreddit.display_name
- Posts from r/AmItheAsshole now save to results/AmItheAsshole/
- Posts from r/tifu now save to results/tifu/
- Posts from r/confession now save to results/confession/
- Previously all posts were grouped under the combined subreddit string
PIPELINE INTEGRATION (main.py)
- Added apply_sentiment_config() call between post fetching and video generation
- Sentiment detection runs before TTS and background selection
- Controlled by settings.config['deepseek']['enabled'] flag
CONFIG CHANGES (config.toml + utils/.config.template.toml)
- Added [deepseek] section with api_key and enabled fields
- elevenlabs_voice_name changed from optional=false to optional=true
- Prevents prompt appearing when ElevenLabs is not the selected TTS provider
pull/2557/head
parent
7c679b8136
commit
af0940045c
@ -0,0 +1,227 @@
|
||||
import json
|
||||
import os
|
||||
from openai import OpenAI
|
||||
from utils import settings
|
||||
from utils.console import print_step, print_substep
|
||||
from utils.sentiment_map import (
|
||||
BACKGROUND_MAP,
|
||||
OPENAI_VOICE_MAP,
|
||||
ELEVENLABS_VOICE_MAP,
|
||||
VALID_SENTIMENTS,
|
||||
DEFAULT_SENTIMENT,
|
||||
)
|
||||
|
||||
|
||||
def _get_client() -> OpenAI:
|
||||
api_key = settings.config["deepseek"]["api_key"]
|
||||
return OpenAI(
|
||||
api_key=api_key,
|
||||
base_url="https://api.deepseek.com",
|
||||
)
|
||||
|
||||
|
||||
def _extract_text(reddit_object: dict) -> tuple:
|
||||
title = reddit_object.get("thread_title", "")
|
||||
post = reddit_object.get("thread_post", "")
|
||||
if isinstance(post, list):
|
||||
post = " ".join([p.get("text", "") for p in post if isinstance(p, dict)])
|
||||
return title, post
|
||||
|
||||
|
||||
def detect_sentiment(reddit_object: dict) -> str:
|
||||
"""
|
||||
Sends the post title + body to DeepSeek and returns a sentiment label.
|
||||
Falls back to DEFAULT_SENTIMENT on any error.
|
||||
"""
|
||||
try:
|
||||
api_key = settings.config["deepseek"]["api_key"]
|
||||
if not api_key:
|
||||
print_substep("No DeepSeek API key found. Using default sentiment.", style="yellow")
|
||||
return DEFAULT_SENTIMENT
|
||||
|
||||
title, post = _extract_text(reddit_object)
|
||||
text = f"Title: {title}\nPost: {post[:500]}"
|
||||
|
||||
client = _get_client()
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="deepseek-chat",
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are a sentiment classifier for Reddit stories. "
|
||||
"Classify the post into exactly one of these labels: "
|
||||
"sad, happy, angry, mysterious, funny, dramatic, wholesome, scary. "
|
||||
"Respond with only the label, nothing else. No punctuation, no explanation."
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": text,
|
||||
},
|
||||
],
|
||||
max_tokens=10,
|
||||
temperature=0,
|
||||
)
|
||||
|
||||
label = response.choices[0].message.content.strip().lower()
|
||||
|
||||
if label not in VALID_SENTIMENTS:
|
||||
print_substep(
|
||||
f"DeepSeek returned unexpected label '{label}'. Using default: {DEFAULT_SENTIMENT}",
|
||||
style="yellow",
|
||||
)
|
||||
return DEFAULT_SENTIMENT
|
||||
|
||||
return label
|
||||
|
||||
except Exception as e:
|
||||
print_substep(f"Sentiment detection failed: {e}. Using default: {DEFAULT_SENTIMENT}", style="yellow")
|
||||
return DEFAULT_SENTIMENT
|
||||
|
||||
|
||||
def generate_metadata(reddit_object: dict, sentiment: str) -> dict:
|
||||
"""
|
||||
Generates YouTube title, description, TikTok/Instagram/Facebook captions,
|
||||
and hashtags in a single DeepSeek call.
|
||||
Saves output as JSON next to the video in results/.
|
||||
Falls back to basic metadata on any error.
|
||||
"""
|
||||
try:
|
||||
api_key = settings.config["deepseek"]["api_key"]
|
||||
if not api_key:
|
||||
return _fallback_metadata(reddit_object, sentiment)
|
||||
|
||||
title, post = _extract_text(reddit_object)
|
||||
text = f"Title: {title}\nPost: {post[:800]}"
|
||||
channel_name = settings.config["settings"].get("channel_name", "Reddit Tales")
|
||||
|
||||
client = _get_client()
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="deepseek-chat",
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are a social media content creator specializing in Reddit story videos. "
|
||||
"Generate engaging titles, captions, and hashtags for a Reddit story video. "
|
||||
"Return ONLY a valid JSON object with these exact keys: "
|
||||
"youtube_title, youtube_description, tiktok_caption, instagram_caption, facebook_caption, hashtags. "
|
||||
"hashtags must be a list of strings. "
|
||||
"Keep youtube_title under 70 characters. "
|
||||
"Keep tiktok_caption under 150 characters including hashtags. "
|
||||
"Make content engaging and click-worthy. "
|
||||
f"The channel name is '{channel_name}'. "
|
||||
f"The story mood is: {sentiment}. "
|
||||
"No markdown, no explanation, just the JSON object."
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": text,
|
||||
},
|
||||
],
|
||||
max_tokens=600,
|
||||
temperature=0.7,
|
||||
)
|
||||
|
||||
raw = response.choices[0].message.content.strip()
|
||||
|
||||
# Strip markdown code blocks if present
|
||||
if raw.startswith("```"):
|
||||
raw = raw.split("```")[1]
|
||||
if raw.startswith("json"):
|
||||
raw = raw[4:]
|
||||
raw = raw.strip()
|
||||
|
||||
metadata = json.loads(raw)
|
||||
|
||||
# Validate all required keys exist
|
||||
required_keys = [
|
||||
"youtube_title", "youtube_description",
|
||||
"tiktok_caption", "instagram_caption",
|
||||
"facebook_caption", "hashtags"
|
||||
]
|
||||
for key in required_keys:
|
||||
if key not in metadata:
|
||||
metadata[key] = ""
|
||||
|
||||
metadata["sentiment"] = sentiment
|
||||
return metadata
|
||||
|
||||
except Exception as e:
|
||||
print_substep(f"Metadata generation failed: {e}. Using fallback.", style="yellow")
|
||||
return _fallback_metadata(reddit_object, sentiment)
|
||||
|
||||
|
||||
def _fallback_metadata(reddit_object: dict, sentiment: str) -> dict:
|
||||
"""Basic fallback metadata if DeepSeek fails."""
|
||||
title = reddit_object.get("thread_title", "Reddit Story")
|
||||
return {
|
||||
"sentiment": sentiment,
|
||||
"youtube_title": title[:70],
|
||||
"youtube_description": f"{title}\n\n#reddit #stories",
|
||||
"tiktok_caption": f"{title[:100]} #reddit #storytime",
|
||||
"instagram_caption": f"{title[:100]} #reddit #stories",
|
||||
"facebook_caption": title,
|
||||
"hashtags": ["#reddit", "#storytime", "#stories", "#redditstories"],
|
||||
}
|
||||
|
||||
|
||||
def save_metadata(metadata: dict, reddit_object: dict) -> None:
|
||||
"""Saves metadata JSON inside the per-video folder."""
|
||||
try:
|
||||
subreddit = reddit_object.get("thread_subreddit", settings.config["reddit"]["thread"]["subreddit"])
|
||||
thread_id = reddit_object.get("thread_id", "unknown")
|
||||
sentiment_bg = settings.config["settings"]["background"].get("background_video", "unknown")
|
||||
video_folder = f"results/{subreddit}/{thread_id}_{sentiment_bg}"
|
||||
os.makedirs(video_folder, exist_ok=True)
|
||||
filepath = f"{video_folder}/metadata.json"
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(metadata, f, indent=2, ensure_ascii=False)
|
||||
print_substep(f"Metadata saved → {filepath}", style="bold green")
|
||||
except Exception as e:
|
||||
print_substep(f"Failed to save metadata: {e}", style="yellow")
|
||||
|
||||
|
||||
def apply_sentiment_config(reddit_object: dict) -> None:
|
||||
"""
|
||||
Detects sentiment, overrides in-memory config for background/voice,
|
||||
generates metadata, and saves it to disk.
|
||||
Does NOT write to config.toml — changes are per-run only.
|
||||
"""
|
||||
print_step("Detecting sentiment and generating metadata... 🎭")
|
||||
|
||||
sentiment = detect_sentiment(reddit_object)
|
||||
|
||||
# ── Background ───────────────────────────────────────────
|
||||
bg_video, bg_audio = BACKGROUND_MAP[sentiment]
|
||||
settings.config["settings"]["background"]["background_video"] = bg_video
|
||||
settings.config["settings"]["background"]["background_audio"] = bg_audio
|
||||
|
||||
# ── Voice ────────────────────────────────────────────────
|
||||
voice_choice = settings.config["settings"]["tts"]["voice_choice"].lower()
|
||||
|
||||
if voice_choice == "elevenlabs":
|
||||
voice = ELEVENLABS_VOICE_MAP[sentiment]
|
||||
settings.config["settings"]["tts"]["elevenlabs_voice_name"] = voice
|
||||
elif voice_choice == "openai":
|
||||
voice = OPENAI_VOICE_MAP[sentiment]
|
||||
settings.config["settings"]["tts"]["openai_voice_name"] = voice
|
||||
else:
|
||||
voice = f"(voice override not supported for {voice_choice})"
|
||||
|
||||
# ── Metadata ─────────────────────────────────────────────
|
||||
print_substep("Generating titles, captions and hashtags... ✍️", style="bold blue")
|
||||
metadata = generate_metadata(reddit_object, sentiment)
|
||||
save_metadata(metadata, reddit_object)
|
||||
|
||||
# ── Log ──────────────────────────────────────────────────
|
||||
print_substep(f"Sentiment detected : {sentiment} 🎯", style="bold green")
|
||||
print_substep(f"Background video : {bg_video}", style="bold blue")
|
||||
print_substep(f"Background audio : {bg_audio if bg_audio else 'none'}", style="bold blue")
|
||||
print_substep(f"Voice : {voice}", style="bold blue")
|
||||
print_substep(f"YouTube title : {metadata['youtube_title']}", style="bold blue")
|
||||
print_substep(f"TikTok caption : {metadata['tiktok_caption']}", style="bold blue")
|
||||
@ -0,0 +1,41 @@
|
||||
# Maps sentiment → (background_video, background_audio)
|
||||
BACKGROUND_MAP = {
|
||||
"sad": ("minecraft", "lofi"), # slow, melancholic
|
||||
"happy": ("fall-guys", "chill-summer"),# upbeat, fun
|
||||
"angry": ("gta", "lofi"), # lofi keeps intensity without distraction
|
||||
"mysterious": ("csgo-surf", "lofi-2"), # lofi-2 is more atmospheric
|
||||
"funny": ("cluster-truck", "chill-summer"),# light and playful
|
||||
"dramatic": ("rocket-league", "lofi"), # lofi under dramatic = tension
|
||||
"wholesome": ("steep", "chill-summer"),# warm and positive
|
||||
"scary": ("minecraft-2", "lofi-2"), # lofi-2 is darker/moodier
|
||||
}
|
||||
|
||||
# Maps sentiment → OpenAI voice name
|
||||
OPENAI_VOICE_MAP = {
|
||||
"sad": "nova",
|
||||
"happy": "shimmer",
|
||||
"angry": "onyx",
|
||||
"mysterious": "echo",
|
||||
"funny": "fable",
|
||||
"dramatic": "alloy",
|
||||
"wholesome": "nova",
|
||||
"scary": "onyx",
|
||||
}
|
||||
|
||||
# Maps sentiment → ElevenLabs voice name
|
||||
ELEVENLABS_VOICE_MAP = {
|
||||
"sad": "Brian - Deep, Resonant and Comforting",
|
||||
"happy": "Jessica - Playful, Bright, Warm",
|
||||
"angry": "Adam - Dominant, Firm",
|
||||
"mysterious": "Callum - Husky Trickster",
|
||||
"funny": "Laura - Enthusiast, Quirky Attitude",
|
||||
"dramatic": "George - Warm, Captivating Storyteller",
|
||||
"wholesome": "Matilda - Knowledgable, Professional",
|
||||
"scary": "Harry - Fierce Warrior",
|
||||
}
|
||||
|
||||
# All valid sentiment labels
|
||||
VALID_SENTIMENTS = list(BACKGROUND_MAP.keys())
|
||||
|
||||
# Fallback if detection fails — maps to rocket-league + lofi + alloy
|
||||
DEFAULT_SENTIMENT = "dramatic"
|
||||
Loading…
Reference in new issue