@yandy0725/mediagen-cli

Non-interactive image generation and editing, plus short video generation, for agents: qwen-image, doubao/seedream, doubao/seedance, MiniMax H3, files on disk, JSON output.

Packages

Package details

skill

Install @yandy0725/mediagen-cli from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@yandy0725/mediagen-cli
Package
@yandy0725/mediagen-cli
Version
0.3.1
Published
Nov 3, 2026
Downloads
577/mo · 577/wk
Author
yandy0725
License
MIT
Types
skill
Size
172.1 KB
Dependencies
2 dependencies · 0 peers
Pi manifest JSON
{
  "skills": [
    "./skills"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

mediagen

English | 简体中文

Generate and edit images through Alibaba Bailian (Qwen-Image) and Volcengine Ark (doubao / seedream), and generate video through Ark (doubao / seedance) or MiniMax (H3). Save results to disk, print their paths. Explicit flags, no interactive prompts, --json for programs.

npm install -g @yandy0725/mediagen-cli

# Any command creates a commented config.toml on first use, and every run
# validates it before the command runs; config show itself only reads it.
mediagen config show

# Add an entry: one account (origin + key); the protocol goes on its task table
mediagen config add my-proxy --api-type openai \
  --base-url https://abc123.cn-beijing.maas.aliyuncs.com \
  --model qwen-image-3.0 --api-key-env DASHSCOPE_API_KEY
# add --task video for a video table; another model is one more entry in models

mediagen login --provider my-proxy --api-key sk-...

mediagen image generate "a red fox in fresh snow, soft morning light" --provider my-proxy --out-dir ./out
# /abs/path/out/my-proxy-qwen-image-3-0-20261025-020839-0.png

Node >= 18, two runtime dependencies, a DashScope and/or Ark API key.

Commands

command what it does
mediagen image generate <prompt> text to image; add --image to edit
mediagen image edit <prompt> the same, with --image required
mediagen video generate <prompt> text to video, or from one first frame; creates a paid task
mediagen video status <task-id> look at one task once, and save its video if it finished
mediagen login / logout / auth status store, remove, inspect API keys
mediagen config show / config path resolved config, and where it lives
mediagen config add <name> create a provider in config.toml; everything else is a manual edit
mediagen skill install --dir <path> copy the packaged agent skill into <path>/mediagen/; re-run to replace it

mediagen image generate --help and mediagen video generate --help are the reference: every flag, the model table, the size reference, examples. Everything below is the short version.

Models and flags

model provider pick it when
qwen-image-3.0 --provider aliyun text rendering (CJK + Latin), editing; needs your workspace host (see below)
doubao-seedream-5-0-flash-260915 --provider volcengine speed: drafts, iteration
doubao-seedream-5-0-pro --provider volcengine --model doubao-seedream-5-0-pro highest quality
video model provider pick it when
doubao-seedance-1-0-pro-250528 --provider volcengine text to video, or animate one first frame
MiniMax-H3 --provider minimax the same, up to 2K, from MiniMax

aliyun, volcengine and minimax are the provider names the first run writes into config.toml; rename them freely. A model is usable only if it is listed in models = [...] under that provider's table for the task (image or video), and a second model of the same vendor is one more entry there — never another provider. Ids are dated snapshots, so edit models (and default) in config.toml as they roll forward.

Protocols

Each provider's task table declares its own api_type, so one account can speak one protocol for images and another for video:

api_type wire format pick it when
openai the OpenAI Images shape: flat {model, prompt, size, n}, data[].url back Bailian's compatible-mode, most third-party proxies
dashscope Alibaba's native shape: input.messages + parameters, output.choices[].message.content[].image back Bailian's native endpoint, or a gateway that exposes it (see below)
ark Volcengine's own API: OpenAI-shaped for images, with its own parameters and per-item errors Volcengine Ark, for images and video
minimax MiniMax's video API: content[] items typed text/image_url, result nested under task MiniMax H3, video only

dashscope serves images only; it has no video task. video generate speaks ark or minimax, and each declares its own resolution tiers (ark: 480p/720p/1080p/4k, minimax: 480p/768p/2k) — a tier the selected dialect does not have fails locally, while which tier a model supports is the vendor's call, checked when the task is created and before anything is billed.

qwen still works as a deprecated alias of openai. A flag the selected protocol does not accept fails before any paid request is sent (--output-format is ark-only, --prompt-extend-mode is openai-only; on video, --seed/--generate-audio/ --output-format/--watermark are ark-only). --size takes a tier (2K), pixels (2048x2048), or auto (openai only); dashscope takes pixels only, because it sends 宽*高 and has no tier or auto concept. --n takes 1-6 — on ark each image is a separate paid request.

Output

Files are written to --out-dir (else output_dir, else the current directory) as <provider>-<model>-<yyyyMMdd-HHmmss>-<index>.<ext>, never overwriting. Human mode prints one absolute path per line on stdout; --json prints one object:

{"ok":true,"partial":false,"files":[{"path":"/tmp/out/aliyun-qwen-image-3-0-20261025-020839-0.png","index":0,"url":"https://dashscope-result.oss-cn-beijing.aliyuncs.com/1.png"}],"errors":[],"usage":{"image_count":1},"request_id":"a1b2c3d4"}

partial: true with exit 0 means some images were saved and some failed: those files are already paid for, so do not re-run the request. Exit codes: 0 success (including partial), 1 API/config/runtime failure, 2 usage error — always raised before a request is sent, so it costs nothing. Paid requests are never retried.

A video run reports the task it created, because creating the task is what is billed:

{"ok":true,"task_id":"cgt-20261028-abcd","status":"succeeded","files":[{"path":"/tmp/out/volcengine-doubao-seedance-1-0-pro-250528-20261028-101500-0.mp4","index":0,"url":"https://...","duration":5,"resolution":"720p"}],"errors":[],"usage":{"completion_tokens":108900}}

There is no partial on video: one request makes one clip. ok means the command succeeded, not that a file exists — --no-wait, and a video status that finds the task still pending or running, are {"ok":true,...,"files":[]} with exit 0. Read status and files: an ok: false there would invite you to submit a task that was already paid for. Note that --no-wait reports status: "pending" without having asked — treat it as "unknown" until a video status call answers. When the poll budget runs out the error carries task_id and code TaskNotFinished, and video status <id> resumes it without paying again.

Configuration

config.toml — the file you edit — lives in the first of $MEDIAGEN_CLI_CONFIG_DIR, $XDG_CONFIG_HOME/mediagen, ~/.config/mediagen.

auth.json (mode 600) is state, not config, and lives apart from it: the first of $MEDIAGEN_CLI_STATE_DIR, $XDG_STATE_HOME/mediagen, ~/.local/state/mediagen. ~/.config is the directory people sync, share and commit; a key store there is one .gitignore mistake away from a leaked key. The state directory is machine-local by definition, which is what a device-bound credential is.

Upgrading from 0.1.1 or earlier: both directories dropped the -cli suffix, and nothing migrates them for you. Move the files, not the directories — a first run of this version already creates the new directories, and mv dir dir would bury your config inside them:

mkdir -p ~/.config/mediagen ~/.local/state/mediagen
mv ~/.config/mediagen-cli/config.toml ~/.config/mediagen/config.toml
mv ~/.local/state/mediagen-cli/auth.json ~/.local/state/mediagen/auth.json

(With XDG_CONFIG_HOME or XDG_STATE_HOME set, the paths are <that>/mediagen-cli → <that>/mediagen instead. If you set MEDIAGEN_CLI_CONFIG_DIR or MEDIAGEN_CLI_STATE_DIR, the names are unchanged and there is nothing to move. Skip a line whose mediagen-cli path does not exist — most users never had an auth.json.)

default_provider = "volcengine"
# output_dir = "./media-output"

[providers.volcengine]              # the account: host + key
base_url    = "https://ark.cn-beijing.volces.com"
api_key_env = "ARK_API_KEY"

[providers.volcengine.image]        # what that account can do, one table per task
api_type = "ark"                    # the protocol is declared here, not on the account
default  = "doubao-seedream-5-0-flash-260915"
models   = ["doubao-seedream-5-0-flash-260915", "doubao-seedream-5-0-pro"]

[providers.volcengine.video]
api_type = "ark"
default  = "doubao-seedance-1-0-pro"
models   = ["doubao-seedance-1-0-pro"]

[providers.aliyun]
base_url    = "https://dashscope.aliyuncs.com"
api_key_env = "DASHSCOPE_API_KEY"

[providers.aliyun.image]
api_type = "openai"
default  = "qwen-image-3.0"
models   = ["qwen-image-3.0"]

[providers.minimax]                 # a video-only account
base_url    = "https://api.minimax.io"
api_key_env = "MINIMAX_API_KEY"

[providers.minimax.video]
api_type = "minimax"
default  = "MiniMax-H3"
models   = ["MiniMax-H3", "MiniMax-H3-Max"]

[providers.my-gateway]              # a gateway that serves the native protocol
base_url    = "https://gateway.example.com"
api_key_env = "MY_GATEWAY_KEY"

[providers.my-gateway.image]
api_type = "dashscope"
path     = "/v1/images/generations" # the gateway's own path, not Bailian's
default  = "qwen-image-2.0-pro"
models   = ["qwen-image-2.0-pro"]

A provider is one account: a host (base_url) and an optional api_key_env. Each task it serves hangs off it by name ([providers.<name>.image], [providers.<name>.video]) and declares its own api_type, the model ids you allow there plus the default used when --model is absent. timeout_seconds is optional per task (300 for image, 1800 for video). Two models of the same vendor are two entries in one models list, not two providers; two protocols for one account are two api_type lines, not two providers. api_type may also sit on the account table as the default for every task under it.

base_url is the origin — an optional gateway prefix, never a version segment. The version segment belongs to the endpoint, which the dialect owns per task (/api/v3/images/generations, /compatible-mode/v1/images/generations, /api/v1/services/aigc/multimodal-generation/generation), so switching task changes only the path. A base_url that still ends with /api/v3, /api/v1 or /compatible-mode/v1 on a vendor host is refused before any request, with the origin to paste in. The file is the only source of truth: nothing is built in, and the first run just writes the above into config.toml for you to edit. Both seeded entries run as written: volcengine on Ark and aliyun on Bailian's native protocol, which is served on the public host. Move aliyun to your workspace host (https://{workspace-id}.cn-beijing.maas.aliyuncs.com) when you switch it to the OpenAI-compatible protocol — that endpoint is not served publicly.

A host whose API does not live at the vendor's own path — a gateway, or a plan that serves /api/plan/v3 instead of /api/v3 — declares that prefix on the task with path, and the adapter appends nothing:

[providers.volcengine]
base_url = "https://ark.cn-beijing.volces.com/api/plan/v3"

[providers.volcengine.image]
path = "/images/generations"        # request goes to .../api/plan/v3/images/generations

Without path, a sub-path under a vendor's own domain is refused before any request (it would be doubled), with the origin or the path value to use. path is only for hosts that do not mirror the vendor's layout; the adapter's own path stays the default.

Video is asynchronous: video generate creates a task (that is what gets billed), writes its id to stderr, then polls until the clip exists — two to five minutes is normal, and the poll budget is the task's timeout_seconds (default 1800). The clip URL is good for 24 hours, so video status <id> can fetch it later; --no-wait hands the id back immediately, which is what an agent wants when its own timeout is shorter than the vendor's render. One clip per request: --n belongs to images.

A key belongs to a provider entry: --api-key, else that entry's api_key_env, else auth.json. There is no dialect-level default key variable, so two entries of the same dialect can use different keys. Keys are only ever printed masked. base_url lives in the file (with a one-off --base-url override on image generate), and it is validated before any request: Bailian's OpenAI-compatible endpoint (compatible-mode) is served only on a workspace-scoped host, and the seed therefore ships the native protocol, whose endpoint is served publicly. A base_url that still contains <...> is rejected before spending anything. mediagen config show prints each provider's tasks, their models, and the endpoint each one would actually hit. Set NODE_USE_ENV_PROXY=1 on Node >= 24 to honour HTTP_PROXY. Timeouts: per request timeout_seconds for the task (default 300 s, video 1800 s), 120 s per image download and 600 s per video download. For video that budget is the whole poll window, not one request: each create or poll gets 60 s, so a hung request cannot eat the budget, and the download has its own 600 s and is not counted against it.

Working from an agent? Inside pi, install the skill as a pi package:

pi install npm:@yandy0725/mediagen-cli

That delivers skills/mediagen/SKILL.md only: pi never puts the mediagen command on PATH, so the CLI still comes from npm install -g @yandy0725/mediagen-cli. For any other agent, mediagen skill install --dir <skills dir> puts the same file where that agent already looks — e.g. --dir ~/.claude/skills — and re-running it after an upgrade replaces the old copy. Use one route, not both: pi loads skills by name, so the two copies collide — the directory you copied wins and pi only reports a name "mediagen" collision warning, which means a stale copy silently shadows the upgraded package. The skill describes what this CLI does, which tool to prefer when the project already generates media another way, and the rules that keep a paid request from being wasted.


Development

npm install
npm run build    # tsup -> dist/cli.js
npm test         # vitest, fully offline: fetch is stubbed everywhere
npm run dev -- image generate --help

Tests live in tests/ — one file per module, imported from ../src/ — and never beside the sources; vitest.config.ts picks up tests/**/*.test.ts and nothing else. tsc --noEmit typechecks them too.

src/cli.ts wires commander and maps errors to exit codes; src/generate.ts runs the pipeline (validate → resolve → call → download → report); src/api/{qwen,ark,dashscope}.ts are the dialect adapters behind the DialectAdapter seam in src/types.ts, with config.ts, auth.ts, http.ts, download.ts and output.ts around them.

Worth preserving: every check runs before the first fetch; paid requests are never retried; stdout carries the result only; no output path ever holds an unmasked key. A provider speaking an existing dialect needs no code — only a config.toml table. A new dialect means an adapter in src/api/, a row in DIALECTS (src/api/index.ts) plus its path field — the endpoint file name the dialect appends to base_url.

Release: cut a vX.Y.Z GitHub Release and Actions publishes it — see docs/guides/release.md (dist/ and skills/ are what ships, and CI runs npm pack --dry-run on every push). Design docs live in docs/superpowers/specs/.

MIT