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
Abdessamad Haddouche 4 weeks ago
parent 7c679b8136
commit af0940045c

@ -49,6 +49,15 @@ reddit_object: Dict[str, str | list]
def main(POST_ID=None) -> None:
global reddit_id, reddit_object
reddit_object = get_subreddit_threads(POST_ID)
# ── SENTIMENT DETECTION ──────────────────────────────────
if settings.config["deepseek"].get("enabled", True):
from utils.sentiment import apply_sentiment_config
apply_sentiment_config(reddit_object)
else:
print_substep("Sentiment detection disabled. Using config defaults.", style="yellow")
# ─────────────────────────────────────────────────────────
reddit_id = extract_id(reddit_object)
print_substep(f"Thread ID is {reddit_id}", style="bold blue")
length, number_of_comments = save_text_to_mp3(reddit_object)

@ -120,6 +120,7 @@ def get_subreddit_threads(POST_ID: str):
content["thread_url"] = threadurl
content["thread_title"] = submission.title
content["thread_id"] = submission.id
content["thread_subreddit"] = submission.subreddit.display_name
content["is_nsfw"] = submission.over_18
content["comments"] = []
if settings.config["settings"]["storymode"]:

@ -47,7 +47,7 @@ background_thumbnail_font_color = { optional = true, default = "255,255,255", ex
[settings.tts]
voice_choice = { optional = false, default = "tiktok", options = ["elevenlabs", "streamlabspolly", "tiktok", "googletranslate", "awspolly", "pyttsx", "OpenAI"], example = "tiktok", explanation = "The voice platform used for TTS generation. " }
random_voice = { optional = false, type = "bool", default = true, example = true, options = [true, false,], explanation = "Randomizes the voice used for each comment" }
elevenlabs_voice_name = { optional = false, default = "Sarah - Mature, Reassuring, Confident", example = "Bella", explanation = "The voice used for elevenlabs", options = [] }
elevenlabs_voice_name = { optional = true, default = "Sarah - Mature, Reassuring, Confident", example = "Bella", explanation = "The voice used for elevenlabs", options = [] }
elevenlabs_api_key = { optional = true, example = "21f13f91f54d741e2ae27d2ab1b99d59", explanation = "Elevenlabs API key" }
aws_polly_voice = { optional = false, default = "Matthew", example = "Matthew", explanation = "The voice used for AWS Polly" }
streamlabs_polly_voice = { optional = false, default = "Matthew", example = "Matthew", explanation = "The voice used for Streamlabs Polly" }
@ -61,3 +61,7 @@ openai_api_url = { optional = true, default = "https://api.openai.com/v1/", exam
openai_api_key = { optional = true, example = "sk-abc123def456...", explanation = "Your OpenAI API key for TTS generation" }
openai_voice_name = { optional = false, default = "alloy", example = "alloy", explanation = "The voice used for OpenAI TTS generation", options = ["alloy", "ash", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "af_heart"] }
openai_model = { optional = false, default = "tts-1", example = "tts-1", explanation = "The model variant used for OpenAI TTS generation", options = ["tts-1", "tts-1-hd", "gpt-4o-mini-tts"] }
[deepseek]
api_key = { optional = true, default = "", explanation = "DeepSeek API key for sentiment detection. Get yours at platform.deepseek.com", example = "sk-xxxxxxxx" }
enabled = { optional = true, type = "bool", default = true, options = [true, false], explanation = "Enable or disable sentiment-aware video generation", example = true }

@ -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"

@ -358,26 +358,23 @@ def make_final_video(
idx = extract_id(reddit_obj)
title_thumb = reddit_obj["thread_title"]
filename = f"{name_normalize(title)[:251]}"
subreddit = settings.config["reddit"]["thread"]["subreddit"]
filename = f"{name_normalize(title)[:100]}"
subreddit = reddit_obj.get("thread_subreddit", settings.config["reddit"]["thread"]["subreddit"])
sentiment = settings.config["settings"]["background"].get("background_video", "unknown")
if not exists(f"./results/{subreddit}"):
print_substep("The 'results' folder could not be found so it was automatically created.")
os.makedirs(f"./results/{subreddit}")
# Per-video folder: results/{subreddit}/{thread_id}_{sentiment}/
video_folder = f"./results/{subreddit}/{idx}_{sentiment}"
os.makedirs(video_folder, exist_ok=True)
if not exists(f"./results/{subreddit}/OnlyTTS") and allowOnlyTTSFolder:
print_substep("The 'OnlyTTS' folder could not be found so it was automatically created.")
os.makedirs(f"./results/{subreddit}/OnlyTTS")
if allowOnlyTTSFolder:
os.makedirs(f"{video_folder}/OnlyTTS", exist_ok=True)
# create a thumbnail for the video
settingsbackground = settings.config["settings"]["background"]
if settingsbackground["background_thumbnail"]:
if not exists(f"./results/{subreddit}/thumbnails"):
print_substep(
"The 'results/thumbnails' folder could not be found so it was automatically created."
)
os.makedirs(f"./results/{subreddit}/thumbnails")
if not exists(f"{video_folder}"):
os.makedirs(f"{video_folder}", exist_ok=True)
# get the first file with the .png extension from assets/backgrounds and use it as a background for the thumbnail
first_image = next(
(file for file in os.listdir("assets/backgrounds") if file.endswith(".png")),
@ -401,7 +398,7 @@ def make_final_video(
height,
title_thumb,
)
thumbnailSave.save(f"./assets/temp/{reddit_id}/thumbnail.png")
thumbnailSave.save(f"{video_folder}/thumbnail.png")
print_substep(f"Thumbnail - Building Thumbnail in assets/temp/{reddit_id}/thumbnail.png")
text = f"Background by {background_config['video'][2]}"
@ -425,12 +422,9 @@ def make_final_video(
old_percentage = pbar.n
pbar.update(status - old_percentage)
defaultPath = f"results/{subreddit}"
defaultPath = video_folder
with ProgressFfmpeg(length, on_update_example) as progress:
path = defaultPath + f"/{filename}"
path = (
path[:251] + ".mp4"
) # Prevent a error by limiting the path length, do not change this.
path = f"{video_folder}/video.mp4"
try:
ffmpeg.output(
background_clip,
@ -455,10 +449,8 @@ def make_final_video(
old_percentage = pbar.n
pbar.update(100 - old_percentage)
if allowOnlyTTSFolder:
path = defaultPath + f"/OnlyTTS/{filename}"
path = (
path[:251] + ".mp4"
) # Prevent a error by limiting the path length, do not change this.
path = f"{video_folder}/OnlyTTS/video.mp4"
# Prevent a error by limiting the path length, do not change this.
print_step("Rendering the Only TTS Video 🎥")
with ProgressFfmpeg(length, on_update_example) as progress:
try:
@ -486,7 +478,7 @@ def make_final_video(
old_percentage = pbar.n
pbar.update(100 - old_percentage)
pbar.close()
save_data(subreddit, filename + ".mp4", title, idx, background_config["video"][2])
save_data(subreddit, f"{idx}_{sentiment}/video.mp4", title, idx, background_config["video"][2])
print_step("Removing temporary files 🗑")
cleanups = cleanup(reddit_id)
print_substep(f"Removed {cleanups} temporary files 🗑")

Loading…
Cancel
Save