H3 Console — internal video generation API
Internal server running MiniMax H3 on our own GPU cluster (4x GPU, single backend node). No per-call cost, unlike fal.ai/MiniMax cloud. Used as the seed video library + free generation queue for the live channel project.
Two exposed instances, same token:
- LAN:
http://<host>:8080 - Public:
http://qwen.wudaima.com/console(=:8081/console)
Credentials live in .env (H3_CONSOLE_TOKEN, H3_CONSOLE_BASE) — not
duplicated here. server.js loads .env at startup via process.loadEnvFile.
Auth
All /v1/* endpoints require:
Authorization: Bearer <H3_CONSOLE_TOKEN>
<video>/download links can't send custom headers, so the file endpoint also
accepts ?token=<H3_CONSOLE_TOKEN> as a query param (same check). No token /
wrong token → 401. The console pages themselves (/console,
/console/newtask.html) load without a token; the page's own JS prompt()s
for it on first API call and caches it in localStorage.
Endpoints (verified against live server)
GET {base}/v1/backends
Cluster/queue status.
{
"status": "ok", "ready": 1, "total": 1, "queued": 0, "running": 0,
"backends": [{"status":"ok","model":"ready","gpu":"0,1,2,3","partition":"raylight-fsdp","queued":0,"running":0,"backend":"http://127.0.0.1:8100"}],
"gpus": [{"index":0,"memory_used_mb":5970,"memory_total_mb":22528,"utilization_pct":0}, ...]
}
GET {base}/v1/videos?limit=N&sort=desc
Library listing. limit=200&sort=desc currently returns all 200 most recent
jobs (184 done, 13 cancelled, 3 failed). partition breakdown seen:
ref2va (reference image/video → video, 164), fl2va (first/last-frame →
video, 21), text (pure text-to-video, 15).
{ "jobs": [ { ...same shape as GET /v1/videos/{id}, see below... } ] }
GET {base}/v1/videos/{id}
Single job status/detail (also the poll URL returned by submit).
{
"id": "44bf5b969e94",
"status": "done", // queued | running | done | error/failed | cancelled
"req": {
"prompt": "...", // full structured H3 prompt (can be 100s of words)
"width": 1024, "height": 576, "frames": 362, "steps": 20,
"turbo": false, "seed": 42, "cfg_scale": null, "negative_prompt": null,
"first_frame_url": null, "last_frame_url": null,
"reference_image_urls": ["https://...png"], "reference_video_urls": [], "reference_audio_urls": []
},
"created_at": 1788162094.0, "started_at": 1788162094.2, "finished_at": 1788163221.6,
"step": 20, "total_steps": 20,
"file": "/data/models/raylight/ComfyUI/output/video/MiniMax_H3_00272_.mp4", // server-local path, not fetchable directly
"size_bytes": 1020185, "error": null,
"partition": "ref2va", "comfy_prompt_id": "bf8bde0e-...",
"backend": "http://127.0.0.1:8100",
"download": "/console/v1/videos/44bf5b969e94/file", // relative path, use this
"deleted_at": null
}
frames/fps ≈ 24 (e.g. 362 frames ≈ 15.08s, matches console UI's "15s" default for 1024x768).
POST {base}/v1/videos
Submit a new job. Confirmed live (test job b20846897627, text-to-video,
832x480, 96 frames/4s, 20 steps): took ~201s wall clock (started_at →
finished_at) on this cluster — much slower than fal.ai's cloud (~4s for a
comparable clip), so treat H3 Console generation as background/best-effort,
never something the live player blocks on.
// request
{
"prompt": "test: a red ball slowly rolling across a plain white floor, static camera, simple lighting",
"width": 832, "height": 480, "frames": 96, "steps": 20, "turbo": false, "seed": 0
}
// response (immediate)
{
"id": "b20846897627", "status": "queued", "queue_position": 1, "eta_seconds": null,
"poll": "/console/v1/videos/b20846897627", "backend": "http://127.0.0.1:8100", "partition": "text",
"req": { ...echoed... }, "created_at": 1788168842.96, "deleted_at": null
}
Same optional fields as req above (first/last frame urls, reference urls)
work for fl2va/ref2va-style submissions — confirmed 2026-09-02 (dance
room project spike S1/S2, see spike/s1-s2-s4-2026-09-02/RESULTS.md):
reference_image_urls (ref2va, attribute transfer via prompt language)
and reference_video_urls (motion/choreography reference) both work as
documented on the console UI, reference_video_urls has a real, strong
effect on output motion (not a no-op) — A/B tested with identical prompt
text and reference images, only toggling this field, produced visibly
different movement energy/vocabulary.
POST {base}/v1/assets — undocumented until 2026-09-02, found by reading the console's own front-end JS
Upload endpoint the console's own file pickers use to turn a local file into
a URL usable in reference_image_urls/reference_video_urls/first/last
frame fields. Tested with an Authorization: Bearer <token> header (same as
every other endpoint) — whether it's actually required wasn't isolated.
Multipart form, field name file:
POST {base}/v1/assets
Content-Type: multipart/form-data; boundary=...
file: <binary>
// response
{ "id": "237d91a054cf4113", "url": "http://192.168.110.67:8080/assets/237d91a054cf4113__person.png", "kind": "image" }
url is a direct LAN-IP URL (not behind the qwen.wudaima.com reverse
proxy), but it's reachable from outside that LAN too (tested from this dev
machine, curl → 200) — treat it as a normal public URL, not LAN-only.
This is the answer to "how does H3 reach our uploaded images/videos"
(dev-plan.md S4): upload through this endpoint first, use the returned URL
in the job submission, instead of standing up our own public file server.
Pasting an external URL directly (skipping /v1/assets) also works — the
backend does a plain urllib.request.urlopen() on whatever URL it's given,
confirmed by the console's own JS source comment.
GET {base}/v1/videos/{id}/file
Downloads the mp4. Content-Type: video/mp4, supports range requests
(Accept-Ranges: bytes), Content-Disposition: attachment. Auth via
Authorization header or ?token=.
DELETE {base}/v1/videos/{id}
Soft-delete — moves the job to the console's own 回收站 (recycle bin), sets
deleted_at, and it stops appearing in GET /v1/videos list results.
{ "id": "b20846897627", "deleted": true }
Gotcha: this does NOT cancel an in-flight job. A queued/running job
you delete keeps running to completion on the GPU — you've only hidden it
from listings, not freed the compute. GET /v1/videos/{id} (single-job
lookup) still returns it in full, deleted_at set, once it finishes. Our
generateForPrompt() in server.js checks deleted_at and treats it as a
failure so a deleted job can't silently get pulled into library.json —
learned this the hard way after deleting jobs mid-generation during testing.
Prompt format
Jobs generated through the console's own UI use a long structured prompt
(sections: subject_definitions:, summary:, retention_analysis:,
detailed_description:, overall_soundscape:, non_diegetic_music:) —
these are drama-production character/expression reference clips (project
looks like a "画皮"-style period-horror short drama), not one-line prompts.
Our library ingest needs to rule-extract a short display prompt from this
(see ingest.js — ponytail: regex extraction from the summary:/
detailed_description: section, not an LLM call, per explicit decision to
keep this free).
Our server (server.js) — what it builds on top of the console API
Runs under pm2 as minimax-live (pm2 start server.js --name minimax-live,
pm2 save). Auto-restarts on crash; does not yet survive a machine
reboot — that needs pm2 startup run once with sudo (prints the exact
command; not run automatically since it's a system-level change).
- Library sync:
ingest.js'ssyncLibrary()runs on server startup and every 5 minutes, rewritinglibrary.jsonfrom the console's current job list (rule-extracts a short display prompt, filters out infra smoke-test jobs, preserveslikesacross re-syncs). So content added directly through the console UI shows up here within 5 minutes, no manual step needed. Admin overrides survive this: any entry withedited: true(set byadmin.html's edit/add) is carried forward as-is instead of being rebuilt from the H3 job — otherwise every admin edit would get silently reverted on the next sync, and manually-added entries (not backed by any H3 job at all) would just vanish. - Live schedule:
schedule/promptQueue(inserver.js) are the shared virtual playhead + prompt queue described in the main project — persisted tolive-state.jsonevery tick so a restart resumes instead of going cold. Ageneratingentry also persists itsh3JobIdonce known, so a restart mid-generation resumes polling that same job instead of losing it. - Endpoints:
GET /api/library,GET /api/live/state,POST /api/prompt,POST /api/like/:id,GET /lib/video/:id(proxiesGET {base}/v1/videos/{id}/fileserver-side soH3_CONSOLE_TOKENnever reaches the browser; if the clip has its ownvideoUrl— set viaadmin.html, or anysource: "manual"entry — this 302-redirects there instead), and theadmin.htmlCRUD trio:POST /api/admin/library,PATCH /api/admin/library/:id,DELETE /api/admin/library/:id(deleting anh3console-sourced entry also callsDELETE {base}/v1/videos/:idso it doesn't reappear on the next sync — see the DELETE gotcha above, it only hides the job, doesn't cancel it if still running). - No auth on any of this — same as the rest of the project, these endpoints (admin CRUD included) trust the network they're reachable on.
ComfyUI direct access (not currently used by our integration)
comfyui.wudaima.com — raw ComfyUI behind HTTP Basic Auth (frpc
http_user/http_pwd, no changes to ComfyUI itself). Creds in .env
(COMFYUI_BASE, COMFYUI_USER, COMFYUI_PASS). We go through H3 Console's
/v1/* wrapper API instead, which already gives us a queue/status/library
interface — no reason to hit ComfyUI directly unless that wrapper is
insufficient later.