Skip to content

Commit 4154328

Browse files
Publish the privacy-safe Ad Video Engine
0 parents  commit 4154328

22 files changed

Lines changed: 1197 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
test:
12+
runs-on: ubuntu-latest
13+
strategy:
14+
matrix:
15+
python-version: ["3.11", "3.12"]
16+
steps:
17+
- uses: actions/checkout@v4
18+
- uses: actions/setup-python@v5
19+
with:
20+
python-version: ${{ matrix.python-version }}
21+
cache: pip
22+
- run: pip install -e '.[dev]'
23+
- run: pytest -q

.gitignore

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
.DS_Store
2+
.env
3+
.env.*
4+
!.env.example
5+
.pytest_cache/
6+
.venv/
7+
__pycache__/
8+
*.py[cod]
9+
*.egg-info/
10+
build/
11+
dist/
12+
runs/
13+
briefs/

CONTRIBUTING.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Contributing
2+
3+
Contributions should keep the engine inspectable and provider-independent.
4+
5+
1. Create a focused branch.
6+
2. Add or update tests for behavioral changes.
7+
3. Run `pytest -q`.
8+
4. Do not commit briefs, outputs, credentials, client material, or paid-provider responses.
9+
5. Open a pull request explaining the user-facing effect and the validation performed.
10+
11+
New planning or rendering providers must preserve the existing campaign and `AdSpec` output contracts.

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Emiliano Ruiz Gomez
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Ad Video Engine
2+
3+
Turn a structured promotion brief into a reviewable short-form video campaign: themes, hooks, scripts, production prompts, posting copy, and render-ready specifications.
4+
5+
The engine grew from a practical marketing problem: creative work becomes difficult to repeat when the brief, strategic reasoning, production prompt, and quality checks live in separate tools. Ad Video Engine makes that process explicit and inspectable.
6+
7+
![Ad Video Engine workflow](assets/architecture.svg)
8+
9+
## What it does
10+
11+
1. Validates a YAML promotion brief.
12+
2. Plans six campaign concepts with a local Ollama model or an intentionally simple offline demo planner.
13+
3. Passes concepts through a replaceable ranking boundary; the public edition preserves model order rather than publishing a production scoring policy.
14+
4. Selects three ads and writes stable JSON specifications.
15+
5. Produces posting copy, a QA report, and a run manifest.
16+
6. Optionally hands specifications to a renderer interface; the public edition ships only a local mock adapter.
17+
18+
The default planning route uses a local model. The deliberately simple demo route is useful for tests, CI, and reviewing the complete output contract without model access.
19+
20+
## Quick start
21+
22+
Requires Python 3.11 or newer.
23+
24+
```bash
25+
python -m venv .venv
26+
source .venv/bin/activate
27+
pip install -e '.[dev]'
28+
29+
adgen create \
30+
--brief examples/brief.yaml \
31+
--planner demo \
32+
--skip-render
33+
```
34+
35+
The command creates a timestamped directory under `runs/` containing:
36+
37+
```text
38+
campaign.json
39+
ad_specs/ad_01.json
40+
ad_specs/ad_02.json
41+
ad_specs/ad_03.json
42+
posting_copy.md
43+
qa_report.md
44+
manifest.json
45+
```
46+
47+
For local-model planning, start Ollama and provide the installed model:
48+
49+
```bash
50+
adgen create \
51+
--brief examples/brief.yaml \
52+
--planner ollama \
53+
--ollama-model llama3:latest \
54+
--skip-render
55+
```
56+
57+
## Brief contract
58+
59+
```yaml
60+
product_name: Northstar Workflow
61+
product_description: A fictional workspace for repeatable operational workflows.
62+
target_audience: Small teams that want a reviewable campaign package.
63+
offer_or_cta: Explore the fictional product brief.
64+
pain_points:
65+
- Manual research and content work consumes hours every week.
66+
proof_points:
67+
- Turns one clear brief into reusable campaign artifacts.
68+
brand_tone: Sharp, practical, and confident.
69+
assets:
70+
logos: []
71+
screenshots: []
72+
```
73+
74+
Required fields are checked before planning. Optional asset paths are verified unless `--no-asset-check` is supplied.
75+
76+
## Design decisions
77+
78+
- **Brief first:** generation cannot begin without an explicit audience, offer, pain, and proof.
79+
- **Stable artifacts:** every run is written to files that can be reviewed, versioned, or sent to another rendering system.
80+
- **Local planning option:** Ollama keeps early creative development local and makes the model replaceable.
81+
- **Deterministic CI:** the demo planner exercises the campaign and artifact contracts without network or model dependencies.
82+
- **Private strategy boundary:** the ranking protocol is injectable; production weights and competitive heuristics are intentionally not included.
83+
- **Private provider boundary:** `VideoProvider` defines the integration contract, while authenticated paid-provider adapters remain private.
84+
- **Safety at the prompt boundary:** known celebrity and entertainment-IP references are removed before a render prompt leaves the engine.
85+
86+
## Current boundary
87+
88+
The repository proves brief validation, production-quality structured prompting, replaceable ranking and rendering boundaries, artifact generation, and QA output. It does not disclose a production ranking policy or authenticated provider adapter, does not claim generated campaigns outperform human creative direction, and contains no real client data.
89+
90+
## Development
91+
92+
```bash
93+
pytest -q
94+
```
95+
96+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the contribution workflow.
97+
98+
## License
99+
100+
[MIT](LICENSE)

