Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f4eb6b7
Fix video render freezing: loop clips via _ensure_duration to prevent…
mdev34-lab May 24, 2026
ae88153
Center-anchor pop-in zoom animation
mdev34-lab May 24, 2026
f92f37d
Eliminate generic filler and strengthen factual accuracy of scripts
mdev34-lab May 24, 2026
3596726
Break circular hallucination loop: independent query generation + pos…
mdev34-lab May 24, 2026
d1a2681
Rewrite all prompts to enforce viral dramatic tone, forbid corporate …
mdev34-lab May 24, 2026
eb2665e
Remove generic template keywords from YouTube bg search query prompt
mdev34-lab May 24, 2026
9e0cd36
Repair bad paragraphs instead of discarding; validate video title aga…
mdev34-lab May 25, 2026
1e9364d
Title: uppercase key sections + lowercase subject-specific tags
mdev34-lab May 25, 2026
1b9ad73
Fix repair fallthrough: return empty when repair fails, check len >= 3
mdev34-lab May 25, 2026
05421fa
Log why repair fails: API count, filler removals, final count vs target
mdev34-lab May 25, 2026
58b016f
batch: single string with semicolon-separated topics instead of list[…
mdev34-lab May 25, 2026
0b26565
batch: sanitize filename; fix hidden type errors (dict .lower(), json…
mdev34-lab May 25, 2026
79a7c47
fix mypy type errors: filter None from urls, guard self.searcher; enf…
mdev34-lab May 25, 2026
89b7cb1
image_searcher: filter NSFW domains and keywords from DDGS results
mdev34-lab May 25, 2026
885a3b1
prompts: replace hyperboles with value-driven language; hooks become …
mdev34-lab May 26, 2026
2507a68
prompts: first sentence drops viewer into action, no setup waste
mdev34-lab May 26, 2026
30f185d
fix: fallback to original paragraphs when verification API returns fe…
mdev34-lab May 26, 2026
8913464
verify: retry API with corrective feedback when paragraph count is wrong
mdev34-lab May 26, 2026
6e7d70e
title: validate hashtag count, length, and case with retry feedback
mdev34-lab May 26, 2026
a55542e
fix: hashtag case regex now matches tags starting with lowercase too
mdev34-lab May 26, 2026
3dff459
fix: whole-word NSFW filter, remove over-broad tokens (cd, adult, bik…
mdev34-lab May 26, 2026
152006f
docs: update README to match current CLI flags, features, and modules
mdev34-lab May 26, 2026
744ef05
remove --tone flag and corporate mode; keep only opinionated/curiosit…
mdev34-lab May 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 36 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
10 changes: 6 additions & 4 deletions src/autoshorts/cli/commands/explainer.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import re
import time
from pathlib import Path

Expand All @@ -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)"
),
Expand All @@ -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:
Expand All @@ -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'}")
Expand All @@ -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"
Expand Down
15 changes: 14 additions & 1 deletion src/autoshorts/generators/explainer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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...")
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions src/autoshorts/modules/image_searcher.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import hashlib
import random
import re
from pathlib import Path
from urllib.parse import quote

Expand All @@ -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__(
Expand All @@ -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
Expand Down Expand Up @@ -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", "")
Expand Down
Loading
Loading