{"id":50673003,"url":"https://github.com/arndvs/cast","last_synced_at":"2026-06-08T13:02:44.658Z","repository":{"id":355934243,"uuid":"1230283939","full_name":"arndvs/cast","owner":"arndvs","description":"Creative automation pipeline for generating localized social ad creatives at scale.","archived":false,"fork":false,"pushed_at":"2026-05-29T23:49:05.000Z","size":35302,"stargazers_count":0,"open_issues_count":2,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-30T00:15:26.085Z","etag":null,"topics":["creative-automation","generative-ai","image-processing","nextjs","openai","sharp","tailwindcss","typescript","vitest","zod"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/arndvs.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-05T21:13:51.000Z","updated_at":"2026-05-08T15:40:01.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/arndvs/cast","commit_stats":null,"previous_names":["arndvs/cast"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/arndvs/cast","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/arndvs%2Fcast","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/arndvs%2Fcast/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/arndvs%2Fcast/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/arndvs%2Fcast/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/arndvs","download_url":"https://codeload.github.com/arndvs/cast/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/arndvs%2Fcast/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34063159,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-08T02:00:07.615Z","response_time":111,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["creative-automation","generative-ai","image-processing","nextjs","openai","sharp","tailwindcss","typescript","vitest","zod"],"created_at":"2026-06-08T13:02:43.795Z","updated_at":"2026-06-08T13:02:44.651Z","avatar_url":"https://github.com/arndvs.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Cast - Creative Automation Studio Toolchain\n\n\u003e From brief to broadcast. A creative automation studio toolchain that turns one campaign brief into on-brand, localized social ad creatives at three aspect ratios.\n\n---\n\n## Quick Start\n\n### Prerequisites\n\n- **Node.js ≥ 20 LTS** (`node --version`)\n- **pnpm** (`npm install -g pnpm` or `corepack enable`)\n- An **`OPENAI_API_KEY`** with access to `dall-e-3` (default) or `gpt-image-1` (`CAST_GENAI_MODE=cheap`)\n\n### Install \u0026 run\n\n```bash\ngit clone https://github.com/arndvs/cast.git\ncd cast\ncp .env.example .env.local          # paste your OPENAI_API_KEY\npnpm install\npnpm dev\n# → open http://localhost:3000\n```\n\nThe app boots with the demo brief at [`inputs/brief.json`](inputs/brief.json) pre-loaded. Click **Generate** to run the pipeline. Outputs land at:\n\n```\noutputs/[campaign]/[market]/[product]/[ratio].png\noutputs/[campaign]/brief.json    # snapshot of the brief that produced this run\noutputs/[campaign]/report.json   # compliance + legal check results\n```\n\nSee [docs/system-map.md](docs/system-map.md) for the canonical filesystem layout.\n\n\u003e **Dropbox export is optional.** The output grid includes an Export to Dropbox button, but it requires a Dropbox App key and a public tunnel; setup takes ~5 minutes and is not needed for the core POC workflow. See [Cloud Export — Dropbox (optional)](#cloud-export--dropbox-optional).\n\n### Scripts\n\n| Command | Purpose |\n| --- | --- |\n| `pnpm dev` | Next dev server (Turbopack) on `http://localhost:3000` |\n| `pnpm build` | Production build |\n| `pnpm start` | Run the production build |\n| `pnpm typecheck` | `tsc --noEmit` |\n| `pnpm lint` | ESLint |\n| `pnpm test` | Vitest (one-shot) |\n| `pnpm test:watch` | Vitest in watch mode |\n\n### Troubleshooting\n\n- **`OPENAI_API_KEY missing` / 401 on Generate.** Confirm `.env.local` exists at the repo root and contains `OPENAI_API_KEY=sk-...`. Restart `pnpm dev` after editing — Next reads env files at process start.\n- **`Brand fixture not found` / brand selector is empty.** The brand picker lists directories under `inputs/brands/` (populated at server start via `listBrandSlugs`). The repo ships `brisa/` and `volt/`. If you removed them or your brief references a slug with no matching directory, the editor shows the missing-brand banner and gates Generate. Restore the directory or pick a brand that exists. Note: color chips and the product catalog in the sidebar are only available for the bundled seed brands.\n- **Port 3000 already in use.** `pnpm dev` defaults to `http://localhost:3000`; if 3000 is busy, Next/Turbopack will pick the next free port and log it to the terminal — open that URL instead. To pin a port explicitly, run `pnpm dev -- -p 3001` (or kill the process on 3000).\n- **Local-mode skipping the GenAI call.** The pipeline prefers a pre-placed asset at `inputs/assets/[product-slug].{png,jpg,jpeg,webp}` over a GenAI call. If that directory is missing or the file extension does not match the allowlist, the resolver falls back to GenAI — create `inputs/assets/` and drop the file with one of the four supported extensions.\n\n---\n\n## Example input\n\n[`inputs/brief.json`](inputs/brief.json) — ships with the repo:\n\n```json\n{\n  \"campaign\": \"summer-refresh-2026\",\n  \"brand\": \"brisa\",\n  \"products\": [\n    { \"name\": \"Brisa Citrus\", \"sku\": \"BRS-CIT-12\" },\n    { \"name\": \"Brisa Berry\", \"sku\": \"BRS-BRY-12\" }\n  ],\n  \"markets\": [\"us-en\", \"mx-es\"],\n  \"audience\": \"18-34, urban, health-conscious\",\n  \"message\": {\n    \"en\": \"Crack open something brighter.\",\n    \"es\": \"Abre algo más brillante.\"\n  },\n  \"ratios\": [\"1x1\", \"9x16\", \"16x9\"]\n}\n```\n\nDrop product photos into the per-product drop zone in the UI (or pre-place files at `inputs/assets/[product-slug].{png,jpg,jpeg,webp}`). Anything missing is generated via the GenAI image API.\n\n## Example output\n\n```\noutputs/\n└── summer-refresh-2026/\n    ├── us-en/\n    │   ├── brisa-citrus/\n    │   │   ├── 1x1.png\n    │   │   ├── 9x16.png\n    │   │   └── 16x9.png\n    │   └── brisa-berry/\n    │       ├── 1x1.png\n    │       ├── 9x16.png\n    │       └── 16x9.png\n    ├── mx-es/\n    │   ├── brisa-citrus/\n    │   │   ├── 1x1.png\n    │   │   ├── 9x16.png\n    │   │   └── 16x9.png\n    │   └── brisa-berry/\n    │       ├── 1x1.png\n    │       ├── 9x16.png\n    │       └── 16x9.png\n    ├── brief.json\n    └── report.json    # compliance + legal check results\n```\n\nFile path shape: `outputs/[campaign]/[market]/[product]/[ratio].png`. The locale used for compositing copy is derived from the market: `locale = market.split('-').pop()`.\n\n---\n\n## Key design decisions\n\n| Decision              | Choice                                                                      | Rationale                                                                                                                                                                                                                                                 |\n| --------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| **Brief format**      | JSON only                                                                   | Brief permits JSON or YAML. JSON gives a dependency-light editor (`\u003ctextarea\u003e` + `JSON.parse`) and matches `Content-Type: application/json` end-to-end. YAML import deferred to v2 — Zod schema is the contract; swapping the parser is a 30-line change. |\n| **Storage backend**   | Local filesystem                                                            | Brief permits Azure / AWS / Dropbox. Local FS is the only option that runs from a clean checkout in under three minutes (Story 1's success metric). Export is handled via client-side ZIP download (zero config, works localhost). **Dropbox Saver is included but optional** — the Export to Dropbox button is fully wired and activates when `NEXT_PUBLIC_DROPBOX_APP_KEY` is set; it requires a Dropbox App key and a public tunnel (~5 min setup). Dropbox API v2 (headless batch upload) remains deferred — see [Cloud Export — Dropbox (optional)](#cloud-export--dropbox-optional). |\n| **Framework**         | Next.js (local web app)                                                     | Brief permits CLI or simple local app. The web app surfaces the live pipeline log and output grid in-browser — the centerpiece of Story 1 (Maya) and Story 3 (Aaron's demo). A CLI hides the pipeline from the audience.                                  |\n| **Image processing**  | Sharp                                                                       | Battle-tested, fast, no native binary surprises in CI.                                                                                                                                                                                                    |\n| **API style**         | NDJSON streaming for `/api/generate`                                        | One request, terminal `complete` event carries the manifest. UI hydrates from the manifest — no second filesystem read, no race with disk writes.                                                                                                                                                 |\n| **Path I/O safety**   | `safeJoin` helper + `SLUG_RE` validation at every boundary                  | `revealOutputFolder`, `/api/upload`, `/api/detected-assets`, and Sharp file reads all interpolate user-influenced strings. Validating every path is a child of a known root prevents traversal. `execFile` with explicit argv prevents shell injection.   |\n| **Upload limits**     | 5 MB max, MIME-allowlisted (PNG / JPEG / WebP), canonical extension mapping | Sharp can OOM on very large files. Canonical extension mapping (`jpeg → .jpg`) prevents stale-shadow files when re-uploading.                                                                                                                             |\n| **Compliance checks** | Heuristic — logo presence, banned-word list                                 | Demonstrates the surface; not a substitute for legal review. Color compliance deferred to v2 — see [docs/flow-diagrams.md](docs/flow-diagrams.md) for rationale.                                                                                         |\n| **Per-brand profile** | Required `brand` slug → `inputs/brands/[brand]/` directory                  | Cast serves arbitrary clients. Brand identity (colors, voice, logo, font, banned words) lives per-brand on disk; a new brand is a directory drop, not a code change. The demo ships two profiles — `brisa` (sparkling water) and `volt` (energy) — sub-brands of the fictional Onda Beverages parent. One brief targets one brand; portfolio runs are sequential briefs.                                       |\n| **GenAI provider**    | OpenAI — `dall-e-3` default (3 native ratios), `gpt-image-1` when `CAST_GENAI_MODE=cheap` | `dall-e-3` natively renders 1024×1024 / 1792×1024 / 1024×1792, so the three ratios are three API calls with no center-crop loss. `cheap` mode collapses to one `gpt-image-1` call + Sharp center-crop for budget-constrained demos.                       |\n\nSee [docs/](docs/) for the full design trail: [user stories](docs/user-stories.md) → [system map](docs/system-map.md) → [flow diagrams](docs/flow-diagrams.md) → [attributes \u0026 screens](docs/attributes-screen-requirements.md). Visual reference: [docs/design/cast-brand-guidelines.html](docs/design/cast-brand-guidelines.html) (with sibling guidelines for [onda](docs/design/onda-brand-guidelines.html), [brisa](docs/design/brisa-brand-guidelines.html), [volt](docs/design/volt-brand-guidelines.html)).\n\n---\n\n## Assumptions \u0026 limitations\n\n- **Single-machine, single-user, no auth.** Runs against `localhost:3000`. No multi-tenancy, no session, no role separation.\n- **Local filesystem only.** No S3 / Azure / Dropbox in the POC. The brief permits any of these; chosen scope cut.\n- **One asset per product slug at a time.** Re-uploading a photo for the same product overwrites the previous file (and its alternate-extension siblings).\n- **GenAI provider: OpenAI Images API.** Default model `dall-e-3` calls one of three native sizes (1024×1024 / 1792×1024 / 1024×1792) per requested ratio, behind `OPENAI_API_KEY`. Setting `CAST_GENAI_MODE=cheap` swaps to `gpt-image-1` + Sharp center-crop. Provider abstraction is deferred to v2.\n- **Static raster only.** Output creatives are PNG. Animated formats (GIF, MP4, WebM) are rejected at upload (`415`) and ignored by the resolver. Motion creatives are a separate capability, out of POC scope.\n- **Compliance checks are heuristic, not a legal review.** Logo presence is detected by template match in a configurable corner; brand-color check samples dominant colors; banned-word check is a flat list scan against the resolved overlay string per `(market, ratio)` (the exact string the compositor draws — not an OCR pass on the PNG). The editor pre-flights the same matcher against the same merged list — `BrandProfile.bannedWords` (default floor ∪ `inputs/brands/[slug]/banned-words.json`, built once on the server in `loadBrandProfile` and forwarded to the client) — across the audience + every locale message, and disables Generate when a banned-list term is present, so the spend is gated before the GenAI call rather than after compliance fails post-hoc.\n- **Banned-word floor is intentionally narrow for the POC.** `getDefaultBannedWords()` covers a small universal floor (violence, hate, NSFW, weapons, drugs, self-harm) plus per-brand additions from `inputs/brands/[brand]/banned-words.json`. A production list would expand to common slurs, the strong-profanity family, and leetspeak/punctuation-obfuscation variants of items already on the floor (`n@zi`, `b0mb`, `m3th`, etc.) to defeat trivial bypass. False-positive review against shipping brand fixtures is the gating cost — out of POC scope.\n- **No run history.** Each Generate run is independent. No multi-run comparison view in the POC.\n- **Cloud export requires a tunnel or public deployment.** The Dropbox Saver (v2 path) requires Dropbox's servers to fetch files from your URLs — `localhost` is unreachable from the internet. See [V2 Upgrade Paths](#v2-upgrade-paths).\n- **Generate and Retry are destructive at the campaign root (clears `outputs/[campaign]/` recursively at run start).** Both clear `outputs/[campaign]/` recursively at run start, then immediately rewrite `brief.json` (before the per-product loop) and `report.json` (after the loop). `brief.json` and `report.json` are run-scoped products, not preserved artifacts — the recursive clear ensures a failed run cannot leave a stale `report.json` claiming success on disk. End state of any successful run is invariant under retry.\n- **Symlinks under `inputs/` and `outputs/` are not followed safely.** `safeJoin` validates that a path is a lexical child of a known root, but does not call `realpath` to re-validate after symlink resolution. Production hardening would add `fs.realpath` re-validation at every boundary that interpolates user-influenced strings (`/api/upload`, `/api/detected-assets`, the `revealOutputFolder` server action, Sharp reads). Implementers touching those routes should add a `TODO(symlink-hardening)` comment alongside each `safeJoin` call so the gap stays visible. Out of POC scope.\n- **Brand-profile cache is time-based, not file-watched.** `loadBrandProfile` caches parsed brand state (`brand.json`, `voice.json`, `banned-words.json`, `logos.json`) for 90 s in-process. Edits to `inputs/brands/[brand]/*` mid-session may not take effect until the cache expires; restart `next dev` to force-refresh. Accepted POC behavior — production would invalidate on file mtime.\n- **Localized message support is provided-not-translated.** The brief carries a locale → string map; the pipeline composites the right one. It does not call a translation API.\n- **`manifest.outputDir` is an absolute filesystem path exposed to the client by design.** S5 (Reveal in file explorer) needs an absolute path to hand to the OS shell command. Acceptable in a localhost-only POC; for any networked deployment, the manifest would expose only the repo-relative `creatives[].path` and the reveal action would resolve absolutes server-side.\n\n---\n\n## V2 Upgrade Paths\n\n### Cloud Export — Dropbox (optional)\n\nThe output grid includes an **Export to Dropbox** button that uses the [Dropbox Saver](https://www.dropbox.com/developers/saver) drop-in. It is fully wired but **disabled by default** — it only activates when a Dropbox App key is configured. The button is not required for the POC workflow; all core export needs are met by **Reveal in folder** and the JSON downloads.\n\n\u003e **Why is this optional?** The POC's core contract is \"runs from a clean checkout in under three minutes.\" Enabling Saver requires a Dropbox developer account, a tunnel to expose localhost, and domain allowlist configuration — that exceeds the 3-minute bar. It's included as working code to demonstrate the cloud export surface, not as a reviewer requirement.\n\n#### Prerequisites\n\n- A [Dropbox account](https://www.dropbox.com/) (free tier is fine)\n- [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) (`cloudflared`) — to make localhost reachable from the internet\n\n\u003e **Why a tunnel?** Dropbox Saver works by having *Dropbox's servers* fetch files from URLs you provide. On `localhost:3000` those URLs are unreachable from the internet. A tunnel gives you a public `https://` URL that proxies to your local dev server. Cloudflare Tunnel is recommended over ngrok because ngrok's free tier injects an interstitial page that blocks Dropbox's server-side fetch.\n\n#### Setup steps (~5 minutes, one-time)\n\n**1. Create a Dropbox App**\n\n1. Go to [dropbox.com/developers/apps/create](https://www.dropbox.com/developers/apps/create)\n2. Choose **Scoped access** → **Full Dropbox**\n3. Name it (e.g. `CAST Export`) → **Create app**\n4. On the Settings page, copy the **App key** (alphanumeric string at the top)\n\n**2. Configure the env var**\n\nAdd to `.env.local`:\n\n```env\nNEXT_PUBLIC_DROPBOX_APP_KEY=\u003cyour-app-key\u003e\n```\n\nRestart the dev server (`pnpm dev`) to pick up the new variable.\n\n**3. Start a Cloudflare Tunnel**\n\n```bash\n# Install (one-time)\n# Windows: curl -sL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe -o ~/bin/cloudflared.exe\n# macOS:   brew install cloudflared\n\n# Start tunnel (no Cloudflare account needed)\ncloudflared tunnel --url http://localhost:3000\n# → Your quick Tunnel has been created! Visit it at:\n# → https://\u003crandom-words\u003e.trycloudflare.com\n```\n\n**4. Add the tunnel domain to Dropbox**\n\nIn your Dropbox app's Settings → **Chooser/Saver/Embedder domains** → add the tunnel domain (e.g. `random-words.trycloudflare.com`, without `https://`).\n\n**5. Test it**\n\n1. Open Cast via the tunnel URL (e.g. `https://random-words.trycloudflare.com`)\n2. Run a generate (or navigate to the output grid if outputs exist)\n3. Click **Export to Dropbox** → authenticate in the popup → choose a folder → Save\n4. Verify files appear in your Dropbox\n\n\u003e **Note:** Cloudflare quick tunnels generate a new random subdomain each time you restart. You'll need to update the Dropbox domain allowlist when the subdomain changes. For a stable subdomain, create a [named tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps) with a Cloudflare account.\n\n#### V2b — Dropbox API v2 (future)\n\nFor batch/headless workflows where a popup is impractical, the next tier is [Dropbox API v2](https://www.dropbox.com/developers/documentation/http/documentation#files-upload) with OAuth2 PKCE (`files.content.write` scope). This pushes files server-side via `content.dropboxapi.com/2/files/upload` (≤150 MB per file) — no tunnel needed since the server initiates the upload. This is a significantly larger integration (OAuth flow, token refresh, upload chunking) and is deferred beyond the POC.\n\n---\n\n## Onboarding a new brand\n\nDrop a directory under `inputs/brands/`:\n\n```\ninputs/brands/[brand-slug]/\n├── brand.json          # primary/accent colors (hex), tokens\n├── voice.json          # tone, do/don't lists, prompt fragments\n├── logos/              # corner-composited logo variants (four per brand: primary-on-light/dark, mono-white/black)\n│   ├── logos.json      # { default: variantId, variants: [{ id, displayName, file }] }\n│   ├── primary-on-light.png\n│   ├── primary-on-dark.png\n│   ├── mono-white.png\n│   └── mono-black.png\n├── font.ttf            # OFL display font\n└── banned-words.json?  # optional brand-specific terms (added on top of lib defaults — union, never replacement)\n```\n\nReference it from a brief: `\"brand\": \"[brand-slug]\"`. No code change. The repo ships two seed profiles — `inputs/brands/brisa/` (sparkling water) and `inputs/brands/volt/` (energy) — representing two sub-brands of the fictional Onda Beverages portfolio. Use them as templates. The recipe for reducing a brand book (HTML, PDF, Figma) into the JSON files above is in [docs/brand-extraction.md](docs/brand-extraction.md).\n\nThe brand selector lists every directory found under `inputs/brands/`, so adding a new profile makes it available in the UI on the next page load.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Farndvs%2Fcast","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Farndvs%2Fcast","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Farndvs%2Fcast/lists"}