Python/OpenCV proof of concept plus first native Swift port

Snapshot before the Python POC is removed. The POC (main.py, effects.py,
panel.py, shaders.py, effects/*.glsl, flake.nix) is the reference for the
native app under native/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Rasyidan Akbar F. 2026-10-05 20:26:47 +07:00
commit 6591138bbf
27 changed files with 2076 additions and 0 deletions

191
main.py Normal file
View file

@ -0,0 +1,191 @@
"""motionlab - a tiny TouchDesigner-style playground: live camera in, motion-reactive graphics out."""
import argparse
import os
import sys
import time
import cv2
import numpy as np
from effects import Context, default_layers, hue_bgr
from panel import Panel
from shaders import ShaderBank
SIZE = (960, 540)
VCAM_SIZE = (1280, 720)
WINDOW = "motionlab"
class Synthetic:
"""Drifting blobs, so the app (and --headless) works without a camera."""
def __init__(self):
self.i = 0
def read(self):
w, h = SIZE
t = self.i / 30
self.i += 1
img = np.full((h, w, 3), (30, 20, 20), np.uint8)
for k in range(5):
x = int(w * (0.5 + 0.4 * np.sin(t * (0.7 + 0.2 * k) + k)))
y = int(h * (0.5 + 0.4 * np.cos(t * (0.5 + 0.3 * k) + 2 * k)))
cv2.circle(img, (x, y), 40 + 12 * k, hue_bgr(k / 5) * 255, -1)
return True, cv2.GaussianBlur(img, (0, 0), 4)
def release(self):
pass
def open_source(args):
if args.demo or args.headless:
return Synthetic()
backend = cv2.CAP_AVFOUNDATION if sys.platform == "darwin" else cv2.CAP_ANY
cap = cv2.VideoCapture(args.camera, backend)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
# without camera permission macOS can hand back all-black frames instead of failing
for _ in range(10):
ok, frame = cap.read()
if ok and frame.any():
return cap
cap.release()
print("Could not read from the camera - falling back to the synthetic demo source.\n"
"On macOS: allow your terminal under System Settings > Privacy & Security > Camera, then rerun.",
file=sys.stderr)
return Synthetic()
def open_virtualcam():
"""Virtual camera other apps (Meet, Zoom, ...) can pick as their webcam."""
import pyvirtualcam
try:
cam = pyvirtualcam.Camera(*VCAM_SIZE, 30, fmt=pyvirtualcam.PixelFormat.BGR)
except RuntimeError as e:
sys.exit(f"Could not start the virtual camera: {e}")
print(f"virtual camera on: pick '{cam.device}' as the camera in your call app")
return cam
def fit(frame):
"""Centre-crop to the output aspect ratio, then resize."""
w, h = SIZE
fh, fw = frame.shape[:2]
cw = min(fw, fh * w // h)
ch = min(fh, fw * h // w)
x, y = (fw - cw) // 2, (fh - ch) // 2
return cv2.resize(frame[y:y + ch, x:x + cw], SIZE, interpolation=cv2.INTER_AREA)
def capture_path(ext):
os.makedirs("captures", exist_ok=True)
return os.path.join("captures", time.strftime(f"motionlab-%Y%m%d-%H%M%S.{ext}"))
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--camera", type=int, default=0, help="camera index (default 0)")
ap.add_argument("--demo", action="store_true", help="use the synthetic source instead of the camera")
ap.add_argument("--virtualcam", action="store_true", help="also send the output to a virtual camera for video calls")
ap.add_argument("--headless", type=int, metavar="N", help="render N synthetic frames without a window, then exit")
ap.add_argument("--out", default="headless.png", help="where --headless writes its last frame")
ap.add_argument("--effects", default="effects", help="folder of .glsl shader effects (default ./effects)")
ap.add_argument("--only", metavar="NAMES", help="enable only these layers, comma separated (e.g. --only liquid,halftone)")
args = ap.parse_args()
w, h = SIZE
src = open_source(args)
layers = default_layers()
# prefer ./effects so edits are picked up live; fall back to the copy shipped next to this file
effects_dir = args.effects if os.path.isdir(args.effects) else os.path.join(os.path.dirname(__file__), "effects")
bank = ShaderBank(effects_dir, layers)
if args.only:
wanted = {name.strip().lower() for name in args.only.split(",")}
unknown = wanted - {layer.name.lower() for layer in layers}
if unknown:
sys.exit(f"unknown layer(s): {', '.join(sorted(unknown))}. Available: {', '.join(l.name for l in layers)}")
for layer in layers:
layer.enabled = layer.name.lower() in wanted
if args.headless and bank.errors:
sys.exit(f"shader compile failed: {', '.join(bank.errors)}")
ctx = Context(SIZE)
panel = Panel(layers)
canvas = np.empty((h, w + Panel.WIDTH, 3), np.uint8)
show_panel, writer, fps = True, None, 30.0
vcam = open_virtualcam() if args.virtualcam else None
if not args.headless:
cv2.namedWindow(WINDOW, cv2.WINDOW_NORMAL | cv2.WINDOW_KEEPRATIO)
cv2.resizeWindow(WINDOW, w + Panel.WIDTH, h)
cv2.setMouseCallback(WINDOW, panel.on_mouse)
start = last = time.perf_counter()
frames = 0
while True:
ok, frame = src.read()
if not ok:
break
now = time.perf_counter()
dt = 1 / 30 if args.headless else max(now - last, 1e-3)
last = now
fps += (1 / dt - fps) * 0.1
frame = cv2.flip(fit(frame), 1) # mirror, so it behaves like a mirror
if frames % 15 == 0:
bank.refresh() # pick up new and edited shader files
ctx.update(frame, dt)
out = frame
for layer in layers:
if layer.enabled:
out = layer.apply(out, ctx)
if writer:
writer.write(out)
if vcam:
# un-mirror: the preview is a mirror, but the people on the call should see the real thing
vcam.send(cv2.resize(cv2.flip(out, 1), VCAM_SIZE))
view = out
if show_panel:
canvas[:, :w] = out
panel.draw(canvas, w, f"{fps:3.0f} fps" + (" REC" if writer else "") + (" CAM" if vcam else ""))
view = canvas
else:
panel.hits = []
frames += 1
if args.headless:
if frames >= args.headless:
cv2.imwrite(args.out, view)
print(f"{frames} frames, {(time.perf_counter() - start) / frames * 1000:.1f} ms/frame -> {args.out}")
break
continue
cv2.imshow(WINDOW, view)
key = cv2.waitKey(1) & 0xFF
if key in (ord("q"), 27) or cv2.getWindowProperty(WINDOW, cv2.WND_PROP_VISIBLE) < 1:
break
elif key == ord("h"):
show_panel = not show_panel
elif key == ord("s"):
path = capture_path("png")
cv2.imwrite(path, out)
print("saved", path)
elif key == ord("r"):
if writer:
writer.release()
writer = None
else:
path = capture_path("mp4")
writer = cv2.VideoWriter(path, cv2.VideoWriter_fourcc(*"mp4v"), round(fps), SIZE)
print("recording", path)
if writer:
writer.release()
if vcam:
vcam.close()
src.release()
cv2.destroyAllWindows()
if __name__ == "__main__":
main()