From a5d15a110037ce755ceedd799617d8813890ad1b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 10:39:25 +0000 Subject: [PATCH] Add Memmoro YouTube Shorts ad package Introduce memmoro/ with five EN ad scripts, 9:16 Shorts presets, and a CLI to batch-generate videos through the existing MoneyPrinterTurbo pipeline. Bump edge-tts to 7.x and add compatibility shims in voice service for SentenceBoundary subtitles. Co-authored-by: apodobe --- .gitignore | 2 + app/services/voice.py | 44 +++++++-- memmoro/README.md | 67 ++++++++++++++ memmoro/ads.json | 78 ++++++++++++++++ memmoro/generate.py | 209 ++++++++++++++++++++++++++++++++++++++++++ memmoro/preset.json | 25 +++++ requirements.txt | 2 +- 7 files changed, 419 insertions(+), 8 deletions(-) create mode 100644 memmoro/README.md create mode 100644 memmoro/ads.json create mode 100644 memmoro/generate.py create mode 100644 memmoro/preset.json diff --git a/.gitignore b/.gitignore index 6aa0ca7e47..eeb2371bd3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ .DS_Store /config.toml /storage/ +/memmoro/output/ +/memmoro/materials/ /.idea/ /app/services/__pycache__ /app/__pycache__/ diff --git a/app/services/voice.py b/app/services/voice.py index e6b4d5971d..a9592c5cc2 100644 --- a/app/services/voice.py +++ b/app/services/voice.py @@ -8,10 +8,20 @@ import edge_tts import requests from edge_tts import SubMaker, submaker -from edge_tts.submaker import mktimestamp from loguru import logger from moviepy.video.tools import subtitles +try: + from edge_tts.submaker import mktimestamp +except ImportError: + import math + + def mktimestamp(time_unit: float) -> str: + hour = math.floor(time_unit / 10**7 / 3600) + minute = math.floor((time_unit / 10**7 / 60) % 60) + seconds = (time_unit / 10**7) % 60 + return f"{hour:02d}:{minute:02d}:{seconds:06.3f}" + from app.config import config from app.utils import utils @@ -1116,6 +1126,23 @@ def convert_rate_to_percent(rate: float) -> str: return f"{percent}%" +def _append_tts_boundary(sub_maker: SubMaker, chunk: dict) -> None: + if hasattr(sub_maker, "create_sub"): + sub_maker.create_sub((chunk["offset"], chunk["duration"]), chunk["text"]) + return + + if not hasattr(sub_maker, "offset"): + sub_maker.offset = [] + if not hasattr(sub_maker, "subs"): + sub_maker.subs = [] + + sub_maker.offset.append((chunk["offset"], chunk["offset"] + chunk["duration"])) + sub_maker.subs.append(chunk["text"]) + + if hasattr(sub_maker, "feed"): + sub_maker.feed(chunk) + + def azure_tts_v1( text: str, voice_name: str, voice_rate: float, voice_file: str ) -> Union[SubMaker, None]: @@ -1133,15 +1160,18 @@ async def _do() -> SubMaker: async for chunk in communicate.stream(): if chunk["type"] == "audio": file.write(chunk["data"]) - elif chunk["type"] == "WordBoundary": - sub_maker.create_sub( - (chunk["offset"], chunk["duration"]), chunk["text"] - ) + elif chunk["type"] in ("WordBoundary", "SentenceBoundary"): + _append_tts_boundary(sub_maker, chunk) return sub_maker sub_maker = asyncio.run(_do()) - if not sub_maker or not sub_maker.subs: - logger.warning("failed, sub_maker is None or sub_maker.subs is None") + if not sub_maker: + logger.warning("failed, sub_maker is None") + continue + + has_subs = bool(getattr(sub_maker, "subs", None) or getattr(sub_maker, "cues", None)) + if not has_subs: + logger.warning("failed, sub_maker has no subtitle cues") continue logger.info(f"completed, output file: {voice_file}") diff --git a/memmoro/README.md b/memmoro/README.md new file mode 100644 index 0000000000..d3074a835f --- /dev/null +++ b/memmoro/README.md @@ -0,0 +1,67 @@ +# Memmoro YouTube Shorts ads + +English 9:16 ad package for [Memmoro](https://pexch.lat/) built on MoneyPrinterTurbo. + +## Contents + +- `ads.json` — five fixed EN scripts with Pexels search terms +- `preset.json` — Shorts defaults (voice, subtitles, aspect ratio) +- `generate.py` — CLI wrapper around `app.services.task.start` +- `output/` — generated MP4 files (gitignored) + +## Requirements + +- Python deps from repo root: `pip install -r requirements.txt` +- `ffmpeg` on PATH +- Internet for `edge-tts` (voice synthesis) +- **Recommended:** `pexels_api_keys` in `config.toml` for stock footage +- **Fallback:** if no Pexels key is configured, the CLI uses local PNG materials from `memmoro/materials/` (seeded from `test/resources/`) + +## Setup + +```bash +cp config.example.toml config.toml +# Add at least one Pexels API key under [app].pexels_api_keys +``` + +LLM keys are **not** required when using the fixed scripts in `ads.json`. + +## Usage + +```bash +python memmoro/generate.py --list +python memmoro/generate.py --ad who-is-talking +python memmoro/generate.py --demo +python memmoro/generate.py --all +``` + +Demo set (used for PR artifacts): + +1. `who-is-talking` +2. `caregiving` +3. `privacy-on-device` + +Outputs: + +- `memmoro/output//memmoro-.mp4` +- `/opt/cursor/artifacts/memmoro-shorts/` (when available) + +## Messaging guardrails + +Scripts intentionally avoid: + +- medical device / diagnosis / treatment claims +- guaranteed accuracy +- transcription positioning + +They emphasize on-device processing and speaker identification. + +## Ad concepts + +| ID | Angle | +|----|-------| +| `who-is-talking` | Social awkwardness, parties, calls | +| `caregiving` | Families supporting loved ones | +| `deaf-hoh` | Accessibility without face recognition | +| `meetings` | Work calls and colleagues | +| `privacy-on-device` | No cloud voice upload | diff --git a/memmoro/ads.json b/memmoro/ads.json new file mode 100644 index 0000000000..3fb3da0d67 --- /dev/null +++ b/memmoro/ads.json @@ -0,0 +1,78 @@ +{ + "product": "Memmoro", + "language": "en", + "platform": "youtube-shorts", + "disclaimer": "Not a medical device. Not transcription. Voice processing stays on your device.", + "ads": [ + { + "id": "who-is-talking", + "title": "Who is talking?", + "subject": "Memmoro — real-time voice identification for social moments", + "cta": "Memmoro — on the App Store", + "script": "That awkward pause when someone talks and you cannot place the voice? Memmoro shows their name on screen — in real time. Register friends and family once. When they speak, you see who it is. Not transcription. Not a medical device. Just less guessing at parties, calls, and everyday moments. Memmoro — on-device voice identification. Try it on the App Store.", + "terms": [ + "friends talking at party", + "phone video call conversation", + "social gathering people talking", + "person speaking microphone", + "family dinner conversation" + ] + }, + { + "id": "caregiving", + "title": "Caregiving support", + "subject": "Memmoro — voice identification for families and caregivers", + "cta": "Memmoro — on the App Store", + "script": "When a loved one cannot always remember who is speaking, every conversation can feel harder. Memmoro registers people by voice and shows their name on screen when they talk. Optional photo. Companion mode for a clear full-screen display. Everything runs on the phone — no voice upload to the cloud. Not a medical device. Not transcription. A small tool that can make daily moments easier. Memmoro — on the App Store.", + "terms": [ + "elderly care family support", + "caregiver helping senior", + "family visiting home", + "grandparent with family", + "warm living room conversation" + ] + }, + { + "id": "deaf-hoh", + "title": "Accessibility for Deaf and HoH", + "subject": "Memmoro — see who is speaking without relying on face recognition", + "cta": "Memmoro — on the App Store", + "script": "You hear someone speak — but who is it? Face recognition fails in bad light and at odd angles. Memmoro goes the other way: voice. Register people once, and their name appears when they speak. On-device processing. No voice data sent to a server. Not transcription — speaker identification. Built for accessibility, caregiving, and real life. Memmoro — on the App Store.", + "terms": [ + "deaf community communication", + "person using smartphone accessibility", + "sign language interpreter meeting", + "inclusive workplace conversation", + "listening with headphones" + ] + }, + { + "id": "meetings", + "title": "Meetings and colleagues", + "subject": "Memmoro — know who is speaking in meetings and calls", + "cta": "Memmoro — on the App Store", + "script": "New colleague on the call. Side conversation in a meeting. Someone walks in mid-discussion. Memmoro registers voices and shows names when people speak — so you spend less time guessing and more time listening. Works on your phone. Processing stays on-device. No cloud voice upload. Not transcription. Memmoro — on the App Store.", + "terms": [ + "business meeting office", + "video conference laptop", + "team standup meeting", + "colleagues talking office", + "workplace presentation" + ] + }, + { + "id": "privacy-on-device", + "title": "Privacy on device", + "subject": "Memmoro — 100% on-device voice identification", + "cta": "Memmoro — on the App Store", + "script": "Your voice is personal. Memmoro processes everything on your iPhone — no voice upload, no cloud server. Register friends, family, or colleagues. See their name when they speak. Real-time speaker identification. Not transcription. Not a medical device. Privacy-first by design. Memmoro — on the App Store.", + "terms": [ + "smartphone privacy security", + "person holding iphone", + "data protection technology", + "secure mobile app", + "home office phone" + ] + } + ] +} diff --git a/memmoro/generate.py b/memmoro/generate.py new file mode 100644 index 0000000000..5baa83e14b --- /dev/null +++ b/memmoro/generate.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Generate Memmoro YouTube Shorts ads via MoneyPrinterTurbo.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import sys +import uuid +from pathlib import Path +from typing import Any + +ROOT_DIR = Path(__file__).resolve().parent.parent +MEMMORO_DIR = Path(__file__).resolve().parent +DEFAULT_ARTIFACTS_DIR = Path("/opt/cursor/artifacts/memmoro-shorts") +LOCAL_MATERIAL_NAMES = ("1.png", "2.png", "3.png", "4.png", "5.png") +DEMO_AD_IDS = ("who-is-talking", "caregiving", "privacy-on-device") + +sys.path.insert(0, str(ROOT_DIR)) + +from app.models.schema import MaterialInfo, VideoParams, VideoTransitionMode # noqa: E402 +from app.services import task as task_service # noqa: E402 + + +def load_json(path: Path) -> dict[str, Any]: + with path.open(encoding="utf-8") as handle: + return json.load(handle) + + +def has_pexels_key() -> bool: + config_path = ROOT_DIR / "config.toml" + if not config_path.is_file(): + return False + + try: + import toml + + config = toml.load(config_path) + keys = config.get("app", {}).get("pexels_api_keys", []) + if isinstance(keys, str): + return bool(keys.strip()) + return bool(keys) + except Exception: + return False + + +def local_materials_dir() -> Path: + materials_dir = MEMMORO_DIR / "materials" + if not materials_dir.is_dir(): + materials_dir.mkdir(parents=True, exist_ok=True) + + test_resources = ROOT_DIR / "test" / "resources" + for name in LOCAL_MATERIAL_NAMES: + source = test_resources / name + target = materials_dir / name + if source.is_file() and not target.exists(): + shutil.copy2(source, target) + + return materials_dir + + +def build_local_materials() -> list[MaterialInfo]: + materials: list[MaterialInfo] = [] + for name in LOCAL_MATERIAL_NAMES: + path = local_materials_dir() / name + if path.is_file(): + materials.append(MaterialInfo(provider="local", url=str(path), duration=0)) + if not materials: + raise FileNotFoundError( + "No local fallback materials found. Add PNG files to memmoro/materials/ " + "or configure pexels_api_keys in config.toml." + ) + return materials + + +def build_video_params(ad: dict[str, Any], preset: dict[str, Any]) -> VideoParams: + params = dict(preset) + params["video_subject"] = ad["subject"] + params["video_script"] = ad["script"] + params["video_terms"] = ad["terms"] + params["video_transition_mode"] = VideoTransitionMode.none + + if not has_pexels_key(): + params["video_source"] = "local" + params["video_materials"] = build_local_materials() + else: + params["video_source"] = preset.get("video_source", "pexels") + params["video_materials"] = None + + return VideoParams(**params) + + +def copy_outputs(task_id: str, ad_id: str, result: dict[str, Any], artifacts_dir: Path) -> Path: + output_dir = MEMMORO_DIR / "output" / ad_id + output_dir.mkdir(parents=True, exist_ok=True) + artifacts_dir.mkdir(parents=True, exist_ok=True) + + final_videos = result.get("videos") or [] + if not final_videos: + raise RuntimeError(f"No final video produced for ad '{ad_id}'") + + source_video = Path(final_videos[0]) + target_name = f"memmoro-{ad_id}.mp4" + output_video = output_dir / target_name + artifact_video = artifacts_dir / target_name + + shutil.copy2(source_video, output_video) + shutil.copy2(source_video, artifact_video) + + metadata = { + "ad_id": ad_id, + "task_id": task_id, + "title": result.get("script"), + "video_path": str(output_video), + "artifact_path": str(artifact_video), + "terms": result.get("terms"), + "audio_duration": result.get("audio_duration"), + } + metadata_path = output_dir / "metadata.json" + with metadata_path.open("w", encoding="utf-8") as handle: + json.dump(metadata, handle, indent=2, ensure_ascii=False) + + return output_video + + +def generate_ad(ad_id: str, artifacts_dir: Path) -> Path: + catalog = load_json(MEMMORO_DIR / "ads.json") + preset = load_json(MEMMORO_DIR / "preset.json") + + ads_by_id = {item["id"]: item for item in catalog["ads"]} + if ad_id not in ads_by_id: + known = ", ".join(sorted(ads_by_id)) + raise ValueError(f"Unknown ad id '{ad_id}'. Available: {known}") + + ad = ads_by_id[ad_id] + params = build_video_params(ad, preset) + task_id = str(uuid.uuid4()) + + source_mode = params.video_source + print(f"[memmoro] generating '{ad_id}' via {source_mode} materials") + + result = task_service.start(task_id=task_id, params=params, stop_at="video") + if not result or not result.get("videos"): + raise RuntimeError(f"Video generation failed for ad '{ad_id}'") + + output_video = copy_outputs(task_id, ad_id, result, artifacts_dir) + print(f"[memmoro] saved {output_video}") + return output_video + + +def list_ads() -> None: + catalog = load_json(MEMMORO_DIR / "ads.json") + for ad in catalog["ads"]: + print(f"- {ad['id']}: {ad['title']}") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Generate Memmoro YouTube Shorts ads") + parser.add_argument("--ad", help="Ad id from memmoro/ads.json") + parser.add_argument("--all", action="store_true", help="Generate all ads") + parser.add_argument("--demo", action="store_true", help="Generate the 3 demo ads") + parser.add_argument("--list", action="store_true", help="List available ad ids") + parser.add_argument( + "--artifacts-dir", + default=str(DEFAULT_ARTIFACTS_DIR), + help="Directory for exported demo MP4 files", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + artifacts_dir = Path(args.artifacts_dir) + + if args.list: + list_ads() + return 0 + + if args.all: + catalog = load_json(MEMMORO_DIR / "ads.json") + ad_ids = [ad["id"] for ad in catalog["ads"]] + elif args.demo: + ad_ids = list(DEMO_AD_IDS) + elif args.ad: + ad_ids = [args.ad] + else: + print("Specify --ad , --demo, --all, or --list", file=sys.stderr) + return 2 + + failures: list[str] = [] + for ad_id in ad_ids: + try: + generate_ad(ad_id, artifacts_dir) + except Exception as exc: # noqa: BLE001 - CLI should report all failures + failures.append(f"{ad_id}: {exc}") + print(f"[memmoro] failed '{ad_id}': {exc}", file=sys.stderr) + + if failures: + print("\nFailures:", file=sys.stderr) + for item in failures: + print(f" - {item}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/memmoro/preset.json b/memmoro/preset.json new file mode 100644 index 0000000000..fcf7d6450a --- /dev/null +++ b/memmoro/preset.json @@ -0,0 +1,25 @@ +{ + "video_aspect": "9:16", + "video_concat_mode": "random", + "video_clip_duration": 4, + "video_count": 1, + "video_source": "pexels", + "video_language": "en", + "voice_name": "en-US-JennyNeural-Female", + "voice_volume": 1.0, + "voice_rate": 1.05, + "bgm_type": "random", + "bgm_file": "", + "bgm_volume": 0.15, + "subtitle_enabled": true, + "subtitle_position": "bottom", + "custom_position": 70.0, + "font_name": "Charm-Bold.ttf", + "text_fore_color": "#FFFFFF", + "text_background_color": true, + "font_size": 58, + "stroke_color": "#000000", + "stroke_width": 1.5, + "n_threads": 2, + "paragraph_number": 1 +} diff --git a/requirements.txt b/requirements.txt index a1731f699c..6f7a7a3917 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ moviepy==2.1.2 streamlit==1.45.0 -edge_tts==6.1.19 +edge_tts>=7.2.8 fastapi==0.115.6 uvicorn==0.32.1 openai==1.56.1