Homebrew Tool — README
mediacreator
Generate media via fal.ai and kie.ai from the CLI and save it to disk
Install
brew install matt-riley/tools/mediacreatorREADME
mediacreator
A small Go CLI for generating media with fal.ai or kie.ai and saving the finished files to disk. It is designed to be agent-friendly: every command prints JSON to stdout, one object per line, so the output can be parsed directly by a shell, an agent, or a script.
Install
Via Homebrew (published from GitHub releases):
brew install matt-riley/tools/mediacreator
Or build from source:
go build -o mediacreator .
The installed binary reports its build version (injected at release time):
mediacreator version
Setup
Export an API key for the provider(s) you want to use:
export FAL_KEY=... # https://fal.ai/dashboard/keys
export KIE_API_KEY=... # https://kie.ai/api-key (KIE_KEY also works)
Optionally, FAL_BASE_URL / KIE_BASE_URL override the API base URL (useful
for proxies). MC_PROVIDER sets a default --provider.
Quickstart
Input is standardized: the same flags and prompt work on every provider.
The two examples below are identical apart from --provider/--model:
# Same prompt, same standard flags — image via fal.ai or kie.ai
./mediacreator generate --provider fal --model fal-ai/flux/dev \
--prompt "a red fox in the snow" --output ./fox.png
./mediacreator generate --provider kie --model bytedance/seedream \
--prompt "a red fox in the snow" --output ./fox.png
# Same prompt — video via fal.ai or kie.ai (model-family endpoint auto-selected)
./mediacreator generate --provider fal --model fal-ai/kling-video/v1/standard/text-to-video \
--prompt "waves crashing on rocks" --output ./clips/
./mediacreator generate --provider kie --model veo3 \
--prompt "waves crashing on rocks" --output ./clips/
# Reference image from a local file — uploaded to the provider's storage first
./mediacreator generate --provider fal --model fal-ai/flux/dev \
--prompt "the same fox, now on a skateboard" --image-url ./ref.png --output ./out/
./mediacreator generate --provider kie --model bytedance/seedream \
--prompt "the same fox, now on a skateboard" --image-url ./ref.png --output ./out/
# Explicit output size — FLUX-style models take image_size presets on both providers
./mediacreator generate --provider fal --model fal-ai/flux/dev \
--prompt "a poster for a jazz festival" --image-size landscape_16_9 --output ./poster.png
./mediacreator generate --provider kie --model bytedance/seedream \
--prompt "a poster for a jazz festival" --image-size landscape_16_9 --output ./poster.png
# Discover available models (no API key required)
./mediacreator list --provider fal --search flux --category text-to-image
./mediacreator list --provider fal --endpoint-id fal-ai/flux/dev # find mode: details for one model
./mediacreator list --provider kie --vendor bytedance
./mediacreator list --provider fal --all --plain | wc -l # full catalog, one id per line
Commands
| Command | What it does |
|---|---|
generate |
Create a job, wait for completion, download the media. |
submit |
Create a job and print its id. Use status / download later. |
status |
Poll a previously created job once and print its state as JSON. |
download |
Wait for an existing job to finish and download its media. |
list |
List available models for a provider (no API key needed). |
version |
Print the build version (injected at release time). |
# Split workflow for long jobs:
./mediacreator submit --provider fal --model fal-ai/flux/dev --prompt "a fox" \
# -> {"provider":"fal","model":"fal-ai/flux/dev","request_id":"<id>","status":"submitted",...}
./mediacreator status --provider fal --request-id <id> --model fal-ai/flux/dev
./mediacreator download --provider fal --request-id <id> --model fal-ai/flux/dev --output ./fox.png
Flags
| Flag | Meaning | Default |
|---|---|---|
--provider |
fal or kie |
fal |
--model |
Model/endpoint id (e.g. fal-ai/flux/dev, bytedance/seedream, veo3) |
– |
--prompt |
Generation prompt — the same standard flag on every provider | – |
--image-url |
Reference image: URL or local file path (repeatable; local files are uploaded to the provider's storage first) | – |
--aspect-ratio |
Aspect ratio, e.g. 16:9 (fal aspect_ratio / kie 4o size; not FLUX-family fal models — see below) |
– |
--image-size |
Output size: preset (landscape_16_9) or 1280x720 (fal image_size preset/object; kie market image_size presets) |
– |
--duration |
Duration in seconds, e.g. 5 (video models) |
– |
--num-images |
Number of images to generate (fal num_images; kie 4o nVariants) |
– |
--seed |
Random seed (standard flag) | – |
--input |
Native input params as JSON; merged over the standard flags | – |
--output |
Destination file or directory | . |
--webhook |
Optional completion webhook URL | – |
--request-id |
Job id from submit (request_id on fal, taskId on kie) |
– |
--timeout |
Max time to wait for completion | 10m |
--interval |
Poll interval | 5s |
--kie-mode |
market, model, or auto (kie only) |
auto |
--verbose |
Print progress and raw provider payloads to stderr | false |
The standard flags (--prompt, --image-url, --aspect-ratio, --image-size,
--duration, --num-images, --seed) are translated per provider using the
documented key conventions of each:
- fal —
prompt,image_url/image_urls,aspect_ratio(nano-banana-style models),image_size(FLUX-family models — preset string likelandscape_16_9or1280x720which becomes a{width,height}object),duration,num_images,seed(per Common Model Arguments). - kie market (
createTaskinput) —prompt,image_urls(always an array, per the seedream-edit and kling docs),image_size(seedream uses fal's preset enums),duration,seed. - kie families — veo/runway:
imageUrls+aspect_ratio; flux-kontext:inputImage+aspectRatio; 4o image:size(--aspect-ratiomaps to it)nVariants(--num-images).
⚠️
image_sizevsaspect_ratiois model-dependent: the fal docs warn that FLUX-family models useimage_sizewhile others (e.g. Nano Banana) useaspect_ratio, and "passing the wrong one will have no effect". Use--image-sizefor FLUX-style models and--aspect-ratiofor the rest.
--image-url accepts either a hosted URL or a local file path: paths are uploaded first — fal via the CDN upload
two-step flow (POST https://rest.fal.ai/storage/upload/initiate then PUT
the presigned URL, returning a v3b.fal.media URL) and kie via the
File Stream Upload API
(POST https://kieai.redpandaai.co/api/file-stream-upload, returning
data.downloadUrl) — so reference images can come straight from disk. Use
--input for model-specific parameters; it is merged over the standard flags.
list flags
| Flag | Meaning | Default |
|---|---|---|
--provider |
fal or kie |
fal |
--page |
Page number (fal only) | 1 |
--limit |
Items per page (fal only) | 50 |
--all |
Fetch the full catalog (fal only) | false |
--search |
Free-text query: name, description, or category | – |
--category |
Filter by category (fal only) | – |
--status |
active, deprecated, or all (fal only) |
active |
--endpoint-id |
Find mode: details for one endpoint id (fal only) | – |
--vendor |
Filter by vendor prefix of the model id | – |
--plain |
Print one model id per line instead of JSON | false |
Output conventions
stdout carries JSON only, and the shape is identical regardless of provider.
generateemits two lines: the submit confirmation, then the completion object:{"provider":"fal","model":"fal-ai/flux/dev","request_id":"req-abc","status":"submitted","status_url":"...","result_url":"..."} {"provider":"kie","model":"bytedance/seedream","request_id":"task-s","status":"completed","media":[{"url":"https://...","path":"/abs/fox.png","size":112358,"type":"image"}],"images":["https://..."]}The completion object is normalized:
media[]lists every generated file withurl,path,size, and atypeofimage,video, oraudio, plus per-kind arrays (images,videos,audios). The raw provider payload is provider-specific, so it is only shown on stderr with--verbose.stderr carries diagnostics and raw payloads (only with
--verbose).Exit code
0on success,1on error (including failed or timed-out jobs).--outputrules:- ends in
/or is an existing directory → media saved inside it, named from the download URLs; - more than one file → treated as a directory;
- otherwise → a single file path (extension filled in from the URL if missing).
- ends in
Providers
fal.ai — uses the queue API (POST /queue.fal.run/<model>, poll
.../requests/<id>/status, fetch .../requests/<id>). Any public fal endpoint
id works, including fal-ai/... and sub-accounts. Model discovery uses the
official catalog API GET https://api.fal.ai/v1/models (override with
FAL_CATALOG_URL); it works without a key, but setting FAL_KEY raises its
rate limits — handy when walking the full catalog with --all.
kie.ai — two API styles, chosen automatically (--kie-mode auto):
- market (default for most models):
POST /api/v1/jobs/createTaskwith{"model": ..., "input": {...}}, pollGET /api/v1/jobs/recordInfo?taskId=. Works with any model listed on the kie.ai market. - model families: when the model name starts with a known family (
veo,runway,aleph,suno,mp4,wav,vocal,midi,voice,lyrics,gpt4o,flux), the dedicated/api/v1/<family>/generate+record-*endpoints are used.
Model discovery for kie uses the public market catalog endpoint
(GET /api/v1/playground/model-paths) — the same one the kie.ai market web
app uses; kie.ai does not document a model-listing API. If that endpoint is
unreachable, list falls back to a snapshot of the market catalog embedded
in the binary. The JSON output includes a "source": "live" | "fallback"
field so callers can tell which was used.
To refresh the embedded snapshot against the live market:
go generate ./... # regenerates internal/provider/kie_catalog.go
# or
KIE_BASE_URL=https://api.kie.ai go run ./cmd/gen-kie-catalog
Result shapes
Providers and models do not return the same shaped data — this is by design; the tool normalizes everything into the canonical output above. Verified against the fal.ai and kie.ai documentation:
- fal.ai (via the catalog's
expand=openapi-3.0schemas): image models returnimages: [{url, ...}], video modelsvideo: {url, ...}, audio modelsaudio: {url, ...}; inputs also differ (promptfor image/video,textfor some TTS models,image_urlvsimage_urlsfor reference images). - kie.ai market (
jobs/recordInfo) is unified:state+resultJson(a string) containingresultUrls: [...]— plusfirstFrameUrl/lastFrameUrlfor Seedance andresultObjectfor text/mask outputs. - kie.ai model families each return their own keys: veo
response.resultUrls/originUrls/fullResultUrls, alephresponse.resultVideoUrl/resultImageUrl, runwayresponse.videoUrl, sunosunoData[].audioUrl/imageUrl, wavaudioWavUrl, flux-kontextoriginImageUrl/resultImageUrl, vocal separationoriginUrl; some (midi, lyrics, voice) return no media at all.
The extractor recognizes all of these shapes (and any key containing url,
minus queue/callback metadata) and classifies each file as image, video,
or audio from the key or the URL extension, so the CLI output is the same
no matter which provider or model produced the media.
Media URLs are found generically in provider results (images[],
video.url, audio.url, resultUrls, resultJson, and similar shapes), so
new models generally work without code changes.
Development
go test ./...
go vet ./...
golangci-lint run --timeout=5m # or: mise run lint
Releases
Releases are driven by Release Please
and GoReleaser: conventional commits on main open a
release PR; merging it creates a vX.Y.Z tag, release, and binaries. GoReleaser
also publishes the Homebrew formula to
matt-riley/homebrew-tools.
Repository secrets required:
PRIVATE_KEY(secret) +APP_ID(variable) — the GitHub App private key and app id used to mint a short-lived token for the Homebrew tap push. This is the same GitHub App managed by the infra repo (bootstrap setsAPP_ID; the private key lives in repo secrets). The app must be installed onmatt-riley/homebrew-toolswithcontents: write.RELEASE_PLEASE_TOKEN(optional) — PAT used to create the release tag and trigger the publish; falls back to the defaultGITHUB_TOKEN.HOMEBREW_TAP_GITHUB_TOKEN(optional, legacy) — classic PAT withreposcope; only used as a fallback if the GitHub App token is unavailable. If neither is available the Homebrew publish is skipped (settap-fail-if-missing-token: trueon the workflow call to fail instead).
Local validation before pushing a release:
goreleaser check # validate .goreleaser.yml
HOMEBREW_TAP_GITHUB_TOKEN=test goreleaser release --snapshot --clean
./dist/mediacreator_*_darwin_arm64/mediacreator version