diff --git a/README.md b/README.md index af89cf3..f444d4d 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,17 @@ AI-powered tool for generating YouTube Shorts / TikTok videos with script genera ## Features -- **AI Script Generation** — viral-optimized scripts in Brazilian Portuguese via Pollinations AI -- **AI Image Generation** — background images via Pollinations AI (images-only mode) +- **AI Script Generation** — curiosity-driven scripts in Brazilian Portuguese via Pollinations AI with fact verification and hallucination guards +- **Web-Grounded Scripts** — automatic web search generates independent queries, grounds the script in real sources, then cross-checks every claim +- **Title Validation** — auto-validates hashtag count (3+), length (≤ 100 chars), and lowercases tags +- **AI Image Generation** — background images via Pollinations AI or **real web images** via DuckDuckGo search (default), with NSFW domain/keyword filter - **Text-to-Speech** — natural audio via Edge TTS - **Subtitle System** — VTT generation + word-level highlight rendering -- **Video Composition** — blurred YouTube background or AI images with smooth overlay animation +- **Video Composition** — blurred YouTube background or AI/web images with smooth overlay animation - **YouTube Integration** — download any video as background footage -- **Two pipelines**: normal (YouTube bg + optional AI image overlays) and images-only (AI images + overlay animation, no YouTube bg) +- **Two pipelines**: normal (YouTube bg + optional image overlays) and images-only (AI/web images + overlay animation, no YouTube bg) - **Typer CLI** — nested subcommands, auto-generated `--help`, shell completion +- **Batch Processing** — semicolon-separated subjects for multi-video runs ## Installation @@ -37,7 +40,7 @@ uv pip install -e ".[dev]" # See available commands autoshorts --help -# Generate an explainer video from a topic +# Generate an explainer video from a topic (web search + web images by default) autoshorts new explainer "artificial intelligence" # AI images only (no YouTube background) @@ -46,14 +49,20 @@ autoshorts new explainer "space exploration" --images-only # Use a YouTube video as background footage autoshorts new explainer --youtube-url "https://youtube.com/watch?v=VIDEO_ID" -# Skip AI image overlays (blurred bg only) +# Skip image overlays (blurred bg only) autoshorts new explainer "climate change" --no-images -# Batch mode -autoshorts new explainer --batch "robotics" "quantum computing" "neural networks" +# Batch mode with semicolon-separated subjects +autoshorts new explainer --batch "robotics; quantum computing; neural networks" -# Web search for richer script content -autoshorts new explainer "oceanography" --web-search +# Image source: 'ai' uses Pollinations (default is 'web' via DDGS) +autoshorts new explainer "oceanography" --images ai + +# Script tone: 'corporate' (neutral, factual) or 'opinionated' (curiosity-driven, narrative) +autoshorts new explainer "bitcoin" --tone corporate + +# Disable web search (uses model knowledge only) +autoshorts new explainer "neural networks" --no-web-search # Auto-shutdown after completion autoshorts new explainer "future technology" --goodnight @@ -102,23 +111,29 @@ AutoShorts/ │ │ └── explainer.py # ExplainerGenerator (both pipelines) │ └── modules/ # Core modules │ ├── config.py +│ ├── image_searcher.py # Web/AI image search + NSFW filter │ ├── logging_system.py -│ ├── script_generator.py +│ ├── script_generator.py # Script gen, fact verification, title validation │ ├── subtitle_system.py │ ├── tts_system.py │ ├── utils.py -│ ├── video_background.py -│ └── video_compositor.py +│ ├── video_background.py # YouTube search & download +│ ├── video_compositor.py +│ └── web_search.py # DuckDuckGo web search ├── tests/ -│ ├── test_cli.py # CLI layer (28 tests) +│ ├── test_cli.py # CLI layer (32 tests) +│ ├── test_config.py +│ ├── test_edge_cases.py │ ├── test_fluximages.py # Explainer generator tests -│ ├── test_video_background.py # Video background (24 tests) -│ ├── test_video_compositor.py # Video compositor (11 tests) +│ ├── test_init.py +│ ├── test_integration.py │ ├── test_script_generator.py │ ├── test_subtitle_system.py -│ ├── test_utils.py -│ ├── test_edge_cases.py │ ├── test_tts_system.py +│ ├── test_utils.py +│ ├── test_video_background.py # Video background (24 tests) +│ ├── test_video_compositor.py # Video compositor (11 tests) +│ ├── test_web_search.py │ └── conftest.py ├── fonts/ # Bundled Bebas Neue font ├── .env.example @@ -134,13 +149,15 @@ Core: - `edge-tts` — text-to-speech - `requests` — HTTP client - `yt-dlp` — YouTube downloading +- `duckduckgo-search` — web search and image search (DDGS) +- `Pillow` — image processing and resizing - `webvtt-py` — subtitle processing - `python-dotenv` — environment loading - `typer` — CLI framework Dev: - `pytest` + `pytest-asyncio` + `pytest-cov` -- `black` + `ruff` + `mypy` +- `ruff` + `mypy` ## Development diff --git a/src/autoshorts/cli/commands/explainer.py b/src/autoshorts/cli/commands/explainer.py index b0c7bba..046871e 100644 --- a/src/autoshorts/cli/commands/explainer.py +++ b/src/autoshorts/cli/commands/explainer.py @@ -1,4 +1,5 @@ import asyncio +import re import time from pathlib import Path @@ -21,7 +22,7 @@ def explainer_command( goodnight: bool = typer.Option( False, "--goodnight", help="Shutdown after processing" ), - batch: list[str] = typer.Option(None, "--batch", help="Batch: multiple subjects"), + batch: str = typer.Option(None, "--batch", help="Batch: semicolon-separated subjects (e.g. 'topic1; topic2')"), no_web_search: bool = typer.Option( False, "--no-web-search", help="Disable web search (use model knowledge only)" ), @@ -46,7 +47,7 @@ def explainer_command( subjects: list[str | None] = [] if batch: - subjects = [s for s in batch] + subjects = [s.strip() for s in batch.split(";") if s.strip()] elif subject: subjects = [subject] elif youtube_url: @@ -56,6 +57,7 @@ def explainer_command( output_path = Path(output) success_count = 0 total_count = len(subjects) + def _sanitize(s): return re.sub(r'[\\/*?:"<>|]', "", s).replace(" ", "_")[:20] for i, subj in enumerate(subjects, 1): log(f"Processing {i}/{total_count}: {subj or 'youtube-url'}") @@ -64,14 +66,14 @@ def explainer_command( out_dir = output_path.parent if is_batch: prefix = "explainer_" if images_only else "as_" - name = f"{prefix}{subj.replace(' ', '_')[:20] if subj else 'video'}_{int(time.time())}.mp4" + name = f"{prefix}{_sanitize(subj) if subj else 'video'}_{int(time.time())}.mp4" else: name = output_path.name else: out_dir = output_path if is_batch: prefix = "explainer_" if images_only else "as_" - name = f"{prefix}{subj.replace(' ', '_')[:20] if subj else 'video'}_{int(time.time())}.mp4" + name = f"{prefix}{_sanitize(subj) if subj else 'video'}_{int(time.time())}.mp4" else: prefix = "explainer_" if images_only else "autoshorts_" name = f"{prefix}{int(time.time())}.mp4" diff --git a/src/autoshorts/generators/explainer.py b/src/autoshorts/generators/explainer.py index 0827069..5b2cb1f 100644 --- a/src/autoshorts/generators/explainer.py +++ b/src/autoshorts/generators/explainer.py @@ -104,6 +104,12 @@ async def _run_normal_pipeline(self) -> bool: script = self.script_generator.generate_script(subject) else: script = self.script_generator.generate_script_from_metadata(title, "") + if not script or len(script) < 3: + log( + f"Script generation failed: got {len(script)} paragraphs, need >= 3", + "ERROR", + ) + return False log(f"Generated script with {len(script)} paragraphs") log("Step 3: Generating TTS audio...") @@ -226,6 +232,12 @@ async def _run_images_only_pipeline(self) -> bool: paragraphs, _ = self.script_generator.generate_script_with_prompts( self.subject ) + if not paragraphs or len(paragraphs) < 3: + log( + f"Script generation failed: got {len(paragraphs)} paragraphs, need >= 3", + "ERROR", + ) + return False log("Step 2: Generating TTS audio...") audio_path = await self.tts_system.generate_audio_only( @@ -315,7 +327,8 @@ def apply_opacity(get_frame, t): return np.minimum(255, frame * opacity).astype("uint8") clip = clip.with_effects([vfx.Resize(scale_anim)]) - return clip.transform(apply_opacity) + clip = clip.transform(apply_opacity) + return clip.with_position(("center", "center")) def _create_flux_video( self, img_paths: list, audio_path: str, paragraphs: list, output_path: str diff --git a/src/autoshorts/modules/image_searcher.py b/src/autoshorts/modules/image_searcher.py index 2c9ada8..6e1dd7c 100644 --- a/src/autoshorts/modules/image_searcher.py +++ b/src/autoshorts/modules/image_searcher.py @@ -1,5 +1,6 @@ import hashlib import random +import re from pathlib import Path from urllib.parse import quote @@ -21,6 +22,18 @@ ) from .logging_system import log +BLOCKED_DOMAINS: set[str] = { + "crossdresser", "sissy", "femboy", "hentai", "rule34", + "xvideos", "xnxx", "xhamster", "pornhub", "onlyfans", + "redtube", "youporn", "erotic", "nsfw", +} + +BLOCKED_KEYWORDS: set[str] = { + "crossdresser", "sissy", "femboy", "hentai", "rule34", + "nsfw", "xxx", "18+", "erotic", + "nude", "naked", "seductive", +} + class ImageSearcher: def __init__( @@ -40,6 +53,21 @@ def __init__( self.max_per_query = max_per_query IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True) + @staticmethod + def _is_nsfw(result: dict) -> bool: + text = ( + f"{result.get('image') or ''} " + f"{result.get('url') or result.get('source') or ''} " + f"{result.get('title') or ''}" + ) + for d in BLOCKED_DOMAINS: + if re.search(rf"(?:^|[\W_]){re.escape(d)}(?:$|[\W_])", text, re.IGNORECASE): + return True + for kw in BLOCKED_KEYWORDS: + if re.search(rf"(?:^|[\W_]){re.escape(kw)}(?:$|[\W_])", text, re.IGNORECASE): + return True + return False + def search_images(self, query: str) -> list[dict]: try: from ddgs import DDGS @@ -115,6 +143,7 @@ def get_images(self, prompts: list[str]) -> list[str]: continue results = self.search_images(prompt) + results = [r for r in results if not self._is_nsfw(r)] downloaded = False for r in results: url = r.get("image", "") diff --git a/src/autoshorts/modules/script_generator.py b/src/autoshorts/modules/script_generator.py index a23c097..7c724a7 100644 --- a/src/autoshorts/modules/script_generator.py +++ b/src/autoshorts/modules/script_generator.py @@ -1,4 +1,6 @@ import json +import re +import time import requests # type: ignore[import-untyped] @@ -11,13 +13,7 @@ from .logging_system import log from .web_search import WebSearcher -FALLBACK_PARAGRAPHS = [ - "Esta hist\u00f3ria come\u00e7a com um fato que marcou \u00e9poca.", - "Os detalhes revelam como tudo aconteceu ao longo do tempo.", - "Cada etapa trouxe consequ\u00eancias que mudaram o rumo dos acontecimentos.", - "O desfecho mostra por que este tema continua relevante at\u00e9 hoje.", - "No final, fica uma li\u00e7\u00e3o que vale a pena conhecer.", -] +FALLBACK_PARAGRAPHS: list[str] = [] class ScriptGenerator: @@ -36,96 +32,166 @@ def __init__(self, web_search: bool = True): self.searcher = WebSearcher() if web_search else None self.generated_title: str | None = None + def _tone_instructions(self) -> str: + return ( + "TONE: Curiosity-driven, narrative, engaging. " + "Write like a storyteller uncovering a fascinating truth \u2014 " + "never like Wikipedia or a corporate press release.\n" + "FIRST SENTENCE: Drop the viewer right into the action \u2014 " + "the goal, the controversy, the fact itself. " + "NO: 'Prepare-se', 'Voc\u00ea sabia', rhetorical questions. " + "Never waste the first 2 seconds on setup.\n" + "STRUCTURE: Hook (the fact itself) \u2192 Context \u2192 Revelation \u2192 Strong conclusion\n" + "FORBIDDEN: Legal names (Ltda, S.A.), addresses, city/state abbreviations. " + "NO corporate language.\n" + "FORBIDDEN: Hyperboles, exaggerated claims, 'designed by a god', " + "'you won't believe', 'shocking truth' \u2014 these sound fake.\n" + ) + return ( + "TONE: Curiosity-driven, narrative, engaging. " + "Write like a storyteller uncovering a fascinating truth \u2014 " + "never like Wikipedia or a corporate press release.\n" + "FIRST SENTENCE: Drop the viewer right into the action \u2014 " + "the goal, the controversy, the fact itself. " + "NO: 'Prepare-se', 'Voc\u00ea sabia', 'Uma pergunta', rhetorical questions. " + "YES: 'O Corinthians tomou uma virada hist\u00f3rica...'\n" + "STRUCTURE: Hook (the fact itself) \u2192 Context \u2192 Revelation \u2192 Strong conclusion\n" + "FORBIDDEN: Legal names (Ltda, S.A.), addresses, city/state abbreviations. " + "NO corporate language.\n" + "FORBIDDEN: Hyperboles, exaggerated claims, 'designed by a god', " + "'you won't believe', 'shocking truth' \u2014 these sound fake.\n" + ) + # ── Public API ────────────────────────────────────────────────────── def generate_script(self, subject: str) -> list: - """Generate script from subject. Two-step when web_search is enabled.""" + """Generate script from subject. Searches web first, then generates grounded script.""" log("Generating script...") + tone_block = self._tone_instructions() + if not self.web_search or not self.searcher or not subject: return self._make_text_api_call( - _SYSTEM_PROMPT_SINGLE, + tone_block + _SYSTEM_PROMPT_SINGLE, _user_prompt_single(subject, ""), ) - # Step 1: generate search queries + title - log("Step 1: generating search queries...") - draft_data = self._generate_draft(subject, num_paragraphs=5) - self.generated_title = draft_data.get("title") or None - queries = draft_data.get("queries") or [] + # Step 1: generate independent search queries (NOT from draft — avoids circular hallucination) + log("Step 1: generating independent search queries...") + queries = self._generate_search_queries(subject) - # Search using LLM-generated queries - results = None - if queries: - log(f"Searching {len(queries)} LLM-generated queries...") - results = self.searcher.search_with_queries(queries) - else: - log("No queries generated, falling back to subject-based search") - results = self.searcher.search(subject) + # Step 2: search the web with neutral queries + results = self.searcher.search_with_queries(queries) - # Step 2: generate final script grounded in search results + # Step 3: generate script grounded in search results if results: context = self.searcher.format_context(results[:15]) log("Step 2: generating script with search context...") script = self._make_text_api_call( - _SYSTEM_PROMPT_SINGLE, + tone_block + _SYSTEM_PROMPT_SINGLE, _user_prompt_single(subject, context), ) + cleaned = self._validate_paragraphs(script) + script = self._ensure_paragraph_count(cleaned, 5) if script: log("Script generated with web sources", "SUCCESS") - return self._ensure_paragraph_count(script, 5) - log("Script generation with context failed", "WARNING") + # Step 4: post-generation fact verification + script = self._verify_factual_claims(script, subject) + self.generated_title = self._generate_title_from_script(script, subject) + log("Script verified", "SUCCESS") + return script + + # Try to repair instead of full regeneration + repair = self._repair_paragraphs(cleaned, subject, 5) + if repair and len(repair) >= 3: + log("Script repaired after validation", "SUCCESS") + script = repair + script = self._verify_factual_claims(script, subject) + self.generated_title = self._generate_title_from_script(script, subject) + log("Script verified", "SUCCESS") + return script + + log("Script generation with context failed or produced filler", "WARNING") else: - log("No search results, returning draft as-is", "WARNING") + log("No search results for grounding", "WARNING") + # Fallback: generate draft for title + fallback content + log("Generating draft as fallback...") + draft_data = self._generate_draft(subject, num_paragraphs=5) + self.generated_title = draft_data.get("title") or None draft = draft_data.get("draft") or [] - return self._ensure_paragraph_count(draft, 5) + draft = self._validate_paragraphs(draft) + draft = self._ensure_paragraph_count(draft, 5) + if draft: + return draft + + log("All script generation paths failed", "ERROR") + return [] def generate_script_from_metadata(self, title: str, description: str) -> list: """Generate script from YouTube video title and description.""" log("Generating script from video metadata...") desc = description[:1000] if description else "" combined_content = f"Title: {title}\n\nDescription: {desc}" + tone_block = self._tone_instructions() return self._make_text_api_call( - _SYSTEM_PROMPT_METADATA, + tone_block + _SYSTEM_PROMPT_METADATA, _user_prompt_metadata(combined_content), ) def generate_script_with_prompts(self, subject: str) -> tuple: - """Generate script paragraphs. Two-step when web_search is enabled.""" + """Generate script paragraphs. Searches web first, then generates grounded script.""" log(f"Generating script paragraphs for: {subject}...") if not self.web_search or not self.searcher or not subject: return self._generate_script_with_prompts_single(subject) - # Step 1: generate search queries + title - log("Step 1: generating search queries...") - draft_data = self._generate_draft(subject, num_paragraphs=7) - self.generated_title = draft_data.get("title") or None - queries = draft_data.get("queries") or [] + # Step 1: generate independent search queries (NOT from draft) + log("Step 1: generating independent search queries...") + queries = self._generate_search_queries(subject) - # Search using LLM-generated queries - results = None - if queries: - log(f"Searching {len(queries)} LLM-generated queries...") - results = self.searcher.search_with_queries(queries) - else: - results = self.searcher.search(subject) + # Step 2: search the web with neutral queries + results = self.searcher.search_with_queries(queries) - # Step 2: generate final script grounded in search results + # Step 3: generate script grounded in search results if results: context = self.searcher.format_context(results[:15]) log("Step 2: generating script with search context...") paragraphs = self._generate_script_with_context(subject, context) if paragraphs: - log("Script generated with web sources", "SUCCESS") - return self._ensure_paragraph_count(paragraphs, 7), [] - log("Script generation with context failed", "WARNING") + cleaned = self._validate_paragraphs(paragraphs) + paragraphs = self._ensure_paragraph_count(cleaned, 7) + if paragraphs: + log("Script generated with web sources", "SUCCESS") + paragraphs = self._verify_factual_claims(paragraphs, subject) + self.generated_title = self._generate_title_from_script(paragraphs, subject) + log("Script verified", "SUCCESS") + return paragraphs, [] + + # Try to repair instead of full regeneration + repair = self._repair_paragraphs(cleaned, subject, 7) + if repair and len(repair) >= 3: + log("Script repaired after validation", "SUCCESS") + paragraphs = self._verify_factual_claims(repair, subject) + self.generated_title = self._generate_title_from_script(paragraphs, subject) + return paragraphs, [] + + log("Script generation with context failed or produced filler", "WARNING") else: - log("No search results, returning draft as-is", "WARNING") + log("No search results for grounding", "WARNING") + # Fallback to draft + log("Generating draft as fallback...") + draft_data = self._generate_draft(subject, num_paragraphs=7) + self.generated_title = draft_data.get("title") or None draft = draft_data.get("draft") or [] - return self._ensure_paragraph_count(draft, 7), [] + draft = self._validate_paragraphs(draft) + draft = self._ensure_paragraph_count(draft, 7) + if draft: + return draft, [] + + log("All script generation paths failed", "ERROR") + return [], [] def generate_image_prompts_from_script( self, paragraphs: list, num_images: int @@ -139,9 +205,12 @@ def generate_image_prompts_from_script( system_prompt = f""" Output ONLY a JSON object with one key: 'images': Array of {num_images} objects, each with: - - 'web_query': short (3-6 word) search query for finding REAL photos on the web. - Use simple keywords like "subject crowd", "subject stadium", "subject close up". - NO descriptive adjectives, just concrete nouns and the subject. + - 'web_query': short (3-8 word) search query for finding REAL photos on the web. + CRITICAL: Include context qualifiers like year, league/country, team name, event name. + NEVER use a generic descriptor alone (e.g. "jogador comemorando") without the team/league context. + Example: "Corinthians Neo Quimica Arena torcida 2024" instead of "stadium crowd". + Use concrete nouns and the specific subject from the story. + NO descriptive adjectives, NO filler words. - 'ai_prompt': detailed English prompt for an AI image generator. Cinematic, dramatic lighting, ultra detailed, 4k photography style. Describe a specific scene matching the story. @@ -154,14 +223,15 @@ def generate_image_prompts_from_script( def _generate_draft(self, subject: str, num_paragraphs: int = 5) -> dict: """Pass 1: generate draft script + verification queries + title.""" + tone_block = self._tone_instructions() system_prompt = ( f"You are a YouTube Shorts scriptwriter. Output ONLY valid JSON with these exact keys:\n" - f'1. "draft": Array of {num_paragraphs} strings (PT-BR), each 2-3 sentences ' - f'\u2014 a first-draft script about "{subject}".\n' - f" - First paragraph MUST start with a specific concrete fact (date, name, number, place).\n" - f" - Include specific names, dates, statistics, locations.\n" - f" - Tell an origin story: how it started, why it matters.\n" - f" - This is a DRAFT \u2014 it may contain errors. Do NOT fact-check yourself.\n" + f'1. "draft": Array of {num_paragraphs} strings (PT-BR), each 1-2 short sentences ' + f"\u2014 a first-draft script about \"{subject}\".\n" + f" {tone_block}" + f" - Every paragraph MUST contain a verifiable fact \u2014 no generalities, no filler.\n" + f" - CRITICAL: Do the math yourself. If you mention a date range, calculate the years correctly.\n" + f" - End with a strong conclusion, NOT 'fica uma li\u00e7\u00e3o' or similar generic phrases.\n" f'2. "queries": Array of 7-9 Portuguese web search queries to VERIFY ' f"the factual claims in your draft.\n" f" - At least 3 queries must be BROADER independent searches about the subject " @@ -191,11 +261,281 @@ def _generate_draft(self, subject: str, num_paragraphs: int = 5) -> dict: log(f"Draft generation failed: {e}", "ERROR") return {"draft": [], "queries": [subject], "title": ""} + # ── Independent query generation (breaks circular hallucination) ───── + + def _generate_search_queries(self, subject: str) -> list[str]: + """Generate neutral, independent search queries — NOT derived from draft content.""" + system_prompt = ( + "You are a research assistant. Output ONLY valid JSON with one key:\n" + "'queries': Array of 6-8 specific web search queries in Portuguese.\n" + "Your goal: find ACCURATE factual data about a topic.\n" + "Each query must target a different angle: origins, dates, key events, statistics, people.\n" + "Be specific: include names, years, locations.\n" + "These queries will be used to fact-check, so prioritize queries that return concrete data.\n" + "NEVER include the topic name alone as a query \u2014 always add qualifiers like year, event, or location." + ) + user_prompt = ( + f"Generate search queries to find accurate factual information about: {subject}" + ) + try: + data = self._make_json_api_call(system_prompt, user_prompt) + queries = data.get("queries") or [] + log(f"Generated {len(queries)} independent search queries", "SUCCESS") + return queries + except Exception as e: + log(f"Search query generation failed: {e}", "WARNING") + return [subject] + + # ── Post-generation fact verification ──────────────────────────────── + + def _verify_factual_claims(self, paragraphs: list, subject: str) -> list: + """Cross-check dates, scores, tabus, and numbers in script against web search results.""" + script_text = " ".join(paragraphs) + verification_queries: list[str] = [] + + # 1. Extract years + years = set(re.findall(r"\b(1[4-9]\d{2}|20[0-2]\d)\b", script_text)) + for y in sorted(years): + verification_queries.append(f"{subject} {y}") + + # 2. Extract score/result patterns: "3 a 2", "3x2", "por 3 a 2", "3-2" + score_matches = re.findall( + r"(\d+)\s*(?:[a\u00e0x-]\s*|a\s+|venceu por\s+|por\s+)(\d+)", + script_text, re.IGNORECASE + ) + for s1, s2 in score_matches: + for sep in (" a ", "x"): + q = f"{subject} {s1}{sep}{s2}" + if q not in verification_queries: + verification_queries.append(q) + + # 3. Detect tabu/streak claims + if re.search( + r"(?:n\u00e3o\s+\w+\s+(?:vence|ganha|perde|supera)|tabu|" + r"sem\s+\w+\s+(?:vence|ganha|perde|supera))", + script_text, re.IGNORECASE + ): + tabu_q = f"{subject} tabu hist\u00f3rico" + if tabu_q not in verification_queries: + verification_queries.append(tabu_q) + + # 4. Extract "em [month] de [year]" / "desde [month] de [year]" contexts + context_years = re.findall( + r"(?:em|desde|no|na)\s+\w+\s+de\s+(\d{4})", + script_text, re.IGNORECASE + ) + for y in context_years: + q = f"{subject} {y}" + if q not in verification_queries: + verification_queries.append(q) + + verification_queries.append( + f"{subject} hist\u00f3rico funda\u00e7\u00e3o dados" + ) + + if not verification_queries: + return paragraphs + + log( + f"Verifying claims with {len(verification_queries)} targeted queries", + "INFO", + ) + + if not self.searcher: + log("Fact verification skipped: searcher not available", "WARNING") + return paragraphs + results = self.searcher.search_with_queries(list(dict.fromkeys(verification_queries))) + if not results: + log("Fact verification: no web sources found", "WARNING") + return paragraphs + + context = self.searcher.format_context(results[:10]) + system_prompt = ( + "You are a strict fact-checker. Output ONLY valid JSON with exactly these keys:\n" + '1. "verified": boolean \u2014 true if ALL claims match the web sources\n' + '2. "corrections": array of objects with "claim" and "correction" strings ' + "\u2014 empty if verified is true\n" + '3. "paragraphs": array of strings (PT-BR) \u2014 corrected script paragraphs, ' + "or the original if no changes needed\n\n" + "CRITICAL rules:\n" + "- Compare EVERY date, number, name, and place against the web sources.\n" + "- If a source contradicts a script claim, the SOURCE wins. " + "NEVER leave a hallucination uncorrected.\n" + "- If NO source confirms a specific claim (score, streak, percentage, event), " + "consider it UNVERIFIED and remove or rephrase it as uncertain.\n" + "- Pay attention to chronology: if sources mention an event ended or a record was broken in year X, " + "do NOT let the script claim it still holds in a later year." + ) + user_prompt = ( + f"Subject: {subject}\n\n" + f"SCRIPT:\n{script_text}\n\n" + f"WEB SOURCES:\n{context}\n\n" + "Cross-check every date, number, score, name, and factual claim. " + "Output corrected paragraphs." + ) + target_count = len(paragraphs) + prev_count = 0 + for attempt in range(2): + try: + prompt = user_prompt + if attempt == 1 and prev_count != target_count: + prompt += ( + f"\n\nCORREÇÃO: Na tentativa anterior você retornou " + f"{prev_count} parágrafos, " + f"mas o script original tem {target_count}. " + f"Retorne EXATAMENTE {target_count} parágrafos. " + f"Não mescle, não remova, não junte parágrafos. " + f"Apenas corrija erros factuais mantendo a estrutura original." + ) + data = self._make_json_api_call(system_prompt, prompt) + corrected = data.get("paragraphs") or [] + prev_count = len(corrected) + corrections = data.get("corrections") or [] + is_verified = data.get("verified", False) + if corrections: + log( + f"Fact verification: {len(corrections)} corrections applied", + "WARNING", + ) + for c in corrections: + log( + f" '{c.get('claim', '?')}' -> '{c.get('correction', '?')}'", + "INFO", + ) + corrected = self._validate_paragraphs(corrected) + corrected = self._ensure_paragraph_count(corrected, target_count) + elif is_verified: + log("Fact verification: all claims match sources", "SUCCESS") + if corrected and len(corrected) >= 3: + return corrected + log( + f"Verification returned {len(corrected)} paragraphs, need >= 3, retrying...", + "WARNING", + ) + except Exception as e: + log(f"Fact verification attempt {attempt + 1} failed: {e}", "WARNING") + corrected = [] + if paragraphs and len(paragraphs) >= 3: + return paragraphs + return paragraphs + + # ── Title generation ───────────────────────────────────────────────── + + @staticmethod + def _validate_title(title: str) -> tuple[bool, list[str]]: + issues: list[str] = [] + hashtags = re.findall(r"#\w+", title) + if not hashtags: + issues.append("n\u00e3o possui hashtags") + elif len(hashtags) < 3: + issues.append(f"tem apenas {len(hashtags)} hashtags (m\u00ednimo 3)") + if len(title) > 100: + issues.append(f"tem {len(title)} caracteres (m\u00e1ximo 100)") + return len(issues) == 0, issues + + @staticmethod + def _fix_hashtags_case(title: str) -> str: + return re.sub(r"#(\w+)", lambda m: f"#{m.group(1).lower()}", title) + + def _generate_title_from_script( + self, paragraphs: list, subject: str + ) -> str | None: + """Generate a YouTube Shorts title from the final script.""" + script_text = " ".join(paragraphs)[:500] + system_prompt = ( + "Output ONLY a JSON object with one key: 'title'.\n" + "Title must be in PT-BR, max 100 characters, YouTube Shorts title.\n" + "UPPERCASE RULES:\n" + "- Use UPPERCASE for key words or short sections of the title text itself.\n" + "- Example: 'A VERDADE sobre o Caso Girabank' (title before tags).\n" + "- Do NOT put tags in uppercase.\n\n" + "TAGS RULES:\n" + "- Append EXACTLY 3-4 lowercase tags at the end, no spaces between words.\n" + "- Tags MUST be SPECIFIC to the video subject, not generic categories.\n" + "- Example tags for Mario: '#supermario #nintendo #galaxy'\n" + "- Example tags for Carlinhos Maia: '#carlinhosmaia #girabank'\n" + "- NEVER tag unrelated topics like #futebol for a movie.\n\n" + "FULL EXAMPLE:\n" + "'A VERDADE sobre o Caso Girabank #carlinhosmaia #girabank'" + ) + user_prompt = f"Crie um t\u00edtulo PT-BR para este roteiro sobre {subject}, com se\u00e7\u00f5es em UPPERCASE e tags SPECIFICAS em lowercase: {script_text}" + for attempt in range(2): + try: + prompt = user_prompt + if attempt == 1: + prompt += ( + "\n\nCORRE\u00c7\u00c3O: Na tentativa anterior o t\u00edtulo tinha problemas. " + "Siga as regras: max 100 chars, 3-4 hashtags em lowercase, " + "nada de hashtags gen\u00e9ricas." + ) + data = self._make_json_api_call(system_prompt, prompt) + title = data.get("title") or "" + title = self._fix_hashtags_case(title) + ok, issues = self._validate_title(title) + if ok: + return title + log(f"Title validation: {', '.join(issues)}, retrying...", "WARNING") + except Exception: + return None + return None + + def _repair_paragraphs(self, good: list, subject: str, target: int) -> list: + """Extend existing good paragraphs to reach target count instead of regenerating everything.""" + if len(good) >= target or not good: + return good + + needed = target - len(good) + good_text = "\n".join(good) + + system_prompt = ( + f"Output ONLY a JSON object with one key:\n" + f"'paragraphs': Array of {needed} strings (PT-BR), each 1-2 short sentences.\n" + "Extend an existing script. Match the style, tone, and factual density of the existing paragraphs.\n" + "Each paragraph MUST contain a verifiable fact. No filler, no generalities, no conclusions.\n" + "Write paragraphs that would fit naturally BETWEEN the existing ones or after them.\n" + ) + user_prompt = ( + f"Topic: {subject}\n\n" + f"EXISTING PARAGRAPHS:\n{good_text}\n\n" + f"Write {needed} more paragraphs (PT-BR) that extend this story. " + "Do NOT repeat existing content." + ) + try: + data = self._make_json_api_call(system_prompt, user_prompt) + new_p = data.get("paragraphs") or [] + log( + f"Repair attempt: API returned {len(new_p)} paragraphs, " + f"had {len(good)} good", + "INFO", + ) + combined = good + new_p + before = len(combined) + combined = self._validate_paragraphs(combined) + after_validation = len(combined) + if before != after_validation: + log( + f"Repair validation: {before} -> {after_validation} " + f"({before - after_validation} removed as filler)", + "INFO", + ) + combined = self._ensure_paragraph_count(combined, target) + if combined: + log(f"Repair success: {len(good)} -> {len(combined)} paragraphs", "SUCCESS") + return combined + log( + f"Repair failed: only {after_validation} good paragraphs " + f"after validation, needed {target}", + "WARNING", + ) + return [] + except Exception as e: + log(f"Repair LLM call failed: {e}", "WARNING") + return [] + # ── API helpers ────────────────────────────────────────────────────── def _make_json_api_call(self, system_prompt: str, user_prompt: str) -> dict: """Make API call expecting JSON response. Retries once on empty content.""" - import time headers = { "Authorization": f"Bearer {self.api_key}", @@ -221,7 +561,14 @@ def _make_json_api_call(self, system_prompt: str, user_prompt: str) -> dict: response.raise_for_status() content = response.json()["choices"][0]["message"]["content"] if content and content.strip(): - return json.loads(content) + result = json.loads(content) + if not isinstance(result, dict): + log( + f"API returned {type(result).__name__} instead of dict", + "WARNING", + ) + return {} + return result log(f"API returned empty content (attempt {attempt + 1})", "WARNING") except (json.JSONDecodeError, KeyError, requests.RequestException) as e: log( @@ -287,20 +634,65 @@ def _make_text_api_call(self, system_prompt: str, user_prompt: str) -> list: lines.append(current_para) if len(lines) < 4: - lines.extend(FALLBACK_PARAGRAPHS[len(lines) :]) + log( + f"Only {len(lines)} paragraphs generated, expected at least 4", + "WARNING", + ) return lines[:5] except Exception as e: log(f"Script generation failed: {e}", "ERROR") - return list(FALLBACK_PARAGRAPHS) + return [] + + @staticmethod + def _is_filler(paragraph: str) -> bool: + """Check if a paragraph is generic filler or corporate language that should be rejected.""" + filler_patterns = [ + "fica uma li\u00e7\u00e3o", + "vale a pena conhecer", + "li\u00e7\u00e3o que vale", + "ningu\u00e9m sabia", + "o segredo", + "a verdade escondida", + "voc\u00ea n\u00e3o vai acreditar", + "poucos conhecem", + "pouca gente sabe", + "muita gente n\u00e3o sabe", + "o que poucos sabem", + "ltda", + "s.a.", + "institui\u00e7\u00e3o de pagamento", + "pessoa jur\u00eddica", + ] + lower = paragraph.lower() + return any(p in lower for p in filler_patterns) + + @staticmethod + def _validate_paragraphs(paragraphs: list) -> list: + """Remove filler paragraphs and validate quality. Returns cleaned list or empty.""" + if not paragraphs: + return [] + cleaned = [p for p in paragraphs if isinstance(p, str) and not ScriptGenerator._is_filler(p)] + if len(cleaned) < 3: + log( + f"Validation: {len(paragraphs)} input, {len(cleaned)} after removing filler", + "WARNING", + ) + return cleaned @staticmethod def _ensure_paragraph_count(paragraphs: list, target: int) -> list: - """Pad or trim paragraphs to target count.""" + """Trim or validate paragraph count. Never pads with filler.""" if len(paragraphs) >= target: return paragraphs[:target] - return paragraphs + FALLBACK_PARAGRAPHS[len(paragraphs) : target] + if len(paragraphs) < 3: + log( + f"Only {len(paragraphs)} paragraphs, need at least 3 \u2014 returning empty", + "ERROR", + ) + return [] + return paragraphs # ── Single-pass path for images-only without web search ────────────── @@ -308,21 +700,22 @@ def _generate_script_with_context( self, subject: str, search_context: str ) -> list | None: """Generate paragraphs grounded in search context (JSON API call).""" + tone_block = self._tone_instructions() system_prompt = ( "You are a master storyteller for viral YouTube Shorts.\n" "Output ONLY a JSON object with:\n" - "1.'paragraphs': Array of 7 strings (PT-BR).\n" - "CRITICAL: First paragraph MUST start with a SPECIFIC FACT (date, name, number, place).\n" + "1.'paragraphs': Array of 7 strings (PT-BR), each 1-2 short sentences.\n" + f"{tone_block}" 'NEVER use "ningu\u00e9m sabia", "o segredo", or "a verdade" \u2014 these are vague.\n' - "Always lead with concrete details: dates, names, places, statistics.\n" - "Include origin stories: explain how it started and why it matters.\n" - "Keep each paragraph 2-3 sentences (~3 seconds audio each).\n" - "Use the provided web sources as your primary source of facts." + "Every paragraph MUST contain a verifiable fact \u2014 no generalities, no filler.\n" + "Keep each paragraph 1-2 sentences (~2-3 seconds audio each).\n" + "End with a strong conclusion, NOT 'fica uma li\u00e7\u00e3o' or similar.\n" + "Use the provided web sources as your primary source of facts. Verify every number against them." ) user_prompt = ( f"Tell a story about: {subject}. " - f"Start with a specific concrete fact (date, name, number). " - f"Include origin and specific details.\n\n" + f"Include origin, key facts, and specific details. " + f"Double-check every date and number \u2014 calculate ranges correctly.\n\n" f"WEB SOURCES:\n{search_context}" ) try: @@ -334,20 +727,22 @@ def _generate_script_with_context( def _generate_script_with_prompts_single(self, subject: str) -> tuple: """Original single-pass JSON generation (no web search).""" + tone_block = self._tone_instructions() system_prompt = ( "You are a master storyteller for viral YouTube Shorts.\n" "Output ONLY a JSON object with:\n" - "1.'paragraphs': Array of 7 strings (PT-BR).\n" - "CRITICAL: First paragraph MUST start with a SPECIFIC FACT (date, name, number, place).\n" + "1.'paragraphs': Array of 7 strings (PT-BR), each 1-2 short sentences.\n" + f"{tone_block}" 'NEVER use "ningu\u00e9m sabia", "o segredo", or "a verdade" \u2014 these are vague.\n' - "Always lead with concrete details: dates, names, places, statistics.\n" - "Include origin stories: explain how it started and why it matters.\n" - "Keep each paragraph 2-3 sentences (~3 seconds audio each)." + "Every paragraph MUST contain a verifiable fact \u2014 no generalities, no filler.\n" + "Keep each paragraph 1-2 sentences (~2-3 seconds audio each).\n" + 'End with a strong conclusion, NOT "fica uma li\u00e7\u00e3o" or similar.\n' + "Do the math yourself. If you mention a date range, calculate the years correctly." ) user_prompt = ( f"Tell a story about: {subject}. " - f"Start with a specific concrete fact (date, name, number). " - f"Include origin and specific details." + f"Include origin, key facts, and specific details. " + f"Double-check every date and number." ) try: data = self._make_json_api_call(system_prompt, user_prompt) @@ -363,36 +758,60 @@ def _generate_script_with_prompts_single(self, subject: str) -> tuple: "You are a master storyteller for viral YouTube Shorts.\n" "CRITICAL RETENTION RULES:\n" "1.Write in Brazilian Portuguese (PT-BR).\n" - "2.First paragraph MUST start with a SPECIFIC FACT (date, number, name, place) " - "\u2014 not a generic teaser.\n" - '3.NEVER start with "ningu\u00e9m sabia", "o segredo", "a verdade escondida" ' - 'or "voc\u00ea n\u00e3o vai acreditar" \u2014 these are vague and weak.\n' - '4.Always lead with concrete, specific details: "Em 1914...", ' - '"Tudo come\u00e7ou quando...", "O placar foi 8 a 0..."\n' - "5.Include origin stories \u2014 explain HOW something started or WHY it matters, " - "not just THAT it exists.\n" - "6.Each paragraph 2-3 sentences for pacing (each ~3 seconds of audio).\n" - "7.Write exactly 4-5 paragraphs.\n" - "8.NO markdown formatting, NO JSON, just plain text paragraphs.\n" - "9.Every paragraph must advance the story with a new specific fact \u2014 no filler.\n" - "10.USE the provided web sources as your primary source of facts. " - "Cite specific data from them." -) + "2.TONE: Curiosity-driven, narrative, engaging. " + "Write like a storyteller uncovering a fascinating truth \u2014 " + "never like Wikipedia or a corporate press release.\n" + "3.FIRST SENTENCE: Drop the viewer right into the action \u2014 " + "the goal, the controversy, the fact itself. " + "NO: 'Prepare-se', 'Voc\u00ea sabia', rhetorical questions. " + "Never waste the first 2 seconds on setup.\n" + "4.STRUCTURE: Hook (the fact itself) \u2192 Context \u2192 Revelation \u2192 Strong conclusion\n" + '5.NEVER start with "ningu\u00e9m sabia", "o segredo", "a verdade escondida" ' + 'or "voc\u00ea n\u00e3o vai acreditar" \u2014 these are weak.\n' + "6.FORBIDDEN: Legal names (Ltda, S.A.), addresses, city/state abbreviations. " + "NO corporate language.\n" + '6a.FORBIDDEN: Hyperboles, exaggerated claims, "designed by a god", ' + '"you won\'t believe", "shocking truth" \u2014 these sound fake.\n' + "7.Each paragraph 1-2 punchy sentences (~2-3 seconds audio each).\n" + "8.Write exactly 4-5 paragraphs.\n" + "9.NO markdown formatting, NO JSON, just plain text paragraphs.\n" + "10.Every paragraph must advance the story with a new specific fact \u2014 no filler.\n" + "11.USE the provided web sources as your primary source of facts. " + "Cite specific data from them.\n" + '12.End with a punchy conclusion, NOT "fica uma li\u00e7\u00e3o" or similar generic phrases.\n' + '13.NEVER use "no final fica uma li\u00e7\u00e3o" or "vale a pena conhecer" \u2014 these are filler.\n' + "14.Do the math yourself. If you mention a date range or time period, calculate the years correctly." + ) def _user_prompt_single(subject: str, search_context: str) -> str: + tone_rules = ( + "- TOM: Curiosidade, narrativa envolvente. " + "Conte como quem revela um fato fascinante \u2014 " + "NUNCA como Wikipedia ou release corporativo.\n" + "- PRIMEIRA FRASE: Jogue o espectador direto na a\u00e7\u00e3o \u2014 " + "o gol, a pol\u00eamica, o pr\u00f3prio fato. " + "NADA de 'Prepare-se', 'Voc\u00ea sabia', perguntas ret\u00f3ricas. " + "N\u00c3O desperdice os primeiros 2 segundos com introdu\u00e7\u00e3o.\n" + "- ESTRUTURA: Gancho (o fato) \u2192 Contexto \u2192 Revela\u00e7\u00e3o \u2192 Conclus\u00e3o forte\n" + "- PROIBIDO: Nomes jur\u00eddicos (Ltda, S.A.), endere\u00e7os, siglas. " + "NADA de linguagem corporativa.\n" + "- PROIBIDO: Hip\u00e9rboles, exageros, 'desenhada por um deus', " + "'voc\u00ea n\u00e3o vai acreditar', 'a verdade chocante' \u2014 soa falso.\n" + ) return ( f'Crie uma hist\u00f3ria envolvente em 4-5 par\u00e1grafos sobre "{subject}".\n\n' f"{search_context}\n\n" f"REGRAS CR\u00cdTICAS:\n" - f"- Primeiro par\u00e1grafo DEVE come\u00e7ar com um FATO CONCRETO " - f"(data, n\u00famero, nome, lugar) \u2014 N\u00c3O use ganchos gen\u00e9ricos\n" + f"{tone_rules}" f'- NUNCA comece com "ningu\u00e9m sabia", "o segredo", ' f'"a verdade escondida" \u2014 isso \u00e9 vago e fraco\n' f"- Inclua nomes, datas, lugares e n\u00fameros espec\u00edficos sempre que poss\u00edvel\n" f"- Conte a ORIGEM: como tudo come\u00e7ou, por que existe\n" - f"- Cada par\u00e1grafo deve avan\u00e7ar a hist\u00f3ria com um novo fato concreto\n" + f"- Cada par\u00e1grafo DEVE conter um FATO VERIFIC\u00c1VEL \u2014 nada de generaliza\u00e7\u00f5es\n" f"- NADA de frases de enchimento\n" + f"- TERMINE com uma conclus\u00e3o forte, N\u00c3O com 'fica uma li\u00e7\u00e3o'\n" + f"- Fa\u00e7a a conta voc\u00ea mesmo: se mencionar um per\u00edodo, calcule os anos corretamente\n" f"- Use as FONTES DA WEB fornecidas como base para sua hist\u00f3ria\n\n" f"Escreva cada par\u00e1grafo em uma linha separada." ) @@ -402,36 +821,63 @@ def _user_prompt_single(subject: str, search_context: str) -> str: "You are a master storyteller for viral YouTube Shorts.\n" "CRITICAL RETENTION RULES:\n" "1.Write in Brazilian Portuguese (PT-BR).\n" - "2.First paragraph MUST start with a SPECIFIC FACT from the video " - "(date, number, name, place).\n" - '3.NEVER start with "ningu\u00e9m sabia", "o segredo", ' + "2.TONE: Curiosity-driven, narrative, engaging. " + "Write like a storyteller uncovering a fascinating truth \u2014 " + "never like Wikipedia or a corporate press release.\n" + "3.FIRST SENTENCE: Drop the viewer right into the action \u2014 " + "the goal, the controversy, the fact itself. " + "NO: 'Prepare-se', 'Voc\u00ea sabia', rhetorical questions. " + "Never waste the first 2 seconds on setup.\n" + "4.STRUCTURE: Hook (the fact itself) \u2192 Context \u2192 Revelation \u2192 Strong conclusion\n" + '5.NEVER start with "ningu\u00e9m sabia", "o segredo", ' 'or "a verdade escondida" \u2014 these are vague and weak.\n' - "4.Extract concrete details from the video metadata: " + "6.FORBIDDEN: Legal names (Ltda, S.A.), addresses, city/state abbreviations. " + "NO corporate language.\n" + '6a.FORBIDDEN: Hyperboles, exaggerated claims, "designed by a god", ' + '"you won\'t believe", "shocking truth" \u2014 these sound fake.\n' + "7.Extract concrete details from the video metadata: " "dates, names, places, statistics, historical context.\n" - "5.Include origin stories \u2014 explain HOW something started, " + "8.Include origin stories \u2014 explain HOW something started, " "not just THAT it happened.\n" - "6.Each paragraph 2-3 sentences for pacing (each ~3 seconds of audio).\n" - "7.Write exactly 4-5 paragraphs.\n" - "8.NO markdown formatting, NO JSON, just plain text paragraphs.\n" - "9.Base your story entirely on the video's content, " - "adding only well-known historical context." -) + "9.Each paragraph 1-2 punchy sentences (~2-3 seconds audio each).\n" + "10.Write exactly 4-5 paragraphs.\n" + "11.NO markdown formatting, NO JSON, just plain text paragraphs.\n" + "12.Base your story entirely on the video's content, " + "adding only well-known historical context.\n" + '13.End with a punchy conclusion, NOT "fica uma li\u00e7\u00e3o" or similar.\n' + "14.Every paragraph must contain a verifiable fact \u2014 no generalities." + ) def _user_prompt_metadata(combined_content: str) -> str: + tone_rules = ( + "- TOM: Curiosidade, narrativa envolvente. " + "Conte como quem revela um fato fascinante \u2014 " + "NUNCA como Wikipedia ou release corporativo.\n" + "- PRIMEIRA FRASE: Jogue o espectador direto na a\u00e7\u00e3o \u2014 " + "o gol, a pol\u00eamica, o pr\u00f3prio fato. " + "NADA de 'Prepare-se', 'Voc\u00ea sabia', perguntas ret\u00f3ricas. " + "N\u00c3O desperdice os primeiros 2 segundos com introdu\u00e7\u00e3o.\n" + "- ESTRUTURA: Gancho (o fato) \u2192 Contexto \u2192 Revela\u00e7\u00e3o \u2192 Conclus\u00e3o forte\n" + "- PROIBIDO: Nomes jur\u00eddicos (Ltda, S.A.), endere\u00e7os, siglas. " + "NADA de linguagem corporativa.\n" + "- PROIBIDO: Hip\u00e9rboles, exageros, 'desenhada por um deus', " + "'voc\u00ea n\u00e3o vai acreditar' \u2014 soa falso.\n" + ) return ( "Crie uma hist\u00f3ria envolvente em 4-5 par\u00e1grafos baseada " "neste v\u00eddeo do YouTube.\n\n" "REGRAS CR\u00cdTICAS:\n" - "- Primeiro par\u00e1grafo DEVE come\u00e7ar com um FATO CONCRETO " - "extra\u00eddo do v\u00eddeo (data, nome, lugar, n\u00famero)\n" + f"{tone_rules}" '- NUNCA comece com "ningu\u00e9m sabia", "o segredo" ' 'ou "a verdade escondida"\n' "- Extraia detalhes espec\u00edficos do t\u00edtulo e descri\u00e7\u00e3o: " "datas, nomes, locais, estat\u00edsticas\n" "- Conte a ORIGEM: como tudo come\u00e7ou, por que \u00e9 importante\n" - "- Cada par\u00e1grafo deve avan\u00e7ar a hist\u00f3ria com um novo fato\n" - "- NADA de ganchos gen\u00e9ricos ou frases de enchimento\n\n" + "- Cada par\u00e1grafo DEVE conter um FATO VERIFIC\u00c1VEL \u2014 nada de generaliza\u00e7\u00f5es\n" + "- NADA de ganchos gen\u00e9ricos ou frases de enchimento\n" + "- TERMINE com uma conclus\u00e3o forte, N\u00c3O com 'fica uma li\u00e7\u00e3o'\n" + "- Fa\u00e7a a conta voc\u00ea mesmo: se mencionar um per\u00edodo, calcule os anos corretamente\n\n" f"V\u00eddeo:\n{combined_content}\n\n" "Escreva cada par\u00e1grafo em uma linha separada." ) diff --git a/src/autoshorts/modules/tts_system.py b/src/autoshorts/modules/tts_system.py index 64456bb..5543b70 100644 --- a/src/autoshorts/modules/tts_system.py +++ b/src/autoshorts/modules/tts_system.py @@ -51,11 +51,15 @@ async def generate_audio_and_subtitles( "default=noprint_wrappers=1:nokey=1", str(audio_file), ] - duration = float( - subprocess.run( - cmd, capture_output=True, text=True, timeout=FFPROBE_TIMEOUT - ).stdout.strip() - ) + try: + duration = float( + subprocess.run( + cmd, capture_output=True, text=True, timeout=FFPROBE_TIMEOUT + ).stdout.strip() + ) + except (ValueError, subprocess.TimeoutExpired, OSError) as e: + log(f"Failed to get audio duration from ffprobe: {e}", "WARNING") + duration = 0.0 # Generate subtitles using subtitle system vtt_file = self.subtitle_system.generate_subtitles( diff --git a/src/autoshorts/modules/video_background.py b/src/autoshorts/modules/video_background.py index aec54da..ddfb856 100644 --- a/src/autoshorts/modules/video_background.py +++ b/src/autoshorts/modules/video_background.py @@ -53,19 +53,18 @@ def generate_search_query(self, subject: str) -> str: """Generate an AI-optimized YouTube search query.""" log("Generating AI-optimized search query...") - system_prompt = """You are an expert at crafting YouTube search queries to find high-quality, family-friendly content. + system_prompt = """You are an expert at crafting YouTube search queries. Output ONLY the query, nothing else. CRITICAL RULES: 1. Use the SAME language as the subject (do NOT translate) 2. Use NATURAL language with spaces, NOT dashes -3. Include terms like "explicado", "hist\u00f3ria", "document\u00e1rio", "reportagem" -4. Add "-shorts" to exclude YouTube Shorts -5. Make it specific and searchable -6. NO quotes, NO special formatting, NO excessive dashes -7. DO NOT just append the original text to template words -8. Example: "flash drive encontrado na rua hist\u00f3ria completa document\u00e1rio" +3. Add "-shorts" at the end to exclude YouTube Shorts +4. Be specific to the subject — use concrete names, events, places +5. NO generic template words like "explicado", "document\u00e1rio", "reportagem", "hist\u00f3ria" +6. NO quotes, NO special formatting +7. DO NOT just repeat the subject — add specific qualifiers """ - user_prompt = f"Subject: {subject}\n\nCreate a YouTube search query in the SAME language as the subject that will find family-friendly, educational videos about this subject. Focus on documentary-style content, news reports, or educational explanations. Avoid anything that might be age-restricted." + user_prompt = f"Subject: {subject}\n\nCreate a YouTube search query that returns videos directly about this subject. Be specific." headers = { "Authorization": f"Bearer {API_KEY}", @@ -114,10 +113,11 @@ def _is_suitable_video( video_info: dict, min_duration: int = MIN_VIDEO_DURATION, max_duration: int = MAX_VIDEO_DURATION, + subject: str | None = None, ) -> bool: - """Filter videos based on duration and availability.""" + """Filter videos based on duration, availability, and title relevance.""" duration = video_info.get("duration", 0) - title = video_info.get("title", "").lower() + title = (video_info.get("title") or "").lower() if ( video_info.get("availability") == "private" @@ -136,6 +136,17 @@ def _is_suitable_video( elif duration > max_duration: log(f"FILTERED: '{title[:30]}...' - Too long: {duration}s", "WARNING") return False + + if subject: + subject_lower = subject.lower() + subject_words = [w for w in subject_lower.split() if len(w) > 3] + if subject_words and not any(w in title for w in subject_words): + log( + f"FILTERED: '{title[:40]}...' - No subject keywords in title", + "WARNING", + ) + return False + return True def _extract_error_message(self, exc: Exception) -> str: @@ -160,14 +171,14 @@ def search_and_download(self, subject: str) -> str: """Search and download video using DDG first, then yt-dlp search as fallback.""" search_query = self.generate_search_query(subject) - video_path = self._search_with_ddg(search_query) + video_path = self._search_with_ddg(search_query, subject) if video_path: return video_path log("DDG search failed, falling back to yt-dlp search...", "WARNING") - return self._search_with_ytdlp(search_query) + return self._search_with_ytdlp(search_query, subject) - def _search_with_ddg(self, search_query: str) -> str | None: + def _search_with_ddg(self, search_query: str, subject: str | None = None) -> str | None: """Search YouTube via DuckDuckGo and download with yt-dlp.""" try: from ddgs import DDGS @@ -177,18 +188,19 @@ def _search_with_ddg(self, search_query: str) -> str | None: DDGS().text(f"site:youtube.com {search_query}", max_results=10) ) urls = [ - r["href"] for r in results if "youtube.com/watch" in r.get("href", "") + u for r in results + if (u := r.get("href")) and "youtube.com/watch" in u ] if not urls: log("No YouTube URLs found via DDG", "WARNING") return None log(f"Found {len(urls)} YouTube videos, extracting metadata...", "INFO") - return self._download_first_suitable(urls) + return self._download_first_suitable(urls, subject) except Exception as e: log(f"DDG search failed: {e}", "WARNING") return None - def _search_with_ytdlp(self, search_query: str) -> str: + def _search_with_ytdlp(self, search_query: str, subject: str | None = None) -> str: """Fallback search using yt-dlp built-in search.""" yt_query = f"ytsearch20:{search_query}" @@ -206,7 +218,7 @@ def _search_with_ytdlp(self, search_query: str) -> str: if not all_videos: raise ValueError("No videos found in search results") - suitable_videos = [v for v in all_videos if self._is_suitable_video(v)] + suitable_videos = [v for v in all_videos if self._is_suitable_video(v, subject=subject)] if not suitable_videos: for v in info["entries"]: @@ -226,7 +238,7 @@ def _search_with_ytdlp(self, search_query: str) -> str: for v in suitable_videos[:10] if v.get("webpage_url") ] - path = self._download_first_suitable(urls) + path = self._download_first_suitable(urls, subject) if path: return path raise ValueError("No available videos could be downloaded") @@ -234,7 +246,7 @@ def _search_with_ytdlp(self, search_query: str) -> str: log(f"yt-dlp search failed: {e}", "ERROR") raise - def _download_first_suitable(self, urls: list[str]) -> str | None: + def _download_first_suitable(self, urls: list[str], subject: str | None = None) -> str | None: """Try URLs one by one, return path of first successful download.""" download_temp_dir = create_temp_dir() ydl_opts_with_dir = self.ydl_opts.copy() @@ -249,7 +261,7 @@ def _download_first_suitable(self, urls: list[str]) -> str | None: try: with yt_dlp.YoutubeDL({"quiet": True, "no_warnings": True}) as ydl: info = ydl.extract_info(video_url, download=False) - if not self._is_suitable_video(info): + if not self._is_suitable_video(info, subject=subject): log(f"Skipping unsuitable video {attempt + 1}", "WARNING") continue title = info.get("title", "Unknown") diff --git a/src/autoshorts/modules/video_compositor.py b/src/autoshorts/modules/video_compositor.py index f358dc3..756c750 100644 --- a/src/autoshorts/modules/video_compositor.py +++ b/src/autoshorts/modules/video_compositor.py @@ -157,7 +157,16 @@ def apply_opacity(get_frame, t): return np.minimum(255, frame * opacity).astype("uint8") clip = clip.with_effects([Resize(scale_anim)]) - return clip.transform(apply_opacity) + clip = clip.transform(apply_opacity) + return clip.with_position(("center", "center")) + + def _ensure_duration( + self, clip: VideoFileClip, min_duration: float + ) -> VideoFileClip: + if clip.duration <= 0 or clip.duration >= min_duration: + return clip + loops = int(min_duration / clip.duration) + 1 + return concatenate_videoclips([clip] * loops).subclipped(0, min_duration) def _jumpcut_background(self, clip, target_duration: float) -> VideoFileClip: """Create background by randomly sampling segments instead of speed scaling.""" @@ -453,10 +462,11 @@ def _create_with_overlay_mode( Uses flattened structure to avoid MoviePy timing bugs with nested composites. """ blurred = self._create_blurred_background_from_clip(video) + blurred = self._ensure_duration(blurred, target_duration) content_h = int(VIDEO_HEIGHT * 0.45) - # Use full video for fg so jumpcut segments never land past its end fg = video.resized((VIDEO_WIDTH, content_h)).with_position(("center", "center")) + fg = self._ensure_duration(fg, target_duration) base_composite = CompositeVideoClip( [blurred, fg], size=(VIDEO_WIDTH, VIDEO_HEIGHT) @@ -582,6 +592,7 @@ def _create_simple_mode( target_duration: float, ) -> CompositeVideoClip: """Simple video without blur.""" + video = self._ensure_duration(video, target_duration) if BG_MODE == "jumpcut": # _jumpcut_background already handles duration trimming final_video = self._jumpcut_background(video, target_duration) diff --git a/src/autoshorts/modules/web_search.py b/src/autoshorts/modules/web_search.py index aa37e85..348a4a8 100644 --- a/src/autoshorts/modules/web_search.py +++ b/src/autoshorts/modules/web_search.py @@ -10,9 +10,9 @@ def generate_queries(subject: str) -> list[str]: s = subject.strip().strip('"').strip("'") return [ s, - f"{s} hist\u00f3ria origem", - f"{s} fatos importantes", - f"{s} contexto hist\u00f3rico", + f"{s} hist\u00f3ria origem funda\u00e7\u00e3o", + f"{s} fatos hist\u00f3ricos dados estat\u00edsticas", + f"{s} contexto hist\u00f3rico data local", ] def search(self, subject: str) -> list[dict] | None: @@ -93,8 +93,8 @@ def format_context(results: list[dict]) -> str: "\u2500" * 60, ] for i, r in enumerate(results, 1): - snippet = r["snippet"][:200] - lines.append(f"[{i}] {r['title']}") + snippet = (r.get("snippet") or "")[:200] + lines.append(f"[{i}] {r.get('title', '')}") lines.append(f" Fonte: {r['url']}") lines.append(f" {snippet}") lines.append("") diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py index f947954..117065c 100644 --- a/tests/test_edge_cases.py +++ b/tests/test_edge_cases.py @@ -52,22 +52,22 @@ def test_very_long_subject(self, mock_post): assert isinstance(result, list) @patch("autoshorts.modules.script_generator.requests.post") - def test_api_timeout(self, mock_post): + def test_api_timeout_returns_empty(self, mock_post): mock_post.side_effect = TimeoutError("Connection timeout") result = self.generator.generate_script("test subject") assert isinstance(result, list) - assert len(result) == 5 + assert len(result) == 0 @patch("autoshorts.modules.script_generator.requests.post") - def test_malformed_api_response(self, mock_post): + def test_malformed_api_response_returns_empty(self, mock_post): mock_response = Mock() mock_response.json.return_value = {} mock_post.return_value = mock_response result = self.generator.generate_script("test subject") assert isinstance(result, list) - assert len(result) == 5 + assert len(result) == 0 class TestEdgeCasesTTSSystem: @@ -264,7 +264,7 @@ async def test_full_pipeline_empty_inputs(self): if temp_dir.exists(): shutil.rmtree(temp_dir) - def test_error_propagation(self): + def test_error_propagation_returns_empty(self): with patch("autoshorts.modules.script_generator.requests.post") as mock_post: mock_post.side_effect = ConnectionError("Network error") @@ -272,4 +272,4 @@ def test_error_propagation(self): result = script_gen.generate_script("test") assert isinstance(result, list) - assert len(result) == 5 + assert len(result) == 0 diff --git a/tests/test_fluximages.py b/tests/test_fluximages.py index b0edc34..daea513 100644 --- a/tests/test_fluximages.py +++ b/tests/test_fluximages.py @@ -365,8 +365,8 @@ async def test_generate_normal_mode( ): gen = ExplainerGenerator(subject="test", image_source="ai") mock_script = Mock() - mock_script.generate_script.return_value = ["Para 1", "Para 2"] - mock_script.generate_script_from_metadata.return_value = ["Para 1", "Para 2"] + mock_script.generate_script.return_value = ["Para 1", "Para 2", "Para 3"] + mock_script.generate_script_from_metadata.return_value = ["Para 1", "Para 2", "Para 3"] gen.script_generator = mock_script mock_bg = Mock() @@ -395,8 +395,8 @@ async def test_generate_images_only_mode( gen = ExplainerGenerator(subject="test", images_only=True, image_source="ai") mock_script = Mock() mock_script.generate_script_with_prompts.return_value = ( - ["Para 1", "Para 2"], - ["Prompt 1", "Prompt 2"], + ["Para 1", "Para 2", "Para 3"], + ["Prompt 1", "Prompt 2", "Prompt 3"], ) gen.script_generator = mock_script @@ -532,12 +532,14 @@ def test_apply_overlay_animation_returns_clip(self, mock_resize): mock_clip.size = (1080, 1920) mock_clip.with_effects = Mock(return_value=mock_clip) mock_clip.transform = Mock(return_value=mock_clip) + mock_clip.with_position = Mock(return_value=mock_clip) result = self.gen._apply_overlay_animation(mock_clip, 3.0) assert result is mock_clip mock_clip.with_effects.assert_called_once() mock_clip.transform.assert_called_once() + mock_clip.with_position.assert_called_once_with(("center", "center")) @patch("autoshorts.generators.explainer.vfx.Resize") def test_apply_overlay_animation_zero_duration(self, mock_resize): @@ -545,6 +547,7 @@ def test_apply_overlay_animation_zero_duration(self, mock_resize): mock_clip.size = (1080, 1920) mock_clip.with_effects = Mock(return_value=mock_clip) mock_clip.transform = Mock(return_value=mock_clip) + mock_clip.with_position = Mock(return_value=mock_clip) result = self.gen._apply_overlay_animation(mock_clip, 0.0) diff --git a/tests/test_script_generator.py b/tests/test_script_generator.py index eee616f..d7f0fdb 100644 --- a/tests/test_script_generator.py +++ b/tests/test_script_generator.py @@ -112,20 +112,18 @@ def test_generate_script_with_web_search(self, mock_post, mock_searcher_class): assert payload.get("response_format") == {"type": "json_object"} @patch("autoshorts.modules.script_generator.requests.post") - def test_generate_script_api_error_fallback(self, mock_post): - """Test script generation with API error returns fallback""" + def test_generate_script_api_error_returns_empty(self, mock_post): + """Test script generation with API error returns empty list""" mock_post.side_effect = Exception("API Error") - # Should return fallback script instead of raising result = self.script_generator.generate_script("test subject") assert isinstance(result, list) - assert len(result) == 5 - assert "história" in result[0].lower() + assert len(result) == 0 @patch("autoshorts.modules.script_generator.requests.post") - def test_generate_script_empty_response(self, mock_post): - """Test script generation with empty response""" + def test_generate_script_empty_response_returns_empty(self, mock_post): + """Test script generation with empty response returns empty list""" mock_response = Mock() mock_response.json.return_value = {"choices": [{"message": {"content": ""}}]} mock_response.raise_for_status.return_value = None @@ -134,7 +132,7 @@ def test_generate_script_empty_response(self, mock_post): result = self.script_generator.generate_script("test subject") assert isinstance(result, list) - assert len(result) == 5 # Should return fallback + assert len(result) == 0 @patch("autoshorts.modules.script_generator.requests.post") def test_generate_script_with_prompts_success(self, mock_post): @@ -233,8 +231,8 @@ def test_generate_script_from_metadata_with_web_search(self, mock_post): assert len(result) > 0 @patch("autoshorts.modules.script_generator.requests.post") - def test_generate_script_from_metadata_error(self, mock_post): - """Test script generation from metadata with error returns fallback""" + def test_generate_script_from_metadata_error_returns_empty(self, mock_post): + """Test script generation from metadata with error returns empty list""" mock_post.side_effect = Exception("API Error") result = self.script_generator.generate_script_from_metadata( @@ -242,7 +240,7 @@ def test_generate_script_from_metadata_error(self, mock_post): ) assert isinstance(result, list) - assert len(result) == 5 + assert len(result) == 0 @patch("autoshorts.modules.script_generator.requests.post") def test_api_request_headers(self, mock_post): @@ -379,7 +377,7 @@ def test_generate_script_with_double_newlines(self, mock_post): @patch("autoshorts.modules.script_generator.requests.post") def test_generate_script_insufficient_paragraphs(self, mock_post): - """Test script generation pads insufficient paragraphs""" + """Test script generation returns what it gets, no padding""" mock_response = Mock() mock_response.json.return_value = { "choices": [{"message": {"content": "Only one paragraph."}}] @@ -390,7 +388,7 @@ def test_generate_script_insufficient_paragraphs(self, mock_post): result = self.script_generator.generate_script("test") assert isinstance(result, list) - assert len(result) == 5 # Should be padded to 5 + assert len(result) == 1 @patch("autoshorts.modules.script_generator.requests.post") def test_generate_script_with_context_success(self, mock_post): @@ -525,18 +523,19 @@ def test_generate_script_with_search_results(self, mock_post, mock_searcher_clas mock_searcher.format_context.return_value = "FONTES DA WEB:\n..." mock_searcher_class.return_value = mock_searcher - draft_json = json.dumps( - { - "draft": ["P1", "P2", "P3", "P4", "P5"], - "queries": ["flamengo hist\u00f3ria", "fluminense origem"], - "title": "Cl\u00e1ssico", - } - ) - draft_response = Mock() - draft_response.json.return_value = { - "choices": [{"message": {"content": draft_json}}] + query_response = Mock() + query_response.json.return_value = { + "choices": [ + { + "message": { + "content": json.dumps( + {"queries": ["flamengo hist\u00f3ria", "fluminense origem"]} + ) + } + } + ] } - draft_response.raise_for_status.return_value = None + query_response.raise_for_status.return_value = None text_response = Mock() text_response.json.return_value = { @@ -556,7 +555,35 @@ def test_generate_script_with_search_results(self, mock_post, mock_searcher_clas } text_response.raise_for_status.return_value = None - mock_post.side_effect = [draft_response, text_response] + verification_response = Mock() + verification_response.json.return_value = { + "choices": [ + { + "message": { + "content": json.dumps({ + "verified": True, + "corrections": [], + "paragraphs": [], + }) + } + } + ] + } + verification_response.raise_for_status.return_value = None + + title_response = Mock() + title_response.json.return_value = { + "choices": [ + { + "message": { + "content": json.dumps({"title": "Cl\u00e1ssico"}) + } + } + ] + } + title_response.raise_for_status.return_value = None + + mock_post.side_effect = [query_response, text_response, verification_response, title_response] generator = ScriptGenerator(web_search=True) result = generator.generate_script("Flamengo x Fluminense") @@ -564,10 +591,9 @@ def test_generate_script_with_search_results(self, mock_post, mock_searcher_clas assert isinstance(result, list) assert len(result) == 5 assert "primeiro" in result[0].lower() - mock_searcher.search_with_queries.assert_called_once() - mock_searcher.format_context.assert_called_once() - # Verify _make_text_api_call received the context - assert mock_post.call_count == 2 + assert mock_searcher.search_with_queries.call_count == 2 + assert mock_searcher.format_context.call_count == 2 + assert mock_post.call_count == 5 @patch("autoshorts.modules.script_generator.WebSearcher") @patch("autoshorts.modules.script_generator.requests.post") @@ -583,18 +609,17 @@ def test_generate_script_with_prompts_web_search_success( mock_searcher.format_context.return_value = "FONTES DA WEB:\n..." mock_searcher_class.return_value = mock_searcher - draft_json = json.dumps( - { - "draft": ["P1", "P2", "P3", "P4", "P5", "P6", "P7"], - "queries": ["query1", "query2"], - "title": "Test Title", - } - ) - draft_response = Mock() - draft_response.json.return_value = { - "choices": [{"message": {"content": draft_json}}] + query_response = Mock() + query_response.json.return_value = { + "choices": [ + { + "message": { + "content": json.dumps({"queries": ["query1", "query2"]}) + } + } + ] } - draft_response.raise_for_status.return_value = None + query_response.raise_for_status.return_value = None final_json = json.dumps({"paragraphs": [f"P{i}" for i in range(1, 8)]}) final_response = Mock() @@ -603,15 +628,43 @@ def test_generate_script_with_prompts_web_search_success( } final_response.raise_for_status.return_value = None - mock_post.side_effect = [draft_response, final_response] + verification_response = Mock() + verification_response.json.return_value = { + "choices": [ + { + "message": { + "content": json.dumps({ + "verified": True, + "corrections": [], + "paragraphs": [], + }) + } + } + ] + } + verification_response.raise_for_status.return_value = None + + title_response = Mock() + title_response.json.return_value = { + "choices": [ + { + "message": { + "content": json.dumps({"title": "Test Title"}) + } + } + ] + } + title_response.raise_for_status.return_value = None + + mock_post.side_effect = [query_response, final_response, verification_response, title_response] generator = ScriptGenerator(web_search=True) paragraphs, prompts = generator.generate_script_with_prompts("test") assert len(paragraphs) == 7 assert prompts == [] - mock_searcher.format_context.assert_called_once() - assert mock_post.call_count == 2 + assert mock_searcher.format_context.call_count == 2 + assert mock_post.call_count == 5 @patch("autoshorts.modules.script_generator.WebSearcher") @patch("autoshorts.modules.script_generator.requests.post") @@ -645,9 +698,7 @@ def test_generate_script_with_prompts_empty_draft_fallback( generator = ScriptGenerator(web_search=True) paragraphs, prompts = generator.generate_script_with_prompts("test") - assert ( - len(paragraphs) == 5 - ) # _ensure_paragraph_count([], 7) pads from 5 fallback entries + assert len(paragraphs) == 0 # No fallback filler, returns empty assert isinstance(paragraphs, list) assert prompts == [] @@ -681,7 +732,7 @@ def test_generate_script_draft_api_error(self, mock_post, mock_searcher_class): generator = ScriptGenerator(web_search=True) result = generator.generate_script("test") assert isinstance(result, list) - assert len(result) == 5 + assert len(result) == 0 @patch("autoshorts.modules.script_generator.requests.post") def test_generate_draft_api_error_returns_fallback(self, mock_post): @@ -787,8 +838,8 @@ def setup_method(self): self.script_generator = ScriptGenerator(web_search=False) @patch("autoshorts.modules.script_generator.requests.post") - def test_generate_script_timeout(self, mock_post): - """Test script generation with timeout returns fallback""" + def test_generate_script_timeout_returns_empty(self, mock_post): + """Test script generation with timeout returns empty list""" import requests mock_post.side_effect = requests.Timeout("Request timed out") @@ -796,11 +847,11 @@ def test_generate_script_timeout(self, mock_post): result = self.script_generator.generate_script("test") assert isinstance(result, list) - assert len(result) == 5 + assert len(result) == 0 @patch("autoshorts.modules.script_generator.requests.post") - def test_generate_script_connection_error(self, mock_post): - """Test script generation with connection error returns fallback""" + def test_generate_script_connection_error_returns_empty(self, mock_post): + """Test script generation with connection error returns empty list""" import requests mock_post.side_effect = requests.ConnectionError("No connection") @@ -808,11 +859,11 @@ def test_generate_script_connection_error(self, mock_post): result = self.script_generator.generate_script("test") assert isinstance(result, list) - assert len(result) == 5 + assert len(result) == 0 @patch("autoshorts.modules.script_generator.requests.post") - def test_generate_script_http_error(self, mock_post): - """Test script generation with HTTP error returns fallback""" + def test_generate_script_http_error_returns_empty(self, mock_post): + """Test script generation with HTTP error returns empty list""" mock_response = Mock() mock_response.raise_for_status.side_effect = Exception("HTTP 500") mock_post.return_value = mock_response @@ -820,7 +871,7 @@ def test_generate_script_http_error(self, mock_post): result = self.script_generator.generate_script("test") assert isinstance(result, list) - assert len(result) == 5 + assert len(result) == 0 if __name__ == "__main__": diff --git a/tests/test_video_background.py b/tests/test_video_background.py index c7a09b9..2edf8e8 100644 --- a/tests/test_video_background.py +++ b/tests/test_video_background.py @@ -215,7 +215,7 @@ def test_ddg_success_returns_path(self, mock_gen_query, mock_ddg): result = self.manager.search_and_download("test") assert result == "/path/to/video.mp4" - mock_ddg.assert_called_once_with("test query") + mock_ddg.assert_called_once_with("test query", "test") @patch.object(VideoBackgroundManager, "_search_with_ddg") @patch.object(VideoBackgroundManager, "_search_with_ytdlp") @@ -229,4 +229,4 @@ def test_ddg_failure_falls_back_to_ytdlp( result = self.manager.search_and_download("test") assert result == "/path/to/video.mp4" - mock_ytdlp.assert_called_once_with("test query") + mock_ytdlp.assert_called_once_with("test query", "test") diff --git a/tests/test_video_compositor.py b/tests/test_video_compositor.py index 1f1a67b..2b3927f 100644 --- a/tests/test_video_compositor.py +++ b/tests/test_video_compositor.py @@ -55,18 +55,21 @@ def test_returns_clip_with_effects_and_transform(self): mock_clip.size = (1080, 1920) mock_clip.with_effects.return_value = mock_clip mock_clip.transform.return_value = mock_clip + mock_clip.with_position.return_value = mock_clip result = self.compositor._apply_overlay_animation(mock_clip, 3.0) assert result is mock_clip mock_clip.with_effects.assert_called_once() mock_clip.transform.assert_called_once() + mock_clip.with_position.assert_called_once_with(("center", "center")) def test_zero_duration(self): mock_clip = MagicMock() mock_clip.size = (1080, 1920) mock_clip.with_effects.return_value = mock_clip mock_clip.transform.return_value = mock_clip + mock_clip.with_position.return_value = mock_clip result = self.compositor._apply_overlay_animation(mock_clip, 0.0) assert result is mock_clip @@ -102,6 +105,7 @@ def test_opacity_transform_midpoint_visible(self): mock_clip.size = (100, 100) mock_clip.with_effects = Mock(return_value=mock_clip) mock_clip.transform = Mock(return_value=mock_clip) + mock_clip.with_position = Mock(return_value=mock_clip) self.compositor._apply_overlay_animation(mock_clip, 1.0)