#!/usr/bin/env python3 """Generate campaign images for any spec. CLI: python generate_campaign_images.py Pipeline: For each campaign in the spec: 1. creative_director.direct_campaign() → scene prompt + headlines + badge + subtitle (Claude Haiku) 2. flux_client.generate_scene() → raw Flux 2 Pro scene (1200×640 PNG, no text) 3. compositor.composite_for_platform() → per-platform branded composite (one call per email + social/ad asset that belongs to this campaign) Writes p-{spec_name}-{entity_id}-{platform}-{W}x{H}.png into hubspot-SIM/src/viewer/ Rewrites spec file in place, setting headerImageUrl / imageUrl / ogImageUrl. Idempotent: if every target file for a campaign already exists, Flux/Haiku are skipped. Failure-loud: any per-campaign failure prints a big warning banner; other campaigns continue; the spec is still saved (other fields untouched). Dotenv: loads hubspot-Sandbox-client/.env so REPLICATE_API_TOKEN and ANTHROPIC_API_KEY are available. """ from __future__ import annotations import json import sys import os import traceback from pathlib import Path # ── Make sibling modules importable ── HERE = Path(__file__).resolve().parent sys.path.insert(0, str(HERE)) # ── Load .env from hubspot-Sandbox-client/.env so we get ANTHROPIC_API_KEY + REPLICATE_API_TOKEN ── def _load_env(): env_path = (HERE / ".." / ".." / "hubspot-Sandbox-client" / ".env").resolve() if not env_path.exists(): return for line in env_path.read_text().splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue k, _, v = line.partition("=") k = k.strip() v = v.strip().strip('"').strip("'") # Load if missing OR empty; don't clobber a real non-empty value that the # caller (e.g. server.ts) already set. if k and not os.environ.get(k): os.environ[k] = v _load_env() from brand import ( PLATFORMS, VIEWER_DIR, BASE_URL, COLORS, detect_platform, ) from creative_director import direct_campaign from flux_client import generate_scene, pause_for_rate_limit from compositor import composite_for_platform def _rgb(name: str) -> tuple: """Turn 'RED' → (220,21,11). Unknown names fall back to WHITE.""" return COLORS.get(name, COLORS["WHITE"]) def _materialize_creative(raw: dict) -> dict: """Resolve colour names into tuples so compositor can use them directly.""" return { "scene_prompt": raw["scene_prompt"], "headlines": [(text, _rgb(color)) for text, color in raw["headlines"]], "badge": raw["badge"], "subtitle": raw["subtitle"], "badge_color": _rgb(raw["badge_color"]), } def _expected_output_path(spec_name: str, entity_id: str, platform: str) -> Path | None: """Where should the composite for this entity+platform land on disk?""" dims = PLATFORMS.get(platform) if dims is None: return None W, H = dims fname = f"p-{spec_name}-{entity_id.lower()}-{platform}-{W}x{H}.png" return VIEWER_DIR / fname def _collect_targets(spec: dict) -> dict: """For each campaign id, list the (entity_id, platform) pairs we need to composite. Emails always land on platform 'email'. Assets get platform via detect_platform(). Overlap handling (entity listed in multiple campaigns): FIRST campaign wins. Ideation convention places entities in their primary campaign first, so this preserves the intended creative brief (e.g. a LinkedIn follow-up email listed under its LinkedIn campaign and later echoed in an outbound campaign stays with the LinkedIn brief). Ad-creative assets on text-only platforms (Google Ads) are skipped — they have no image dimension and are externally managed. Visual ad platforms (LinkedIn, Facebook/Meta) DO get composited images, same as organic posts. """ campaign_targets: dict[str, list[tuple[str, str]]] = {} # Map emails → their campaign (first-wins) email_to_camp: dict[str, str] = {} for camp in spec.get("campaigns", []): for eid in camp.get("emails", []) or []: email_to_camp.setdefault(eid, camp["id"]) for email in spec.get("emails", []) or []: cid = email_to_camp.get(email["id"]) if cid: campaign_targets.setdefault(cid, []).append((email["id"], "email")) # Map assets → their campaign (first-wins) asset_to_camp: dict[str, str] = {} for camp in spec.get("campaigns", []): for aid in camp.get("assets", []) or []: asset_to_camp.setdefault(aid, camp["id"]) for asset in spec.get("assets", []) or []: purpose = (asset.get("purpose") or "").lower() if purpose == "ad_creative": # Visual ad platforms (LinkedIn, Facebook) need generated images. # Text-only ad formats (Google) are skipped — no image dimension. plat = detect_platform(asset) if plat not in ("linkedin", "facebook"): continue cid = asset_to_camp.get(asset["id"]) if not cid: continue platform = detect_platform(asset) campaign_targets.setdefault(cid, []).append((asset["id"], platform)) return campaign_targets def _apply_urls_to_spec(spec: dict, spec_name: str, outputs: dict): """Mutate spec so emails/assets reference the composited images. outputs keys are (entity_id, platform). Emails get headerImageUrl. Social platforms (linkedin/twitter/discord) get imageUrl + ogImageUrl. Content platforms (youtube/blog/generic) get ogImageUrl only. No-image platforms (hn/reddit/github) get imageUrl/ogImageUrl removed. """ id_to_url: dict[str, tuple[str, str]] = {} # entity_id → (platform, url) for (eid, platform), out_path in outputs.items(): if out_path is None: id_to_url[eid] = (platform, "") continue id_to_url[eid] = (platform, f"{BASE_URL}/{out_path.name}") for email in spec.get("emails", []) or []: if email["id"] in id_to_url: _, url = id_to_url[email["id"]] if url: email["headerImageUrl"] = url for asset in spec.get("assets", []) or []: purpose = (asset.get("purpose") or "").lower() # Text-only ad formats (Google Ads) — strip image fields, skip if purpose == "ad_creative": plat = detect_platform(asset) if plat not in ("linkedin", "facebook"): asset.pop("imageUrl", None) asset.pop("ogImageUrl", None) continue # Visual ad platforms fall through — get image URLs like social posts if asset["id"] not in id_to_url: continue platform, url = id_to_url[asset["id"]] if not url: asset.pop("imageUrl", None) asset.pop("ogImageUrl", None) continue if platform in ("linkedin", "twitter", "discord"): asset["imageUrl"] = url asset["ogImageUrl"] = url else: asset["ogImageUrl"] = url asset.pop("imageUrl", None) def _scene_path(spec_name: str, campaign_id: str) -> Path: return VIEWER_DIR / f"adapted-{spec_name}-{campaign_id.lower()}-scene.png" def run(spec_path: Path) -> int: """Process a spec end-to-end. Returns 0 on success, non-zero on hard failure.""" if not spec_path.exists(): print(f"[image-gen] ✗ spec not found: {spec_path}", file=sys.stderr) return 2 spec = json.loads(spec_path.read_text()) spec_name = spec_path.stem # e.g. "Oracle_SLG_09" # Token presence check — loud warning but not a hard failure so the caller can # decide what to do. The entrypoint in server.ts will have already checked this # when running in ideation mode. if not os.environ.get("REPLICATE_API_TOKEN"): print("[image-gen] ⚠ REPLICATE_API_TOKEN not set — cannot call Flux", file=sys.stderr) return 3 if not os.environ.get("ANTHROPIC_API_KEY"): print("[image-gen] ⚠ ANTHROPIC_API_KEY not set — cannot call Haiku", file=sys.stderr) return 3 campaigns = spec.get("campaigns", []) or [] if not campaigns: print("[image-gen] (spec has no campaigns; nothing to do)") return 0 targets = _collect_targets(spec) VIEWER_DIR.mkdir(parents=True, exist_ok=True) all_outputs: dict[tuple[str, str], Path | None] = {} campaigns_failed: list[str] = [] flux_calls_made = 0 print(f"[image-gen] spec={spec_name} campaigns={len(campaigns)} targets={sum(len(v) for v in targets.values())}") for camp in campaigns: cid = camp["id"] camp_targets = targets.get(cid, []) if not camp_targets: print(f"[image-gen] [{cid}] no emails/assets — skipping") continue # ── Idempotency: if every expected output for this campaign already exists, skip ── expected_paths = {(eid, plat): _expected_output_path(spec_name, eid, plat) for eid, plat in camp_targets} all_present = all( (p is None) or p.exists() # None = no-image platform, counts as "present" for p in expected_paths.values() ) if all_present: print(f"[image-gen] [{cid}] ✓ all images already on disk — skipping generation") all_outputs.update(expected_paths) continue # ── Per-campaign try block: failure does not stop other campaigns ── try: print(f"[image-gen] [{cid}] calling Haiku for creative spec…") raw_creative = direct_campaign(camp) creative = _materialize_creative(raw_creative) # event_meta is structured data, not creative inference — pull # straight from spec.campaign.event. Skill rule writes this for # event campaigns; evergreen campaigns omit the field and the # compositor skips the footer band quietly. camp_event = camp.get("event") if isinstance(camp.get("event"), dict) else None if camp_event and camp_event.get("when") and camp_event.get("kind"): creative["event_meta"] = { "kind": str(camp_event.get("kind", "")).lower().strip(), "when": str(camp_event.get("when", "")).strip()[:32], "where": str(camp_event.get("where", "")).strip()[:32], "timezone": str(camp_event.get("timezone", "")).strip()[:8], } if flux_calls_made > 0: print(f"[image-gen] [{cid}] rate-limit pause…") pause_for_rate_limit() scene_path = _scene_path(spec_name, cid) # Scene-level reuse (added 2026-05-28): the orch's ideate handler # copies adapted---scene.png → adapted---scene.png # whenever a peer spec's same-id campaign has a matching THEME # fingerprint (loose name-token hash, ignores LLM wording drift). # If that pre-copy succeeded for this campaign, the scene exists # on disk and we skip Flux — the most expensive step in the # pipeline (~$0.04 + ~30s per campaign). Composite still runs # per-target with the reused scene + this campaign's Haiku # headlines. Verified saves ~95% of cost on re-ideate workflows. if scene_path.exists(): print(f"[image-gen] [{cid}] ✓ scene reused (theme match) — skipping Flux") else: print(f"[image-gen] [{cid}] calling Flux 2 Pro for scene…") generate_scene(creative["scene_prompt"], scene_path) flux_calls_made += 1 # Composite every target for this campaign for (eid, platform), out_path in expected_paths.items(): if out_path is None: all_outputs[(eid, platform)] = None continue W, H = PLATFORMS[platform] print(f"[image-gen] [{cid}] composite {eid} → {platform} {W}x{H}") composite_for_platform( scene_path=scene_path, output_path=out_path, W=W, H=H, headlines=creative["headlines"], badge_text=creative["badge"], subtitle=creative["subtitle"], badge_color=creative["badge_color"], event_meta=creative.get("event_meta"), ) all_outputs[(eid, platform)] = out_path except Exception as e: campaigns_failed.append(cid) print("", file=sys.stderr) print("⚠" * 40, file=sys.stderr) print(f"⚠ [image-gen] CAMPAIGN {cid} FAILED: {e}", file=sys.stderr) print("⚠ Other campaigns will continue. Entities in this campaign will fall back to", file=sys.stderr) print("⚠ intent placeholders at preview time until the error is fixed and ideation is re-run.", file=sys.stderr) traceback.print_exc() print("⚠" * 40, file=sys.stderr) print("", file=sys.stderr) continue # ── Write URLs back into the spec (only for successfully composited outputs) ── _apply_urls_to_spec(spec, spec_name, all_outputs) spec_path.write_text(json.dumps(spec, indent=2)) total_composited = sum(1 for p in all_outputs.values() if p is not None) total_skipped_no_image = sum(1 for p in all_outputs.values() if p is None) print(f"[image-gen] done: composited={total_composited} no-image-platforms={total_skipped_no_image} failed-campaigns={len(campaigns_failed)}") if campaigns_failed: print(f"[image-gen] ⚠ failed campaigns: {', '.join(campaigns_failed)}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": if len(sys.argv) != 2: print("Usage: python generate_campaign_images.py ", file=sys.stderr) sys.exit(64) sys.exit(run(Path(sys.argv[1]).resolve()))