Drive Mjudio from a script: create a project from a template, give it a song, set the title, render the video and fetch the file back — without touching the app window.
Updated 26 August 2026
Where it lives. The API is a small REST server that runs
inside Mjudio, on your own machine, and answers only on
127.0.0.1. It is off until you switch it on in
Mjudio ▸ Settings ▸ Automation, and every request has to carry a token that
the same screen shows you. Nothing outside your Mac can reach it — not a web page, not
another machine on your network.
Open Mjudio ▸ Settings ▸ Automation and switch Enable local automation API on. The Status row then shows the address the server is listening on:
http://127.0.0.1:8787
Two more controls on that screen matter:
401 on its very next request, mid-loop.Turning the switch off stops the server and deletes the discovery file described next.
For a quick experiment, press Copy in Settings and paste the token into your shell:
BASE=http://127.0.0.1:8787
AUTH="Authorization: Bearer paste-the-token-here"
For anything you intend to run twice, read them instead from the discovery file the server writes when it starts. It is created with owner-only permissions in the app's own container:
~/Library/Containers/com.mjudio.musicvideomaker/Data/Library/Application Support/Mjudio/Automation/endpoint.json
{
"baseURL": "http://127.0.0.1:8787",
"port": 8787,
"startedAt": "2026-08-26T09:12:04Z",
"token": "htxfgw…"
}
On macOS you can jump straight to it: the Automation screen has a Show in Finder button for exactly this file.
Every request carries the token in a header:
Authorization: Bearer <token>
If a client makes Authorization awkward, X-Mjudio-Token: <token>
works identically.
Two small traps. Write 127.0.0.1, not
localhost — localhost may resolve to ::1 first and
take a detour before it connects. And the file being present is not proof the app is
running: a force-quit can leave it behind. Treat a refused connection as the
authoritative "Mjudio is not up".
GET /v1/health tells you the app is listening, the token is right, and what
your plan allows:
curl -s -H "$AUTH" "$BASE/v1/health"
{
"status": "ok",
"app": "Mjudio",
"version": "1.0",
"build": "20260826.091204",
"apiVersion": 1,
"plan": "free",
"rendererAvailable": true,
"projectCount": 4,
"remainingFreeExportsToday": 2,
"maxResolution": "p720",
"addsWatermark": true,
"pendingExports": 0,
"openInEditor": []
}
Read those last six fields before you build anything on top: they are the difference
between a script that renders what you asked for and one that quietly renders something
smaller. openInEditor lists the projects an editor window currently holds —
writes to those are refused, which is the next section's subject.
A project that is open in an editor window cannot be written to by the API. The window holds its own copy of the document and rewrites the whole bundle on its next autosave, so a background edit underneath it would simply vanish. Any write to such a project returns:
409 {"error": "project_open_in_editor", "message": "…"}
Reads always work. Close the window (or work on a project you created from the script) and the writes go through.
Templates are the look: background, palette, visualizer and text layout, already composed.
List them with GET /v1/templates, which returns an id,
name, category, premium flag and the
designedFor aspect ratio for each. Free-plan starters include
lofi-dusk, edm-pulse, hiphop-gold,
lyric-classic and podcast-studio.
curl -s -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"name":"Night Drive","templateID":"lofi-dusk","aspectRatio":"portrait9x16"}' \
"$BASE/v1/projects"
201 comes back with the whole project, including the layers the template
created and their ids — you need one of those in step 4. aspectRatio is
landscape16x9, portrait9x16 or square1x1, and an
explicit value wins over the template's own design ratio: every coordinate in a Mjudio project
is stored as a fraction of the canvas, so a landscape template rescales into a
portrait frame rather than breaking.
curl -s -X PUT -H "$AUTH" --data-binary @song.mp3 \
"$BASE/v1/projects/$PID/audio?filename=song.mp3"
The audio goes in as raw bytes in the request body. There is no endpoint that takes a file path, and that is not an oversight: Mjudio is sandboxed and may only open files you picked in a panel, so a path your script sends is a path the app is not allowed to open.
?filename= is how the decoder learns the format, so keep the real extension.
(Content-Disposition: attachment; filename="…" works as an alternative.)
This call blocks, on purpose. It does not return until the frequency analysis has been computed and cached in the project. That cache is what every visualizer reads at render time — without it the bars and rings render flat. A three-minute track takes a few seconds; give your HTTP client a generous timeout (a minute is plenty, ten is safer for long mixes).
Accepted: mp3, wav, m4a, aac,
aif, aiff, aifc, caf, flac,
and audio inside mp4 and m4b. Up to 4 hours per track. OGG/Vorbis is
absent because macOS has no decoder for it.
Text layers are edited by id. Take the id from the project you got back in step 2:
curl -s -X PATCH -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"text":"Night Drive"}' \
"$BASE/v1/projects/$PID/layers/$LAYER_ID"
The same call accepts name, hidden and opacity.
Sending text to a layer that is not a text layer returns
409 not_a_text_layer.
Timed lyrics go in as a whole file — .lrc, .srt or plain text,
one line per line:
curl -s -X POST -H "$AUTH" --data-binary @lyrics.lrc \
"$BASE/v1/projects/$PID/lyrics?format=lrc"
And artwork becomes the full-canvas backdrop, replacing whatever was there:
curl -s -X PUT -H "$AUTH" --data-binary @cover.jpg \
"$BASE/v1/projects/$PID/artwork?filename=cover.jpg"
Trim and fades are project settings rather than layer settings, so they go through
PATCH /v1/projects/{id}: trimStartSeconds,
trimEndSeconds, fadeInSeconds, fadeOutSeconds. Fades cap
at 5 seconds, and trimming needs the audio to be uploaded first.
curl -s -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"resolution":"1080p","frameRate":30,"codec":"hevc"}' \
"$BASE/v1/projects/$PID/exports"
202 comes back immediately with a job — the render itself has only been
queued. resolution takes 720p, 1080p,
1440p or 2160p (4K also works),
frameRate is 30 or 60, codec is h264 or
hevc. Every field is optional; anything you leave out is inherited from the
project's last export.
Resolution reads back in a different spelling than it is written. Requests
accept the friendly forms above, but every reply — maxResolution in
/v1/health, settings.resolution on a job — serialises the internal
name: p720, p1080, p1440, p2160. Compare
against those when you check what you got.
Read the settings object in the reply rather than assuming you got
what you asked for. Requests above your plan's ceiling are clamped, not
refused — ask for 2160p HEVC on the free plan and the job comes back as
"resolution": "p720", "codec": "h264" and
"watermark": true. omitsPremiumLayers tells you whether Premium-only
layers were dropped from the render.
curl -s -H "$AUTH" "$BASE/v1/exports/$JOB_ID"
status moves queued → running → completed (or failed
or cancelled), alongside progress from 0 to 1 and
estimatedSecondsRemaining. There are no webhooks — poll every couple of seconds.
A DELETE on a running job cancels it.
Rendering keeps the Mac awake for the duration, and closing the main window does not stop it. Quitting the app does.
curl -s -H "$AUTH" -o night-drive.mp4 "$BASE/v1/exports/$JOB_ID/file"
curl -s -X DELETE -H "$AUTH" "$BASE/v1/exports/$JOB_ID"
The video is streamed back over HTTP because it was written inside the app's
container, which your script cannot browse. Fetching it before the job is
completed returns 409 export_not_ready.
The DELETE matters: finished exports sit in the container and are
not cleaned up on their own. Delete each job once you have the bytes, or a
nightly batch will fill your disk with videos you already downloaded.
Nothing but curl and the python3 that ships with macOS, used only
to pick fields out of JSON:
#!/bin/bash
set -euo pipefail
CFG=~/Library/Containers/com.mjudio.musicvideomaker/Data/Library/Application\ Support/Mjudio/Automation/endpoint.json
BASE=$(python3 -c "import json;print(json.load(open('$CFG'))['baseURL'])")
AUTH="Authorization: Bearer $(python3 -c "import json;print(json.load(open('$CFG'))['token'])")"
# 1. A project built on a template, in portrait
PID=$(curl -s -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"name":"Night Drive","templateID":"lofi-dusk","aspectRatio":"portrait9x16"}' \
"$BASE/v1/projects" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
# 2. The music. Blocks while the track is analysed.
curl -s -X PUT -H "$AUTH" --data-binary @song.mp3 \
"$BASE/v1/projects/$PID/audio?filename=song.mp3" >/dev/null
# 3. The title, by layer id
LAYER=$(curl -s -H "$AUTH" "$BASE/v1/projects/$PID" \
| python3 -c 'import json,sys;print([l["id"] for l in json.load(sys.stdin)["layers"] if l["kind"]=="text"][0])')
curl -s -X PATCH -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"text":"Night Drive"}' "$BASE/v1/projects/$PID/layers/$LAYER" >/dev/null
# 4. Render
JID=$(curl -s -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"resolution":"1080p","codec":"hevc"}' \
"$BASE/v1/projects/$PID/exports" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
# 5. Wait. Read the status field rather than grepping the whole reply, and give up
# on a job that will never finish.
while :; do
STATUS=$(curl -s -H "$AUTH" "$BASE/v1/exports/$JID" \
| python3 -c 'import json,sys;print(json.load(sys.stdin)["status"])')
[[ $STATUS == completed ]] && break
[[ $STATUS == failed || $STATUS == cancelled ]] && { echo "export $STATUS" >&2; exit 1; }
sleep 2
done
# 6. Collect the file, then delete the job so the container does not fill up
curl -s -H "$AUTH" -o night-drive.mp4 "$BASE/v1/exports/$JID/file"
curl -s -X DELETE -H "$AUTH" "$BASE/v1/exports/$JID"
One video per track in ./tracks. Standard library only — no packages to
install:
"""Render one Mjudio video per track in ./tracks."""
import json, pathlib, time, urllib.error, urllib.parse, urllib.request
CONFIG = pathlib.Path.home() / (
"Library/Containers/com.mjudio.musicvideomaker/Data/Library/"
"Application Support/Mjudio/Automation/endpoint.json")
class Mjudio:
def __init__(self):
config = json.loads(CONFIG.read_text()) # also proves the app is running
self.base, self.token = config["baseURL"], config["token"]
def raw(self, method, path, body=None, content_type="application/json"):
"""Every call goes through here, so the token is never forgotten."""
data = json.dumps(body).encode() if isinstance(body, (dict, list)) else body
request = urllib.request.Request(self.base + path, data=data, method=method)
request.add_header("Authorization", f"Bearer {self.token}")
if data:
request.add_header("Content-Type", content_type)
try:
with urllib.request.urlopen(request, timeout=600) as response:
return response.read()
except urllib.error.HTTPError as error:
detail = json.loads(error.read() or b"{}")
raise RuntimeError(f"{error.code} {detail.get('error')}: "
f"{detail.get('message')}") from None
def call(self, method, path, body=None, content_type="application/json"):
payload = self.raw(method, path, body, content_type)
return json.loads(payload) if payload else None # DELETE answers 204, no body
def health(self):
return self.call("GET", "/v1/health")
def create(self, name, template_id, aspect="landscape16x9"):
return self.call("POST", "/v1/projects",
{"name": name, "templateID": template_id, "aspectRatio": aspect})
def set_audio(self, project_id, path):
# Blocks until the analysis is cached; without it visualizers render flat.
name = urllib.parse.quote(path.name)
return self.call("PUT", f"/v1/projects/{project_id}/audio?filename={name}",
path.read_bytes(), "application/octet-stream")
def set_text(self, project_id, layer_id, text):
return self.call("PATCH", f"/v1/projects/{project_id}/layers/{layer_id}",
{"text": text})
def export(self, project_id, **settings):
return self.call("POST", f"/v1/projects/{project_id}/exports", settings)
def wait(self, job_id, poll=2.0):
while True:
job = self.call("GET", f"/v1/exports/{job_id}")
if job["status"] in {"completed", "failed", "cancelled"}:
if job["status"] != "completed":
raise RuntimeError(job.get("errorMessage") or job["status"])
return job
time.sleep(poll)
def download(self, job_id, destination):
# A plain GET: the reply is the MP4 itself, not JSON.
destination.write_bytes(self.raw("GET", f"/v1/exports/{job_id}/file"))
self.call("DELETE", f"/v1/exports/{job_id}") # the container is not self-cleaning
if __name__ == "__main__":
app = Mjudio()
status = app.health()
# The field is absent on Premium, so .get() rather than [].
print("plan:", status["plan"], "| exports left today:",
status.get("remainingFreeExportsToday", "unlimited"))
for track in sorted(pathlib.Path("tracks").glob("*.mp3")):
project = app.create(track.stem, "lofi-dusk", "portrait9x16")
app.set_audio(project["id"], track)
title = next(layer for layer in project["layers"] if layer["kind"] == "text")
app.set_text(project["id"], title["id"], track.stem)
job = app.export(project["id"], resolution="1080p", codec="hevc")
app.wait(job["id"])
app.download(job["id"], pathlib.Path(f"{track.stem}.mp4"))
print("rendered", track.stem)
Everything lives under /v1. Replies are JSON unless noted.
| Call | What it does |
|---|---|
GET /v1/health | Version, plan, renderer state, project count, exports left today, which projects are open in a window |
GET /v1/templates | Every built-in template: id, name, category, premium, designedFor |
| Call | What it does |
|---|---|
GET /v1/projects | Summaries, newest first |
POST /v1/projects | {name?, templateID?, aspectRatio?} → 201 with the full project |
GET /v1/projects/{id} | The full project, including its layers |
PATCH /v1/projects/{id} | {name?, aspectRatio?, templateID?, trimStartSeconds?, trimEndSeconds?, fadeInSeconds?, fadeOutSeconds?} |
DELETE /v1/projects/{id} | 204 |
| Call | What it does |
|---|---|
PUT /v1/projects/{id}/audio?filename=song.mp3 | Raw bytes. Embeds the track and runs the analysis before replying |
PUT /v1/projects/{id}/artwork?filename=cover.jpg | Raw bytes. Becomes the full-canvas backdrop |
POST /v1/projects/{id}/lyrics?format=lrc | .lrc, .srt or txt as the body |
PATCH /v1/projects/{id}/layers/{layerID} | {name?, text?, hidden?, opacity?} |
| Call | What it does |
|---|---|
POST /v1/projects/{id}/exports | {resolution?, frameRate?, codec?, audioBitrate?, fileName?} → 202 |
GET /v1/exports | All jobs; ?projectID=<uuid> filters |
GET /v1/exports/{id} | One job: status, progress, estimatedSecondsRemaining, settings |
GET /v1/exports/{id}/file | The finished MP4, streamed |
DELETE /v1/exports/{id} | Cancels a running job, or removes a finished one and deletes its file |
Every non-2xx reply has the same shape. Branch on error, which is stable;
message is prose and may be reworded.
{"error": "project_open_in_editor", "message": "…"}
| Code | error | What to do |
|---|---|---|
| 401 | — | Missing or stale token. Re-read endpoint.json; the token may have been regenerated |
| 403 | premium_required | A Premium-only template on a free plan. Pick another templateID |
| 403 | project_limit_reached | The free plan's project count is full. Delete a project |
| 403 | export_limit_reached | Today's free exports are used up. remainingFreeExportsToday in /v1/health is the counter |
| 403 | export_queue_busy | The free plan runs one export at a time. Wait for the running job |
| 404 | project_not_found, export_not_found, layer_not_found, template_not_found | Check the id — and that you have not deleted the job already |
| 409 | project_open_in_editor | Close the editor window, or script a different project |
| 409 | no_audio | Upload the track before trimming or exporting |
| 409 | not_a_text_layer | text only applies to text layers |
| 409 | export_not_ready | The job has not finished. Keep polling |
| 410 | export_file_missing | The file is gone from the container — re-render |
| 411 / 413 | — | Send a Content-Length (no chunked encoding); bodies cap at 512 MB |
| 415 | unsupported_audio_format, unsupported_lyric_format | Check the ?filename= extension or ?format= |
| 422 | unreadable_audio, analysis_failed, unreadable_image, no_lyric_lines | The file arrived but could not be decoded or parsed |
| 500 | internal_error | A bug. The message carries the underlying error |
The most common surprise is a 403 from browser JavaScript that looks correct.
That is deliberate. The server refuses any request carrying an Origin header,
never sends CORS headers, and rejects a Host that is not a loopback literal — so
a page you happen to be visiting cannot quietly drive your copy of Mjudio, and no DNS trick
can point a hostname at it. Use a shell, a script or a native tool instead.
| Guard | Behaviour |
|---|---|
| Bind address | 127.0.0.1 only, never the wildcard |
| Non-loopback peer | Connection dropped before a byte is read |
| Missing or wrong token | 401, after a deliberate delay |
Any Origin header | 403 |
Host not a loopback literal | 403 |
| Chunked encoding | 411 — send a Content-Length |
| Body over 512 MB | 413 |
| More than 8 live connections | Refused |
The API submits exports through the same code path as the Export button in the app, so the rules are identical either way. A script cannot obtain a render the app itself would refuse.
As Mjudio ships today the free plan allows a handful of exports per day, caps resolution at
720p, adds a watermark, holds a limited number of projects at once and runs one export at a
time. Those numbers are configuration and can change, so trust GET /v1/health
(plan, maxResolution, addsWatermark,
remainingFreeExportsToday) over any number printed on this page. On Premium,
remainingFreeExportsToday is absent rather than a large number — there is no
allowance to count down.
Premium-only layers behave differently from Premium templates: a free export does
not fail, it renders without them, and the job reports
"omitsPremiumLayers": true.
templateID — that is the intended workflow, not a workaround.The same server is in the iOS app and the same endpoints work, with one practical
difference: loopback means that device. An iPhone's automation server is reachable
only from software running on the iPhone itself — typing 127.0.0.1:8787 into a
browser on your Mac addresses the Mac and is refused. There is also no
endpoint.json you can open on iOS, so copy the token from
Settings ▸ Automation. And because iOS suspends a backgrounded app, sending
Mjudio to the background stops the server until you return to it.
The Automation screen also shows a request counter and the last request the server answered — the quickest way to tell "my script never arrived" from "it arrived and was rejected".