Get the Mac app

Docs / API and agents

HTTP API for agents

This website documents selected supported workflows and fields, not the complete internal API.

Use original or stylised characters, or your self-depiction with documented consent. Any depiction of another identifiable person requires their documented consent. Follow the Acceptable Use Policy for all reference images, training data, audio and outputs.

External agents (Claude Code, Codex, OSS, or anything that speaks HTTP) drive Phosphene through this API. The in-panel chat was removed 2026-05-15 — there’s no agent embedded inside the panel anymore. Everything that the chat used to do can be done by an external agent through these endpoints.

Ready-made tools on top of this API: the phosphene command and an MCP server for Claude Code, Codex or any MCP client, in agents/. See Agent tools.

Server

  • Base URL: http://127.0.0.1:8198 on a normal install. 8199 is the DEV profile’s port, and every example on this page uses it because that is the panel these were written against. The rule is one line in the source (mlx_ltx_panel.py:275): DEFAULT_PORT = 8199 if PROFILE == "dev" else 8198, overridable with LTX_PORT. If a request here returns nothing, you are almost certainly talking to a port that has no panel on it — try the other one.
  • Process: mlx_ltx_panel.py (in the repo root)
  • Wire format: form-encoded POST bodies; JSON exceptions are noted per-endpoint. Responses are HTTP/1.0 — BaseHTTPRequestHandler’s default, never overridden — so the connection closes after every request and there is no keep-alive to pipeline against.
  • Auth: none — bound to loopback only.

