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

View file

@ -0,0 +1,12 @@
// Recolours the picture by brightness with a smooth colour ramp.
// order 10
// slider amount 0.0 1.0 0.8
// slider palette 0.0 1.0 0.0
// slider contrast 0.5 3.0 1.2
float4 effect(float2 uv, Inputs in) {
float3 c = in.image.sample(in.smp, uv).rgb;
float luma = pow(dot(c, float3(0.299, 0.587, 0.114)), in.p.contrast);
float3 ramp = 0.5 + 0.5 * cos(6.2832 * (luma * 0.8 + in.p.palette + float3(0.55, 0.4, 0.2)));
float3 mapped = ramp * (0.15 + 0.85 * luma);
return float4(mix(c, mapped, in.p.amount), 1.0);
}

View file

@ -0,0 +1,18 @@
// Halftone dots sized by brightness; dots turn hot orange wherever something moves.
// order 70
// slider cell 4.0 40.0 12.0
// slider heat 0.0 20.0 8.0
float4 effect(float2 uv, Inputs in) {
float2 grid = in.resolution / in.p.cell;
float2 centre = (floor(uv * grid) + 0.5) / grid;
float3 c = in.image.sample(in.smp, centre).rgb;
float luma = dot(c, float3(0.299, 0.587, 0.114));
float radius = 0.5 * sqrt(luma);
float dist = length(fract(uv * grid) - 0.5);
float mask = 1.0 - smoothstep(radius - 0.05, radius + 0.05, dist);
float moved = length(in.camera.sample(in.smp, centre).rgb - in.last.sample(in.smp, centre).rgb);
float3 hot = mix(c, float3(1.0, 0.35, 0.1), clamp(moved * in.p.heat, 0.0, 1.0));
return float4(hot * mask, 1.0);
}

View file

@ -0,0 +1,13 @@
// Folds the picture into mirrored wedges around the centre.
// order 90
// slider segments 2.0 16.0 6.0
// slider spin -2.0 2.0 0.2
float4 effect(float2 uv, Inputs in) {
float2 shape = float2(in.resolution.x / in.resolution.y, 1.0);
float2 c = (uv - 0.5) * shape;
float wedge = 6.2832 / floor(in.p.segments);
float a = atan2(c.y, c.x) + in.time * in.p.spin;
a = abs(a - wedge * floor(a / wedge) - wedge * 0.5) - 1.5708; // fold into one mirrored wedge, pointing up
float2 from = float2(cos(a), sin(a)) * length(c) / shape + 0.5;
return float4(in.image.sample(in.smp, from).rgb, 1.0);
}

View file

@ -0,0 +1,13 @@
// Liquid trails: the previous output is dragged along the motion, swirled a little,
// and slowly fades back to the live picture.
// order 45
// slider persistence 0.0 0.99 0.92
// slider drag 0.0 6.0 2.5
// slider swirl 0.0 0.01 0.002
float4 effect(float2 uv, Inputs in) {
float2 centred = uv - 0.5;
float2 from = uv - in.flow.sample(in.smp, uv).xy * in.p.drag + float2(-centred.y, centred.x) * in.p.swirl;
float3 trail = in.prev.sample(in.smp, from).rgb;
float3 live = in.image.sample(in.smp, uv).rgb;
return float4(mix(live, trail, in.p.persistence), 1.0);
}

View file

@ -0,0 +1,20 @@
// Whatever moves lights up and the glow lingers. The heat is carried from frame to frame
// in the alpha channel of this effect's own previous output.
// order 50
// slider sensitivity 1.0 30.0 10.0
// slider linger 0.5 0.99 0.93
// slider dim 0.0 1.0 0.45
// slider hue 0.0 1.0 0.07
// hue 0..1 -> a fully saturated colour (0 red, 0.08 orange, 0.33 green, 0.66 blue)
static float3 hueColor(float h) {
return clamp(abs(fract(h + float3(0.0, 2.0 / 3.0, 1.0 / 3.0)) * 6.0 - 3.0) - 1.0, 0.0, 1.0);
}
float4 effect(float2 uv, Inputs in) {
float moved = length(in.camera.sample(in.smp, uv).rgb - in.last.sample(in.smp, uv).rgb);
float fresh = smoothstep(0.3, 1.0, moved * in.p.sensitivity);
float heat = max(in.prev.sample(in.smp, uv).a * in.p.linger, fresh);
float3 glow = hueColor(in.p.hue);
return float4(in.image.sample(in.smp, uv).rgb * in.p.dim + glow * heat, heat);
}

View file

