Mjudio — Music Studio Visualizer

Automation API

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.

On this page

1. Turn it on

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:

Turning the switch off stops the server and deletes the discovery file described next.

2. Find the address and the token

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".

3. Your first call

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.

The one rule that surprises people

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.

4. A whole video, step by step

Step 1 — Pick a template

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.

Step 2 — Create the project

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.

Step 3 — Send the music

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.

Step 4 — Set the words and the cover art

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.

Step 5 — Render

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.

Step 6 — Wait for it

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.

Step 7 — Collect the file, then clean up

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.

5. The same thing as one shell script

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"

6. A Python client for batch work

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)

7. Endpoint reference

Everything lives under /v1. Replies are JSON unless noted.

Status and templates

CallWhat it does
GET /v1/healthVersion, plan, renderer state, project count, exports left today, which projects are open in a window
GET /v1/templatesEvery built-in template: id, name, category, premium, designedFor

Projects

CallWhat it does
GET /v1/projectsSummaries, 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

Media and layers

CallWhat it does
PUT /v1/projects/{id}/audio?filename=song.mp3Raw bytes. Embeds the track and runs the analysis before replying
PUT /v1/projects/{id}/artwork?filename=cover.jpgRaw 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?}

Exports

CallWhat it does
POST /v1/projects/{id}/exports{resolution?, frameRate?, codec?, audioBitrate?, fileName?} → 202
GET /v1/exportsAll jobs; ?projectID=<uuid> filters
GET /v1/exports/{id}One job: status, progress, estimatedSecondsRemaining, settings
GET /v1/exports/{id}/fileThe finished MP4, streamed
DELETE /v1/exports/{id}Cancels a running job, or removes a finished one and deletes its file

8. When something goes wrong

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": "…"}
CodeerrorWhat to do
401—Missing or stale token. Re-read endpoint.json; the token may have been regenerated
403premium_requiredA Premium-only template on a free plan. Pick another templateID
403project_limit_reachedThe free plan's project count is full. Delete a project
403export_limit_reachedToday's free exports are used up. remainingFreeExportsToday in /v1/health is the counter
403export_queue_busyThe free plan runs one export at a time. Wait for the running job
404project_not_found, export_not_found, layer_not_found, template_not_foundCheck the id — and that you have not deleted the job already
409project_open_in_editorClose the editor window, or script a different project
409no_audioUpload the track before trimming or exporting
409not_a_text_layertext only applies to text layers
409export_not_readyThe job has not finished. Keep polling
410export_file_missingThe file is gone from the container — re-render
411 / 413—Send a Content-Length (no chunked encoding); bodies cap at 512 MB
415unsupported_audio_format, unsupported_lyric_formatCheck the ?filename= extension or ?format=
422unreadable_audio, analysis_failed, unreadable_image, no_lyric_linesThe file arrived but could not be decoded or parsed
500internal_errorA bug. The message carries the underlying error

Why a web page cannot call this

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.

GuardBehaviour
Bind address127.0.0.1 only, never the wildcard
Non-loopback peerConnection dropped before a byte is read
Missing or wrong token401, after a deliberate delay
Any Origin header403
Host not a loopback literal403
Chunked encoding411 — send a Content-Length
Body over 512 MB413
More than 8 live connectionsRefused

9. Plans, limits and the watermark

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.

10. What it deliberately will not do

11. On iPhone and iPad

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.

Still stuck?

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".