Rebelle Control
Rebelle simulates real paint — water flows, pigments granulate, impasto catches light — and both builds installed here take the same JSON event vocabulary:
| Build | How you drive it | Best for |
|---|---|---|
/Applications/Rebelle 8.app (Pro) |
WebSocket server, live | painting into the app the user is watching, exploring, one-off artwork |
/Applications/Rebelle 8 Motion IO.app |
-batch-json file, headless-ish |
reproducible renders, animation frame sequences, data-layer in/out |
Start live unless the task is an animation or needs data layers: the round-trip is seconds instead of a minute, and you can look at the canvas whenever you want.
- references/json-events.md — the full event vocabulary (both paths). Read before writing anything beyond simple strokes.
- references/websocket.md — live protocol, the undocumented
cmdAPI, what is not supported live. - references/batch.md — Motion IO CLI, data layers, frames→video, licensing.
- references/assets.md — resolving brush preset and paper names (they fail silently if wrong).
- scripts/rebelle_ws.py — dependency-free WebSocket client (library + CLI).
- scripts/rebelle_events.py — builds event files; encodes the batch frame rules and the stroke geometry helpers.
- scripts/rebelle-batch.sh — runs a batch and returns when it is actually done.
Path A — live, over the WebSocket
Rebelle only listens when it was launched with the flag, and it does not have to be restarted for each task — check first, ask second:
python3 scripts/rebelle_ws.py --ping # prints e.g. "Rebelle 8 Pro here"
If nothing answers, ask the user before launching: a fresh instance is a new blank document, and painting goes into whatever artwork is open, with no undo grouping.
"/Applications/Rebelle 8.app/Contents/MacOS/Rebelle 8" \
-websocket-server-enable \
-websocket-allowed-ip-addresses "::ffff:127.0.0.1,127.0.0.1" &
The allowlist is matched against the socket's literal peer address, and a local client
arrives as the IPv6-mapped ::ffff:127.0.0.1 — listing plain 127.0.0.1 alone refuses
your own connection (... refused in Rebelle's output).
Then drive it from Python:
from rebelle_ws import Rebelle
from rebelle_events import catmull_rom, taper
with Rebelle() as r:
r.event({"event_type": "NEW_ARTWORK", "width": 1200, "height": 800, "units": "px"})
r.event({"event_type": "SET_BRUSH", "tool": "WATERCOLOR", "preset": "Watercolor/Round",
"size": 55, "water": 70, "opacity": 90, "paint_type": "PAINT",
"color": {"r": 30, "g": 90, "b": 200}})
r.stroke(catmull_rom([(150, 450), (400, 250), (800, 600), (1050, 320)]),
pressure=taper(0.95))
r.event({"event_type": "SIMULATION", "repeats": 20}) # let the water spread
r.sync() # wait until Rebelle is really finished
r.export("/tmp/check.png") # then Read the PNG
sync() sends a BOOKMARK and waits for the echo. Nothing else acknowledges anything,
so without it you are exporting a canvas that is still mid-stroke.
SAVE/LOAD events are rejected live — export() (cmd: export_canvas) is the only
way out, and it writes the composited canvas exactly as the user sees it.
Path B — batch rendering with Motion IO
python3 build_painting.py # writes events.json via rebelle_events.Doc
bash scripts/rebelle-batch.sh events.json out/
Motion IO never exits by itself on macOS and reports progress only as
batch frame end: i/n on stdout, so always go through rebelle-batch.sh — it waits for
i == n, surfaces the ERROR: lines, then stops the app.
One frame = one output image + one implicit fluid-simulation step. That makes animation natural: paint a bit, close the frame, repeat.
from rebelle_events import Doc, catmull_rom, taper
d = Doc(1200, 800, paper={"preset": "Handmade/HM01 Handmade", "deckled_edges": True})
d.set_brush("WATERCOLOR", "Watercolor/Round", size=55, water=70, opacity=90,
paint_type="PAINT", color=(30, 90, 200))
d.stroke(catmull_rom([(150, 450), (400, 250), (800, 600), (1050, 320)]), pressure=taper())
d.frame() # one animation frame ends here
d.simulation(15, frames=20) # 20 frames of the paint spreading and drying
d.write("events.json")
The frame rules that make batch work
Doc applies these already; hand-written JSON must too, or the run wedges forever or
loses work silently. Motion IO is still finishing its own startup while it reads the
first frames, and these are the shapes that survive it:
- Frame 0 is a warm-up that must load a brush. A
SET_BRUSHthere is enough real work to let startup complete. A frame with only aBOOKMARK— or an empty frame — deadlocks before a single frame is processed. NEW_ARTWORKgoes in frame 1, never frame 0. In frame 0 it is dropped without a word and you silently get Rebelle's default A4 canvas (2339×1654 at 200 dpi).- Something must follow
NEW_ARTWORKin its frame (anotherSET_BRUSHdoes). A frame that ends right afterNEW_ARTWORKdeadlocks. - Don't paint in the
NEW_ARTWORKframe — the canvas re-init wipes it. Start painting in the next frame (Doc.first_content_frameis where real content begins; pass it toffmpeg -start_number).
If you must skip NEW_ARTWORK entirely, -input artwork.reb opens an existing artwork
and is the sturdiest way to fix a canvas size — but the user has to have saved one.
Painting that looks painted
- Coordinates are canvas pixels, y down from the top-left, and may go outside the canvas — start and end strokes off-canvas for edge-to-edge washes.
- A
POINTER_MOVEdraws the previous segment, so a stroke must end with a release at the last move's position.stroke()/stroke_events()handle it; hand-written events routinely lose their final segment to this. - Sample paths densely. Rebelle interpolates between points, so four points give a
smooth-but-generic curve;
catmull_rom()at ~16 points per segment lets the brush texture, spacing and pressure actually show. - Vary pressure. A constant 0.9 reads as machine-drawn.
taper()thins both ends,ramp()builds up — that alone is most of the difference between a plot and a stroke. - Wet media need simulation time. Water only spreads on simulation steps: paint,
then spend frames (or
{"event_type":"SIMULATION","repeats":N}) letting it bloom, andDRY/FAST_DRYbefore painting a layer that should not bleed into the one below. - Build in layers of colour, as in real painting: pale washes first,
DRY, then darker glazes over them.SET_ENGINE_PARAMScontrols absorbency, drips, granulation. - Pick brushes by simulation, not by name:
WATERCOLORfor washes and bleeds,INK_PENfor line work that can be re-wetted,OIL_AND_ACRYLICfor impasto and colour mixing on the canvas,PENCIL/PASTELfor dry texture,AIRBRUSHfor soft gradients.
Always look at the result
Rebelle answers almost nothing, and a wrong paper or preset produces a plausible,
completely wrong image. Export and Read the PNG after each meaningful step, the same
way you would glance at the canvas: live via r.export(path), in batch by reading the
last rendered frame. Judge it as a picture — coverage, colour, whether the water did
anything — and iterate. Then, when the user wants the finished artwork, export at full
size (rgba_canvas, or SAVE with scaling for a NanoPixel-scale export).
Gotchas worth knowing before you hit them
- Wrong preset name → loud error; wrong paper name → silence. Resolve both against
the filesystem first (assets.md); the official quickstart's
"Default/HM01 Handmade"is itself wrong for Rebelle 8. SET_BRUSHwithoutpresetpaints nothing and logsPreset for brush was not set.- Only one Rebelle at a time. Running the GUI build and Motion IO together is asking for trouble; stop one before starting the other.
- Force-stopping the app leaves a
Data/Sessions/<pid>/folder behind in~/Library/Application Support/Escape Motions/Rebelle 8/. Harmless, but they pile up at tens of MB each — worth mentioning to the user rather than deleting behind their back. - Colours are 8-bit RGB objects (
{"r":…,"g":…,"b":…}) even though the engine mixes in 16-bit; 16-bit output only exists in Motion IO.exrexports. - The docs run ahead of the build. The 8.3 docs promise
SAVE/LOADover WebSockets; the shipped 8.3.0 rejects them. Trust an export you have looked at over any documented behaviour. - Animation licensing has strings attached — see the note at the end of batch.md before helping with a commercial production.
Security
Path B opens nothing — Motion IO reads a file and exits. Path A does: launching Rebelle
with -websocket-server-enable turns it into a code-execution server for its event
vocabulary. Say so before asking the user to relaunch.
- The server listens on
127.0.0.1:8265(REBELLE_WS_PORT) and applies any event JSON it receives to the artwork the user has open, andexport()writes files with their privileges. There is no authentication: every local process, and every other user on a shared machine, can paint into their canvas. -websocket-allowed-ip-addressesis the only access control, and it matches the socket's peer address. Keep it to loopback (::ffff:127.0.0.1,127.0.0.1) — never add a LAN address, which would expose the canvas to the network. Note that a web page in the user's own browser is also a loopback peer, and WebSocket connections aren't subject to CORS, so don't count on the allowlist to exclude one.- To stop it, quit Rebelle and relaunch without the flag. The flag is per-launch, so a normally started Rebelle listens on nothing.