From 0627b021ab7707b58cc3ce8a425dbfaf294216c4 Mon Sep 17 00:00:00 2001 From: Ramzi Sultan <33006097+GenRamzi@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:45:40 +0300 Subject: [PATCH 1/3] build no-key AI provider and media engine --- .gitignore | 10 +++ app/__init__.py | 1 + app/core/__init__.py | 0 app/core/config.py | 36 ++++++++ app/models/__init__.py | 0 app/models/schemas.py | 50 +++++++++++ app/providers/__init__.py | 3 + app/providers/base.py | 33 ++++++++ app/providers/cli.py | 174 ++++++++++++++++++++++++++++++++++++++ app/providers/registry.py | 77 +++++++++++++++++ app/services/__init__.py | 0 app/services/media.py | 125 +++++++++++++++++++++++++++ app/services/script.py | 24 ++++++ app/services/subtitles.py | 33 ++++++++ app/services/tts.py | 19 +++++ pyproject.toml | 27 ++++++ storage/.gitkeep | 0 17 files changed, 612 insertions(+) create mode 100644 .gitignore create mode 100644 app/__init__.py create mode 100644 app/core/__init__.py create mode 100644 app/core/config.py create mode 100644 app/models/__init__.py create mode 100644 app/models/schemas.py create mode 100644 app/providers/__init__.py create mode 100644 app/providers/base.py create mode 100644 app/providers/cli.py create mode 100644 app/providers/registry.py create mode 100644 app/services/__init__.py create mode 100644 app/services/media.py create mode 100644 app/services/script.py create mode 100644 app/services/subtitles.py create mode 100644 app/services/tts.py create mode 100644 pyproject.toml create mode 100644 storage/.gitkeep diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f9be96 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +.venv/ +.env +.DS_Store +.pytest_cache/ +.ruff_cache/ +storage/* +!storage/.gitkeep +*.log diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..9b37c65 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +@dataclass(frozen=True) +class Settings: + app_name: str = "MoneyPrinterTurbo NoAPI" + host: str = os.getenv("MPT_HOST", "127.0.0.1") + port: int = int(os.getenv("MPT_PORT", "8501")) + storage_dir: Path = Path(os.getenv("MPT_STORAGE", ROOT / "storage")).resolve() + max_upload_mb: int = int(os.getenv("MPT_MAX_UPLOAD_MB", "500")) + default_provider: str = os.getenv("MPT_PROVIDER", "gemini") + default_ollama_model: str = os.getenv("MPT_OLLAMA_MODEL", "qwen3:8b") + ffmpeg_bin: str = os.getenv("FFMPEG_BIN", "ffmpeg") + ffprobe_bin: str = os.getenv("FFPROBE_BIN", "ffprobe") + + @property + def uploads_dir(self) -> Path: + return self.storage_dir / "uploads" + + @property + def tasks_dir(self) -> Path: + return self.storage_dir / "tasks" + + def ensure_dirs(self) -> None: + self.uploads_dir.mkdir(parents=True, exist_ok=True) + self.tasks_dir.mkdir(parents=True, exist_ok=True) + + +settings = Settings() +settings.ensure_dirs() diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/schemas.py b/app/models/schemas.py new file mode 100644 index 0000000..146e017 --- /dev/null +++ b/app/models/schemas.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from enum import Enum +from typing import Literal + +from pydantic import BaseModel, Field + + +class TaskState(str, Enum): + queued = "queued" + running = "running" + completed = "completed" + failed = "failed" + + +class ProviderInfo(BaseModel): + id: str + name: str + icon: str + kind: Literal["account", "local"] + installed: bool + authenticated: bool | None = None + status: str + login_hint: str | None = None + install_hint: str | None = None + + +class GenerateRequest(BaseModel): + topic: str = Field(min_length=2, max_length=1000) + provider: str = "gemini" + language: str = "Arabic" + script: str | None = None + duration: int = Field(default=45, ge=10, le=600) + aspect_ratio: Literal["9:16", "16:9", "1:1"] = "9:16" + voice: str = "ar-SA-HamedNeural" + subtitles: bool = True + subtitle_position: Literal["bottom", "center"] = "bottom" + material_ids: list[str] = Field(default_factory=list) + bgm_id: str | None = None + bgm_volume: float = Field(default=0.12, ge=0.0, le=1.0) + batch_count: int = Field(default=1, ge=1, le=4) + + +class TaskInfo(BaseModel): + id: str + state: TaskState + progress: int = 0 + message: str = "" + output_files: list[str] = Field(default_factory=list) + error: str | None = None diff --git a/app/providers/__init__.py b/app/providers/__init__.py new file mode 100644 index 0000000..9f6e424 --- /dev/null +++ b/app/providers/__init__.py @@ -0,0 +1,3 @@ +from .registry import provider_registry + +__all__ = ["provider_registry"] diff --git a/app/providers/base.py b/app/providers/base.py new file mode 100644 index 0000000..dddfef3 --- /dev/null +++ b/app/providers/base.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ProviderDescriptor: + id: str + name: str + icon: str + kind: str + executable: str + login_command: tuple[str, ...] | None + install_hint: str + login_hint: str + + +class ProviderError(RuntimeError): + pass + + +class TextProvider(ABC): + descriptor: ProviderDescriptor + + @abstractmethod + def installed(self) -> bool: ... + + @abstractmethod + def auth_status(self) -> tuple[bool | None, str]: ... + + @abstractmethod + def generate(self, prompt: str) -> str: ... diff --git a/app/providers/cli.py b/app/providers/cli.py new file mode 100644 index 0000000..7c0b457 --- /dev/null +++ b/app/providers/cli.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +from app.core.config import settings +from app.providers.base import ProviderDescriptor, ProviderError, TextProvider + + +def _run(args: list[str], timeout: int = 180, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env.setdefault("NO_COLOR", "1") + return subprocess.run( + args, + text=True, + capture_output=True, + timeout=timeout, + cwd=str(cwd or settings.storage_dir), + env=env, + stdin=subprocess.DEVNULL, + check=False, + ) + + +class CodexProvider(TextProvider): + descriptor = ProviderDescriptor( + id="chatgpt", + name="ChatGPT", + icon="✦", + kind="account", + executable="codex", + login_command=("codex", "login"), + install_hint="npm install -g @openai/codex", + login_hint="Sign in with your ChatGPT account in the official Codex login flow.", + ) + + def installed(self) -> bool: + return shutil.which("codex") is not None + + def auth_status(self) -> tuple[bool | None, str]: + if not self.installed(): + return False, "Codex CLI is not installed" + p = _run(["codex", "login", "status"], timeout=15) + text = (p.stdout + "\n" + p.stderr).strip() + ok = p.returncode == 0 and "logged in" in text.lower() and "not logged in" not in text.lower() + return ok, text or ("Connected" if ok else "Not connected") + + def generate(self, prompt: str) -> str: + if not self.installed(): + raise ProviderError("Install Codex CLI first") + p = _run( + ["codex", "exec", "--ephemeral", "--skip-git-repo-check", prompt], + timeout=240, + ) + if p.returncode != 0: + raise ProviderError((p.stderr or p.stdout).strip() or "Codex failed") + text = p.stdout.strip() + if not text: + raise ProviderError("Codex returned an empty response") + return text + + +class ClaudeProvider(TextProvider): + descriptor = ProviderDescriptor( + id="claude", + name="Claude", + icon="A", + kind="account", + executable="claude", + login_command=("claude", "auth", "login"), + install_hint="npm install -g @anthropic-ai/claude-code", + login_hint="Sign in with your Claude account (supported subscription/organization required).", + ) + + def installed(self) -> bool: + return shutil.which("claude") is not None + + def auth_status(self) -> tuple[bool | None, str]: + if not self.installed(): + return False, "Claude Code is not installed" + p = _run(["claude", "auth", "status"], timeout=15) + text = (p.stdout + "\n" + p.stderr).strip() + lowered = text.lower() + ok = p.returncode == 0 and ("loggedin" in lowered or "logged in" in lowered) + if '"loggedin": false' in lowered or "not logged" in lowered: + ok = False + return ok, text or ("Connected" if ok else "Not connected") + + def generate(self, prompt: str) -> str: + if not self.installed(): + raise ProviderError("Install Claude Code first") + p = _run(["claude", "-p", prompt], timeout=240) + if p.returncode != 0: + raise ProviderError((p.stderr or p.stdout).strip() or "Claude failed") + text = p.stdout.strip() + if not text: + raise ProviderError("Claude returned an empty response") + return text + + +class GeminiProvider(TextProvider): + descriptor = ProviderDescriptor( + id="gemini", + name="Gemini", + icon="◆", + kind="account", + executable="gemini", + login_command=("gemini",), + install_hint="npm install -g @google/gemini-cli", + login_hint="Open Gemini CLI, choose Login with Google, and complete the browser sign-in.", + ) + + def installed(self) -> bool: + return shutil.which("gemini") is not None + + def auth_status(self) -> tuple[bool | None, str]: + if not self.installed(): + return False, "Gemini CLI is not installed" + # Gemini CLI does not expose a stable auth-status command across releases. + # We intentionally do not inspect/copy token files; installed is reported and a live prompt validates auth. + return None, "Installed — connection is verified on first generation" + + def generate(self, prompt: str) -> str: + if not self.installed(): + raise ProviderError("Install Gemini CLI first") + p = _run(["gemini", "-p", prompt, "--output-format", "json"], timeout=240) + if p.returncode != 0: + raise ProviderError((p.stderr or p.stdout).strip() or "Gemini failed") + raw = p.stdout.strip() + try: + payload = json.loads(raw) + text = payload.get("response") or payload.get("result") or "" + except json.JSONDecodeError: + text = raw + if not str(text).strip(): + raise ProviderError("Gemini returned an empty response") + return str(text).strip() + + +class OllamaProvider(TextProvider): + descriptor = ProviderDescriptor( + id="ollama", + name="Ollama Local", + icon="◉", + kind="local", + executable="ollama", + login_command=None, + install_hint="Install Ollama from the official Ollama app, then pull a model (for example: ollama pull qwen3:8b).", + login_hint="No account or API key required.", + ) + + def installed(self) -> bool: + return shutil.which("ollama") is not None + + def auth_status(self) -> tuple[bool | None, str]: + if not self.installed(): + return False, "Ollama is not installed" + p = _run(["ollama", "list"], timeout=20) + ok = p.returncode == 0 + return ok, "Local runtime ready" if ok else (p.stderr.strip() or "Ollama is not running") + + def generate(self, prompt: str) -> str: + if not self.installed(): + raise ProviderError("Install Ollama first") + p = _run(["ollama", "run", settings.default_ollama_model, prompt], timeout=300) + if p.returncode != 0: + raise ProviderError((p.stderr or p.stdout).strip() or "Ollama failed") + text = p.stdout.strip() + if not text: + raise ProviderError("Ollama returned an empty response") + return text diff --git a/app/providers/registry.py b/app/providers/registry.py new file mode 100644 index 0000000..5ef9287 --- /dev/null +++ b/app/providers/registry.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import platform +import shutil +import subprocess +from dataclasses import asdict + +from app.models.schemas import ProviderInfo +from app.providers.base import ProviderError, TextProvider +from app.providers.cli import ClaudeProvider, CodexProvider, GeminiProvider, OllamaProvider + + +class ProviderRegistry: + def __init__(self) -> None: + providers: list[TextProvider] = [CodexProvider(), GeminiProvider(), ClaudeProvider(), OllamaProvider()] + self._providers = {provider.descriptor.id: provider for provider in providers} + + def get(self, provider_id: str) -> TextProvider: + provider = self._providers.get(provider_id) + if not provider: + raise ProviderError(f"Unknown provider: {provider_id}") + return provider + + def list(self) -> list[ProviderInfo]: + result: list[ProviderInfo] = [] + for provider in self._providers.values(): + d = provider.descriptor + installed = provider.installed() + authenticated, status = provider.auth_status() if installed else (False, f"{d.name} is not installed") + result.append( + ProviderInfo( + id=d.id, + name=d.name, + icon=d.icon, + kind=d.kind, + installed=installed, + authenticated=authenticated, + status=status[:400], + login_hint=d.login_hint, + install_hint=d.install_hint, + ) + ) + return result + + def generate(self, provider_id: str, prompt: str) -> str: + return self.get(provider_id).generate(prompt) + + def open_login(self, provider_id: str) -> str: + provider = self.get(provider_id) + command = provider.descriptor.login_command + if not command: + return provider.descriptor.login_hint + if not provider.installed(): + return provider.descriptor.install_hint + + system = platform.system().lower() + cmd_text = " ".join(command) + try: + if system == "windows": + subprocess.Popen(["cmd.exe", "/c", "start", "", "cmd.exe", "/k", cmd_text]) + elif system == "darwin": + escaped = cmd_text.replace('"', '\\"') + subprocess.Popen(["osascript", "-e", f'tell application "Terminal" to do script "{escaped}"']) + else: + terminal = shutil.which("x-terminal-emulator") or shutil.which("gnome-terminal") or shutil.which("konsole") + if not terminal: + return f"Run this command in a terminal: {cmd_text}" + if terminal.endswith("gnome-terminal"): + subprocess.Popen([terminal, "--", "bash", "-lc", f"{cmd_text}; exec bash"]) + else: + subprocess.Popen([terminal, "-e", "bash", "-lc", f"{cmd_text}; exec bash"]) + except Exception: + return f"Run this command in a terminal: {cmd_text}" + return f"Login opened. Complete the sign-in flow, then refresh provider status. Command: {cmd_text}" + + +provider_registry = ProviderRegistry() diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/media.py b/app/services/media.py new file mode 100644 index 0000000..8cec403 --- /dev/null +++ b/app/services/media.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +from app.core.config import settings + +VIDEO_EXT = {".mp4", ".mov", ".mkv", ".webm", ".avi", ".m4v"} +IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp", ".bmp"} +AUDIO_EXT = {".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac"} + + +def require_ffmpeg() -> None: + if not shutil.which(settings.ffmpeg_bin) or not shutil.which(settings.ffprobe_bin): + raise RuntimeError("FFmpeg and ffprobe are required. Install FFmpeg and make both commands available in PATH.") + + +def run_ffmpeg(args: list[str], cwd: Path | None = None) -> None: + require_ffmpeg() + p = subprocess.run( + [settings.ffmpeg_bin, "-hide_banner", "-loglevel", "error", "-y", *args], + text=True, + capture_output=True, + cwd=str(cwd) if cwd else None, + check=False, + ) + if p.returncode != 0: + raise RuntimeError(p.stderr.strip() or "FFmpeg failed") + + +def duration(path: Path) -> float: + require_ffmpeg() + p = subprocess.run( + [settings.ffprobe_bin, "-v", "error", "-show_entries", "format=duration", "-of", "json", str(path)], + text=True, + capture_output=True, + check=False, + ) + if p.returncode != 0: + raise RuntimeError(p.stderr.strip() or "ffprobe failed") + data = json.loads(p.stdout) + return float(data["format"]["duration"]) + + +def dimensions(aspect_ratio: str) -> tuple[int, int]: + return {"9:16": (1080, 1920), "16:9": (1920, 1080), "1:1": (1080, 1080)}[aspect_ratio] + + +def normalize_material(source: Path, target: Path, seconds: float, size: tuple[int, int]) -> Path: + w, h = size + vf = f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h},fps=30,format=yuv420p" + suffix = source.suffix.lower() + if suffix in IMAGE_EXT: + args = ["-loop", "1", "-i", str(source), "-t", f"{seconds:.3f}", "-vf", vf] + elif suffix in VIDEO_EXT: + args = ["-stream_loop", "-1", "-i", str(source), "-t", f"{seconds:.3f}", "-vf", vf] + else: + raise RuntimeError(f"Unsupported visual material: {source.name}") + args += ["-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "21", str(target)] + run_ffmpeg(args) + return target + + +def make_background(materials: list[Path], target: Path, seconds: float, aspect_ratio: str) -> Path: + size = dimensions(aspect_ratio) + if not materials: + w, h = size + run_ffmpeg([ + "-f", "lavfi", "-i", f"color=c=0x111318:s={w}x{h}:r=30:d={seconds:.3f}", + "-vf", "format=yuv420p", "-an", "-c:v", "libx264", "-preset", "veryfast", str(target), + ]) + return target + + valid = [path for path in materials if path.suffix.lower() in VIDEO_EXT | IMAGE_EXT] + if not valid: + return make_background([], target, seconds, aspect_ratio) + + segment_seconds = seconds / len(valid) + segments: list[Path] = [] + for index, source in enumerate(valid): + segment = target.parent / f"segment-{index:03}.mp4" + normalize_material(source, segment, segment_seconds, size) + segments.append(segment) + + concat_file = target.parent / "concat.txt" + concat_file.write_text("\n".join(f"file '{p.name.replace(chr(39), '')}'" for p in segments), encoding="utf-8") + run_ffmpeg(["-f", "concat", "-safe", "0", "-i", concat_file.name, "-c", "copy", target.name], cwd=target.parent) + return target + + +def render_final( + background: Path, + voice: Path, + output: Path, + srt: Path | None = None, + subtitle_position: str = "bottom", + bgm: Path | None = None, + bgm_volume: float = 0.12, +) -> Path: + inputs = ["-i", str(background), "-i", str(voice)] + filter_parts: list[str] = [] + video_map = "0:v:0" + + if srt: + margin = 70 if subtitle_position == "bottom" else 600 + escaped = srt.name.replace("'", "\\'") + style = f"FontName=Arial,FontSize=18,PrimaryColour=&H00FFFFFF,OutlineColour=&H90000000,BorderStyle=3,Outline=1,Shadow=0,MarginV={margin},Alignment=2" + filter_parts.append(f"[0:v]subtitles='{escaped}':force_style='{style}'[vout]") + video_map = "[vout]" + + if bgm: + inputs += ["-stream_loop", "-1", "-i", str(bgm)] + filter_parts.append(f"[2:a]volume={bgm_volume:.3f}[music];[1:a][music]amix=inputs=2:duration=first:dropout_transition=2[aout]") + audio_map = "[aout]" + else: + audio_map = "1:a:0" + + args = [*inputs] + if filter_parts: + args += ["-filter_complex", ";".join(filter_parts)] + args += ["-map", video_map, "-map", audio_map, "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-c:a", "aac", "-b:a", "192k", "-shortest", "-movflags", "+faststart", str(output)] + run_ffmpeg(args, cwd=output.parent) + return output diff --git a/app/services/script.py b/app/services/script.py new file mode 100644 index 0000000..eb47b43 --- /dev/null +++ b/app/services/script.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from app.providers.registry import provider_registry + + +def build_script_prompt(topic: str, language: str, duration: int) -> str: + words = max(45, int(duration * 2.15)) + return f"""You are a professional short-form video writer. +Create a compelling voice-over script about: {topic} +Language: {language} +Target spoken duration: about {duration} seconds (roughly {words} words). +Requirements: +- Start with a strong hook immediately. +- Use natural human phrasing, short spoken sentences, and clear progression. +- No markdown headings, no bullet labels, no scene directions, no emojis. +- Avoid unsupported factual claims. +- End with a concise memorable takeaway or call to action. +Return ONLY the narration text. +""".strip() + + +def generate_script(provider: str, topic: str, language: str, duration: int) -> str: + prompt = build_script_prompt(topic, language, duration) + return provider_registry.generate(provider, prompt).strip() diff --git a/app/services/subtitles.py b/app/services/subtitles.py new file mode 100644 index 0000000..6df6fca --- /dev/null +++ b/app/services/subtitles.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import re +from pathlib import Path + + +def _stamp(seconds: float) -> str: + ms = max(0, int(round(seconds * 1000))) + h, ms = divmod(ms, 3_600_000) + m, ms = divmod(ms, 60_000) + s, ms = divmod(ms, 1000) + return f"{h:02}:{m:02}:{s:02},{ms:03}" + + +def split_sentences(text: str) -> list[str]: + chunks = re.split(r"(?<=[.!?؟。!?])\s+|\n+", text.strip()) + return [chunk.strip() for chunk in chunks if chunk.strip()] + + +def write_srt(text: str, duration: float, output: Path) -> Path: + sentences = split_sentences(text) or [text.strip()] + weights = [max(1, len(sentence.split())) for sentence in sentences] + total = sum(weights) + cursor = 0.0 + lines: list[str] = [] + for index, (sentence, weight) in enumerate(zip(sentences, weights, strict=True), start=1): + segment = duration * (weight / total) + start = cursor + end = duration if index == len(sentences) else min(duration, cursor + segment) + lines.extend([str(index), f"{_stamp(start)} --> {_stamp(end)}", sentence, ""]) + cursor = end + output.write_text("\n".join(lines), encoding="utf-8") + return output diff --git a/app/services/tts.py b/app/services/tts.py new file mode 100644 index 0000000..94ec036 --- /dev/null +++ b/app/services/tts.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +import edge_tts + + +async def _save(text: str, voice: str, output: Path) -> None: + communicate = edge_tts.Communicate(text=text, voice=voice) + await communicate.save(str(output)) + + +def synthesize(text: str, voice: str, output: Path) -> Path: + output.parent.mkdir(parents=True, exist_ok=True) + asyncio.run(_save(text, voice, output)) + if not output.exists() or output.stat().st_size == 0: + raise RuntimeError("TTS did not create audio output") + return output diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..92a3b6d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["hatchling>=1.27.0"] +build-backend = "hatchling.build" + +[project] +name = "moneyprinterturbo-noapi" +version = "0.1.0" +description = "Local-first short video generator with account-based AI CLI providers and no user API keys." +readme = "README.md" +requires-python = ">=3.11" +license = { text = "MIT" } +dependencies = [ + "fastapi==0.116.1", + "uvicorn[standard]==0.35.0", + "python-multipart==0.0.20", + "edge-tts==7.2.3", + "pydantic==2.11.7", +] + +[project.optional-dependencies] +dev = ["pytest==8.4.1", "httpx==0.28.1", "ruff==0.12.8"] + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[tool.ruff] +line-length = 100 diff --git a/storage/.gitkeep b/storage/.gitkeep new file mode 100644 index 0000000..e69de29 From b6a44c8cd377b6118237183be30948c28c5a4857 Mon Sep 17 00:00:00 2001 From: Ramzi Sultan <33006097+GenRamzi@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:47:43 +0300 Subject: [PATCH 2/3] add task pipeline and modern web studio --- app/api.py | 104 +++++++++++++++++++++++++++++ app/main.py | 46 +++++++++++++ app/services/tasks.py | 151 ++++++++++++++++++++++++++++++++++++++++++ main.py | 4 ++ web/assets/app.css | 2 + web/assets/app.js | 18 +++++ web/index.html | 79 ++++++++++++++++++++++ 7 files changed, 404 insertions(+) create mode 100644 app/api.py create mode 100644 app/main.py create mode 100644 app/services/tasks.py create mode 100644 main.py create mode 100644 web/assets/app.css create mode 100644 web/assets/app.js create mode 100644 web/index.html diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..7dde553 --- /dev/null +++ b/app/api.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import shutil +import uuid +from pathlib import Path + +from fastapi import APIRouter, File, HTTPException, UploadFile +from fastapi.responses import FileResponse + +from app.core.config import settings +from app.models.schemas import GenerateRequest, TaskInfo +from app.providers.registry import provider_registry +from app.services.media import AUDIO_EXT, IMAGE_EXT, VIDEO_EXT +from app.services.tasks import task_manager + +router = APIRouter(prefix="/api") +ALLOWED_UPLOADS = VIDEO_EXT | IMAGE_EXT | AUDIO_EXT + + +@router.get("/health") +def health() -> dict: + return { + "ok": True, + "app": settings.app_name, + "ffmpeg": bool(shutil.which(settings.ffmpeg_bin)), + "ffprobe": bool(shutil.which(settings.ffprobe_bin)), + } + + +@router.get("/providers") +def providers(): + return provider_registry.list() + + +@router.post("/providers/{provider_id}/login") +def provider_login(provider_id: str) -> dict: + try: + message = provider_registry.open_login(provider_id) + except Exception as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return {"ok": True, "message": message} + + +@router.get("/voices") +def voices() -> list[dict[str, str]]: + return [ + {"id": "ar-SA-HamedNeural", "name": "Hamed — Arabic (Saudi)"}, + {"id": "ar-SA-ZariyahNeural", "name": "Zariyah — Arabic (Saudi)"}, + {"id": "ar-EG-ShakirNeural", "name": "Shakir — Arabic (Egypt)"}, + {"id": "ar-EG-SalmaNeural", "name": "Salma — Arabic (Egypt)"}, + {"id": "en-US-AndrewNeural", "name": "Andrew — English (US)"}, + {"id": "en-US-AvaNeural", "name": "Ava — English (US)"}, + {"id": "en-GB-RyanNeural", "name": "Ryan — English (UK)"}, + {"id": "en-GB-SoniaNeural", "name": "Sonia — English (UK)"}, + ] + + +@router.post("/uploads") +async def upload(files: list[UploadFile] = File(...)) -> list[dict[str, str]]: + saved: list[dict[str, str]] = [] + max_bytes = settings.max_upload_mb * 1024 * 1024 + for file in files: + suffix = Path(file.filename or "").suffix.lower() + if suffix not in ALLOWED_UPLOADS: + raise HTTPException(status_code=415, detail=f"Unsupported file type: {suffix or 'unknown'}") + data = await file.read(max_bytes + 1) + if len(data) > max_bytes: + raise HTTPException(status_code=413, detail=f"File exceeds {settings.max_upload_mb} MB") + file_id = f"{uuid.uuid4().hex}{suffix}" + target = settings.uploads_dir / file_id + target.write_bytes(data) + saved.append({"id": file_id, "name": file.filename or file_id}) + return saved + + +@router.post("/tasks", response_model=TaskInfo) +def create_task(request: GenerateRequest) -> TaskInfo: + return task_manager.create(request) + + +@router.get("/tasks", response_model=list[TaskInfo]) +def list_tasks() -> list[TaskInfo]: + return task_manager.list() + + +@router.get("/tasks/{task_id}", response_model=TaskInfo) +def get_task(task_id: str) -> TaskInfo: + task = task_manager.get(task_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + return task + + +@router.get("/tasks/{task_id}/files/{filename}") +def task_file(task_id: str, filename: str): + if Path(task_id).name != task_id or Path(filename).name != filename: + raise HTTPException(status_code=400, detail="Invalid path") + task = task_manager.get(task_id) + if not task or filename not in task.output_files: + raise HTTPException(status_code=404, detail="File not found") + path = (settings.tasks_dir / task_id / filename).resolve() + if path.parent != (settings.tasks_dir / task_id).resolve() or not path.exists(): + raise HTTPException(status_code=404, detail="File not found") + return FileResponse(path, media_type="video/mp4", filename=filename) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..25d4d71 --- /dev/null +++ b/app/main.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import FastAPI +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from app import __version__ +from app.api import router +from app.core.config import ROOT, settings + +app = FastAPI( + title="MoneyPrinterTurbo NoAPI", + version=__version__, + description="Local-first AI short-video studio with account-based providers and no user API keys.", +) +app.include_router(router) + +web_dir = ROOT / "web" +assets_dir = web_dir / "assets" +if assets_dir.exists(): + app.mount("/assets", StaticFiles(directory=assets_dir), name="assets") + + +@app.get("/", include_in_schema=False) +def home(): + return FileResponse(web_dir / "index.html") + + +@app.get("/{path:path}", include_in_schema=False) +def spa(path: str): + candidate = (web_dir / path).resolve() + if candidate.is_file() and web_dir in candidate.parents: + return FileResponse(candidate) + return FileResponse(web_dir / "index.html") + + +def run() -> None: + import uvicorn + + uvicorn.run("app.main:app", host=settings.host, port=settings.port, reload=False) + + +if __name__ == "__main__": + run() diff --git a/app/services/tasks.py b/app/services/tasks.py new file mode 100644 index 0000000..27e6933 --- /dev/null +++ b/app/services/tasks.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import json +import threading +import uuid +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from app.core.config import settings +from app.models.schemas import GenerateRequest, TaskInfo, TaskState +from app.services.media import AUDIO_EXT, IMAGE_EXT, VIDEO_EXT, duration, make_background, render_final +from app.services.script import generate_script +from app.services.subtitles import write_srt +from app.services.tts import synthesize + + +class TaskManager: + def __init__(self) -> None: + self._tasks: dict[str, TaskInfo] = {} + self._lock = threading.RLock() + self._pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="mpt-video") + + def _set(self, task_id: str, **changes) -> None: + with self._lock: + task = self._tasks[task_id] + self._tasks[task_id] = task.model_copy(update=changes) + self._persist(self._tasks[task_id]) + + def _persist(self, task: TaskInfo) -> None: + folder = settings.tasks_dir / task.id + folder.mkdir(parents=True, exist_ok=True) + (folder / "task.json").write_text(task.model_dump_json(indent=2), encoding="utf-8") + + def create(self, request: GenerateRequest) -> TaskInfo: + task_id = str(uuid.uuid4()) + task = TaskInfo(id=task_id, state=TaskState.queued, progress=0, message="Queued") + with self._lock: + self._tasks[task_id] = task + self._persist(task) + self._pool.submit(self._run, task_id, request) + return task + + def get(self, task_id: str) -> TaskInfo | None: + with self._lock: + task = self._tasks.get(task_id) + if task: + return task + manifest = settings.tasks_dir / task_id / "task.json" + if manifest.exists(): + try: + task = TaskInfo.model_validate_json(manifest.read_text(encoding="utf-8")) + with self._lock: + self._tasks[task_id] = task + return task + except Exception: + return None + return None + + def list(self) -> list[TaskInfo]: + with self._lock: + known = list(self._tasks.values()) + return sorted(known, key=lambda item: item.id, reverse=True)[:50] + + def _upload_path(self, material_id: str) -> Path | None: + safe = Path(material_id).name + if safe != material_id: + return None + candidate = (settings.uploads_dir / safe).resolve() + if candidate.parent != settings.uploads_dir: + return None + return candidate if candidate.exists() else None + + def _run(self, task_id: str, request: GenerateRequest) -> None: + folder = settings.tasks_dir / task_id + folder.mkdir(parents=True, exist_ok=True) + try: + self._set(task_id, state=TaskState.running, progress=5, message="Preparing project") + (folder / "request.json").write_text( + json.dumps(request.model_dump(), ensure_ascii=False, indent=2), encoding="utf-8" + ) + + script = (request.script or "").strip() + if not script: + self._set(task_id, progress=12, message=f"Writing script with {request.provider}") + script = generate_script(request.provider, request.topic, request.language, request.duration) + (folder / "script.txt").write_text(script, encoding="utf-8") + + self._set(task_id, progress=28, message="Generating voice-over") + voice_file = synthesize(script, request.voice, folder / "voice.mp3") + audio_seconds = max(1.0, duration(voice_file)) + + visual_materials: list[Path] = [] + for item in request.material_ids: + path = self._upload_path(item) + if path and path.suffix.lower() in (VIDEO_EXT | IMAGE_EXT): + visual_materials.append(path) + + bgm = self._upload_path(request.bgm_id) if request.bgm_id else None + if bgm and bgm.suffix.lower() not in AUDIO_EXT: + bgm = None + + srt: Path | None = None + if request.subtitles: + self._set(task_id, progress=38, message="Creating subtitles") + srt = write_srt(script, audio_seconds, folder / "captions.srt") + + outputs: list[str] = [] + for index in range(request.batch_count): + base = 42 + int(index * (48 / max(1, request.batch_count))) + self._set( + task_id, + progress=min(base, 88), + message=f"Rendering video {index + 1}/{request.batch_count}", + ) + ordered = visual_materials[index:] + visual_materials[:index] if visual_materials else [] + background = make_background( + ordered, + folder / f"background-{index + 1:02}.mp4", + audio_seconds, + request.aspect_ratio, + ) + output = folder / f"video-{index + 1:02}.mp4" + render_final( + background=background, + voice=voice_file, + output=output, + srt=srt, + subtitle_position=request.subtitle_position, + bgm=bgm, + bgm_volume=request.bgm_volume, + ) + outputs.append(output.name) + + self._set( + task_id, + state=TaskState.completed, + progress=100, + message="Video ready", + output_files=outputs, + ) + except Exception as exc: + self._set( + task_id, + state=TaskState.failed, + progress=100, + message="Generation failed", + error=str(exc)[:2000], + ) + + +task_manager = TaskManager() diff --git a/main.py b/main.py new file mode 100644 index 0000000..f7065ba --- /dev/null +++ b/main.py @@ -0,0 +1,4 @@ +from app.main import run + +if __name__ == "__main__": + run() diff --git a/web/assets/app.css b/web/assets/app.css new file mode 100644 index 0000000..a51e995 --- /dev/null +++ b/web/assets/app.css @@ -0,0 +1,2 @@ +@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap'); +:root{--bg:#08090c;--panel:#111318;--panel2:#151820;--line:#242833;--text:#f7f7f8;--muted:#9298a5;--accent:#8b5cf6;--accent2:#b794f6;--ok:#4ade80;--danger:#fb7185;--radius:18px}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:radial-gradient(circle at 70% -20%,rgba(139,92,246,.16),transparent 30%),var(--bg);color:var(--text);font-family:'IBM Plex Sans Arabic',Inter,sans-serif}.app-shell{min-height:100vh;display:grid;grid-template-columns:230px minmax(0,1fr)}.sidebar{position:sticky;top:0;height:100vh;padding:28px 18px;border-left:1px solid var(--line);background:rgba(8,9,12,.82);backdrop-filter:blur(18px);display:flex;flex-direction:column;gap:30px}.brand{display:flex;align-items:center;gap:11px;direction:ltr}.brand-mark{width:38px;height:38px;display:grid;place-items:center;border-radius:11px;background:linear-gradient(135deg,#7c3aed,#a78bfa);font:700 18px Inter;box-shadow:0 8px 30px rgba(139,92,246,.26)}.brand b{display:block;font:700 14px Inter}.brand span{display:block;color:var(--muted);font:500 11px Inter;margin-top:2px}.sidebar nav{display:flex;flex-direction:column;gap:6px}.nav-item{width:100%;border:0;background:transparent;color:var(--muted);padding:11px 12px;border-radius:10px;font:600 14px inherit;text-align:right;cursor:pointer;display:flex;gap:10px;align-items:center}.nav-item:hover,.nav-item.active{background:#151820;color:#fff}.nav-item span{color:var(--accent2);width:18px}.side-status{margin-top:auto;padding:12px;border:1px solid var(--line);border-radius:12px;display:flex;align-items:center;gap:10px}.dot,.pulse-dot{width:8px;height:8px;border-radius:50%;background:#fbbf24;box-shadow:0 0 0 4px rgba(251,191,36,.1)}.dot.ok{background:var(--ok);box-shadow:0 0 0 4px rgba(74,222,128,.1)}.side-status b,.side-status small{display:block}.side-status b{font-size:12px}.side-status small,.side-note{font-size:10px;color:var(--muted);line-height:1.6}.side-note{direction:ltr;text-align:left;padding:0 4px}main{padding:32px clamp(24px,4vw,64px) 70px;max-width:1500px;width:100%;margin:auto}.topbar{display:flex;justify-content:space-between;gap:28px;align-items:flex-start;margin-bottom:28px}.eyebrow{margin:0 0 7px;color:var(--accent2);font:700 10px Inter;letter-spacing:.16em}.topbar h1{font-size:clamp(25px,3vw,42px);letter-spacing:-.035em;line-height:1.25;margin:0;max-width:720px}.top-actions{display:flex;gap:8px}.ghost,.mini{background:#12151b;border:1px solid var(--line);color:#d9dde6;border-radius:10px;padding:9px 12px;text-decoration:none;font:600 12px inherit;cursor:pointer}.ghost:hover,.mini:hover{border-color:#3b4150}.panel{border:1px solid var(--line);background:linear-gradient(180deg,rgba(19,21,27,.96),rgba(13,15,19,.96));border-radius:var(--radius);box-shadow:0 20px 60px rgba(0,0,0,.18)}.accounts-panel,.results-panel{padding:22px;margin-bottom:20px}.section-head{display:flex;justify-content:space-between;align-items:flex-end;gap:25px;margin-bottom:18px}.section-head.compact{align-items:center}.section-head h2{margin:0;font-size:20px}.section-head>p{max-width:500px;color:var(--muted);font-size:12px;margin:0}.badge{border:1px solid #353047;background:#1b1725;color:#c9b6ff;border-radius:999px;padding:6px 9px;font-size:10px}.provider-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}.provider-card{min-height:118px;padding:14px;border:1px solid var(--line);border-radius:14px;background:#0d0f14;display:flex;flex-direction:column;gap:10px}.provider-top{display:flex;justify-content:space-between;align-items:center}.provider-icon{width:31px;height:31px;display:grid;place-items:center;border-radius:9px;background:#1b1824;color:#cbb8ff;font:700 14px Inter}.provider-state{font-size:9px;padding:4px 7px;border-radius:999px;background:#1c2028;color:#a4aab7}.provider-state.connected{background:rgba(74,222,128,.11);color:#82efaa}.provider-card b{font:700 13px Inter}.provider-card p{font-size:10px;color:var(--muted);margin:0;line-height:1.5;min-height:30px}.provider-card button{margin-top:auto;width:max-content}.skeleton{height:118px;border-radius:14px;background:linear-gradient(90deg,#101218,#171a22,#101218);background-size:220% 100%;animation:shimmer 1.5s infinite}@keyframes shimmer{to{background-position:-220% 0}}.creator-grid{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(280px,.7fr);gap:20px;margin-bottom:20px}.creator-card{padding:24px}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.field{display:flex;flex-direction:column;gap:7px}.field.full{grid-column:1/-1}.field label{font-size:11px;color:#b9bec9;font-weight:600}.field small{color:#717784;font-weight:400;margin-right:4px}textarea,select,input[type=text]{width:100%;border:1px solid var(--line);background:#0b0d11;color:var(--text);border-radius:10px;padding:11px 12px;font:500 13px inherit;outline:0}textarea{resize:vertical;min-height:80px}textarea:focus,select:focus{border-color:#5c477e;box-shadow:0 0 0 3px rgba(139,92,246,.08)}select{appearance:auto}.range-wrap{display:flex;align-items:center;gap:12px}input[type=range]{accent-color:var(--accent);width:100%}output{font-size:10px;color:var(--muted);min-width:55px}.segmented{display:grid;grid-template-columns:repeat(3,1fr);gap:5px}.segmented input{display:none}.segmented span{display:block;text-align:center;padding:9px;border:1px solid var(--line);border-radius:9px;font:600 11px Inter;color:var(--muted);cursor:pointer}.segmented input:checked+span{background:#20192f;border-color:#634a91;color:#fff}.advanced{margin:17px 0;border:1px solid var(--line);border-radius:11px;background:#0d0f13}.advanced summary{cursor:pointer;padding:11px 13px;font-size:11px;color:#b9bec9}.advanced-body{border-top:1px solid var(--line);padding:14px;display:grid;grid-template-columns:1fr 1fr;gap:12px}.check{display:flex!important;flex-direction:row;align-items:center;gap:8px}.upload-zone{margin-top:16px;border:1px dashed #3a3f4d;background:#0c0e12;border-radius:13px;padding:14px;display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:12px;cursor:pointer}.upload-zone.drag{border-color:#7957b1;background:#12101a}.upload-icon{width:34px;height:34px;border-radius:10px;background:#171a21;display:grid;place-items:center;font-size:20px;color:#a990d5}.upload-zone b,.upload-zone span{display:block}.upload-zone b{font-size:11px}.upload-zone span{font-size:10px;color:var(--muted);margin-top:2px}.file-list{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}.file-pill{padding:5px 8px;border:1px solid var(--line);border-radius:8px;font-size:9px;color:#bfc4ce;background:#101217}.bgm-row{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:16px 0}.field input[type=file]{font-size:10px;color:var(--muted)}.primary{width:100%;border:0;border-radius:11px;padding:13px;background:linear-gradient(135deg,#7c3aed,#9f7aea);color:#fff;font:700 13px inherit;cursor:pointer;box-shadow:0 10px 30px rgba(124,58,237,.2)}.primary:disabled{opacity:.5;cursor:not-allowed}.task-card{padding:18px;align-self:start;position:sticky;top:22px}.preview-frame{aspect-ratio:9/13;border-radius:14px;background:radial-gradient(circle at 40% 30%,#2d2142 0,transparent 25%),linear-gradient(145deg,#151822,#090a0d);position:relative;overflow:hidden;display:grid;place-items:center;border:1px solid #292d37}.preview-frame:after{content:'';position:absolute;inset:0;background:linear-gradient(180deg,transparent,rgba(0,0,0,.4))}.preview-noise{position:absolute;inset:0;opacity:.12;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.35'/%3E%3C/svg%3E")}.preview-copy{position:relative;z-index:2;text-align:center;padding:20px}.pulse-dot{display:inline-block;background:var(--ok);box-shadow:0 0 0 5px rgba(74,222,128,.08)}.preview-copy p{margin:12px 0 2px;font-weight:700}.preview-copy small{color:var(--muted);font-size:10px}.progress-box{padding:14px 2px 5px}.hidden{display:none}.progress-meta{display:flex;justify-content:space-between;font-size:10px;margin-bottom:7px}.progress-track{height:5px;background:#222630;border-radius:99px;overflow:hidden}.progress-track div{height:100%;width:0;background:linear-gradient(90deg,#7c3aed,#b794f6);transition:width .3s}.feature-list{margin-top:14px;display:flex;flex-direction:column}.feature-list>div{display:flex;gap:10px;padding:10px 0;border-top:1px solid var(--line)}.feature-list>div>span{font:700 9px Inter;color:#685985;padding-top:3px}.feature-list b,.feature-list small{display:block}.feature-list b{font:700 10px Inter}.feature-list small{font-size:9px;color:var(--muted);margin-top:2px}.results-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.result-card{border:1px solid var(--line);background:#0b0d11;border-radius:13px;overflow:hidden}.result-card video{display:block;width:100%;aspect-ratio:9/13;object-fit:cover;background:#050506}.result-footer{padding:10px;display:flex;justify-content:space-between;align-items:center}.result-footer b{font-size:10px}.result-footer a{font-size:10px;color:#c8b5ff;text-decoration:none}.empty-state{grid-column:1/-1;padding:45px;text-align:center;color:#626876}.empty-state span{font-size:26px}.empty-state p{font-size:11px}.toast{position:fixed;left:24px;bottom:24px;background:#171a21;border:1px solid #303642;border-radius:10px;padding:11px 14px;font-size:11px;transform:translateY(120px);opacity:0;transition:.25s;max-width:420px;z-index:20}.toast.show{transform:translateY(0);opacity:1}.toast.error{border-color:#713445;color:#ffc1ce}@media(max-width:1050px){.app-shell{grid-template-columns:1fr}.sidebar{display:none}.creator-grid{grid-template-columns:1fr}.task-card{position:static}.provider-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:650px){main{padding:22px 14px 50px}.topbar,.section-head{flex-direction:column;align-items:stretch}.top-actions{display:none}.provider-grid,.form-grid,.results-grid,.bgm-row{grid-template-columns:1fr}.advanced-body{grid-template-columns:1fr}.upload-zone{grid-template-columns:auto 1fr}.upload-zone .mini{grid-column:1/-1}.creator-card,.accounts-panel,.results-panel{padding:16px}} diff --git a/web/assets/app.js b/web/assets/app.js new file mode 100644 index 0000000..9aec0b4 --- /dev/null +++ b/web/assets/app.js @@ -0,0 +1,18 @@ +const $=s=>document.querySelector(s);const $$=s=>[...document.querySelectorAll(s)]; +const state={providers:[],materials:[],bgm:null,task:null,poll:null}; +function toast(message,error=false){const el=$('#toast');el.textContent=message;el.classList.toggle('error',error);el.classList.add('show');setTimeout(()=>el.classList.remove('show'),4200)} +async function api(url,options={}){const r=await fetch(url,options);if(!r.ok){let msg=`HTTP ${r.status}`;try{const p=await r.json();msg=p.detail||msg}catch{}throw new Error(msg)}return r.json()} +async function loadHealth(){try{const h=await api('/api/health');const ok=h.ffmpeg&&h.ffprobe;$('#systemStatus .dot').classList.toggle('ok',ok);$('#systemStatus b').textContent=ok?'النظام جاهز':'FFmpeg مطلوب';$('#systemStatus small').textContent=ok?'Renderer online':'Install ffmpeg + ffprobe'}catch{$('#systemStatus b').textContent='غير متصل'}} +function providerStatus(p){if(!p.installed)return['غير مثبت',''];if(p.authenticated===true)return['متصل','connected'];if(p.authenticated===null)return['جاهز للفحص',''];return['غير متصل','']} +async function loadProviders(){const grid=$('#providerGrid');try{state.providers=await api('/api/providers');grid.innerHTML=state.providers.map(p=>{const[s,c]=providerStatus(p);return `
${p.icon}${s}
${p.name}

${escapeHtml(p.status)}

${p.kind==='local'&&p.installed?'':``}
`}).join('');$('#provider').innerHTML=state.providers.map(p=>``).join('');const preferred=state.providers.find(p=>p.id==='gemini'&&p.installed)||state.providers.find(p=>p.installed);if(preferred)$('#provider').value=preferred.id;$$('.provider-login').forEach(b=>b.onclick=()=>connectProvider(b.dataset.id))}catch(e){grid.innerHTML='
تعذر قراءة الحسابات
';toast(e.message,true)}} +async function connectProvider(id){const p=state.providers.find(x=>x.id===id);if(p&&!p.installed){toast(p.install_hint||'ثبّت الأداة الرسمية أولاً');return}try{const r=await api(`/api/providers/${id}/login`,{method:'POST'});toast(r.message);setTimeout(loadProviders,1500)}catch(e){toast(e.message,true)}} +async function loadVoices(){try{const voices=await api('/api/voices');$('#voice').innerHTML=voices.map(v=>``).join('')}catch(e){toast(e.message,true)}} +function escapeHtml(s=''){return s.replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]))} +async function uploadFiles(files){if(!files.length)return[];const fd=new FormData();[...files].forEach(f=>fd.append('files',f));return api('/api/uploads',{method:'POST',body:fd})} +function renderMaterials(){const box=$('#materialList');box.innerHTML=state.materials.map(x=>`${escapeHtml(x.name)}`).join('')} +async function onMaterials(files){try{const saved=await uploadFiles(files);state.materials.push(...saved);renderMaterials();toast(`تمت إضافة ${saved.length} ملف`)}catch(e){toast(e.message,true)}} +async function submit(e){e.preventDefault();const btn=$('#generateBtn');btn.disabled=true;try{let bgmId=null;if($('#bgm').files[0]){const saved=await uploadFiles($('#bgm').files);bgmId=saved[0]?.id||null}const body={topic:$('#topic').value.trim(),provider:$('#provider').value,language:$('#language').value,script:$('#script').value.trim()||null,duration:+$('#duration').value,aspect_ratio:$('input[name=aspect]:checked').value,voice:$('#voice').value,subtitles:$('#subtitles').checked,subtitle_position:$('#subtitlePosition').value,material_ids:state.materials.map(x=>x.id),bgm_id:bgmId,bgm_volume:+$('#bgmVolume').value,batch_count:+$('#batch').value};state.task=await api('/api/tasks',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});$('#progressBox').classList.remove('hidden');$('#previewStatus').textContent='بدأ الإنشاء';$('#previewHint').textContent=`Task ${state.task.id.slice(0,8)}`;pollTask()}catch(err){toast(err.message,true);btn.disabled=false}} +async function pollTask(){if(!state.task)return;clearTimeout(state.poll);try{const t=await api(`/api/tasks/${state.task.id}`);state.task=t;$('#progressMessage').textContent=t.message;$('#progressPercent').textContent=`${t.progress}%`;$('#progressBar').style.width=`${t.progress}%`;$('#previewStatus').textContent=t.state==='completed'?'الفيديو جاهز':t.state==='failed'?'حدث خطأ':t.message;$('#previewHint').textContent=t.error||`Task ${t.id.slice(0,8)}`;if(t.state==='completed'){renderResults(t);$('#generateBtn').disabled=false;toast('اكتمل إنشاء الفيديو')}else if(t.state==='failed'){$('#generateBtn').disabled=false;toast(t.error||'Generation failed',true)}else{state.poll=setTimeout(pollTask,1800)}}catch(e){toast(e.message,true);$('#generateBtn').disabled=false}} +function renderResults(t){const box=$('#resultsGrid');box.innerHTML=t.output_files.map((f,i)=>{const url=`/api/tasks/${t.id}/files/${encodeURIComponent(f)}`;return `
`}).join('');$('#resultsCaption').textContent=`${t.output_files.length} ملف جاهز`;document.querySelector('#results').scrollIntoView({behavior:'smooth'})} +function bind(){$('#refreshProviders').onclick=loadProviders;$('#createForm').onsubmit=submit;$('#duration').oninput=e=>$('#durationOut').value=`${e.target.value} ثانية`;$('#pickMaterials').onclick=e=>{e.stopPropagation();$('#materials').click()};$('#uploadZone').onclick=()=>$('#materials').click();$('#materials').onchange=e=>onMaterials(e.target.files);const z=$('#uploadZone');['dragenter','dragover'].forEach(n=>z.addEventListener(n,e=>{e.preventDefault();z.classList.add('drag')}));['dragleave','drop'].forEach(n=>z.addEventListener(n,e=>{e.preventDefault();z.classList.remove('drag')}));z.addEventListener('drop',e=>onMaterials(e.dataTransfer.files));$$('.nav-item').forEach(b=>b.onclick=()=>document.getElementById(b.dataset.scroll).scrollIntoView({behavior:'smooth'}))} +bind();loadHealth();loadProviders();loadVoices(); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..0da4cfa --- /dev/null +++ b/web/index.html @@ -0,0 +1,79 @@ + + + + + + + MoneyPrinterTurbo NoAPI + + + +
+ + +
+
+

AI VIDEO STUDIO

حوّل فكرتك إلى فيديو، بدون مفاتيح API.

+
API Docs
+
+ +
+

CONNECTED AI

حسابات الذكاء الاصطناعي

سجّل الدخول عبر الأدوات الرسمية. لا يوجد LLM Settings ولا نسخ ولصق لمفاتيح.

+
+
+ +
+
+

CREATE

مشروع جديد

4 خطوات في شاشة واحدة
+ +
+ +
+
+
+
45 ثانية
+
+
+
+
+ +
خيارات متقدمة
+
+
+
+
+ +
+
أضف صورًا أو فيديوهاتاسحب الملفات هنا أو اضغط للاختيار. بدون مواد سيُنشأ خلفية نظيفة تلقائيًا.
+
+ +
+ + +
+ + +
+ +
+

OUTPUT

النتائج

ستظهر الفيديوهات المكتملة هنا.

+
▣

لم يتم إنشاء فيديو بعد

+
+
+
+
+ + + From 5fa71714798220a3b36e692f67e030d11b30df5f Mon Sep 17 00:00:00 2001 From: Ramzi Sultan <33006097+GenRamzi@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:49:09 +0300 Subject: [PATCH 3/3] add docs packaging docker and tests --- .github/workflows/ci.yml | 22 +++++ Dockerfile | 25 ++++++ LICENSE | 22 +++++ NOTICE.md | 11 +++ README.md | 160 +++++++++++++++++++++++++++++++++++- docker-compose.yml | 12 +++ scripts/start.bat | 2 + scripts/start.sh | 3 + tests/test_schemas.py | 16 ++++ tests/test_script_prompt.py | 9 ++ tests/test_subtitles.py | 16 ++++ 11 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 NOTICE.md create mode 100644 docker-compose.yml create mode 100644 scripts/start.bat create mode 100755 scripts/start.sh create mode 100644 tests/test_schemas.py create mode 100644 tests/test_script_prompt.py create mode 100644 tests/test_subtitles.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..82d0a6b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + branches: [main, "agent/**"] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - name: Install + run: python -m pip install -e ".[dev]" + - name: Compile + run: python -m compileall -q app + - name: Test + run: pytest -q diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..477451f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM node:22-bookworm-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + VIRTUAL_ENV=/opt/venv \ + PATH="/opt/venv/bin:$PATH" + +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 python3-venv python3-pip ffmpeg ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN python3 -m venv /opt/venv +RUN npm install -g @openai/codex @anthropic-ai/claude-code @google/gemini-cli + +WORKDIR /app +COPY pyproject.toml README.md LICENSE ./ +COPY app ./app +COPY web ./web +RUN pip install --no-cache-dir . + +RUN mkdir -p /app/storage/uploads /app/storage/tasks +VOLUME ["/app/storage"] +EXPOSE 8501 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8501"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c7cf26c --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2024 Harry +Copyright (c) 2026 GenRamzi contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..c83762b --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,11 @@ +# Attribution and project notice + +MoneyPrinterTurbo NoAPI is a modified/rebuilt project inspired by and derived from ideas and portions of the MIT-licensed MoneyPrinterTurbo project: + +- Upstream: https://github.com/harry0703/MoneyPrinterTurbo +- Upstream author/copyright notice: Copyright (c) 2024 Harry +- Upstream license: MIT + +This repository adds a new account/CLI provider architecture, a new web interface, a local-first media workflow, new task orchestration, and NoAPI-specific documentation and behavior. + +OpenAI, ChatGPT, Codex, Anthropic, Claude, Google, Gemini, Ollama, and other product names are trademarks of their respective owners. This project is not endorsed by or affiliated with those vendors. diff --git a/README.md b/README.md index 846eeaf..c321bc5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,161 @@ # MoneyPrinterTurbo NoAPI -Modern no-key rebuild of MoneyPrinterTurbo. Development happens on feature branches and is merged through pull requests. +A modern, local-first rebuild of [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo) that removes the old **LLM Settings / API-key workflow** and replaces it with **connected AI accounts and local models**. + +> Status: first functional rebuild. The UI, account-provider layer, local media pipeline, TTS, subtitles, task runner, and FFmpeg renderer are implemented. This repository intentionally does **not** scrape browser cookies or impersonate ChatGPT/Claude/Gemini web sessions. + +## What changed + +- New responsive web studio instead of the large Streamlit settings screen. +- No user API-key form. +- ChatGPT through the official **Codex CLI** after `codex login`. +- Claude through the official **Claude Code CLI** after account login. +- Gemini through the official **Gemini CLI** after Google login. +- Ollama as a fully local fallback with no cloud account. +- Prompt → script → neural TTS → SRT subtitles → media composition → MP4 pipeline. +- Local image/video uploads, background music, 9:16 / 16:9 / 1:1 output, and batch generation. +- FastAPI backend with task progress and downloadable results. +- No credential copying: account credentials stay where the official provider CLI stores them. + +## Important meaning of “NoAPI” + +“NoAPI” means **the user does not enter or manage an API key in this application**. Cloud models still communicate with their vendors through the vendor’s official authenticated software. ChatGPT, Claude, and Gemini subscriptions/usage rules still apply. Ollama is the option for fully local inference. + +The app does not use unofficial cookie extraction, hidden browser endpoints, session-token copying, or credential harvesting. + +## Requirements + +- Python 3.11+ +- FFmpeg + ffprobe available in `PATH` +- At least one AI provider below +- Internet access for cloud-account providers and Edge neural TTS + +### ChatGPT + +```bash +npm install -g @openai/codex +codex login +codex login status +``` + +### Gemini + +```bash +npm install -g @google/gemini-cli +gemini +``` + +Inside Gemini CLI, choose **Login with Google**. After that, MoneyPrinterTurbo NoAPI uses Gemini CLI headless mode for script generation. + +### Claude + +```bash +npm install -g @anthropic-ai/claude-code +claude auth login +claude auth status +``` + +### Ollama (fully local) + +Install Ollama, then: + +```bash +ollama pull qwen3:8b +ollama list +``` + +Change the default local model with: + +```bash +export MPT_OLLAMA_MODEL="qwen3:8b" +``` + +On Windows PowerShell: + +```powershell +$env:MPT_OLLAMA_MODEL="qwen3:8b" +``` + +## Run locally + +```bash +git clone https://github.com/GenRamzi/MoneyPrinterTurboNoAPI.git +cd MoneyPrinterTurboNoAPI +python -m venv .venv +``` + +Activate the virtual environment, then: + +```bash +pip install -e . +python main.py +``` + +Open `http://127.0.0.1:8501`. + +The UI can launch a provider login terminal on common desktop systems. If the app is running in Docker, a remote server, or a desktop environment where terminal launching is unavailable, it shows the exact login command instead. + +## Docker + +Docker is useful for the renderer and Ollama-oriented deployments. Account-based CLI login is usually simpler when running the app directly on the host OS. + +```bash +docker compose up --build +``` + +Then open `http://localhost:8501`. + +## Current video workflow + +1. Choose a connected AI account. +2. Enter a topic or paste your own script. +3. Choose language, duration, aspect ratio, voice, and subtitle position. +4. Upload any number of images/videos; they are normalized and rotated across batch outputs. +5. Optionally add background music. +6. Generate. The task engine writes the script, voice, captions, intermediate media, and final MP4 files under `storage/tasks//`. + +If no visual media is uploaded, the renderer creates a clean generated background so the pipeline remains usable without any stock-media API. + +## Architecture + +```text +web/ Modern zero-build frontend +app/main.py FastAPI entrypoint + SPA hosting +app/api.py Providers, uploads, tasks, downloads +app/providers/ Official CLI account adapters +app/services/script.py AI script generation +app/services/tts.py Edge neural TTS +app/services/subtitles.py SRT timing/generation +app/services/media.py FFmpeg normalization/composition +app/services/tasks.py Background job orchestration +storage/ User uploads and generated projects +``` + +Provider adapters are intentionally isolated. Adding another provider only requires implementing the `TextProvider` interface; the creator UI consumes the registry automatically. + +## Security decisions + +- No API keys are accepted by the web UI. +- No browser cookies are requested, parsed, or stored. +- Login commands are fixed per provider; user input is never concatenated into an arbitrary shell command. +- Upload and task-download paths are normalized and constrained to application storage. +- Provider credentials remain managed by the official CLIs. +- Generated files are local unless the user deploys this server elsewhere. + +## Differences from upstream + +This is a clean architectural rebuild inspired by and derived from MoneyPrinterTurbo’s MIT-licensed concept and workflow. It is not a claim of affiliation with Harry or the AI vendors. See `NOTICE.md` and `LICENSE`. + +The original project supports many API-backed LLM and stock-media providers. This rebuild deliberately prioritizes **account login, local inference, local materials, and a much simpler product surface** instead of reproducing API-key configuration screens. + +## Development + +```bash +pip install -e ".[dev]" +pytest -q +python -m compileall -q app +``` + +## License + +MIT. Upstream copyright and license notice are retained as required by the original license. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..391d616 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + moneyprinter-noapi: + build: . + ports: + - "8501:8501" + volumes: + - ./storage:/app/storage + environment: + MPT_HOST: 0.0.0.0 + MPT_PORT: 8501 + MPT_OLLAMA_MODEL: ${MPT_OLLAMA_MODEL:-qwen3:8b} + restart: unless-stopped diff --git a/scripts/start.bat b/scripts/start.bat new file mode 100644 index 0000000..65077c1 --- /dev/null +++ b/scripts/start.bat @@ -0,0 +1,2 @@ +@echo off +python main.py diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100755 index 0000000..294596c --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh +set -eu +python main.py diff --git a/tests/test_schemas.py b/tests/test_schemas.py new file mode 100644 index 0000000..0e9905f --- /dev/null +++ b/tests/test_schemas.py @@ -0,0 +1,16 @@ +import pytest +from pydantic import ValidationError + +from app.models.schemas import GenerateRequest + + +def test_generate_request_defaults(): + request = GenerateRequest(topic="A useful topic") + assert request.provider == "gemini" + assert request.aspect_ratio == "9:16" + assert request.batch_count == 1 + + +def test_batch_limit(): + with pytest.raises(ValidationError): + GenerateRequest(topic="A useful topic", batch_count=5) diff --git a/tests/test_script_prompt.py b/tests/test_script_prompt.py new file mode 100644 index 0000000..48db9c6 --- /dev/null +++ b/tests/test_script_prompt.py @@ -0,0 +1,9 @@ +from app.services.script import build_script_prompt + + +def test_prompt_contains_topic_language_and_duration(): + prompt = build_script_prompt("Focus habits", "Arabic", 45) + assert "Focus habits" in prompt + assert "Arabic" in prompt + assert "45 seconds" in prompt + assert "Return ONLY" in prompt diff --git a/tests/test_subtitles.py b/tests/test_subtitles.py new file mode 100644 index 0000000..d95d9d2 --- /dev/null +++ b/tests/test_subtitles.py @@ -0,0 +1,16 @@ +from app.services.subtitles import split_sentences, write_srt + + +def test_split_sentences_supports_arabic_question_mark(): + assert split_sentences("مرحبا بالعالم. كيف حالك؟ ممتاز!") == [ + "مرحبا بالعالم.", + "كيف حالك؟", + "ممتاز!", + ] + + +def test_write_srt_ends_at_duration(tmp_path): + path = write_srt("One sentence. Another sentence.", 10.0, tmp_path / "captions.srt") + content = path.read_text(encoding="utf-8") + assert "00:00:10,000" in content + assert content.count(" --> ") == 2