Conventions

  • Every JSON response is Content-Type: application/json.
  • Errors return {"error": "<message>"} plus a non-2xx HTTP status.
  • 451 licence_required (4.19.8). A request that would download or run a model whose licence asks for an explicit “I accept” - Hailuo H3 (MiniMax H3 Community License) and the non-commercial models Ideogram 4, YuE2, FLUX.1 Krea [dev] and Bria FIBO - answers HTTP 451 with {"ok": false, "code": "licence_required", "licence": "<key>", "error": "...", "url": "<licence>"} until that licence is accepted once on this Mac. Applies to /queue/add, /queue/retry, /queue/batch, /image/generate, /h3/turbo/install, /h3/tristep/install, /music/install, /music/cover/install and /music/lora/fetch; a job that arrives any other way fails with the same sentence. Read the terms with GET /licences and accept with POST /licences/accept (below), or once in the panel, which shows the screen by itself.
  • The /agent/* namespace is reserved-and-removed: any path under /agent/ except /agent/image/config returns 410 Gone with {"error": "agentic flows removed; see docs/API.md"}. New endpoints should not be added under /agent/.

Generation — submit and manage jobs

POST /queue/add — enqueue a video render

Add a job to the panel’s queue. Returns immediately; the helper renders it asynchronously.

Form fields (all required unless marked optional):

FieldTypeNote
modet2v | i2v | extend | keyframe | a2v | v2a | imageJob type. v2a (Add sound) is Hailuo H3 only.
promptstringFull prompt text. Trigger words for LoRAs go here.
negative_promptstringOptional. Honoured only where the pipeline runs classifier-free guidance: high, extend, keyframe, a2v. The distilled quick / balanced / standard paths have no guidance branch and ignore it (they used to fold the terms into the prompt, which made the model draw them, #81).
width, heightintBoth divisible by 32.
framesintMust satisfy frames % 8 == 1. 121 = 5s, 169 = 7s, 241 = 10s.
frame_ratefloatDefault 24. LTX is trained at 24 fps; deviation degrades quality.
character_strengthfloatDefault 1.0. The character LoRA’s strength, 0–2.
seedint or -1-1 = random.
qualityquick | balanced | standard | highFor character LoRA work, use high. balanced silently routes >121f clips to the Q4 distilled transformer where current LoRAs lose identity.
stage1_steps, stage2_stepsintHQ two-stage pipeline. Validated defaults: 10 / 3.
teacache_threshfloatValidated speedup plateau at 1.8–2.0. Below 1.6 no speedup. Higher = more aggressive caching.
cfg_scalefloatClassifier-free guidance. 3.0 is the validated default.
bongmath_max_iterintSampler inner-loop cap. 100 is upstream max.
upscaleoff | fit_720p | fit_1080pOptional post-process.
upscale_methodlanczos | pipersrIf upscale != off.
acceloff | (other modes — see panel source)Acceleration knob.
enhancetrue | falseIf true, Gemma rewrites the prompt before encoding. Set false when the prompt contains LoRA trigger words — the rewriter can strip them.
hdrtrue | falseHDR ic-lora pass.
imagepathI2V only. Absolute path to a reference PNG/JPG. Required — since v4.6.1 an omitted or empty image on i2v / i2v_clean_audio is refused at job start with a readable error instead of defaulting to examples/reference.png, a demo file no install has ever shipped (that default was the widest-spread render failure in the fleet: image not found: <path>, 35 events across 22 people in 14 days). LTX_DEFAULT_IMAGE still supplies a default when it points at a file that actually exists.
audiopathOptional audio reference.
labelstringOptional UI label for the queue card.
temporal_modenative | fps12_interp24 | windows. windows renders past one 121-frame window as a SEQUENCE — one generate, then one extend per later window on the kept tail of the last (ltx_windows.py: stride = window - discard - overlap, count = 1 + ceil((total - window + discard) / stride)), trimmed to the asked length. Needs the Q8 add-on and a tier that allows Extend; refused with a sentence otherwise. Stored on the job as long_mode.
window_promptsJSON array (or newline-separated) of one line per window; line 1 blank = the prompt; a later blank line holds the previous moment. Each later window’s prompt is its line FIRST, then the continuation clause, then Throughout: <window_invariants>. — the H3 chain contract, and the lead-with-the-move rule.
window_invariantsWhat must not change between windows (who, where, light, lens). Re-injected into every later window.
lorasJSON-encoded arraySee LoRA payload below.

Hailuo H3 (engine=h3), 4.19 fields. H3 renders t2v, i2v, keyframe, extend, a2v and v2a; each mode past i2v needs a flag on the INSTALLED runner (/status.h3.keyframes / .extend / .audio_drive / .video_to_audio), and a job for one the runner lacks renders on LTX with a sentence (Add sound has no LTX lane and is refused with the update sentence instead).

FieldTypeNote
h3_quality, h3_lengthstringThe cell. h3_quality=fast_hd = the 640×384 Fast pass + Upscale & Face Fix to 1280×768, queued together; Fast and the fix are forced.
h3_continuityon | offDefault on: a chained window carries the last 17 frames of the previous one and their sound (--chain-context-frames 17). off = the one-still hand-over.
start_image, end_image, keyframes_jsonpath / JSONmode=keyframe. On H3 the start frame is optional (an end frame alone is MiniMax’s last-frame task); keyframes_json beats land at their frame_index on the 24 fps timeline.
video_path, h3_extend_secondspath / floatmode=extend: any clip, continued from its last 17 frames and their sound; 0.5–15 s added after it.
audio, image, frames, audio_start_time, audio_stem_automode=a2v with engine=h3: the track is held exactly and the picture rendered to it; frames (24 fps) is the length, capped at the audio left after audio_start_time and at 30 s; audio_stem_auto=on conditions on the separated vocal and delivers the original.
video_pathpathmode=v2a (Add sound, H3 only): the clip’s picture is kept exactly and only its sound is generated; up to 60 s.
h3_prompt_guards"" | off | all | comma listWhich prompt guards run on the text the runner encodes (h3_prompt_guards.py): ellipsis, spoken, hands, nouns. Empty = the default set (ellipsis, spoken, hands; nouns is opt-in); off sends the prompt verbatim. The guards applied are recorded as h3_prompt_guards_applied; prompt keeps what was typed.

LoRA payload: the loras field is a JSON-encoded array of {path, strength} objects. Stack as many as needed: On Hailuo H3, stacking needs the engine’s repeatable --lora (/status.h3.loras.max_stack > 1); an older pack takes one adapter and the h3_lora_slot field decides whether it goes to Turbo or to the LoRA.

[
  {"path": "<panel-root>/mlx_models/loras/explorertrn_v2.safetensors", "strength": 1.0}
]

Return: {"ok": true, "id": "j-<...>"} — the job ID for polling.

Example:

curl -s -X POST http://127.0.0.1:8199/queue/add \
  --data-urlencode "mode=t2v" \
  --data-urlencode "prompt=Cinematic close-up of bizarrotrn man in a wood-paneled study, golden hour, photorealistic." \
  --data-urlencode "width=1024" --data-urlencode "height=576" \
  --data-urlencode "frames=169" --data-urlencode "steps=8" \
  --data-urlencode "quality=high" --data-urlencode "temporal_mode=native" \
  --data-urlencode "stage1_steps=10" --data-urlencode "stage2_steps=3" \
  --data-urlencode "teacache_thresh=2.0" --data-urlencode "cfg_scale=3.0" \
  --data-urlencode "bongmath_max_iter=100" --data-urlencode "accel=off" \
  --data-urlencode "enhance=false" --data-urlencode "upscale=fit_720p" \
  --data-urlencode "upscale_method=lanczos" --data-urlencode "seed=-1" \

POST /run — alias for /queue/add (identical behavior).

POST /queue/retry

Re-queue a job from history with the same params. params.open_when_done is forced to false (retries are background).

FieldTypeNote
idstringSource job ID.

Returns {"ok": true, "id": "<new-id>", "source_id": "<old-id>"}.

POST /queue/remove

Remove a queued job. (Running job can’t be removed — use /stop.)

FieldTypeNote
idstringJob ID.

Returns {"ok": true}.

POST /queue/clear

Empty the queue. Returns {"cleared": <count>, "ids": [...], "undo_token": "<hex>"|null}. The token undoes THIS clear once, for 60 s (POST /queue/restore with {"token": "<hex>"} puts back exactly the removed jobs, original ids included, ahead of anything queued since). A later clear supersedes it; an expired or spent token answers 410.

POST /queue/pause, POST /queue/resume

Pause/resume the queue dispatcher (does not affect a job already running).

POST /stop

Request the running job to stop. Returns {"ok": true}. Optional ?id=<job id> (what the panel sends): stop only if that job is still the one running — otherwise 409 {"stale": true} and nothing is stopped. /stop/after_part takes the same id.

POST /queue/batch

Submit multiple prompts as one batch.

FieldTypeNote
promptsstringNewline-separated prompts.
(plus all the standard /queue/add fields, applied to each row)

Status — poll job state

One Shot — its own door: POST /oneshot (2026-09-08)

One Shot is a workflow tab and a first-class API. A script posts the same document the tab posts — one JSON object — and gets the queued job back:

curl -s -X POST http://127.0.0.1:8198/oneshot -H 'Content-Type: application/json' -d '{
  "prompt": "A red vintage tram rolls through a rain-soaked Lisbon street at night; a woman with a yellow umbrella waits at the stop. The bell rings twice; rain on the roof.",
  "seconds": 60,
  "engine": "ltx",
  "quality": "balanced",
  "camera": "a slow, steady push in along the street",
  "beats": ["the tram comes round the corner", "", "the woman steps to the kerb"],
  "handoff": "last"
}'
# → {"ok": true, "id": "j-…", "plan": {"seconds": 60, "parts": 6, "beats": 12, "engine": "ltx", "handoff": "last", "minutes": 28.2, "eta": "~28 min"}}
FieldTypeDefaultMeaning
promptstrrequiredThe whole shot, once: who, where, the time of day and the weather, the sound. With no beats it carries every five seconds.
secondsint60One of 30 / 45 / 60 / 90 / 120. Any other value is refused, never rounded (a 75 used to render as one clip).
engineltx / h3ltxLTX renders 10 s parts (faces, voices, dialogue); H3 renders 15 s parts (motion, landscapes). H3 is refused when not installed.
qualitystrengine defaultLTX: quick / balanced / standard / high; with a character: draft / pro / high / high_720p; H3: draft / standard / high / native.
beatslist or newline textnoneOne line per five seconds. A blank line holds that moment; extra lines are dropped. Absent = the prompt is every beat.
camerastr""One move for the whole shot, its direction and speed; carried into every part (take_camera).
imagepathnoneA start frame already on this Mac — upload first with POST /upload (field image) and pass the returned path. Makes the first part i2v.
character_idstrnoneA trained character (LTX only). Defaults quality to pro, the export to 720p Sharp, and handoff to speech. character_strength optional.
handofflast / speechlast (speech with a character)Where a part hands off: its last frame, or a talking frame with the last word carried over the next part (see the take contract below).
light_lock / retakeon/offonThe continuity sentence on every beat; one drift retake per part.
seed, label, no_music, turbo (H3), upscale, upscale_methodAs on /queue/add.

GET /oneshot/options — what this Mac can do: seconds, engines.{ltx,h3} (available, part_seconds, qualities), character_qualities, characters (id, name, trigger), planner. GET /oneshot/estimate?engine=&quality=&seconds= — parts, part_seconds, beats, minutes, eta. POST /oneshot/plan {prompt, seconds, engine} — the Storyboard planner writes one beat per five seconds from the prompt (blocks 20–40 s): {ok, beats:[…]}. GET /oneshot/status — current (id, label, part, parts, lipsync per part, drift, handoff), queued, recent, and the take’s own log lines.

Under the hood every One Shot is the take contract below — POST /oneshot maps the document onto make_job’s form, so the tab, the API and /queue/add render exactly the same job.

One take — take_seconds + beats on POST /queue/add

A clip longer than a single pass, on either engine. take_seconds is one of 30 / 45 / 60 / 90 / 120; beats is a JSON list (or newline text) with one prompt per five seconds — a blank beat holds the previous moment, extras are dropped. No beats at all (the field absent, empty, or only blank lines) means the prompt is every beat: the shot is the prompt sustained for the whole take. Before 4.12.1 that case rendered every window as the hold sentence — a take about nothing. The take overrules the engine’s own length field. On BOTH engines it renders as PARTS chained by last-frame handoff — run_take_job_inner runs one ordinary render per part, each starting from the last frame of the one before (i2v, anchored), hides the parts from the gallery and joins them into <name>_take<seconds>s.mp4:

enginepartwhat make_job doesworker
ltx10 s = two beats = 241 frames (a 45 s take ends on a one-beat 121-frame part)frames = 241, temporal_mode = native (a take is never the windows chain, whatever the form sent)one ordinary LTX render per part through run_job_inner: part 1 is t2v, or i2v from the job’s own image when it came in as i2v; every later part is i2v from the previous part’s last frame with i2v_reference_mode = anchor. Any quality; the Q8 pack is only needed by an HQ quality, as for a single clip
h315 s = three beatsh3_length=15s, h3_chain_prompts = the first part’s beatsone ordinary H3 render per part (--first-frame from the previous last frame)

Two optional fields, both default on:

fieldoff means
take_light_lockthe continuity sentence (take_light_lock() — “it is still night…”) is NOT appended to the beats; take.light_lock is "" and a blank beat holds with the hold sentence alone
take_retakea part whose light drifts past TAKE_DRIFT_MAX is kept as rendered instead of being retaken once with a fresh seed; take.retake is false
take_cameraoptional, free text, one move for the WHOLE shot (“a slow, steady clockwise arc around the table at eye level, wide to close”). take_camera_lock() puts it first in part 1’s prompt (“Camera: …; one steady move at one speed, never stops, never changes direction”) and, in every later part, a sentence that the camera is already moving that way and continues in the same direction at the same speed — the handoff anchors the picture between parts, not the motion, and the seam was visible as a camera that changed direction or stopped and restarted at every join. Left blank, later parts still get the generic continuity sentence. Recorded as take.camera
take_handofflast (default) or speech. With speech the runner cuts every part but the last on a TALKING frame: take_handoff_points() finds where the line ends (audio RMS), stops the picture TAKE_TALKING_BACK = 0.2 s before it (the mouth still mid-word — the only anchor measured to carry the voice onto the next part’s mouth: +0.54 / +0.47 against −0.15…−0.01 on a closed mouth), keeps the sound to the end of the line plus its 0.4 s decay, and mixes that tail (part{k}_tail.wav, amix normalize=0) over the head of the next part (part{k+1}_lead.mp4) — a J-cut, so the last word is heard whole. Write each part’s first beat to OPEN IN SILENCE so the tail has room. The silent tail after each line is dropped, so the take runs shorter than its nominal length; the last part is not trimmed. Every spoken part is scored by take_lipsync_score() and retaken up to TAKE_LIPSYNC_RETAKES = 2 times with fresh seeds (ids -pNl, -pNl2) when under TAKE_LIPSYNC_MIN = 0.20; the best clip is kept and its score recorded in job["take_lipsync"]. Recorded as take.handoff

params.take on the job: {seconds, beats, parts, frames, part_frames, beats_per_part, engine, beat_prompts, light_lock, retake} — frames is the whole take (seconds·24+1 on LTX), part_frames one full part. The output’s sidecar carries take.{seconds, beats, parts, engine, beats_per_part, part_frames, light_lock, retake} with parts the hidden part files, and image only when the take opened on the user’s own anchor. Load Params restores a take as a take.

GET /take/estimate

?engine=h3|ltx&quality=<engine quality key>&seconds=<take_seconds> → { ok, seconds, beats, parts, beats_per_part, part_frames, engine, frames, minutes, eta, needs_q8 }. parts is the part count on both engines. Minutes are parts × the single-clip price of one part from the same cost model as every other estimate (H3: the measured 15 s cell; LTX: the quality’s 10s cell of LTX_TIERS, the one-beat tail at its 5s cell); null where this Mac has no number. needs_q8 is always false now — an LTX take renders on whatever the chosen quality renders on.

GET /status

Every outputs[] row carries q: the output’s searchable words, lower-cased, built from its sidecar in the listing pass that already reads it — prompt, label, mode, quality, engine, character, seed, WxH, Nf, LoRA file stems, the model directory name, the temporal mode and windows. The gallery’s search box is a substring test over name + q; every typed word must match, and typing pulls the older outputs in so the search is over everything.

Every video row also carries silent: true when the clip has no sound (no audio track in its mp4 header, or a track that never rises above −80 dB), false with sound, null while not known yet. The header read is inline; the level check runs once per file on a background thread and is cached per (mtime, size) in state/output_silence.json, so a poll never decodes anything. The gallery card and the player offer Add sound on a silent clip when /status.h3.video_to_audio is true.

Returns the full panel state.

{
  "running": false,
  "paused": false,
  "current": null,
  "queue": [],
  "history": [ { "id": "j-...", "status": "done", "params": {...}, "elapsed_sec": 426.1, "output_path": "...", "raw_path": "...", "error": null }, ... ],
  "log": [ "[HH:MM:SS] helper line...", ... ]
}

Field semantics:

  • deferred_downloads (4.20) — { active, state, key, message, pending: [{key, name, size_gb, left_gb}], left_gb, finished }. The packs a fresh install queued for after Start (state/install_deferred.json, written by install.js; required_files.json -> install.after_start). state is idle, starting, downloading, waiting (another download holds the slot), waiting_disk (the panel keeps install.background_floor_gb free; message says how much to free), retry_wait, failed or done. Empty and idle on any install install.js never queued — an updated install downloads nothing. download carries background: true while the fetcher holds the slot.
  • current — the job actively running (null when idle).
  • gpu (4.19.10) — { busy, holder, since, outside_queue }: the panel’s one GPU gate. holder is "queue" for the job in current, or what holds the GPU outside the queue: "image" (an Image Studio render), "character_sheet", "enhance" (Prompt Enhance), "lyrics", "update" (the post-update script). A client that must not interrupt work (stopping or updating the panel) waits for current to be null, the queue to be paused or empty, AND gpu.busy to be false.
  • app_models (Mac app only) — the app’s own model download: { state, packs, active, locked, done_bytes, total_bytes }. locked (4.19.10) is true while the app holds its download lock (<Phosphene home>/.locks/models.lock); the panel then offers no Download for those packs and refuses one with 409 app_downloading. A second scripts/fetch_pack_release.py on a pack folder that is already being written stops untouched with exit code 75.
  • queue — jobs waiting, FIFO order.
  • history — completed/failed jobs. Each entry has status, elapsed_sec, output_path, raw_path (native pre-upscale), error.
  • log — rolling buffer of helper stdout lines (last ~50 jobs).

Polling loop: GET /status every 15–30s. A job is terminal when its status is one of done, failed, cancelled, stopped, error. Until then it’s queued or running.

stopped means the user stopped the render on purpose (see POST /stop?mode=early). It is not a failure: nothing was saved because the clip was never finished, but nothing went wrong. stopped_reason carries the runner’s own sentence.

current.progress

Present while a job runs.

{
  "pct": 41, "phase": "denoise", "phase_label": "Denoising · step 5 / 10",
  "elapsed_sec": 62, "remaining_sec": 84, "eta_sec": 146,
  "preview": {
    "url": "/image?path=…/live/preview_latest.png&t=1786…",  // cache-busted server-side
                          // /image, NOT /file: the preview lives under STATE_DIR and
                          // /file serves OUTPUT only. Writing /file here is what shipped
                          // a correct preview to a broken-image glyph in every client.
    "estimate": 4, "total": 12,
    "meaningful": true,   // THE gate — the server decides, the client renders
    "abortable": true,    // meaningful && the runner published an abort sentinel
    "saves_sec": 182      // remaining_sec at the moment it became abortable
  },
  "preview_url": "/image?path=…"  // top-level alias of preview.url, same /image
                                  // form, present only once meaningful
}

preview is absent unless the live preview is on (Settings → Memory / speed) and the tiny decoder is installed. meaningful is computed server-side and must not be re-derived: the rule differs per pipeline — estimate 6 of 8 on the distilled schedule, whose first estimates are still essentially noise; estimate 2 on the two-stage HQ path, which evaluates the denoiser twice per stage-2 step. A client counting estimates would have to know which schedule is running.

POST /stop?mode=early

Asks the running render to stop at the next forward boundary by touching an ABORT sentinel. The runner exits 75 — a distinct code meaning “the user stopped this”, not a crash — and the job resolves status: "stopped" with no output file. Returns 404 when nothing is rendering and 409 when the render has no live preview to stop through (use the hard POST /stop, which kills the helper).

Encoding caveat: the log buffer can contain literal control characters from prompts with embedded newlines. Strip them before JSON-parsing:

import re, json
raw = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', ' ', raw)
data = json.loads(raw, strict=False)

LoRAs

GET /loras

Returns {"user": [...], "curated": [...], "loras_dir": "...", ...}. Each entry has id, name, path, filename, size_bytes, recommended_strength, kind (train_character for LoRAs trained via the panel; null otherwise), adapter_verdict (see below), and CivitAI metadata if known.

adapter_verdict — whether this file can do anything

A LoRA can train to completion, write a valid safetensors, load without a warning, and change nothing: the deltas it learned are numerically too small to move the model. Every structural gate passes, because every structural gate asks whether the KEYS land and none asks whether there is anything in them (#61, #62).

adapter_verdict (_adapter_verdict) is read out of the sidecar’s adapter_strength block and is one of:

ValueMeaning
okMeasured, and the delta RMS is in the working band.
weakMeasured, and the deltas are at or under WEAK_DELTA_RMS (2.0e-4). The file is kept; the render will very likely look LoRA-free.
unknownTrained by a build that did not measure. Not a synonym for weak — silence is not weakness, and every LoRA older than the measurement reads this way.

Anything else is whatever the trainer called it, passed through verbatim.

Callers should surface weak before a render is spent finding out, and should not decorate unknown.

GET /train/list carries the same verdict plus adapter_advice — one sentence naming what to do about it, empty when the verdict is ok or unknown. It is hardware-aware: on a sub-64 GB Mac it does not say “train again on High”, because that machine cannot reach the graded recipe (see the profile section below). The panel’s Train tab renders it as a banner above the trained-character chips and in the finished job’s history row; a verdict a user cannot act on is a measurement, not an answer (#62).

GET /hf/loras

Hugging Face as a browsable source beside CivitAI. ?lane=h3|ltx&q=<a name, author:someone, or owner/repo>&refresh=1. Phosphene names no org: the query is a search (empty = the lane’s default search term), an author listing, or one repo; results are kept by lane from the repo name and tags, and each repo’s largest .safetensors plus its example clip become one item in the same shape the CivitAI grid renders (name, creator, likes, size_kb, preview_url / preview_type, download_url, filename, base_model, hf_url, source: "huggingface"). Cached ten minutes per lane and query.

POST /hf/loras/download

Form repo, filename, meta (the item JSON). Downloads through huggingface_hub (the saved HF token when present) into the lane’s LoRA directory, runs the H3 layout probe, and writes the same sidecar a CivitAI install writes plus source, hf_repo, hf_url. Returns { ok, skipped, name, path, sidecar_path, lane, layout, converted }.

GET /civitai/download/state · POST /civitai/download/cancel (4.18.0)

POST /civitai/download takes an optional form field token (8-64 characters, A-Z a-z 0-9 _ -) chosen by the caller. While that download runs, GET /civitai/download/state?token=… returns { ok, state: "running"|"cancelled", bytes, total, name } (404 once it has finished), and POST /civitai/download/cancel with token stops it at the next 256 KB chunk: the partial file is removed, nothing is registered, and the download’s own response is { ok: false, cancelled: true, error }. Without a token a download runs exactly as before.

GET /loras/updates

Asks CivitAI whether any installed LoRA with a civitai_id in its sidecar has a newer version (modelVersions[0].id ≠ the sidecar’s civitai_version_id). One request per such LoRA; nothing is downloaded.

{ "ok": true, "checked": 3,
  "items": [ { "path": "...", "name": "...", "current_version_id": 1, "latest_version_id": 2,
               "latest_version_name": "v2", "published_at": "...", "base_model": "LTXV 2.3",
               "download_url": "...",            // what /civitai/download takes
               "meta": { ... } } ],             // the sidecar the download will write
  "errors": [ { "path": "...", "error": "..." } ] }

Installing the update is the ordinary POST /civitai/download with that download_url + meta; the new file lands beside the old one, which stays until deleted.

POST /loras/guide

Form: path (an installed LoRA from GET /loras). The planner model writes a one-paragraph guide — what the LoRA does, how to prompt it, a strength to start from, what it fights with — from the sidecar’s own name / description / trigger words, and saves it in the sidecar under guide; GET /loras rows then carry guide. Refused with 409 while a render runs (the planner is a 12B model). Returns { ok, guide }.

POST /loras/refresh

Re-scan mlx_models/loras/. Returns the same payload as /loras.

POST /loras/delete

FieldTypeNote
idstringLoRA id from /loras.

Deletes the file. Returns {"ok": true}.

POST /h3/loras/import

multipart/form-data. Installs one Hailuo H3 adapter you already have into H3’s own LoRA library (mlx_models/hailuo-h3/loras/, or wherever the installed pack keeps it). Deliberately NOT /upload: reference media can be any image or audio, whereas an H3 adapter has to pass the runner’s layout contract before it may appear in the picker.

FieldTypeNote
filefileExactly one .safetensors. The filename is reduced to its basename and sanitised; it may not collide with a file already in the library.

The body is streamed to a hidden staging directory — not buffered — so a multi-gigabyte adapter costs about 1 MiB of resident memory. Content-Length is required and capped at H3_LORA_UPLOAD_MAX_BYTES (4 GiB).

What happens to the file, in order: staged under .import.<pid>.<tid>/<name> → _h3_lora_prepare (a ComfyUI repack has its diffusion_model. namespace stripped in place, a header-only rewrite) → the import-only payload and target-module checks → os.replace into the library → sidecar JSON. A refusal at any step leaves nothing behind.

Returns:

{
  "ok": true,
  "path": "/abs/path/in/the/h3/library.safetensors",
  "filename": "my_lora.safetensors",
  "name": "my lora",
  "layout": "bare" | "comfyui",
  "converted": false,
  "pairs": 208,
  "recommended_strength": 1.0,
  "scale_source": "folded" | "per_module" | "uniform" | "advisory" | "none",
  "scale_evidence": "…"
}

recommended_strength is written into the sidecar and picked up by the picker’s per-LoRA strength control. The H3 loader applies B @ A and no alpha, so an adapter whose PEFT alpha/rank is not already folded into lora_B needs that number here — this is reported, never used as grounds for refusal.

Errors: 411 when Content-Length is absent or zero, 413 when the body exceeds the cap, 400 for a bad file (wrong extension, empty, duplicate name, unusable layout, malformed payload, wrong model), 500 otherwise. Every message names the file the user chose.


Training

POST /train/upload

multipart/form-data upload. Adds one image to a training dataset — this route is one-file-per-request, not a batch. Send the images in a loop, echoing the returned train_job_id back on every call after the first so the whole set lands in one directory.

FieldTypeNote
job_idstringOptional on the FIRST call (a fresh id is minted), required after, to keep the batch together. Note the name: job_id, not train_job_id — the response returns it as train_job_id.
filefileExactly ONE PNG/JPG/JPEG/WEBP/BMP, or a .txt caption.

The request body is capped at TRAIN_MAX_BYTES_PER_IMAGE (32 MB). Because that cap is on the whole request and the route takes one file, a multi-file body is refused even when every individual image is small.

Returns the dataset state (train_job_id, image count, etc.).

POST /train/start

Kick off a character LoRA training. The panel shells out to lora_lab.train_character and streams progress events back into STATE['log'].

FieldTypeNote
train_job_idstringFrom /train/upload.
triggerstringTrigger token, e.g. explorertrn. Must be compound, rare and letters-only so it doesn’t collide with normal language — digits tokenize as single, very common tokens (mrz07 → m / rz / 0 / 7), and every trigger that has carried an identity here was letters-only; the panel suggests <3 consonants>trn (mrztrn) and logs a note when a trigger carries digits (#62). The character LoRA lands at mlx_models/loras/<trigger>_v2.safetensors.
presetquick | medium | highHyperparameter preset. Default changed in v4.6.1: omitting it now gives high for train_type=character and quick for train_type=style (TRAIN_DEFAULT_PRESET) — it used to give quick for both, which is the tier that has never carried an identity (#62). See the table below — the step count is not a constant, and on a sub-64 GB Mac neither is anything else.
image_countintConfirmed by the panel from the uploaded images.

Advanced overrides (all optional, fall back to preset defaults):

FieldType
rank, alpha, steps, lr, resolutionint / float
caption_strategyclass_word | trigger_only | auto_caption
crop_strategycenter

Returns {"ok": true, "id": "j-<...>"}. Track progress via /status (the training job appears in current then history).

What the presets actually are

Steps are derived, not fixed: steps = epochs × image_count, floored at 1 and capped by the preset’s max_steps (_preset_steps_for, mlx_ltx_panel.py:1629). The cap is part of the server contract — a hand-written API call must not be able to enqueue a multi-thousand-step run on a memory-constrained Mac.

On a Mac with 64 GB or more (TRAIN_PRESETS, mlx_ltx_panel.py:1558):

presetepochsrankresolutionmax_stepsGraded for identity?
quick3085128000No. The pill says “identity ungraded”.
medium601657612000No. Same.
high1003251220000Yes — the validated v2 recipe (Aria_v2, Bizarro_v2). The default, and the one the pill badges “Recommended”.

Measured with lora_compat.py: rank-32 adapters that carry an identity sit at 5.4e-04 to 1.6e-03 delta_rms; two independent users’ rank-8 quick runs measured 1.54e-04 and 1.98e-04, both at or under the WEAK_DELTA_RMS floor of 2.0e-04 (#62).

So high at 50 images is 5000 steps, at 20 images 2000, at 300 images 20000 (capped). Only the rank-32 recipe has ever been put in front of a human eye and called good.

⚠️ Under 64 GB, every preset is quietly a different preset

_select_train_profile (mlx_ltx_panel.py:1650) runs once at import and, when total RAM is 0 < ram < 64 GB, it mutates TRAIN_PRESETS and TRAIN_STYLE_PRESETS in place. The names do not change. The numbers all do:

presetepochsrankresolutionmax_stepstarget modules
quick24384120compact set
medium58384300compact set
high108448500compact set

make_job mirrors the same clamps server-side, so rank / steps / resolution passed as advanced overrides are capped too (max_rank 8, max_resolution 448, max_steps 500) — you cannot ask for the graded recipe on this hardware and get it.

This is a deliberate trade and the reason for it is real: full 512 px LTX LoRA training materialises backward activations for the dev transformer and a 48 GB Mac falls into swap thrash and never finishes. But be clear about what it means: on a sub-64 GB Mac there is no preset that trains the recipe anybody has graded. rank 8 at ≤500 steps is exactly the regime adapter_verdict was built to catch, so expect weak more often here, and read it as “this hardware, this recipe” rather than “this dataset”.

GET /status exposes the active profile as train_profile (key: compact_training | full_training, plus label, ram_gb, max_rank, max_resolution, max_steps, note) and the effective tables themselves as train_presets / train_style_presets (mlx_ltx_panel.py:21842-21844). Read those before trusting a preset name — they are the post-mutation values, so they are the only place the two tables above are told apart at runtime.

The adapter_strength trainer event

Not an analytics event — nothing about it leaves the machine (see docs/ANALYTICS.md for the complete list of what does). It is a progress event on the lora_lab subprocess stream, emitted once after the trainer measures the file it just wrote (mlx_ltx_panel.py:18005). The panel logs the median delta RMS, the max, and how many modules are carrying, then finishes the job in a WARNING state rather than a bare done when the verdict is not ok. The verdict is written into the LoRA’s sidecar as adapter_strength: {"verdict": …} and read back out as adapter_verdict on /loras and /characters.

POST /train/install

After training succeeds, the LoRA is automatically copied into mlx_models/loras/. This endpoint is for explicit re-install / metadata refresh.

POST /train/delete, POST /train/remove-image

Delete training datasets / individual training images.


Characters

Discover and drive trained character LoRAs as bundles.

GET /characters

Returns {"characters": [...]}. Each entry describes one character discovered in mlx_models/loras/:

FieldTypeNote
id, triggerstringSame value — the rare trigger token.
name, pronoun, subject_nounstringFrom the optional mlx_models/characters/<trigger>/bundle.json.
face_lora_pathstringAbsolute path to <trigger>_v2.safetensors. Always present.
sample_image_urlstring | nullURL to a preview image from the training dataset.
sheet_image_path, sheet_image_urlstring | nullThe generated multi-view character sheet (mlx_models/characters/<trigger>/sheet.png), when one exists. Made by POST /characters/<id>/sheet/generate, served by GET /characters/<id>/sheet.

Discovery rule: <trigger>_v2.safetensors is required; everything else is optional.

GET /characters/<id>/preview

Serves the sample training image for the character (PNG/JPEG).

GET /characters/<id>/sheet

Serves the generated character sheet PNG. ?w=<px> returns a cached thumbnail (JPEG) through the same resize lane as /image?w=. 404 until a sheet has been generated.

POST /characters/<id>/sheet/generate

Renders a multi-view turnaround sheet from the character’s reference image (bundle avatar first, training-sample fallback) and composites it into mlx_models/characters/<id>/sheet.png + a sheet.json sidecar (phosphene/character_sheet@1). Synchronous, like /image/generate — the caller blocks for the whole render; 429 when the GPU is already held by a render/training/image job (never queued).

Content-Type: application/json. Every field optional (an empty body renders the default 3-view sheet):

{
  "engine_override": "qwen_edit_inline",
  "views": ["front", "profile_left", "three_quarter"],
  "wardrobe": "a red flight jacket",
  "seed": -1
}

Only ref-honoring engines are accepted (hidream_*_inline, qwen_edit_*_inline, mock_inline) — a text-only engine would render a stranger. seed >= 0 gives view i seed + i so a whole sheet reproduces from one number. Every view prompt pins wardrobe to the reference (“wearing exactly the same clothes as in the reference image”); wardrobe re-states the outfit in words on top of that. Views after the first also chain the first rendered view as a second reference: measured on a dim reference image, solo side-angle renders re-imagined hair color across seeds and phrasings, and a clean frontal from the same run re-anchors the attributes the raw reference under-specifies. Per-view framing can still drift (a subject off-center in one view) — regenerate or vary the seed. On success, bundle.json’s preview is pointed at sheet.png only if it was null — a curated preview is never clobbered.

POST /characters/<id>/generate

Assembles the locked production recipe + queues a T2V render. The endpoint accepts prompt_body, framing, duration, quality, and an optional integer seed (-1 means random, matching /queue/add). For exact replay/debug calls, callers may pass full_prompt plus HQ override fields such as stage1_steps, stage2_steps, teacache_thresh, cfg_scale, bongmath_max_iter, video_skip_step, and audio_skip_step.


Image generation (separate from video)

POST /image/generate

Generates still images via the bundled image engine (mflux, BFL, mock backend). Pluggable per call.

Content-Type: application/json. Payload:

{
  "prompt": "...",
  "n": 4,
  "engine_override": "mflux",
  "aspect": "1:1",
  "refs": ["/path/to/ref.png"],
  "loras": [{"path": "...", "strength": 1.0}]
}

See image_engine.py for backend-specific options.

GET /agent/image/config, POST /agent/image/config

Kept under the legacy /agent/ path for backward compatibility with the Image Studio frontend. Read/write the image engine config (default backend, mflux family, BFL key, etc.). Despite the path, this is the image-engine config, NOT the removed chat agent.


Storyboard — plan a film, then shoot it

A board is a film: a concept, a cast, locations, and a numbered shot list. Every render it starts goes through the same make_job → queue contract as /queue/add; none of these routes has a private execution path. Boards live under state/storyboards/<board_id>/, with storyboard.json written atomically — renders run for hours and a torn board would lose the whole run.

Read

RouteReturns
GET /storyboard/listEvery board, newest first.
GET /storyboard/get?id=<bid>One board document.
GET /storyboard/filmsThe assembled films on disk — player path, runtime, picture size, size on disk, when it was made. list_outputs globs OUTPUT/*.mp4 and never descends, so without this the one thing the feature makes is the one thing the app cannot show you.

Brief switches (on plan)

FieldBoard keyEffect
autoautoafter the plan: render every shot, cut, assemble — no more buttons
anchor_stillsanchor_stillsbefore a text/character shot renders, an ordinary image job makes its first frame (still_job_id → still; the character’s sheet is the reference through qwen_edit_inline when it exists) and the clip renders as i2v · anchor from it. A still that fails sets still_error and the shot renders unanchored, once
long_windowslong_windowsan LTX shot longer than 121 frames renders as temporal_mode=windows (a chain on the Q8 dev transformer) with the board’s style and the shot’s location as window_invariants, instead of being cut to fit. Extend cannot re-inject an image per window, so an anchored long shot is anchored by its first window only
take_secondstake_secondsone take: the film is one shot of 30 / 45 / 60 / 90 / 120 s. The planner writes one beat per 5 s (_sb_take_concept), the board keeps ONE shot with beats (collapse_take), the card shows the beats for editing, and shot_to_job posts take_seconds + beats — the Video tab’s take, so the render is the same on both doors. 0 turns it off

Each field is patched only when sent; an absent field keeps the board’s value.

POST /storyboard/restill — form id, n, optional pass: forgets shot n’s still and the clip rendered from it, turns anchor_stills on if it was off, and renders that shot alone (the still first, then the clip from it). Same refusals as render (planning, already rendering).

Write — POST /storyboard/<action>

The Director. plan accepts soundtrack (an audio file on this Mac) and bars_per_shot (1/2/4, default 2). With a track the plan is a MUSIC VIDEO: beat_map() — the same fit the Editor’s Prepare runs — turns the downbeats into slots of bars_per_shot bars, the shot count is the slot count (the shots chips stand down; capped at STORYBOARD_MAX_SHOTS with a note), the planner’s brief carries the bpm, the slot length, an arc instruction and two laws (begin every description with the movement; no dialogue, the track replaces every clip’s sound), and each planned shot’s duration_s is its slot plus a one-second handle, with slot: {start, end} on the shot. The song map decides how often. song_map() (storyboard_edit) puts SECTIONS on the same bar grid — {start, end, label, energy, brightness} with labels intro / verse / chorus / bridge / outro from position, relative loudness and brightness — and director_pacing() turns bars_per_shot into a per-section stride: the chorus cuts twice as often, the intro and outro half as often. Each slot carries its section; the brief tells the planner which shots fall in which section and how hot it is; a track the map cannot read is cut at the base stride and the log says so. The board records soundtrack: {path, bpm, bars_per_shot, total_sec, count, slots, sections}, the Editor’s first auto-cut uses that track when Prepare has not cached another — so the film opens already cut on the beat — and GET /storyboard/edit returns sections so the ruler can paint the arc. A bad path is a 400 before the planner runs; the planner stage grid is reported while the beat is read.

Auto. plan accepts auto=1: when the plan lands the draft render of every shot starts on its own (_sb_auto_after_plan, once the planner has given the memory back), and when the render thread finishes with every shot rendered the film is cut (_sbe_auto_edit — on the beat when there is a track) and assembled by the same assembler Render uses (_sb_auto_film); the board records auto_film. A shot that did not render leaves the film waiting and the log says how many. Stop still stops the render; the cut and the film are the ordinary ones and can be re-cut afterwards.

Form-encoded unless noted. id is the board id throughout.

ActionFieldsNote
planconcept, id, notes, shots, must, engine, character_id, style, locations, wardrobePlan or re-plan. 409 while the renderer holds memory or the one planner slot is taken. shots is honoured or refused — never silently swapped for a different count. A re-plan PATCHES locations and wardrobe rather than overwriting them.
cancelidRelease a planner slot.
saveJSON {id, board}Write a hand-edited board.
gradeid, n, grade, noteGrade one shot.
estimateid, pass, onlyCost/wall estimate before spending.
renderid, pass, only, out, music, music_modeShoot the board.
stopidStop the running board render.
replan-shotsid, nsRe-roll named shots. A repair that breaks a law is rejected; re-rolls splice into the CURRENT plan, not the original.
add-shotid, path
import-shotsid, from, onlyREFERENCE clips from another board. Provenance (imported_from), the source LOCATIONS and the source CAST all travel with them — leaving any one behind breaks something specific. Refuses while the target film is rendering.
exportid, auto_edit, music, target_seconds, music_mode
revealid, name, whatShow in Finder (macOS).
deleteid

Board schema — the geography fields

A shot is composed from its location. Since 2026-08-18 a location can also be faced from more than one direction, which is what makes a reverse angle a reverse angle instead of a second location.

"locations": [
  {"id": "carwash", "name": "The car wash", "description": "…",
   "views": [                                  // ABSENT, never [], when there are none
     {"id": "establishing", "name": "…",
      "light": "camera left",                  // flips with the camera
      "description": "…"},                     // self-contained: readable without the floor plan
     {"id": "reverse", "name": "…",
      "light": "camera right",
      "description": "… no car in frame …"}    // says what it does NOT hold, in those words
   ]}
],
"shots": [
  {"n": 1, "location_id": "carwash",
   "view": "reverse",        // must be a view id OF that location
   "eyeline": "right",       // "left" | "right" | "lens"
   …}
]
FieldWhereRules
locations[].viewslocationList of {id, name, light, description}. Absent, not [], when a location has none — every board written before views existed has no key, and an empty list would be a new shape for the validator to have opinions about for no gain. Ids follow the same charset as location ids.
shots[].viewshotA view id belonging to that shot’s location_id. A view on a shot with no location, or a view that is not one of that location’s, is a validation error (unknown_view) and fails exactly the way an unknown location does — a silently-dropped view composes the reverse angle from the establishing description, and the car the view existed to get out of frame is back in it with nothing said.
shots[].eyelineshotOne of left, right, lens — a fact about the FRAME, not the room. right = the eyes go off past the right edge. Anything else is a validation error (bad_eyeline).

What the server does with them. shot_scene_text injects the VIEW’s description when a shot names one and falls back to the location’s otherwise — so a board with no views, or a shot that names no view, renders exactly as it always did. eyeline_clause composes "his eyes fixed past the right edge of frame" at render time; lens deliberately produces nothing (telling these models to look down the barrel buys a stiffer performance than silence), and it exists so a shot can say it holds the lens, which is what lets the 180-degree check tell “faces the camera” apart from “nobody wrote one”.

The 180-degree rule is enforced, not suggested. After planning, _enforce_eyelines walks the shots in screen order and flips the second of any two adjacent shots that cut between two different characters in the same location and both claim the same side, and says so in warnings. It repairs mechanically because eyeline is a discrete field with exactly one complement — flipping it cannot damage anybody’s prose. Prose-level laws get a model round trip instead, precisely because they lack that property.


Editor — the timeline

The Editor is a top-level workflow, not a stage of the storyboard: engine-agnostic, opens without a board being mid-render, and its document is edit.json under the board dir. One video track, one music lane. Per-clip sound is linked by default and can be pulled apart (J/L cuts) — that is not a second audio track.

Read

RouteReturns
GET /storyboard/edit?id=<bid>The arrangement. On the first call for a board it runs the auto-edit ONCE, persists it into the automatic lane, and returns it with generated: true. Every later GET is a file read. A corrupt edit.json returns 500 with corrupt: true and is never silently replaced — that would throw away the arrangement it is a broken copy of.
GET /storyboard/edit/status?id=<bid>The prepare job’s state.
GET /storyboard/edit/peaks?id=<bid>The waveform. Hundreds of KB — fetch once, never on a poll. 404 until prepare has run.
GET /storyboard/edit/proxy?id=<bid>&name=<file>.mp4A proxy video, served with range support. Safari will not seek a <video> whose server answers plain 200, which would undo the entire reason proxies exist. name must match [A-Za-z0-9._-]{1,120}\.mp4 — it is a basename the server minted, so anything else is a probe.
GET /storyboard/edit/uploadsVideos the user brought with them (panel_uploads/timeline/, newest first, capped at 200). Kept out of OUTPUT on purpose, which is why they need a route at all; images do not, they land in the library the gallery already walks.
GET /storyboard/edit/sounds?id=<bid>The media pool’s Sound source: {sounds: [{path, name, where, mtime}], dir} — the film’s own audio/ folder (where: "film") first, then the sound files (.wav .m4a .mp3 .aac .flac .aif .ogg .opus) at the top of OUTPUT, newest first, capped at 300. No probing; the length is read when a sound is added.
GET /storyboard/edit/drafts?id=<bid>{active, drafts: [...], backup} — see Drafts below.
GET /storyboard/edit/versions?id=<bid>Metadata only: name, revision, when kept, clip count, duration, plus keep (= EDIT_HISTORY_KEEP, 50). The documents themselves are read once, by restore, on the user’s word. Opening the panel must not be a download.

Write — POST /storyboard/edit/<sub>

SubFieldsNote
prepareid, music, target_secondsBuild proxies + peaks. 400 if music is not a file.
add-clipid, from, only, path, title, kindkind is video | still | slug — passing it is what stops an image landing as a video with no frames.
add-soundid, pathOne file for an audio track: probed ({path, title, duration_s, has_video, imported}), 400 when it carries no sound. A file outside OUTPUT is hardlinked (copied across volumes) into the film’s own audio/ folder first, because /file only serves OUTPUT and a sound the preview cannot play is half a sound. The strip is placed and saved by the client.
relinkid, onlyRe-point clips at their finished (delivery) files. A finished RETAKE is never part of that batch — it is offered against its clip with retake: true in the payload’s relink rows and adopted one clip at a time with only=<clip id>.
cancelidCancel the prepare job.
autoid, music, target_seconds, min_shot, max_shotRe-run the machine’s cut. Lands in the automatic lane.
saveJSON {id, edit, expect_revision?}See below.
draftid, op, slug, name, fromSee Drafts.
backupJSON {id, edit}The quiet lane. See Drafts.
recoveridAdopt the pending backup.
discard-backupidThrow it away.
versionid, labelName a version. Naming is not saving — no revision bump, no write to edit.json, nothing about the timeline changes, which is exactly why it is safe to press at any moment.
restoreid, fileRestore a history entry. Refuses while the film renders.
renderid, out, music, music_mode, format, sizeAssemble the film. music_mode is replace | under. Deliver as: format is h264 (default, the panel’s preset) | hevc (VideoToolbox, hvc1, about half the bytes) | prores (422 HQ, 10-bit 4:2:2, PCM sound, a .mov no browser previews); size is native (as cut) | 1080p | 2160p — one Lanczos scale after the overlays, up only, never a crop, skipped when the cut is already that height. finish is none | grain | heavy_grain — moving film grain (noise t+u, strength 9 / 18) added AFTER the size so a 4K delivery gets 4K grain; a delivery-time treatment, never in the document, never in the preview or the export. A different delivery is a different file: <slug>_film_hevc_1080p.mp4, <slug>_film_prores.mov, <slug>_film_1080p_grain.mp4. The facts carry deliver: {format, size, finish, label}.
export-nleidFCP7 XML + After Effects JSX + linked media.
revealid, whatShow in Finder.
generateid, prompt, duration, duration_s, film_start, pass, character_id, seed, title, engine, trigger, retake_ofShoot a new clip straight into a slot. With retake_of=<clip id> it is a retake: the clip’s own shot is CLONED (character, refs, location, engine) and only the prompt, the length and the seed change; the new shot carries edit_slot.retake_of, and when it renders the Editor offers it against that clip (“Use it” / “Keep the old one”). 400 if the clip is no longer on the timeline.

save semantics. revision is the server’s counter, taken from disk, never from what the client remembered — a stale tab must not be able to wind it backwards. Pass expect_revision to find out you were overtaken instead of silently overwriting the other tab: mismatch returns 409 with conflict: true and the current revision. Validation errors come back as all of them at once (errors: [...], 400) with the file on disk untouched, so a client can highlight every bad clip in one pass instead of playing whack-a-mole.

edit.json schema

version is 2 (EDIT_VERSION). Version 1 is upgraded on READ only (migrate_edit) — bumping the version without a read-path upgrade would not refuse old builds, it would refuse every timeline anybody already had. A version from the future is left alone so the validator can say the honest thing.

{
  "version": 2,
  "board_id": "sb_…",
  "revision": 17,
  "origin": "manual",          // "manual" | "auto" | "backup" — who asked for this save
  "updated_at": 1786…,
  "duration": 43.5,
  "clips": [ /* below */ ],
  "audio": { /* below */ },
  "beats": null,
  "settings": {}
}