ad_video_engine/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Cinematic social ad video engine."""
2+
3+
__version__ = "0.1.0"

ad_video_engine/cli.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import sys
5+
from pathlib import Path
6+
7+
from .errors import AdGenError
8+
from .io import copy_brief, create_run_dir, load_brief, write_json
9+
from .models import AdSpec, assert_asset_paths_exist
10+
from .outputs import manifest, posting_copy, qa_report
11+
from .planner import DemoPlanner, OllamaPlanner, build_campaign
12+
from .providers import provider_from_name
13+
14+
15+
def main(argv: list[str] | None = None) -> int:
16+
parser = build_parser()
17+
args = parser.parse_args(argv)
18+
try:
19+
if args.command == "create":
20+
return create(args)
21+
parser.error("Unknown command")
22+
except AdGenError as exc:
23+
print(f"adgen: error: {exc}", file=sys.stderr)
24+
return 2
25+
26+
27+
def build_parser() -> argparse.ArgumentParser:
28+
parser = argparse.ArgumentParser(prog="adgen", description="Create cinematic social ad videos from promotion briefs.")
29+
subparsers = parser.add_subparsers(dest="command", required=True)
30+
31+
create_parser = subparsers.add_parser("create", help="Generate a campaign and render ads.")
32+
create_parser.add_argument("--brief", required=True, type=Path, help="Path to promotion brief YAML.")
33+
create_parser.add_argument("--root", type=Path, default=Path.cwd(), help="Project root containing runs/ output.")
34+
create_parser.add_argument("--planner", choices=["ollama", "demo"], default="ollama", help="Planning backend.")
35+
create_parser.add_argument("--ollama-base-url", default="http://localhost:11434/v1", help="OpenAI-compatible Ollama base URL.")
36+
create_parser.add_argument("--ollama-model", default="llama3:latest", help="Ollama model for planning.")
37+
create_parser.add_argument("--provider", choices=["mock"], default="mock", help="Public-edition render adapter.")
38+
create_parser.add_argument("--skip-render", action="store_true", help="Write campaign/ad specs without rendering videos.")
39+
create_parser.add_argument("--no-asset-check", action="store_true", help="Do not verify optional asset paths exist.")
40+
return parser
41+
42+
43+
def create(args: argparse.Namespace) -> int:
44+
brief_path = args.brief.expanduser().resolve()
45+
if not brief_path.exists():
46+
raise AdGenError(f"Brief file not found: {brief_path}")
47+
48+
brief = load_brief(brief_path)
49+
if not args.no_asset_check:
50+
assert_asset_paths_exist(brief, brief_path)
51+
52+
run_dir = create_run_dir(args.root.resolve(), brief.product_name)
53+
copy_brief(brief_path, run_dir)
54+
55+
planner = (
56+
OllamaPlanner(base_url=args.ollama_base_url, model=args.ollama_model)
57+
if args.planner == "ollama"
58+
else DemoPlanner()
59+
)
60+
campaign = build_campaign(brief, planner)
61+
write_json(run_dir / "campaign.json", campaign)
62+
63+
ad_specs = [
64+
AdSpec(**raw)
65+
for raw in campaign["selected_ads"]
66+
]
67+
for spec in ad_specs:
68+
write_json(run_dir / "ad_specs" / f"{spec.id}.json", spec.to_dict())
69+
70+
rendered = []
71+
if not args.skip_render:
72+
provider = provider_from_name(args.provider)
73+
for spec in ad_specs:
74+
rendered.append(provider.render(spec, run_dir / "renders"))
75+
76+
(run_dir / "posting_copy.md").write_text(posting_copy(ad_specs), encoding="utf-8")
77+
(run_dir / "qa_report.md").write_text(qa_report(ad_specs, rendered), encoding="utf-8")
78+
write_json(run_dir / "manifest.json", manifest(run_dir, campaign, rendered))
79+
80+
print(f"Run complete: {run_dir}")
81+
for item in rendered:
82+
print(f"Rendered {item.ad_id}: {item.path}")
83+
if args.skip_render:
84+
print("Render skipped. Ad specs are ready for paid rendering.")
85+
return 0
86+
87+
88+
if __name__ == "__main__":
89+
raise SystemExit(main())

ad_video_engine/errors.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
class AdGenError(Exception):
2+
"""Base application error with user-facing message."""
3+
4+
5+
class BriefValidationError(AdGenError):
6+
"""Raised when a promotion brief is missing required fields."""
7+
8+
9+
class PlannerError(AdGenError):
10+
"""Raised when the planning backend cannot generate a campaign."""
11+
12+
13+
class RenderError(AdGenError):
14+
"""Raised when video rendering fails."""

ad_video_engine/io.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import re
5+
import shutil
6+
from datetime import datetime
7+
from pathlib import Path
8+
from typing import Any
9+
10+
import yaml
11+
12+
from .models import PromotionBrief
13+
14+
15+
def load_brief(path: Path) -> PromotionBrief:
16+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
17+
if not isinstance(data, dict):
18+
raise ValueError("Brief YAML must contain a mapping.")
19+
return PromotionBrief.from_dict(data)
20+
21+
22+
def write_yaml(path: Path, data: dict[str, Any]) -> None:
23+
path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True), encoding="utf-8")
24+
25+
26+
def write_json(path: Path, data: Any) -> None:
27+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
28+
29+
30+
def slugify(value: str) -> str:
31+
slug = re.sub(r"[^a-zA-Z0-9]+", "-", value.lower()).strip("-")
32+
return slug[:60] or "campaign"
33+
34+
35+
def create_run_dir(root: Path, product_name: str) -> Path:
36+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
37+
run_dir = root / "runs" / f"{timestamp}-{slugify(product_name)}"
38+
(run_dir / "ad_specs").mkdir(parents=True, exist_ok=False)
39+
(run_dir / "renders").mkdir(parents=True, exist_ok=True)
40+
return run_dir
41+
42+
43+
def copy_brief(source: Path, target_dir: Path) -> Path:
44+
target = target_dir / "brief.yaml"
45+
shutil.copyfile(source, target)
46+
return target

0 commit comments

Comments
 (0)