mirror of
https://github.com/digitalsamba/claude-code-video-toolkit.git
synced 2026-09-18 19:41:13 +08:00
290d7d345b
* Adopt uv for Python dependency management Replace manual pip/venv setup with `uv sync` (pyproject.toml + uv.lock), and switch all tool invocations across docs, skills, commands, and examples to `uv run`. Optional extras: --extra whisper, --extra modal. tools/requirements.txt is kept as a pip fallback. * FIX: bring uv migration current with main — post-June deps and docs Adds websocket-client (60db websocket transport) to core deps and a 'youtube' extra for the google-* packages (tools/youtube_upload.py), regenerates uv.lock, bumps pyproject version to 0.18.0, and converts the 51 'python3 tools/…' invocations added since the PR was opened (60db, YouTube, Kiro docs, publish command) to 'uv run'. Verified with uv sync --frozen --extra youtube; voiceover, sixtydb_tts, youtube_upload and migrate_to_kiro all run through the project environment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Asharib Ali <asharibarain4@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
307 lines
12 KiB
Python
307 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
AI text-to-image generation using Ideogram 4 (hosted v4 API).
|
|
|
|
Ideogram 4's strength is best-in-class *in-image text rendering* plus exact color and
|
|
layout control — ideal for title cards, thumbnails, and CTAs with baked-in text. That
|
|
advantage lives in the model's structured JSON caption format. See the `ideogram4` skill
|
|
(.claude/skills/ideogram4/) for how to author captions; Claude is the recommended
|
|
"magic prompt" expander.
|
|
|
|
Backend: Ideogram's hosted v4 API (POST /v1/ideogram-v4/generate). Paid plans include a
|
|
commercial license. Needs IDEOGRAM_API_KEY in .env (get a key at developer.ideogram.ai).
|
|
|
|
Two prompt modes (mutually exclusive, mirroring the API's text_prompt vs json_prompt):
|
|
--json Post a structured JSON caption as json_prompt (recommended for text/layout).
|
|
Claude authors the caption via the ideogram4 skill. Pass a file or '-' for stdin.
|
|
--prompt Post plain text as text_prompt (Ideogram's server-side magic prompt expands it).
|
|
|
|
Examples:
|
|
# Structured caption (recommended) — Claude writes caption.json via the skill
|
|
uv run tools/ideogram4.py --json caption.json --output title.png
|
|
|
|
# Caption from stdin
|
|
cat caption.json | uv run tools/ideogram4.py --json - --output title.png
|
|
|
|
# Plain prompt (server-side magic prompt)
|
|
uv run tools/ideogram4.py --prompt "Title card: 'AI ENGINEERING REVIEW' bold white on dark" --output title.png
|
|
|
|
# Inject brand palette into a JSON caption's style_description.color_palette
|
|
uv run tools/ideogram4.py --json caption.json --brand digital-samba --output cta.png
|
|
|
|
# Quality tier + resolution
|
|
uv run tools/ideogram4.py --json caption.json --speed QUALITY --resolution 2048x2048 --output slide.png
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
try:
|
|
import requests
|
|
from dotenv import load_dotenv
|
|
except ImportError as e:
|
|
print(f"Missing dependency: {e}")
|
|
print("Install with: uv sync")
|
|
sys.exit(1)
|
|
|
|
load_dotenv()
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
|
|
API_URL = "https://api.ideogram.ai/v1/ideogram-v4/generate"
|
|
RENDERING_SPEEDS = ["FLASH", "TURBO", "DEFAULT", "QUALITY"]
|
|
# Rough per-image cost by tier (USD) for cost-awareness logging.
|
|
SPEED_COST = {"FLASH": 0.02, "TURBO": 0.03, "DEFAULT": 0.06, "QUALITY": 0.09}
|
|
|
|
|
|
def log(msg: str, level: str = "info"):
|
|
"""Print formatted log message."""
|
|
colors = {
|
|
"info": "\033[94m",
|
|
"success": "\033[92m",
|
|
"error": "\033[91m",
|
|
"warn": "\033[93m",
|
|
"dim": "\033[90m",
|
|
}
|
|
reset = "\033[0m"
|
|
prefix = {"info": "->", "success": "OK", "error": "!!", "warn": "??", "dim": " "}
|
|
color = colors.get(level, "")
|
|
print(f"{color}{prefix.get(level, '->')} {msg}{reset}", file=sys.stderr)
|
|
|
|
|
|
def load_brand_palette(brand_name: str) -> list[str]:
|
|
"""Load brand.json and return its colors as uppercase #RRGGBB hex strings."""
|
|
workspace = Path(__file__).parent.parent
|
|
brand_path = workspace / "brands" / brand_name / "brand.json"
|
|
if not brand_path.exists():
|
|
log(f"Brand not found: {brand_path}", "warn")
|
|
return []
|
|
try:
|
|
brand = json.loads(brand_path.read_text())
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
log(f"Error reading brand: {e}", "warn")
|
|
return []
|
|
|
|
palette: list[str] = []
|
|
for value in (brand.get("colors") or {}).values():
|
|
if isinstance(value, str) and value.startswith("#") and len(value) in (4, 7):
|
|
hx = value.upper()
|
|
if hx not in palette:
|
|
palette.append(hx)
|
|
return palette[:16] # API caps style palette at 16
|
|
|
|
|
|
def read_caption(source: str) -> dict:
|
|
"""Read a JSON caption from a file path or '-' for stdin. Returns the parsed object."""
|
|
raw = sys.stdin.read() if source == "-" else Path(source).read_text()
|
|
caption = json.loads(raw)
|
|
if not isinstance(caption, dict):
|
|
raise ValueError("JSON caption must be an object (got a non-object top level)")
|
|
return caption
|
|
|
|
|
|
def inject_brand_palette(caption: dict, palette: list[str]) -> dict:
|
|
"""Merge brand hex colors into the caption's style_description.color_palette.
|
|
|
|
Brand colors are prepended (they take priority) and de-duplicated; existing palette
|
|
colors are preserved after them. Capped at the API's 16-color limit.
|
|
"""
|
|
if not palette:
|
|
return caption
|
|
style = caption.setdefault("style_description", {})
|
|
existing = style.get("color_palette") or []
|
|
merged = palette + [c for c in existing if c.upper() not in {p.upper() for p in palette}]
|
|
style["color_palette"] = merged[:16]
|
|
return caption
|
|
|
|
|
|
def generate(
|
|
api_key: str,
|
|
*,
|
|
text_prompt: Optional[str] = None,
|
|
json_prompt: Optional[dict] = None,
|
|
output_path: str,
|
|
rendering_speed: str = "DEFAULT",
|
|
resolution: Optional[str] = None,
|
|
copyright_detection: bool = False,
|
|
timeout: int = 300,
|
|
) -> Optional[str]:
|
|
"""Call the Ideogram v4 generate endpoint and download the result.
|
|
|
|
Exactly one of text_prompt / json_prompt must be provided. Returns the output path
|
|
on success, None on failure.
|
|
"""
|
|
if (text_prompt is None) == (json_prompt is None):
|
|
log("Provide exactly one of text_prompt / json_prompt.", "error")
|
|
return None
|
|
|
|
# Multipart/form-data: send each field as (None, value). json_prompt is serialized
|
|
# compactly with non-ASCII preserved, per the model's caption format.
|
|
fields: dict[str, tuple] = {"rendering_speed": (None, rendering_speed)}
|
|
if text_prompt is not None:
|
|
fields["text_prompt"] = (None, text_prompt)
|
|
log(f"text_prompt: {text_prompt}", "info")
|
|
else:
|
|
caption_str = json.dumps(json_prompt, separators=(",", ":"), ensure_ascii=False)
|
|
fields["json_prompt"] = (None, caption_str)
|
|
hld = json_prompt.get("high_level_description", "")
|
|
log(f"json_prompt: {hld or '(structured caption)'}", "info")
|
|
if resolution:
|
|
fields["resolution"] = (None, resolution)
|
|
if copyright_detection:
|
|
fields["enable_copyright_detection"] = (None, "true")
|
|
|
|
cost = SPEED_COST.get(rendering_speed, 0.06)
|
|
log(f"Speed: {rendering_speed} (~${cost:.2f}/image) Resolution: {resolution or 'API default'}", "dim")
|
|
|
|
try:
|
|
response = requests.post(
|
|
API_URL,
|
|
headers={"Api-Key": api_key},
|
|
files=fields,
|
|
timeout=timeout,
|
|
)
|
|
except requests.exceptions.Timeout:
|
|
log(f"Request timed out ({timeout}s)", "error")
|
|
return None
|
|
except requests.exceptions.RequestException as e:
|
|
log(f"Request failed: {e}", "error")
|
|
return None
|
|
|
|
if response.status_code != 200:
|
|
log(f"API returned HTTP {response.status_code}: {response.text[:500]}", "error")
|
|
return None
|
|
|
|
try:
|
|
result = response.json()
|
|
except json.JSONDecodeError:
|
|
log("Invalid JSON response from API", "error")
|
|
return None
|
|
|
|
images = result.get("data") or []
|
|
urls = [img.get("url") for img in images if isinstance(img, dict) and img.get("url")]
|
|
if not urls:
|
|
log(f"No image URL in response: {json.dumps(result)[:500]}", "error")
|
|
return None
|
|
|
|
# Download. If multiple images came back, suffix _2, _3, ...
|
|
out = Path(output_path)
|
|
saved: list[str] = []
|
|
for i, url in enumerate(urls):
|
|
target = out if i == 0 else out.with_name(f"{out.stem}_{i + 1}{out.suffix}")
|
|
try:
|
|
img_resp = requests.get(url, timeout=timeout)
|
|
img_resp.raise_for_status()
|
|
target.write_bytes(img_resp.content)
|
|
except requests.exceptions.RequestException as e:
|
|
log(f"Download failed for {url}: {e}", "error")
|
|
continue
|
|
saved.append(str(target))
|
|
meta = images[i]
|
|
log(
|
|
f"Saved: {target} ({len(img_resp.content) // 1024} KB)"
|
|
f"{' seed=' + str(meta.get('seed')) if meta.get('seed') is not None else ''}"
|
|
f"{' safe=' + str(meta.get('is_image_safe')) if 'is_image_safe' in meta else ''}",
|
|
"success",
|
|
)
|
|
|
|
if not saved:
|
|
return None
|
|
|
|
if sys.platform == "darwin":
|
|
import subprocess
|
|
subprocess.run(["open", saved[0]], check=False)
|
|
|
|
return saved[0]
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Ideogram 4 text-to-image (hosted v4 API) — best-in-class in-image text.",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
%(prog)s --json caption.json --output title.png
|
|
cat caption.json | %(prog)s --json - --output title.png
|
|
%(prog)s --prompt "Title card: 'SHIP FASTER' bold" --output thumb.png
|
|
%(prog)s --json caption.json --brand digital-samba --speed QUALITY --output cta.png
|
|
|
|
Authoring captions: see the `ideogram4` skill (.claude/skills/ideogram4/). The --json path
|
|
posts the caption as the API's json_prompt (no server-side magic prompt — Claude is the expander).
|
|
""",
|
|
)
|
|
|
|
prompt_group = parser.add_mutually_exclusive_group(required=True)
|
|
prompt_group.add_argument(
|
|
"--json", dest="json_src", metavar="FILE",
|
|
help="Structured JSON caption file (or '-' for stdin) — posted as json_prompt",
|
|
)
|
|
prompt_group.add_argument(
|
|
"--prompt", "-p",
|
|
help="Plain text prompt — posted as text_prompt (server-side magic prompt expands it)",
|
|
)
|
|
|
|
parser.add_argument("--output", "-o", required=True, help="Output PNG path")
|
|
parser.add_argument("--brand", help="Brand name — inject brands/<name>/brand.json colors into the caption palette (JSON mode)")
|
|
parser.add_argument("--speed", choices=RENDERING_SPEEDS, default="DEFAULT",
|
|
help="Rendering speed / quality tier (default: DEFAULT)")
|
|
parser.add_argument("--resolution", help="Resolution e.g. 2048x2048 (omit for API default; see Ideogram docs for valid values)")
|
|
parser.add_argument("--copyright-detection", action="store_true",
|
|
help="Enable Ideogram's copyright detection")
|
|
parser.add_argument("--timeout", type=int, default=300, help="Request timeout seconds (default: 300)")
|
|
parser.add_argument("--json-out", action="store_true", help="Emit a machine-readable result line to stdout")
|
|
|
|
args = parser.parse_args()
|
|
|
|
from config import get_ideogram_api_key
|
|
api_key = get_ideogram_api_key()
|
|
if not api_key:
|
|
log("IDEOGRAM_API_KEY not set.", "error")
|
|
log("Get a key at https://developer.ideogram.ai/ then: echo 'IDEOGRAM_API_KEY=your_key' >> .env", "info")
|
|
sys.exit(1)
|
|
|
|
text_prompt = None
|
|
json_prompt = None
|
|
if args.json_src is not None:
|
|
try:
|
|
json_prompt = read_caption(args.json_src)
|
|
except (json.JSONDecodeError, OSError, ValueError) as e:
|
|
log(f"Could not read JSON caption: {e}", "error")
|
|
sys.exit(1)
|
|
if args.brand:
|
|
palette = load_brand_palette(args.brand)
|
|
if palette:
|
|
json_prompt = inject_brand_palette(json_prompt, palette)
|
|
log(f"Brand palette: {', '.join(palette)}", "dim")
|
|
else:
|
|
text_prompt = args.prompt
|
|
if args.brand:
|
|
log("--brand only applies in --json mode (no palette field in plain text). Ignoring.", "warn")
|
|
|
|
print(file=sys.stderr)
|
|
log("Ideogram 4 (hosted v4 API)", "info")
|
|
|
|
result = generate(
|
|
api_key,
|
|
text_prompt=text_prompt,
|
|
json_prompt=json_prompt,
|
|
output_path=args.output,
|
|
rendering_speed=args.speed,
|
|
resolution=args.resolution,
|
|
copyright_detection=args.copyright_detection,
|
|
timeout=args.timeout,
|
|
)
|
|
|
|
if args.json_out:
|
|
print(json.dumps({"success": result is not None, "output": result}))
|
|
|
|
sys.exit(0 if result else 1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|