diff --git a/main.py b/main.py index 742fedf..f0b531a 100755 --- a/main.py +++ b/main.py @@ -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) diff --git a/reddit/subreddit.py b/reddit/subreddit.py index daeb439..6bfb33b 100644 --- a/reddit/subreddit.py +++ b/reddit/subreddit.py @@ -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"]: diff --git a/utils/.config.template.toml b/utils/.config.template.toml index 52eeb06..b1693cf 100644 --- a/utils/.config.template.toml +++ b/utils/.config.template.toml @@ -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 } \ No newline at end of file diff --git a/utils/sentiment.py b/utils/sentiment.py new file mode 100644 index 0000000..362688d --- /dev/null +++ b/utils/sentiment.py @@ -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") \ No newline at end of file diff --git a/utils/sentiment_map.py b/utils/sentiment_map.py new file mode 100644 index 0000000..8259c80 --- /dev/null +++ b/utils/sentiment_map.py @@ -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" \ No newline at end of file diff --git a/video_creation/final_video.py b/video_creation/final_video.py index b231cda..5764fde 100644 --- a/video_creation/final_video.py +++ b/video_creation/final_video.py @@ -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 🗑")