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?' ':`${p.installed?'تسجيل الدخول':'طريقة التثبيت'} `} `}).join('');$('#provider').innerHTML=state.providers.map(p=>`${p.name}${p.installed?'':' — غير مثبت'} `).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=>`${v.name} `).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.
+
+
+
+
+ CONNECTED AI
حسابات الذكاء الاصطناعي سجّل الدخول عبر الأدوات الرسمية. لا يوجد LLM Settings ولا نسخ ولصق لمفاتيح.
+
+
+
+
+
+
+
+
جاهز للإنشاء
اختر حساب AI وأضف فكرتك
+
+ 01 Script كتابة تلقائية أو نصك الخاص
02 Voice Edge neural voices بدون مفتاح
03 Compose مواد + ترجمة + موسيقى + FFmpeg
+
+
+
+
+ ستظهر الفيديوهات المكتملة هنا.
+
+
+
+
+
+
+
+
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