// ── Before / After reveal ────────────────────────────────────────────────
// The same photo drawn twice, perfectly superposed: the raw frame, and the
// same frame run through an SkSL portrait filter. Both images use the SAME full-size layout,
// so they line up by construction — dragging the handle changes only the top
// image's CLIP rect, never any geometry. Nothing re-measures while you drag.
// The AFTER look: the portrait grade from the SKSL preset — 200mm perspective
// compression, shallow depth of field, beauty grading and film grain, all
// scaled by uIntensity (0 = untouched frame, 1 = full filter).
const string portraitLook = """
uniform float4 iMouse; // Mouse drag pos=.xy Click pos=.zw (pixels)
uniform float iTime; // Shader playback time (s)
uniform float2 iResolution; // Viewport resolution (pixels)
uniform float2 iImageResolution; // iImage1 resolution (pixels)
uniform shader iImage1; // Texture
uniform float2 iOffset; // Top-left corner of DrawingRect
uniform float2 iOrigin; // Mouse drag started here
uniform float uIntensity; // 0 = original image, 1 = full filter
float2 telephotoCompression(float2 uv, float amount) {
float2 centered = uv - 0.5;
float distance = length(centered);
// Compress TOWARDS center (pulls outer pixels inward - makes noses smaller!)
// amount 0 -> compressionStrength 0 -> identity mapping (uv unchanged).
float compressionStrength = 0.05 * amount;
float compressionFactor = 1.0 + distance * compressionStrength;
float2 compressed = centered / compressionFactor;
// Calculate zoom needed to fill frame after compression
// Maximum compression happens at corners (distance ~0.707)
float maxDistance = 0.707; // sqrt(0.5^2 + 0.5^2)
float maxCompressionFactor = 1.0 + maxDistance * compressionStrength;
// Calculate zoom needed to fill frame after compression (cut in half)
float zoomToFill = 1.0 + (maxCompressionFactor - 1.0) * 0.5; // Half way between no zoom and full zoom
// Apply gentle zoom IN to partially fill black borders
compressed /= zoomToFill; // DIVIDE to zoom IN (magnify center area)
return 0.5 + compressed;
}
half3 shallowDepthOfField(float2 coord, float2 uv, float amount) {
// amount 0 -> blurRadius 0 -> plain sample (early-out below).
float blurStrength = 0.65 * amount;
float2 focusCenter = float2(0.5, 0.3);
float distanceFromFocus = length(uv - focusCenter);
float blurRadius = smoothstep(0.15, 0.45, distanceFromFocus) * blurStrength;
if (blurRadius < 0.5) {
return iImage1.eval(coord).rgb;
}
half3 blurred = half3(0.0);
float totalWeight = 0.0;
for (int i = -2; i <= 2; i++) {
for (int j = -2; j <= 2; j++) {
float2 offset = float2(float(i), float(j)) * blurRadius;
float weight = exp(-dot(offset, offset) * 0.1);
blurred += iImage1.eval(coord + offset).rgb * weight;
totalWeight += weight;
}
}
return blurred / totalWeight;
}
float subtleFilmGrain(float2 coord) {
float2 grainCoord = coord * 0.8;
float noise1 = fract(sin(dot(grainCoord, float2(127.1, 311.7))) * 43758.5453);
float noise2 = fract(sin(dot(grainCoord * 1.3, float2(269.5, 183.3))) * 17951.3421);
return (noise1 + noise2 - 1.0) * 0.015; // subtle
}
half3 portraitBeautyGrading(half3 color) {
// Gentle contrast enhancement
half3 enhanced = pow(color, half3(0.9, 0.9, 0.9));
enhanced = (enhanced - 0.5) * 1.15 + 0.5;
// Skin lift, hue-neutral: the preset raised r and g and left b behind,
// which is exactly a yellow cast. Raise all three by the same gain and
// the skin still glows without the tint.
enhanced *= 1.02;
// Gentle saturation boost — 1.2 pushed the yellow further, 1.08 keeps
// the colour alive without amplifying the warm channels.
float luminance = dot(enhanced, half3(0.299, 0.587, 0.114));
enhanced = mix(half3(luminance), enhanced, 1.08);
// Enhanced whites and soft highlight glow
float brightness = (enhanced.r + enhanced.g + enhanced.b) / 3.0;
float highlight = smoothstep(0.6, 1.0, brightness) * 0.08;
enhanced += half3(highlight * 0.8, highlight * 0.9, highlight);
// White enhancement - boost bright areas
float whiteness = smoothstep(0.7, 0.95, brightness);
enhanced = mix(enhanced, enhanced * 1.1, whiteness * 0.3);
// Lift shadows slightly for softer look
enhanced = max(enhanced + 0.02, 0.0) * 0.98 + 0.01;
//reduce reds saturation
enhanced = mix(enhanced, half3(dot(enhanced, half3(0.299,0.587,0.114))), clamp(enhanced.r - max(enhanced.g, enhanced.b), 0.0, 1.0) * 0.15);
// reduce highlights
enhanced -= smoothstep(0.1, 1.75, dot(enhanced, half3(0.299, 0.587, 0.114))) * 0.25;
// Final de-yellow: yellow is red+green with blue missing, so nudge blue
// back up wherever it lags both of them. Skin keeps its warmth, the cast
// goes. Raise 0.35 for cooler, lower for warmer.
float yellowness = clamp(min(enhanced.r, enhanced.g) - enhanced.b, 0.0, 1.0);
enhanced.b += yellowness * 0.1;
return clamp(enhanced, 0.0, 1.0);
}
half4 main(float2 fragCoord)
{
float2 renderingScale = iImageResolution.xy / iResolution.xy;
float2 inputCoord = (fragCoord - iOffset) * renderingScale;
float2 uv = inputCoord / iImageResolution.xy;
// ===============================================
// INJECTED LENS PROCESSING (APPLIED FIRST)
// ===============================================
// Apply 200mm perspective compression (scaled by intensity).
float2 compressedUV = telephotoCompression(uv, uIntensity);
float2 compressedCoord = compressedUV * iImageResolution.xy;
// Get color with shallow depth of field (scaled by intensity).
half3 baseColor = shallowDepthOfField(compressedCoord, compressedUV, uIntensity);
// ===============================================
// PORTRAIT BEAUTY PROCESSING
// ===============================================
// Color grade lerps back to ungraded base at intensity 0.
half3 enhancedColor = mix(baseColor, portraitBeautyGrading(baseColor), uIntensity);
// Film grain fades out at intensity 0.
float grain = subtleFilmGrain(compressedCoord) * uIntensity;
enhancedColor += half3(grain);
enhancedColor = clamp(enhancedColor, 0.0, 1.0);
return half4(enhancedColor, 1.0);
}
""";
const double Handle = 44; // width of the draggable strip
const double Edge = 30; // how close to the border the split may travel
SkiaImage after = null; // the filtered copy, clipped to the right of the split
SkiaLayout divider = null; // the strip you drag
double splitX = 100; // split position in points, from the photo's left
double wide = 0; // photo width in points
// Moves the divider and repaints the clipped image. That is the whole
// interaction: no layout pass, no cache rebuild, just a new clip rect.
void SetSplit(double x)
{
if (wide <= 0)
return;
splitX = Math.Clamp(x, Edge, wide - Edge);
divider.TranslationX = splitX - Handle / 2;
after.Repaint();
}
// small pill caption over a corner of the photo
SkiaControl Pill(string text, bool right, string back, string ink) => new SkiaShape
{
UseCache = SkiaCacheType.Image,
Type = ShapeType.Rectangle,
CornerRadius = 13,
BackgroundColor = Color.Parse(back),
Padding = new Thickness(11, 6),
ZIndex = 3,
HorizontalOptions = right ? LayoutOptions.End : LayoutOptions.Start,
VerticalOptions = LayoutOptions.Start,
Margin = right ? new Thickness(0, 14, 14, 0) : new Thickness(14, 14, 0, 0),
Children = new List<SkiaControl>
{
new SkiaLabel(text)
{
FontSize = 11,
CharacterSpacing = 3,
TextColor = Color.Parse(ink),
}
}
};
return new SkiaLayer
{
BackgroundColor = Color.Parse("#0B0B0D"),
WidthRequest = 600, //limit max
HorizontalOptions = LayoutOptions.Center,
Children = new List<SkiaControl>
{
new SkiaStack
{
Spacing = 14,
Padding = 18,
VerticalOptions = LayoutOptions.Center,
Children = new List<SkiaControl>
{
new SkiaLabel("Before / After")
{
FontSize = 21,
TextColor = Colors.White,
HorizontalOptions = LayoutOptions.Center,
},
// the photo stage: rounded and clipped, so the images and the
// divider can never paint outside the frame
new SkiaShape
{
Type = ShapeType.Rectangle,
CornerRadius = 18,
IsClippedToBounds = true,
HeightRequest = 340,
HorizontalOptions = LayoutOptions.Fill,
BackgroundColor = Color.Parse("#15161A"),
Children = new List<SkiaControl>
{
new SkiaLayer
{
HorizontalOptions = LayoutOptions.Fill,
VerticalOptions = LayoutOptions.Fill,
Children = new List<SkiaControl>
{
// BEFORE — the untouched frame
new SkiaImage
{
Source = "cam.jpg",
Aspect = TransformAspect.AspectCover,
HorizontalOptions = LayoutOptions.Fill,
VerticalOptions = LayoutOptions.Fill,
// no cache: drawing a bitmap IS a blit, caching it
// would only copy the same pixels into a second surface
UseCache = SkiaCacheType.None,
},
// AFTER — same source, same layout, plus the SkSL grade.
// Only the clip differs, which is what makes the seam exact.
new SkiaImage
{
Source = "cam.jpg",
Aspect = TransformAspect.AspectCover,
HorizontalOptions = LayoutOptions.Fill,
VerticalOptions = LayoutOptions.Fill,
// Cached, unlike BEFORE: a post-render shader needs the
// control rasterised into a texture to sample. The photo
// never changes, so it runs ONCE and the drag only
// re-clips that surface.
UseCache = SkiaCacheType.Image,
LoadSourceOnFirstDraw = false,
VisualEffects =
{
new SkiaShaderEffect
{
TileMode = SKShaderTileMode.Mirror,
ShaderCode = portraitLook,
}
.SetUniform("uIntensity", 1f)
// SkSL compile errors land here instead of being swallowed
.OnShaderError((me, error) => Console.WriteLine($"[SkSL] {error}")),
},
// reveal everything to the RIGHT of the split
Clipping = (path, dest) =>
{
path.Reset();
var left = dest.Left + (float)(splitX * after.RenderingScale);
path.AddRect(new SKRect(left, dest.Top, dest.Right, dest.Bottom));
},
}.Assign(out after),
Pill("BEFORE", false, "#B3000000", "#C8CDD6"),
Pill("AFTER", true, "#E6000000", "#FFFFFF"),
// the draggable strip: a hairline plus a round grip.
// It is wider than the line so there is something to grab.
new SkiaLayout
{
ZIndex = 5,
WidthRequest = Handle,
HorizontalOptions = LayoutOptions.Start,
VerticalOptions = LayoutOptions.Fill,
Children = new List<SkiaControl>
{
new SkiaShape
{
Type = ShapeType.Rectangle,
BackgroundColor = Colors.White,
WidthRequest = 2,
HorizontalOptions = LayoutOptions.Center,
VerticalOptions = LayoutOptions.Fill,
},
new SkiaShape
{
Type = ShapeType.Circle,
BackgroundColor = Colors.White,
WidthRequest = 38,
LockRatio = 1,
HorizontalOptions = LayoutOptions.Center,
VerticalOptions = LayoutOptions.Center,
Children = new List<SkiaControl>
{
// arrows as SVG: the bundled fonts have no ⇄ glyph
new SkiaSvg
{
SvgString = "<svg viewBox=\"0 0 24 24\"><path d=\"M7 9 L3 12 L7 15 M3 12 H13 M17 5 L21 8 L17 11 M21 8 H11\" fill=\"none\" stroke=\"#000\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>",
WidthRequest = 19,
LockRatio = 1,
HorizontalOptions = LayoutOptions.Center,
VerticalOptions = LayoutOptions.Center,
}
}
},
}
}
.Assign(out divider)
.WithGestures((me, args, apply) =>
{
// pan moves the split by the finger delta; Down is
// claimed so the drag keeps arriving here
if (args.Type == TouchActionResult.Panning)
{
SetSplit(splitX + args.Event.Distance.Delta.X / me.RenderingScale);
return me;
}
if (args.Type == TouchActionResult.Down)
return me;
return null;
}),
}
}
// the photo width is only known after layout, and it changes
// with the canvas, so the split is placed (and kept) from here
.ObserveSelf((me, prop) =>
{
if (prop != "Width" || me.Width <= 0)
return;
wide = me.Width;
SetSplit(splitX < 0 ? wide / 2 : splitX);
}),
}
},
new SkiaLabel("Drag the handle to compare")
{
FontSize = 13,
TextColor = Color.Parse("#8A8F99"),
HorizontalOptions = LayoutOptions.Center,
},
}
}
}
};