clips[]

FieldNote
id, path, proxypath/proxy are null on a slug.
start, endThe window INSIDE the source.
film_start, film_endThe slot on the film. Never independent — a clip plays at 1x, so film_end - film_start == end - start within LENGTH_TOLERANCE (2 ms).
kindvideo (absent means video — stamping it on 400 clips would rewrite every edit.json on the machine to say what its absence already says), still (held for the length of its slot; start/end are synthesised from the slot, duration: null), slug (black, no file).
sourceauto | human.
lockedbool.
adjust{brightness}, an ffmpeg eq=brightness additive offset clamped to ±BRIGHTNESS_LIMIT (0.5). Neutral is absent — dragging the slider back to zero leaves a document identical to one that never had a slider.
frame{zoom, x, y} — a reframe. The picture is magnified zoom times (1–3) and the window is centred at the fraction (x, y) of the source. Render: a crop of the source’s own pixels before the fit, so one string is right at every size; preview: a CSS scale about the same anchor (approximate); export: FCP7 Basic Motion (scale + centre) and AE scale + position. Neutral is absent; a slug has nothing to reframe.
speedPlay rate, SPEED_MIN–SPEED_MAX (0.25–4.0), video only. The slot is (end - start) / speed, so the 1x rule above reads “at its speed”. Picture is setpts, sound is atempo chained past ffmpeg’s 0.5–2.0 window, and the sound’s envelope (afx) stays on the strip’s PLAYED clock — a keyframe at 2 s is still at 2 s of the strip after a retime. Absent is 1x. Never set automatically.
audioThe split edit. See below.

clip.audio — the J-cut and the L-cut

"audio": {"start": 3.5, "end": 7.0, "film_start": 12.25}

Video clips only. Same three numbers as the picture, for the SOUND — so the sound can start before its picture (J) or run past it (L), which is how a cut stops being a butt join.

Absent means linked, and that is the whole migration: every clip ever written has no audio key and every one of them plays its own sound under its own picture. EDIT_VERSION did not move for it and no document was rewritten.

The PRESENCE of the field is the switch, not the values in it. Do not derive “linked” from audio equalling the picture window: unlinking writes the window the clip already has, so a clip the user had just unlinked would read as linked and refuse to be dragged. The toggle adds the object or deletes it; nothing else decides. normalise_edit rounds the field but never removes it for the same reason, and strips it entirely from a still or a slug.

Audio windows may not overlap each other any more than the pictures may. A split edit is a butt join that lands somewhere else, not a mix — the mix is what audio_tracks is for.

audio_tracks[] — A3, A4, … under the music

"audio_tracks": [
  {"id": "t…", "name": "Laughs", "muted": true, "gain": 0.8,
   "strips": [{"id": "k…", "path": "/…/laugh.wav", "start": 0.0, "end": 2.0,
               "film_start": 12.5, "duration": 4.1, "gain": 0.5,
               "muted": true, "locked": true, "title": "laugh (copy)",
               "afx": {"fade_in": 0.5, "fade_out": 0.25, "points": [[1.0, 0.6]]}}]}]

Any number of tracks, each holding any number of strips. start/end are the in/out points inside the file (a strip plays at 1x, so it is end - start long on the film); film_start is a timeline second, laid out with adelay exactly as the bed is. afx is the same envelope object a clip’s sound and the bed carry, read by audio_gain_points; the strip’s gain and the track’s gain (linear 0–1) multiply it — track_strip_gain_points is the one curve the preview, the render and the export read. Absent is none and neutral is absent: an edit with no tracks has no key, so it loads, saves and renders byte-identically; an empty track is kept.

Strips on the same track may not overlap (audio_track_strips_overlap, an error, TOUCH_TOLERANCE for a butt join); strips on different tracks overlap and mix. Shape errors: audio_tracks_shape, audio_track_shape, audio_track_name, audio_track_muted, audio_track_gain(_range), audio_track_strips, track_strip_shape, track_strip_path, track_strip_start, track_strip_end, track_strip_film_start, track_strip_window, track_strip_past_the_end, track_strip_gain(_range), track_strip_muted, track_strip_locked, track_strip_title, track_strip_afx*.

Render: every audible strip (not muted, track not muted, curve not silent) is one -vn -i after the overlays, atrim → volume (its curve, on the strip clock) → aresample → adelay (in samples) → apad → atrim to the film, and all of them are amixed (normalize=0, duration=first) over the clips’ sound and the bed under the one asoftclip limiter; the film facts carry sound_strips and mix_peak. A strip whose file is gone refuses the render with its path and track. Export: each track is its own FCP7 audio track (clipitems with in/out, level keyframes, <enabled>FALSE</enabled> for a mute) and each strip an After Effects layer named <track> · <title>.

transitions[] — a typed object that owns a BOUNDARY

"transitions": [{"id": "t1", "after_clip": "<clip id>",
                 "kind": "dissolve" | "fade_black", "duration": 0.5}]

A cross-dissolve is two clips in the same second, and the picture lane’s one-clip-at-a-time rule (clips_overlap) is load-bearing — so a transition is not an overlap. It names the OUTGOING clip and sits on the cut between it and its successor in film order; one per boundary. The clips’ own film_start/film_end do not move and the film stays exactly as long as the timeline says. The render pulls half the duration of extra tail from beyond the outgoing clip’s out-point and half of extra head from before the incoming clip’s in-point (source the trims left behind), splits the picture concat at the boundary and joins the two runs with xfade (fade / fadeblack) centred on the cut. The sound needs nothing new: it takes the lane path (_sb_split_audio_plan) exactly as a J-cut does.

duration is clamped on every read (transition_duration) to min(asked, half the shorter neighbour, 2.0 s) and snapped to an even number of frames; the document keeps the number typed. A side with no spare material is refused — transition_no_handles names the side and the shortfall (“clip 2 has only 0.10s before its in-point and the dissolve needs 0.25s there”). Every transition code (transitions_shape, transition_shape, transition_unknown_clip, transition_last_clip, transition_duplicate_boundary, transition_kind, transition_duration, transition_no_handles) is an error; none is in WARNING_CODES. render refuses with the same sentence. A still and a slug have all the handles anybody could ask for.

overlays[] — cards, mattes, and titles

kind is still | video | text. An explicit kind wins; with none, the path’s suffix decides (.png/.webp/.tif are stills), so every overlay written before titles existed reads as it did. A title is an overlay whose pixels the render draws:

{"id": "o1", "kind": "text", "text": "FIN", "film_start": 40.0, "film_end": 44.0,
 "style": {"font_size": 96, "color": "#ffffff", "align": "center", "x": 0.5, "y": 0.8,
           "box": true, "box_color": "#000000", "box_opacity": 0.5},
 "fx": {"fade_in": 0.5, "fade_out": 0.5}}

font_size is px at a 1080-high frame and scales with the film; x/y are the anchor as fractions of the frame; align says which edge of the text sits on x. overlay_text() is the one accessor and the style is written only where it differs from the defaults. The font is a FILE — LTX_TITLE_FONT, else the first of TITLE_FONT_CANDIDATES present on the machine — verified before ffmpeg is built; with none, render answers 400 with a sentence rather than a film with a hole in it. The raster is a frame-sized RGBA PNG beside the film (.titles/) fed through the same overlay chain as an uploaded card, so a title inherits the lane’s alpha, fades and z-order. Titles are drawn on the stage in the DOM at the same anchor and size; they do not travel in the NLE export (no path).

audio — the soundtrack, as an object on the timeline

"audio": {"path": "…", "duration": 180.0, "peaks": "peaks.json",
          "offset": -4.0, "trim_start": 12.0, "trim_end": 96.0}
FieldMeaning
offsetThe second of the TRACK that plays at film time 0. It has meant that since before the editor existed, so every document on disk keeps working. It may now be negative: the track begins -offset seconds into the film, with silence in front.
trim_start / trim_endIn/out points INSIDE the track, in track seconds. Absent means untrimmed, which is why an edit.json written before this is still valid: no field, no trim, same filtergraph. A trim at or past the end of the track is not a trim, and normalise_edit drops it — a handle dragged all the way back out leaves a byte-identical graph.

music_window(audio, duration=…) is the single place these become the numbers ffmpeg and every exporter need: start = max(trim_start, offset) (a head trim and a positive offset are the same gesture from two directions; whichever cuts more wins), end or None for “play to the end”, and delay/film_start = start - offset. Trimming the left edge does not slide the rest of the track earlier — that is a ripple, and music does not ripple — so the seconds a head trim removes come back as silence in front. A window the trims close entirely returns “no music” rather than a zero-length atrim ffmpeg refuses.

Drafts, backups, and history — three different things

Drafts are named variations of the same film, in drafts/ with an index.json naming the active one. A board that has only edit.json has exactly one draft and always did; it just had no name for it. load_draft_index names it on the way out rather than in a rewrite pass, so the upgrade cannot half-happen.

POST /storyboard/edit/draft is one route with five verbs — they are five edits to the same active pointer, and splitting them would be five places for that pointer to be wrong:

opExtra fieldsEffect
newname, from=currentNew draft, empty or copied from what is on screen. Becomes active.
duplicateslug, name
renameslug, name
deleteslug
activateslugStashes the current active draft first — never loses it.

new, duplicate, delete and activate return 409 with busy: true while that film is rendering — the render is reading this document clip by clip, and swapping the timeline under it is the same hazard import-shots and restore refuse.

The backup is ours, the saving is the user’s. POST /storyboard/edit/backup writes history/backup-<draft>.json and nothing else — no edit.json, no revision, no conflict check. The user’s saved draft stays exactly what he last saved. It is surfaced by GET …/drafts as backup and offered, never applied: recover adopts it, discard-backup throws it away.

History is two lanes plus a keep lane, told apart by filename prefix rather than by opening the files — a prune that has to read fifty documents to decide what to delete fails halfway on the first corrupt one:

PrefixWhatPruned?
edit-r*An automatic snapshot.Yes, capped at EDIT_HISTORY_KEEP (50).
save-r*A save the user pressed.Never.
keep-r*A version the user named (version).Never.

Every save archives its predecessor before the new document lands (~5 KB each). History failing must never block a save — losing a breadcrumb is nothing next to refusing to persist the arrangement on screen. origin is also stamped inside the JSON, for anything reading one file on its own.


Output management

POST /output/delete

FieldTypeNote
pathstringMust be under mlx_outputs/.

POST /output/hide, POST /output/show, POST /output/show_all

Hide/unhide outputs from the gallery without deleting the file.

POST /output/open_folder

Opens the output’s containing folder in Finder (Mac).


Model licences (4.19.8)

GET /licences

Every licence the panel asks about, with this Mac’s acceptance state: {"licences": [{"key": "h3", "model": ..., "licence": ..., "licensor": ..., "version": ..., "url": ..., "non_commercial": false, "title": ..., "summary": ..., "terms": [...], "badge": ..., "accept_label": ..., "accepted": false, "accepted_at": null, ...}, ...], "ui": {...}}. Keys: h3, ideogram4, yue2, flux1_dev, fibo. H3’s entry also carries territory_notice, excluded_territories, report_line, report_url and credit_line.

POST /licences/accept

Form fields key=<licence key> and accept=1 (the box the user ticked). Records the acceptance for the licence’s current version in state/model_licences.json (plus a marker file the H3 installer reads) and returns {"ok": true, "key": ..., "record": {...}, "licence": {...}}. A changed licence version asks again. Accepting on someone’s behalf is accepting for them: an agent should show the user the terms from GET /licences first.

Helper / system

POST /helper/restart

Restart the warm helper subprocess (used after settings changes that need a fresh process).

POST /stop_comfy

Shut down a ComfyUI process if Phosphene started one.

GET /version/check, POST /version/pull

Self-update against the GitHub repo. /version/pull is a git pull only: it does not run scripts/post_update.sh, so anything an Update installs (new packages, weights) is not installed by it. Since 4.17.4 the panel installs a missing Lip-sync voice separator itself at the next start (see /a2v/separator).

POST /engine/repair

Re-runs only the engine-environment step (scripts/pinokio/ltx_engine_env.sh, the same step Install runs): re-installs the render engine’s Python packages into its venv, in the background. No downloads, no venv rebuild, no Stop. Answers 202 (started), 200 {nothing_to_do} (the engine is fine), or 409 (a repair is running, or installing: true - Pinokio’s own Install is still writing the venv). Progress and outcome ride GET /status -> engine_env (ok, fault, installing, repair.{state, active, log, error}).

POST /h3/repair

