The idea
Most AI video tools take one prompt and return one result. Mortiflix works like a real studio instead: the work moves through stages, and a Claude session stops at every gate. It submits what it made, writes a handoff and ends. When you respond (approve, pin notes on a frame or a moment, answer questions), a fresh session picks up from the journal. Waiting on you costs nothing, for hours or for days.
Three ideas carry the design:
TASTE.md.pipeline.json (steps, review modes, checks), a PIPELINE.md (the craft) and skills. Anyone can write one.Who does what
One person owns the studio: they write briefs and review every stage. Sessions (Claude) do the work and can only reach the studio through mfx. The studio process enforces the rules between them.
flowchart LR
owner(["👤 Owner"])
claude(["🤖 Claude session"])
subgraph studio ["Mortiflix studio"]
direction TB
uc1(["Write a brief / start a project"])
uc2(["Review a step: approve or ask for changes"])
uc3(["Pin notes on frames, moments, paragraphs"])
uc4(["Answer questions"])
uc5(["Approve proposed error checks"])
uc6(["Pause · resume · cancel"])
uc7(["Do the step's work"])
uc8(["Report status and progress"])
uc9(["Submit at a gate (with checks)"])
uc10(["Ask a question · propose a check"])
uc11(["Queue a render"])
uc12(["Hand off and stop"])
end
owner --- uc1 & uc2 & uc3 & uc4 & uc5 & uc6
claude --- uc7 & uc8 & uc9 & uc10 & uc11 & uc12
Diagrams need the Mermaid library from cdn.jsdelivr.net (open this page online).
mfx.Components
One Node process (mortiflix serve or mortiflix run) built around one folder, the studio. Two front doors for you (the CLI and the web studio) and one for the session (mfx over a private Unix socket). All of them end in gates.mjs, the only code that changes a project's record.
flowchart TB
subgraph you ["For you"]
cli["cli.mjs
bin/mortiflix"]
web["web/server.mjs
+ web/app.js"]
end
subgraph sess ["For the session"]
mfx["mfx.mjs
bin/mfx"]
bridge["bridge.mjs
Unix socket, per-session token"]
end
subgraph core ["Core"]
gates["gates.mjs
every rule"]
projects["projects.mjs
record, events, journal"]
pipelines["pipelines.mjs
load · validate · snapshot"]
studio["studio.mjs
paths, config, secrets, locks"]
end
subgraph run ["Running sessions"]
runner["runner.mjs
Runner"]
torch["torch.mjs
workdir + CLAUDE.md"]
renderq["renderq.mjs
RenderQueue"]
subgraph backends ["backends/"]
cc["claude-code"]
api["anthropic-api"]
demo["demo"]
end
end
voice["voice/
index · elevenlabs · qwen"]
cli & web --> gates
cli & web --> runner
cli & web --> voice
mfx -- "JSON over socket" --> bridge
bridge --> gates
bridge --> renderq
runner --> torch
runner --> bridge
runner --> renderq
runner --> backends
runner -- "settle()" --> gates
demo -. "drives, like a session" .-> mfx
gates --> projects --> pipelines
studio.mjs (not drawn). The demo backend drives the real bridge through mfx, exactly like a session would.Deployment
Everything runs on one machine. The studio process starts sessions as child processes (Claude Code) or runs its own agent loop against the Claude API, and starts renders (Remotion, ffmpeg, Chrome) one at a time.
flowchart LR
subgraph machine ["Your computer"]
subgraph node ["Node process: mortiflix serve"]
srv["Web server :4646
JSON API + SSE"]
rn["Runner"]
rq["RenderQueue"]
br["Bridge
(per session)"]
end
browser["Browser
web studio"]
sock[("Unix socket
OS temp dir")]
claudecli["claude -p
(optional bubblewrap sandbox)"]
tools["Remotion · ffmpeg · Chrome"]
comfy["ComfyUI + Qwen3-TTS
(optional, GPU)"]
fs[("Studio folder
~/Mortiflix")]
end
anth["Anthropic API"]
eleven["ElevenLabs API
(optional)"]
browser -- "HTTP + SSE" --> srv
rn -- "spawns" --> claudecli
claudecli -- "mfx" --> sock --> br
rn -- "agent loop (API backend)" --> anth
claudecli --> anth
rq -- "spawns, one at a time" --> tools
claudecli -. "vo.mjs" .-> eleven & comfy
node --- fs
--host 0.0.0.0) adds an access-token cookie; by default it binds to 127.0.0.1.The studio folder
The split that matters: sessions write to projects/<id>/; only Mortiflix writes to state/<id>/. A submission's files are copied into the record when submitted, so what you reviewed can't change afterwards.
flowchart LR root["~/Mortiflix ($MORTIFLIX_STUDIO)"] root --> cfg["config.json
settings, no secrets"] root --> sec["secrets.json (mode 600)
your keys, web token"] root --> env["session.env (mode 600)
other keys handed to sessions"] root --> chk["checks.json
the studio's error checklist"] root --> taste["TASTE.md
learned across projects"] root --> pl["pipelines/
your own (override built-ins)"] root --> projects["projects/‹id›/
working folder · session cwd"] root --> state["state/‹id›/
the record"] root --> runf["run/
runner lock, render logs, shared Remotion install"] projects --> w1["CLAUDE.md · JOURNAL.md · checklist.md"] projects --> w2["pipeline/ · .claude/skills/ · .mortiflix/GATES.md"] projects --> w3["input/ · feedback/ · out/ · video/"] state --> s1["project.json · events.jsonl"] state --> s2["pipeline/ (pinned snapshot)"] state --> s3["reviews/‹step›/v‹n›/ submission + copied files + feedback"] state --> s4["sessions/*.jsonl transcripts + activity"] classDef rec stroke:#b18cff,stroke-width:2px; class state,s1,s2,s3,s4 rec;
Classes & data
The code is mostly functions over plain JSON records. The few real classes are the long-lived ones (Runner, RenderQueue, the API backend's Shell, the voice clients). Backends share one informal interface.
classDiagram
direction LR
class Runner {
+root
+renders: RenderQueue
+current
+loop(watch, pollMs)
+runSession(projectId)
+stopProject(id)
+stop()
emits change, activity
}
class RenderQueue {
-jobs: Map
-order: id[]
+add(projectId, sessionId, label, argv, cwd, env) id
+wait(id, projectId, seconds) view
+get(id, projectId) view
+stopSession(sessionId)
-pump()
}
class Backend {
<<interface>>
+name
+available(config, root) bool
+run(root, projectId, workdir, prompt, env, transcript, onActivity, signal, config) Result
}
class ClaudeCode {
+sandboxArgs()
+describeTool()
}
class AnthropicApi {
+DEFAULT_MODEL
+credentials(root)
+editorTool()
+confine()
}
class Demo
class Shell {
+run(command, timeout)
+restart()
}
class UserError
class GateError
class ElevenLabs {
+account() models() voices()
+library() dictionaries()
+speak(text)
}
class ComfyUI {
+nodes() status()
+queue(graph)
}
Backend <|.. ClaudeCode
Backend <|.. AnthropicApi
Backend <|.. Demo
AnthropicApi *-- Shell
Runner *-- RenderQueue
Runner ..> Backend : runs one session at a time
UserError <|-- GateError
Error <|-- UserError
GateError: a plain sentence the session can act on.
classDiagram
direction LR
class Project {
id
title
pipeline (slug)
backend?
state
answers
steps: Map~key, StepRecord~
questions: Question[]
needs_you?
no_progress
without_gate
usage
}
class StepRecord {
state
version
last_feedback
}
class Pipeline {
slug
name
makes
shared_skills[]
intake: IntakeField[]
steps: PipelineStep[]
status_lines
checks: Check[]
}
class PipelineStep {
key
name
review
work[]
after[]
delivers
}
class Check {
id
title
applies_to[]
how
}
class Submission {
note
questions[]
items[]
error_checks[]
pin_changes[]
}
class Feedback {
verdict: approve|changes
overall
notes[]
answers
}
class Note {
text
item?
x?, y?
time_sec?
paragraph?
}
class Question {
id
text
choices?
default?
answered_at?
}
Project "1" --> "1" Pipeline : pinned snapshot
Project "1" *-- "*" StepRecord
Project "1" *-- "*" Question
Pipeline "1" *-- "1..*" PipelineStep
Pipeline "1" *-- "*" Check
StepRecord "1" --> "*" Submission : versions v1..vn
Submission "1" --> "0..1" Feedback
Feedback "1" *-- "*" Note
Submission ..> Note : pin_changes answers each
state/<id>/ and pipeline.json.State machines
A project's state is recomputed from its steps after every change (settle()): queued when there's work the studio can do, waiting when it's your turn, delivered when every step is finished. Pause and cancel are yours; two sessions in a row without progress pause it automatically.
stateDiagram-v2
[*] --> draft : createProject
draft --> queued : startProject (required intake answered)
queued --> waiting : settle - a step is in review or a question is open
waiting --> queued : you approve / ask for changes / answer
queued --> delivered : settle - every step finished
queued --> paused : no progress twice, 8 sessions without a gate, needs-you
queued --> paused : you pause
waiting --> paused : you pause
paused --> queued : you resume
draft --> cancelled : you cancel
queued --> cancelled : you cancel
waiting --> cancelled : you cancel
paused --> cancelled : you cancel
delivered --> [*]
cancelled --> [*]
projects.STATES, gates.settle, runner guards).
stateDiagram-v2
[*] --> blocked
blocked --> ready : every step in "after" is finished
ready --> working : mfx step start / first work
working --> in_review : mfx submit (reviewed step)
ready --> in_review : mfx submit
in_review --> approved : you approve
in_review --> changes : you ask for changes
changes --> in_review : next version, every note answered
working --> done : mfx step done --checks (internal step)
approved --> [*]
done --> [*]
note right of blocked : derived states (blocked, ready) are computed by stepView; the rest are stored
stateDiagram-v2
[*] --> waiting : mfx render
waiting --> running : pump() - nothing else running
running --> done : exit 0
running --> failed : exit ≠ 0
waiting --> failed : session ended
running --> failed : session ended (SIGTERM to the group)
done --> [*]
failed --> [*]
One session, start to finish
The runner takes the oldest queued project, one session at a time (a run/runner.pid lock means serve and run never overlap). The session is told only to read CLAUDE.md, which torch.mjs writes fresh each time: where things stand, the steps table, what you said, the error checks, the brief (fenced as data), your taste and the journal.
sequenceDiagram autonumber participant R as Runner participant T as torch.mjs participant B as Bridge participant K as Backend participant S as Claude session participant M as mfx participant G as gates.mjs R->>R: next queued project
(oldest queued_at) R->>T: prepareWorkdir
(pinned pipeline, skills, GATES.md) R->>T: writeTorch → CLAUDE.md R->>B: openBridge (Unix socket + token) R->>K: run(prompt "Read ./CLAUDE.md…",
env MFX_SOCKET / MFX_TOKEN) K->>S: start
(claude -p or the API agent loop) loop until it reaches a gate S->>M: mfx status / checks / render … M->>B: POST {command, args, token} B->>G: same function, same rules G-->>S: result, or a refusal
as a plain sentence end S->>M: mfx submit step submission.json M->>B: submit B->>G: submit(): validate,
copy files into state/ S->>M: mfx handoff "…" S-->>K: ends its turn K-->>R: {ok, usage, cost_usd} R->>R: stop leftover renders,
close bridge R->>G: settle() →
waiting · queued · delivered R-->>R: guards: pause after 2 no-progress
sessions or 8 without a gate
Runner.runSession(). A session past maxSessionMinutes is stopped; the next one resumes from the handoff.A review round
You review in the web studio or with mortiflix review. Notes can point at an item, a spot on a picture, a moment in a video or a paragraph. The next version must answer each note in pin_changes, or it's refused.
sequenceDiagram autonumber actor O as Owner participant W as Web studio / CLI participant G as gates.mjs participant FS as Studio folder participant R as Runner participant S as Next session O->>W: pin notes (frame x,y · moment · paragraph)
verdict "changes" W->>G: respond(step, version,
{verdict, overall, notes, answers}) G->>G: step must be in_review at that version,
notes must point at real items G->>FS: state/…/feedback.json
projects/…/feedback/step-vN.json G->>G: step → changes, settle → queued G-->>W: SSE "change" R->>S: new session
(CLAUDE.md summarizes the notes) S->>S: sort each note:
error or taste, fix both opt an error no check would have caught S->>G: mfx propose-check
(approved once, every future video runs it) end S->>G: mfx submit v(N+1)
pin_changes: one entry per note G-->>O: back in review,
with what changed per note
gates.respond() is reachable only from the web API and the CLI, never from mfx.The gates
Every rule is enforced by src/gates.mjs in the studio process. A refusal returns a sentence the session can act on, for example "pin_changes: note 2 from directions v1 has no answer".
flowchart TB
start(["mfx submit step submission.json"]) --> internal{"step is internal?"}
internal -- yes --> r1["refuse: use mfx step done --checks"]
internal -- no --> note{"has a note?"}
note -- no --> r2["refuse"]
note -- yes --> qs{"questions valid?
text, unique ids, default ∈ choices"}
qs -- no --> r3["refuse"]
qs -- yes --> items{"every item: exists, a file,
inside the project (symlinks resolved)?"}
items -- no --> r4["refuse"]
items -- yes --> mode{"review mode satisfied?
frames→images · video→video · document→text"}
mode -- no --> r5["refuse"]
mode -- yes --> checks{"every check for the step's work
reported? fixed / n/a have a note?"}
checks -- no --> r6["refuse"]
checks -- yes --> lock["under the project lock"]
lock --> st{"step state ok?
not blocked, not in review, not approved"}
st -- no --> r7["refuse"]
st -- yes --> pins{"last version had notes?
one pin_changes entry each, with a change and a status"}
pins -- no --> r8["refuse"]
pins -- yes --> copy["copy files into state/…/reviews/step/vN/
write submission.json"]
copy --> done(["step → in_review · event SUBMITTED · settle"])
gates.submit(), in the order the checks run.| Rule | Where |
|---|---|
| Only you approve a reviewed step | respond(): web API and CLI only |
| Internal steps finish only with their checks | stepDone() |
| Every check for the step's kind of work is reported | validateChecks() |
| Every note on the last version gets an answer | submit() → pin_changes |
| Submitted files exist, are inside the project, and are copied | submit() |
| A step can't start before the steps it runs after | stepView(), submit(), stepStart() |
| Status lines come from the pipeline's whitelist | status() |
Renders
Heavy work (Remotion, anything that starts Chrome or the GPU) goes through mfx render. It queues the job studio-wide and returns an id at once; mfx render-wait waits up to a timeout. No tool call blocks longer than a backend allows, and two renders never fight for memory.
sequenceDiagram participant S as Session participant B as Bridge participant Q as RenderQueue participant P as Remotion / ffmpeg S->>B: mfx render --label "animatic"
-- npx remotion render … B->>Q: add(job) Q-->>S: {id: 3, state: waiting, renders_ahead: 1} Q->>Q: pump(): previous job finished Q->>P: spawn (own process group, log file) S->>B: mfx render-wait 3 B->>Q: wait(3, 100 s) P-->>Q: exit 0 Q-->>S: {state: done, output_tail} Note over S,Q: when the session ends, its waiting and running jobs are stopped
renderq.mjs. The web studio shows "Waiting to render" or "Rendering: <label>".Pipelines
A pipeline describes how one kind of video gets made: pipeline.json (intake questions, steps, how each is reviewed, the error checks, status lines), PIPELINE.md (the craft every session reads first) and skills. A project pins a snapshot of its pipeline when it's created, so editing a pipeline only affects new projects.
flowchart LR brief["Brief
questions"] script["Script
document · script"] frames["Style frames
frames · stills"] animatic["Animatic
video · motion, audio"] build["Build
internal · motion, audio"] final["Final
video · delivers"] brief --> script & frames script & frames --> animatic --> build --> final classDef you stroke:#ff8fd1,stroke-width:2px; class brief,script,frames,animatic,final you;
| Pipeline | Steps you review | Error checks | Good for |
|---|---|---|---|
explainer | brief → script → style frames → animatic → final | 9 | 30 s – 2 min explainers, narrated or not |
social-short | brief → hook frames → final | 8 | 15–45 s vertical shorts, hook first, works with sound off |
logo-sting | directions → final | 6 | a 3–8 s logo animation; the quickest real run |
song | blueprint → final | 7 | an instrumental song from a genre and a topic: researched genre, MIDI, an instrument per channel, a master |
Shared skills (pipelines/_shared/skills/)
motion-design | the craft of motion: timing, easing, composition |
remotion-motion | the Remotion template; setup.mjs installs it once per studio (was 748 MB per project) |
voiceover | vo.mjs narration (ElevenLabs or Qwen3-TTS), checked by speech-to-text; sound.mjs effects and music beds |
music | the music engine (strudel.mjs): genre → blueprint → score → MIDI → an instrument per channel → audition → master, or your own master from the MIDI pack; every stage checked and written down in MUSIC-SHEET.md |
final-pass | qc.mjs: format, black or frozen frames, loudness, and a frame sheet Claude has to look at |
Backends
A backend exports run(…) → { ok, error?, usage?, cost_usd? } and available(config, root). Adding one is adding a module to BACKENDS in runner.mjs.
| Backend | What runs | Who pays |
|---|---|---|
claude-code | claude -p … --output-format stream-json in the project folder with the configured permission flags; the stream feeds the activity log; optional bubblewrap sandbox | your Claude plan |
anthropic-api | Mortiflix's own streaming agent loop: a persistent bash shell, a text editor confined to the project that returns images as image blocks, optional web search and fetch, adaptive thinking, prompt caching, server-side compaction, refusal fallbacks. Default model claude-opus-5-5 | your API key, per token |
demo | no model: walks every gate with placeholder frames and a test-pattern video through the real bridge | nobody |
Narration
voice/index.mjs holds the studio's choice (none, elevenlabs or qwen) and hands a session MFX_VOICE, plus ELEVENLABS_API_KEY only while ElevenLabs is the engine. Sessions narrate with the voiceover skill's vo.mjs; every line is checked by speech-to-text and retaken if words go missing, and word timings drive the animation.
elevenlabs | your voices, the default voices or the Voice Library; Eleven v4 by default and every API option (models, stability and similarity, language, normalization, pronunciation dictionaries, formats, data-residency servers). Settings shows your plan and whether you may use the audio commercially |
qwen | Qwen3-TTS through ComfyUI on your own GPU: free and private. Offered when it fits (4 GB for 0.6B, 8 GB for 1.7B with delivery instructions) |
none | on-screen text and music |
CLI, web studio & mfx
For you: mortiflix
init · doctor · demo | make the studio and pick a backend · check the setup · a free walk-through with placeholder work |
pipelines · new <slug> | list and validate pipelines · start a project (asks the brief's questions) |
run · serve | run sessions until something waits on you · the web studio on 127.0.0.1:4646 (sessions start by themselves) |
list · status · review | projects · one project · read the note, answer, pin notes, approve or ask for changes |
pause · resume · cancel | control a project |
keys · config | your keys (hidden input, checked with a free API call, saved mode 600) · settings |
checks · voice | approve proposed checks · narration setup |
For you: the web studio
A plain node:http server and a no-build vanilla JS app (web/app.js). JSON API under /api, live updates over server-sent events (/api/events: change and activity), submitted media under /files/<id>/reviews/… with Range support.
| Endpoint | Does |
|---|---|
GET /api/studio · PUT /api/config | studio info and backends · settings |
GET /api/pipelines · GET /api/checks · POST /api/checks/:id | pipelines · the error checklist · approve or reject a proposed check |
GET|POST /api/projects | list · create |
GET /api/projects/:id · POST …/files · POST …/start | detail · upload intake files · start |
POST …/pause|resume|cancel | control |
POST …/reviews/:step/:version | approve or ask for changes (the only way past a gate) |
POST …/questions/:qid | answer a session's question |
/api/voice/… | narration: settings, ElevenLabs account/models/voices/library/dictionaries/preview, Qwen status, a test line |
GET /api/events | server-sent events |
For the session: mfx
status <key> [--rendering] | the one sentence you see while it works (from the pipeline's whitelist) |
step start|done · checks <step> | step progress · the error checks a step must report |
submit <step> submission.json | submit at a gate |
ask · needs-you · propose-check | a question with a default · a blocker only you can fix · a new error check |
render -- … · render-wait <id> | queue heavy work · wait for it |
feedback · files · handoff · taste · log | read your response · the submitted files · hand off · learn taste · log an event |
Security
sandbox: true (Linux, claude-code) it runs in bubblewrap: system read-only, its own project read-write, Mortiflix code read-only, its socket; your home, other projects, state/ and secrets aren't there.state/), no submitting files from outside the project.mortiflix keys (or Settings › Keys) asks for yours with hidden input, checks each with a free API call, and saves it mode 600. The API key is never returned by the API and sessions don't get it; the ElevenLabs key goes to a session only while it narrates with ElevenLabs. A project that needs a missing key asks before it starts.Host on loopback is refused (DNS rebinding); state-changing requests need an X-Mortiflix header.Where the compute goes
Measured on a real 5-second logo sting (claude-code backend, Claude Opus 5.5): the model is the cost, everything else is noise.
| Whole sting, brief → approved final | 2 sessions · 12.5 min · $3.52 at list price |
| Input tokens served from the prompt cache | 95% |
| Mortiflix's own text a session starts with | ~6.8k tokens |
| Studio process per web refresh | ~1 ms per project |
| Runner while idle | one directory listing every 1.5 s |
qc.mjs on a 60 s 1080p video | 2.2 s |
Why sessions end at gates: waiting is free. A session that stayed alive to wait would pay for its whole context again on every check. Fewer, shorter sessions (specific briefs, defaults on every question, checks that catch mistakes before a revision round) lower every part of the bill.
Module reference
| File | Role | Lines |
|---|---|---|
bin/mortiflix · bin/mfx | entry points | 3 · 3 |
src/cli.mjs | the mortiflix command: init, new, run, review, serve, config, checks, voice… | 532 |
src/gates.mjs | every rule: step view, settle, submit, respond, checks, questions, pause/resume/cancel | 427 |
src/web/server.mjs | HTTP + SSE web studio API, file serving with Range, guards | 364 |
src/backends/anthropic-api.mjs | the API agent loop, Shell, confined editor | 308 |
src/voice/elevenlabs.mjs | ElevenLabs client and request building | 217 |
src/voice/qwen.mjs | GPU detection, ComfyUI client, speak/listen graphs | 186 |
src/runner.mjs | Runner: one session at a time, guards, BACKENDS | 183 |
src/pipelines.mjs | list, load, validate, hash and snapshot pipelines; checks per step | 181 |
src/torch.mjs | prepare the working folder, write CLAUDE.md, append taste | 176 |
src/projects.mjs | create/start/list projects, the record, events, journal | 175 |
src/studio.mjs | studio paths, config, secrets, session env, JSON I/O, file locks, isInside | 149 |
src/backends/claude-code.mjs | claude -p runner, activity parsing, bubblewrap args | 134 |
src/mfx.mjs | the session's command-line client to the bridge | 117 |
src/renderq.mjs | RenderQueue: one render at a time, studio-wide | 108 |
src/backends/demo.mjs | scripted stand-in that walks every gate | 97 |
src/bridge.mjs | per-session HTTP server on a Unix socket | 87 |
src/voice/index.mjs | the studio's narration choice and session environment | 83 |
web/app.js · app.css · index.html | the no-build web studio: projects, live log, review room | 835 · 234 · 26 |
harness/GATES.md | the protocol every session follows | — |
test/ | flow, gates, CLI, web, API backend, skills, voice | 25 tests |
Extending it
A new pipeline (where help is most wanted)
cp -r pipelines/logo-sting ~/Mortiflix/pipelines/my-pipeline # studio pipelines override built-ins $EDITOR ~/Mortiflix/pipelines/my-pipeline/pipeline.json mortiflix pipelines # validates it mortiflix new my-pipeline --backend demo # walk its gates for free
Wanted: music videos, product films, data stories, kinetic type, 3D in Blender, captions for existing footage. See docs/PIPELINES.md.
A new backend
A module exporting name, available(config, root) and run({ root, projectId, workdir, prompt, env, transcript, onActivity, signal, config }), added to BACKENDS in src/runner.mjs. The session reaches the studio only through mfx with the MFX_SOCKET/MFX_TOKEN it's given.
Further reading
Architecture · Gate protocol · Pipelines · Security · Compute · Voice · Contributing
Mortiflix · project overview