SKILL.md
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](references/json-events.md) — the full event vocabulary (both paths). Read before writing anything beyond simple strokes.
- [references/websocket.md](references/websocket.md) — live protocol, the undocumented
cmdAPI, what is not supported live. - [references/batch.md](references/batch.md) — Motion IO CLI, data layers, frames→video, licensing.
- [references/assets.md](references/assets.md) — resolving brush preset and paper names (they fail silently if wrong).
- [scripts/rebellews.py](scripts/rebellews.py) — dependency-free WebSocket client (library + CLI).
- [scripts/rebelleevents.py](scripts/rebelleevents.py) — builds event files; encodes the batch frame rules and the stroke geometry helpers.
- [scripts/rebelle-batch.sh](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 a BOOKMARK — 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
NEWARTWORKin its frame (anotherSETBRUSHdoes).
A frame that ends right after NEW_ARTWORK deadlocks.
- Don't paint in the
NEW_ARTWORKframe — the canvas re-init wipes it. Start
painting in the next frame (Doc.firstcontentframe is where real content begins; pass it to ffmpeg -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 {"eventtype":"SIMULATION","repeats":N}) letting it bloom, and DRY/FASTDRY before 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. SETENGINEPARAMS controls absorbency, drips, granulation.
- Pick brushes by simulation, not by name:
WATERCOLORfor washes and bleeds,
INKPEN for line work that can be re-wetted, OILAND_ACRYLIC for impasto and colour mixing on the canvas, PENCIL/PASTEL for dry texture, AIRBRUSH for 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](references/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 .exr exports.
- 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](references/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(REBELLEWSPORT) and applies any event JSON it
receives to the artwork the user has open, and export() 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.