4.19.5. Re-runs only scripts/pinokio/h3_deps.sh on the Hailuo H3 checkout: re-installs H3’s Python packages into its own venv at the set this release was tested with (scripts/pinokio/h3_constraints.txt, passed with -c; uv moves anything already outside it back). No weights, no Stop - the panel never imports the H3 venv. Answers 202 (started) or 409 (H3 is not installed, an H3 render is running, or a repair is already running). Progress and outcome ride GET /status -> h3_env (ok, fault, repair.{state, active, log, error}). An H3 render on a venv off the set is refused at its start with H3 cannot start: the Hailuo H3 venv ... and this repair on the card.

Push alerts — the completion alert for a closed tab

The in-tab chime and browser alert (Settings → Completion alerts) need the page open. Web Push does not: the browser keeps a subscription for this origin, the panel signs each message with a VAPID key pair it generates once (state/vapid.json), and the browser’s push service wakes /sw.js to show the notification. No relay: the panel posts straight to the browser vendor. Offered only when pywebpush imports (GET /settings → push_available).

RouteWhat
GET /sw.jsthe service worker, served from the root scope with Service-Worker-Allowed: /
GET /push/key{ ok, available, public_key, subscriptions } — the VAPID public key the browser subscribes with (503 when unavailable)
POST /push/subscribeform subscription = the PushSubscription JSON; one entry per endpoint
POST /push/unsubscribeform endpoint
POST /push/testsends “This is what a finished render will say.” to every subscriber; { ok, sent }

Every job that ends done or failed pushes Phosphene — render done / — a render failed with the job’s label (or the first 60 characters of its prompt), off the GPU lock, only while notify_done is on. A subscription the vendor reports gone (404 / 410) is dropped.

POST /settings

FieldTypeNote
(any setting key)stringSee state/panel_settings.json for the canonical set.
notify_done1/0Completion alerts: a chime in the tab when a render finishes or fails, and a browser notification when the tab is in the background and the person has allowed them. Default on; GET /settings returns it.

POST /prompt/enhance

Form-encoded. Pre-rewrites a prompt with Gemma. Used by the panel’s Enhance buttons (Video and Lip-sync).

FieldNote
promptrequired
modet2v (default), i2v or a2v (Lip-sync: the i2v helper prompt plus the lip-sync prompt law)
preserve_tokensJSON list (or comma list) of words the result must keep, case-exact — trigger words
translate0 keeps a non-Latin prompt in its own language; default translates to English
loras4.18.0: JSON list of installed LoRA paths on the render. The panel reads each one’s name, trigger words and guide (else the creator’s description) from the LoRA library — paths it does not list are ignored — and hands at most 4, bounded, to the helper as fenced reference text. A LoRA whose trigger words are all missing from the result gets its first trigger put back at the front.

Returns { ok, original, enhanced, mode, elapsed_sec, source_non_latin, translated, lora_notes_used: [names], lora_triggers_added: [words] }; { error } with 409 while a render holds the GPU.

409 needs_pack (4.20). Enhance runs on Gemma 3, which a fresh install downloads in the background after the first boot (see deferred_downloads under GET /status). Until it is on disk the route answers 409 { ok: false, error, needs_pack: "gemma", pack_name, pack_size, pack_state } with pack_state one of downloading, queued, waiting_disk, missing, and never asks the helper to load it. /storyboard/plan, /storyboard/replan-shots, /oneshot/plan and /train/auto-caption answer the same shape for the same reason. POST /models/download repo_key=<needs_pack> fetches it now: while the background fetcher holds the download slot that answers 202 { queued: true } and moves the pack to the front of the queue ({ already: true } when it is the one downloading).

POST /upload

multipart/form-data. Uploads a reference image for I2V or keyframe modes. Returns {"path": "<absolute path under panel_uploads/>"}.


Sidecar JSON format

Every completed clip writes a sidecar at <output>.mp4.json alongside the MP4. Schema (simplified):

{
  "output": "/abs/path/to/clip_720p.mp4",
  "raw_output": "/abs/path/to/clip.mp4",
  "params": { /* the full params from /queue/add, plus seed_used, model, etc. */ },
  "command": "helper",
  "started": "YYYY-MM-DD HH:MM:SS",
  "elapsed_sec": 426.13,
  "video_duration_sec": 7.0,
  "fps": 24,
  "model": "mlx_models/ltx-2.5-mlx-q4",
  "queue_id": "j-<...>",
  "helper_elapsed_sec": 425.5,
  "output_codec": { "preset": "standard", "pix_fmt": "yuv420p", "crf": "18" },
  "memory_policy": { "requested": "auto", "effective": "auto", "frames": 169, "tier": "standard", "pressure_pct": 30, "swap_gb": 3.9 }
}

Read sidecars to compare runs without re-querying the panel.


Production recipe (validated 2026-05-15)

For character-LoRA video work, the validated locked recipe:

mode:               t2v
width × height:     1024 × 576   (also validated: 736 × 416 for ~2× faster wall)
frames:             169 (7s) / 241 (10s) / 361 (15s)
quality:            high          ← CRITICAL — forces Q8 dev transformer
temporal_mode:      native
stage1_steps:       10
stage2_steps:       3
teacache_thresh:    1.8–2.0       (plateau speedup; below 1.6 = no speedup)
cfg_scale:          3.0
bongmath_max_iter:  100
upscale:            off | fit_720p (lanczos)
accel:              off
enhance:            false         ← critical when trigger words are in the prompt
seed:               42 for A/B, -1 for production variety
LoRAs:              <trigger>_v2.safetensors @ 1.0

Wall on M4 Max 64 GB: ~7:06 per 7s clip at 1024×576, ~3:24 at 736×416.


Worked example — one agent driver

Submit a clip with two LoRAs, poll until done, print the output path. Pure stdlib.

import json, time, re, urllib.parse, urllib.request

PANEL = "http://127.0.0.1:8199"

def submit(prompt, loras, **overrides):
    form = {
        "mode": "t2v",
        "prompt": prompt,
        "width": "1024", "height": "576", "frames": "169", "steps": "8",
        "seed": "-1",
        "quality": "high", "temporal_mode": "native",
        "stage1_steps": "10", "stage2_steps": "3",
        "teacache_thresh": "1.8", "cfg_scale": "3.0",
        "bongmath_max_iter": "100", "accel": "off",
        "enhance": "false",
        "upscale": "fit_720p", "upscale_method": "lanczos",
        "loras": json.dumps(loras),
    }
    form.update({k: str(v) for k, v in overrides.items()})
    data = urllib.parse.urlencode(form).encode()
    req = urllib.request.Request(f"{PANEL}/queue/add", data=data, method="POST")
    with urllib.request.urlopen(req, timeout=15) as resp:
        return json.loads(resp.read())["id"]

def wait_terminal(job_id, timeout=1800):
    deadline = time.time() + timeout
    while time.time() < deadline:
        with urllib.request.urlopen(f"{PANEL}/status", timeout=15) as resp:
            raw = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", " ", resp.read().decode())
        d = json.loads(raw, strict=False)
        for src in ([d.get("current")] if d.get("current") else [], d.get("queue") or [], d.get("history") or []):
            for j in src:
                if not j or j.get("id") != job_id:
                    continue
                st = j.get("status")
                if st in ("done", "failed", "cancelled", "error"):
                    return j
        time.sleep(20)
    raise TimeoutError(f"job {job_id} did not finish")

if __name__ == "__main__":
    # Adjust to wherever your Phosphene install lives:
    LORAS_DIR = "<panel-root>/mlx_models/loras"
    job_id = submit(
        prompt="An original illustrated explorer in a wood-paneled study, storybook illustration.",
        loras=[
            {"path": f"{LORAS_DIR}/explorertrn_v2.safetensors", "strength": 1.0},
        ],
    )
    print(f"queued: {job_id}")
    result = wait_terminal(job_id)
    print(f"status={result['status']} wall={result['elapsed_sec']}s output={result.get('output_path')}")

That’s the full pattern: submit → poll → read output path.


What was removed (and what replaced it)

The in-panel agentic flows feature (chat-driven shot planner) was removed 2026-05-15. Pre-removal snapshot is durable on GitHub as tag pre-agent-removal-2026-05-15.

Everything that the chat used to do — plan multiple shots, generate candidate anchor images, render I2V conditioned on those anchors, manage sessions — is achievable by an external agent through the API documented above. Specifically:

  • Shot planning: agent-side text reasoning, no panel involvement.
  • Anchor generation: POST /image/generate.
  • Anchor-to-video: POST /queue/add with mode=i2v and image=<anchor_path>.
  • Session state: agent maintains it externally.

External agents can use the documented workflows through this API.

Source: docs/API.md in the public repo. Reviewed for this website before publication. Suggest an edit.

Phosphene for Mac

The Mac app is almost here.

It’s in closed testing now. Pick one and leave your email. Everything comes to Free. Pro gets it first.

Which one are you here for?

We'll email when the Mac app is ready or Pro opens, and may send rare product news. We don't share or sell the list. Every email includes an unsubscribe link. Privacy

Want it today? Phosphene runs in Pinokio right now, free, with one-click setup. Install with Pinokio