@ -0,0 +1,38 @@
// Glowing outlines with a slowly cycling colour.
// order 20
// slider detail 1.0 12.0 5.0
// slider glow 0.0 6.0 2.5
// slider source 0.0 1.0 0.35
// slider hue 0.0 1.0 0.55
// slider cycle 0.0 1.0 0.3
// hue 0..1 -> a fully saturated colour (0 red, 0.08 orange, 0.33 green, 0.66 blue)
static float3 hueColor(float h) {
return clamp(abs(fract(h + float3(0.0, 2.0 / 3.0, 1.0 / 3.0)) * 6.0 - 3.0) - 1.0, 0.0, 1.0);
}
static float luma(Inputs in, float2 uv) {
return dot(in.image.sample(in.smp, uv).rgb, float3(0.299, 0.587, 0.114));
}
// how strongly brightness changes around this point
static float edge(Inputs in, float2 uv) {
float2 t = 1.5 / in.resolution;
float dx = luma(in, uv + float2(t.x, 0.0)) - luma(in, uv - float2(t.x, 0.0));
float dy = luma(in, uv + float2(0.0, t.y)) - luma(in, uv - float2(0.0, t.y));
return length(float2(dx, dy));
}
float4 effect(float2 uv, Inputs in) {
float sharp = smoothstep(0.1, 0.5, edge(in, uv) * in.p.detail);
float halo = 0.0; // edges found in a ring around this pixel = a cheap glow
for (int i = 0; i < 12; i++) {
float a = 6.2832 * float(i) / 12.0;
float2 dir = float2(cos(a), sin(a)) / in.resolution;
halo += edge(in, uv + dir * 4.0) + edge(in, uv + dir * 9.0);
}
halo = halo / 24.0 * in.p.detail * in.p.glow;
float3 tint = hueColor(in.p.hue + in.time * in.p.cycle * 0.2);
float3 base = in.image.sample(in.smp, uv).rgb * in.p.source;
return float4(base + tint * (sharp + halo), 1.0);
}

View file

@ -0,0 +1,22 @@
// Particles spawn where you move and drift along with the motion.
// order 30
// on
// slider brightness 0.0 3.0 1.4
// slider glow 0.0 4.0 1.5
// slider hue 0.0 1.0 0.08
// hue 0..1 -> a fully saturated colour (0 red, 0.08 orange, 0.33 green, 0.66 blue)
static float3 hueColor(float h) {
return clamp(abs(fract(h + float3(0.0, 2.0 / 3.0, 1.0 / 3.0)) * 6.0 - 3.0) - 1.0, 0.0, 1.0);
}
float4 effect(float2 uv, Inputs in) {
float core = in.particles.sample(in.smp, uv).r;
float halo = 0.0;
for (int i = 0; i < 8; i++) {
float a = 6.2832 * float(i) / 8.0;
halo += in.particles.sample(in.smp, uv + float2(cos(a), sin(a)) * 5.0 / in.resolution).r;
}
float3 tint = hueColor(in.p.hue);
float3 base = in.image.sample(in.smp, uv).rgb;
return float4(base + tint * (core * in.p.brightness + halo / 8.0 * in.p.glow), 1.0);
}

View file

@ -0,0 +1,16 @@
// Feedback trails: the previous output is zoomed, turned and faded, so bright things leave streaks.
// "push" smears the trails in the direction things are moving.
// order 40
// on
// slider fade 0.5 0.995 0.92
// slider zoom -0.04 0.04 0.012
// slider spin -0.05 0.05 0.005
// slider push 0.0 4.0 0.0
float4 effect(float2 uv, Inputs in) {
float2 c = uv - 0.5;
float2 turned = float2(c.x * cos(in.p.spin) - c.y * sin(in.p.spin), c.x * sin(in.p.spin) + c.y * cos(in.p.spin));
float2 from = turned * (1.0 - in.p.zoom) + 0.5 - in.flow.sample(in.smp, uv).xy * in.p.push;
float3 trail = in.prev.sample(in.smp, from).rgb * in.p.fade;
float3 live = in.image.sample(in.smp, uv).rgb;
return float4(max(live, trail), 1.0);
}

View file

@ -0,0 +1,13 @@
// RGB split that ripples down the image and gets pushed around by your movement.
// order 60
// slider amount 0.0 0.05 0.012
// slider waves 1.0 80.0 24.0
// slider speed 0.0 10.0 3.0
// slider react 0.0 3.0 1.0
float4 effect(float2 uv, Inputs in) {
float2 d = float2(in.p.amount * sin(uv.y * in.p.waves + in.time * in.p.speed), 0.0);
d += in.flow.sample(in.smp, uv).xy * in.p.react;
return float4(in.image.sample(in.smp, uv + d).r,
in.image.sample(in.smp, uv).g,
in.image.sample(in.smp, uv - d).b, 1.0);
}