zinc:mapping

Video mapping on the GPU: layers of video, images or generated content, each warped onto a physical surface (corner pin, mesh warp), masked, edge-blended and colour-corrected in shaders, and controlled over OSC. Plugin: plugins/mapping/ (module zinc:mapping). It draws through display-gl, so the project needs "display": "gl". Example: examples/video/mapper/ with its web companion.

import * as gfx from 'zinc:gfx';
import * as mapping from 'zinc:mapping';
import { Player, EXTENSIONS } from 'zinc:video';

const p = new Player(640, 360);
p.addFolder('media', EXTENSIONS);
p.play();
if (!mapping.load('mapping.json')) {                   // previous setup, else a fresh one
  const l = mapping.addLayer('pattern', 'facade');     // test card until the video has a frame
  mapping.set(l, 'corners', [0.1, 0.1, 0.9, 0.05, 0.95, 0.9, 0.05, 0.95]);
}
mapping.listen(9000);                                  // OSC server: the whole address space below
gfx.onFrame(() => { if (p.image >= 0) mapping.setImage(0, p.image); gfx.clear(0); });

API: command(address, numbers, strings), addLayer(source, name), set(layer, key, numbers), setImage(layer, imageId), layerCount(), toJson(), fromJson(json), save(path), load(path), listen(port).

Support matrix

targetstatusnotes
macossupported, verifieddriven over OSC and through the companion, screenshots before/after (warp, masks, colour, blend, video layer, save/load)
rpi1 on Pi 4 / 5 (32-bit OS)builds in docker; not run on hardwareGLES 2 on Mesa v3d; 1080p output with a few layers is the expected sweet spot
rpi1 on Pi 3B+builds; not run on hardwareMesa vc4 (VideoCore IV): keep to 720p/1080p30 and 2–3 layers; video uploads (glTexSubImage2D of every new frame) are the bottleneck, decode small (new Player(640, 360))
rpi1 on Pi 1 / Zeronot supportedsame GPU, but the ARMv6 CPU cannot feed texture uploads and decoding at the same time
linuxnot supporteddisplay-gl has no linux backend wired (the KMS one would work)
esp32, ps1, ps2, wasmnono GL
simaccepted and ignoredcommands return true, nothing is drawn

Coordinates

Everything is normalized. Output: (0,0) top-left to (1,1) bottom-right of the screen, whatever its resolution. Surface: a layer’s own unit square (the source image spans it); masks, crop and edge blend live there, so they follow the warp. Per vertex: surface → mesh warp → corner pin → transform → output.

OSC address space

UDP port given to listen (9000 in the example). Numbers may be i, f or d; strings s. <n> is the layer index (0 = bottom, drawn first).

addressargumentseffect
/add[source] [name]appends a layer (max 16); source pattern (test card), solid, gradient, image
/removendeletes layer n
/cleardeletes every layer
/movefrom tochanges draw order
/selectn (−1: none)shows the calibration grid on layer n
/save, /load[path] (default mapping.json)whole setup as JSON (zinc:fs)
/synchost portreplies to host:port with /state/info {json} then /state/layer i {json} per layer
/layer/<n>/names
/layer/<n>/visible0/1
/layer/<n>/sourcekind [imageId]image shows image (any runtime image id: video player, camera, gfx.createImage; or a baked asset); a missing image shows the test card
/layer/<n>/imageidimage id used by source image
/layer/<n>/color, color2r g b (0..1)solid colour / gradient ends / test-card tint
/layer/<n>/angledegreesgradient direction (0: left→right, 90: top→bottom)
/layer/<n>/cornersx0 y0 x1 y1 x2 y2 x3 y3corner pin, output space, order TL TR BR BL
/layer/<n>/corneri x yone corner
/layer/<n>/meshcols rows [x y ...]mesh warp grid (2..16 each); without points the current warp is resampled
/layer/<n>/pointi j x yone mesh control point (column i, row j), pre-corner-pin space
/layer/<n>/meshresetflat mesh
/layer/<n>/positionx yoffset in output units
/layer/<n>/scale, rotations, degreesabout the quad centre (aspect-corrected)
/layer/<n>/opacity0..1
/layer/<n>/blendnormal, add, multiply, screen
/layer/<n>/brightness−1..1 (0)
/layer/<n>/contrast, saturation(1)
/layer/<n>/huedegrees (0)
/layer/<n>/gamma(1)> 1 brightens mid-tones
/layer/<n>/gainr g b (1 1 1)
/layer/<n>/cropx0 y0 x1 y1 [feather]surface-space rectangle, feathered inwards
/layer/<n>/edgeleft right top bottom [gamma]edge-blend ramp widths in surface units; gamma = projector gamma (2.2)
/layer/<n>/mask/<k>feather invert x0 y0 x1 y1 ...polygon mask k (0..3, 3..32 points, surface space); invert 0 = show inside, 1 = hide inside; fewer than 3 points removes it
/layer/<n>/maskimageid (−1: none)multiplies by the image’s luminance (updates with the image)
/layer/<n>/grid0/1calibration grid (mesh lines + border)
/layer/<n>/resetdefaults, keeps the name

The saved JSON uses the same vocabulary: {"version":1,"layers":[{"corners":[...],"mask/0":[...],...}]}, one key per /layer/<n>/<key> command with its arguments, so a file can be edited by hand and replayed as OSC. Keys unknown to an older version are ignored. Image ids are saved as numbers: they refer to the same player/image when the program creates its images in the same order (the example does).

Incoming OSC datagrams are limited to 2048 bytes (zinc:osc), so a 16×16 mesh cannot be sent in one /mesh message: send /mesh cols rows then /point per point (the companion does).

Rendering

  • Geometry: the mesh is a Catmull-Rom surface through the control points (a cubic Bézier patch per cell whose handles come from the neighbours), tessellated on the CPU into 32×32 quads only when it changes. Corner pin (square→quad homography), transform and NDC are one 3×3 matrix applied in the vertex shader with w, so the GPU interpolates texture coordinates perspective-correctly.
  • Masks: polygons (signed distance to the outline, smoothstep over the feather), crop, edge blending (smoothstep^(1/gamma), so two overlapping projectors sum to constant light) and the image mask are rendered into a 512×512 mask texture per layer, only when one of them changes; drawing a layer then costs one texture fetch.
  • Colour: gains, hue rotation, saturation, contrast and brightness are affine, combined on the CPU into one 4×4 matrix, then gamma. Output is premultiplied; blend modes are GL blend functions.
  • Sources: one program per source kind (#define SRC), compiled on first use. Runtime images are uploaded only when image_version changes.
  • UI: the program’s zinc:gfx frame is the overlay; gfx.clear(0) leaves it transparent (display-gl key colour).

Companion

examples/video/mapper/companion/: node server.mjs --app <pi-ip>:9000 serves the editor on http://<this machine>:8080 and relays it to the app as OSC; no npm dependencies. Layer list, drag corner pins or mesh points over the output preview, masks/crop/edge blend on the surface view, colour and transform sliders, save/load. node demo.mjs <url> drives a running app through the same HTTP API and checks the state it reports back. The server listens on 127.0.0.1 (--host 0.0.0.0 for a tablet on the LAN) and its API needs the token it prints in the editor URL (http://host:8080/?token=..., header x-zinc-token; --token or ZINC_COMPANION_TOKEN to fix one), which also keeps other web pages open in the same browser from driving the app.

The app’s OSC port has no authentication (UDP senders can be spoofed): listen(port) binds every interface unless ZINC_BIND=127.0.0.1 restricts it (the companion then runs on the same machine). Over OSC, /save and /load take a plain file name in the working directory (show.json, no directories), and /sync answers at most 10 times per second. save(path) / load(path) from the program itself accept any path.

Not done

  • Source crop (showing part of a video on a layer) and per-layer content transform; soft edge on the mesh border (use crop feather).
  • Masks are 8-bit: very wide, very soft edge blends can band.
  • Multiple outputs/projectors from one program; output colour calibration (LUT).

Edit this page on GitHub ↗