Skip to content

Repository files navigation

asset-forge

Generates CloudsForge's brand marks and art from a checked-in manifest, using OpenAI's image models. Every asset has a stable id, a prompt and a size, so a regenerated asset lands at the same path and the diff is reviewable.

pnpm install
cp .env.example .env      # add OPENAI_API_KEY
pnpm generate

Without a key it writes deterministic SVG placeholders instead and never calls the API, so the pipeline stays runnable offline.

Usage

pnpm generate                            # everything missing
pnpm generate --dry-run                  # print the plan and the estimate, call nothing
pnpm generate --force                    # rewrite existing assets too
pnpm generate --track=brand              # the whole registry-derived brand set
pnpm generate --only=mark-hearth         # one asset, or a comma-separated list
pnpm generate --limit=3                  # cap a run (cheap smoke test)
pnpm generate --yes                      # approve the spend without a prompt

ASSET_QUALITY is low | medium | high (default high).

ASSET_MODEL picks the image model; the default, gpt-image-1, is the only one the CloudsForge key can reach. The pinned SDK also types dall-e-3 and dall-e-2, and both answer 400 The model does not exist on that key — they are listed in src/model.ts with their real sizes and prices in case another org's key reaches them, not because they work here. An ASSET_MODEL with no row is refused before the first call rather than after it.

Spending

A full run at quality=high is roughly $12, and a mistyped flag used to be enough to start one. So:

  • --dry-run prints exactly what would be generated, what it would be re-fitted to, and the estimated cost — and calls nothing and writes nothing.
  • Above ASSET_SPEND_LIMIT (default $2) a run stops and asks for a typed yes. Off a terminal there is nobody to ask, so it refuses unless --yes was passed.
  • An unrecognised option is a hard error. --dryrun is not --dry-run, and the difference is $12.

The estimate is an estimate, not a quote: prices come from a table in src/model.ts that cannot know the API has repriced.

OPENAI_API_KEY= pnpm generate forces offline placeholder mode, which costs nothing. asset-forge reads only its own .env — never the platform root one, so no database or service secret is ever loaded into this process.

Two tracks

brand is derived from @cloudsforge/shared/products in src/brand.ts, not hand-written. Every registry surface that owns a markId gets the same five assets — mark 1024², wordmark 1024×384, favicon source 512², OG card 1200×630, GitHub social preview 1280×640 — from the same construction language, differing only in the product's registry accent and its one symbol. That is the entire point: six marks that read as one family. Adding a sixth product to the registry generates its whole brand set for free, into the conventional <repo>/apps/<repo>/public layout; a row in LAYOUT overrides that where a product's reality differs (Forge Pay has no front-end of its own, Hearth has two).

The 192, 32 and apple-touch icons are derived from the 512² favicon source downstream — this pipeline generates the source, not the derivatives.

game is Ninety Days After key-art and the marketing motifs that go with it, hand-written entry by entry in src/manifest.ts. There is deliberately no way to pass a prompt on the command line, so every generated asset has a reviewable prompt in version control.

The two tracks have different art directions and picking the wrong one is the usual mistake. ART_STYLE is the game's weathered post-apocalyptic illustration and applies by default. brandStyle(accent) is flat geometric vector; without it an org mark comes out looking like game art.

Some brand assets are also written under a second filename — mark-cloudsforge as cloudsforge-logo and org-avatar, mark-hearth as hearth-logo — because apps already ask for those. The registry markId is the canonical name; the aliases exist so nothing breaks in the meantime.

Sizes are guarantees

No image model renders arbitrary dimensions — gpt-image-1 accepts exactly three sizes — and none of them is either of the two that are non-negotiable: an Open Graph card must be 1200×630 and a GitHub social preview must be 1280×640, or the platform rejects it.

So src/model.ts asks the model for its legal size nearest the target's aspect ratio, and src/resize.ts centre-crops to the target aspect and resamples to the exact pixels — crop first, then scale, so a 1536×1024 render keeps every pixel it can. Every legal size is at least 1024 on the short edge and no manifest size exceeds that, so this only ever downsamples. It uses sips, which ships with macOS; there is no ImageMagick and no sharp, and a native dependency is not worth it for a tool that runs a few dozen times a year. Off macOS the stage degrades to a warning rather than a failure, and sizes are then whatever the model returned.

The run log says what happened per asset, e.g. 1536x1024 → crop 1536x768 → scale 1280x640.

Because a file that already exists is otherwise never looked at again, an asset whose delivered pixels do not match its manifest size is re-fitted from its own bytes on the next run — no API call, no cost, no --force. Twelve game assets shipped at 1024² against declared 512² and 256² before this stage existed; pnpm generate --dry-run lists them as ↻ refit and a plain pnpm generate corrects them in place.

Output paths

Manifest targets are <repo>/<path inside it>, resolved against the repos root — the directory holding every CloudsForge repository. asset-forge is one of those repositories, so the default is simply its parent and a generated asset lands where it is consumed with no configuration. Set ASSET_OUT_ROOT to stage a run somewhere else without touching working trees.

Two targets start ../: the stack repo (../branding, ../cv/assets) sits one level above the repos root rather than inside it. Resolving those literally against a staging root would write outside it — the one thing staging exists to prevent — so a staged run keeps them inside the stage under _stack/, and any target that would still escape its root is a hard error rather than a silent write into a working tree. ASSET_STACK_ROOT overrides both behaviours.

How this was built

This repository is the AI artwork pipeline, so the disclosure is more specific than elsewhere in the estate.

  • The imagery it produces — brand marks, favicons, social cards, tiles and in-game art — is generated with OpenAI's image models (GPT Image 1, 1.5 and 2), from the manifests in src/. Every asset in the sibling repositories' branding/ and art/ directories came through here.
  • The pipeline code itself was written with Claude Opus 5 and Claude Opus 4.8 (Anthropic).

The models were used under paid API access and the output is the project's to use. Generated assets are committed to the repositories that consume them, so what ships is reviewable in version control rather than regenerated unseen.

About

Manifest-driven brand and art generation for CloudsForge, using OpenAI image models

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages