How I made this site's avatar look at you
One photo, nine AI-generated poses, 48 in-between frames, and a blink that does most of the work.
The portrait on fresko.dev follows your cursor, and blinks while it waits. It is nine photos of me, 48 in-between frames and a small state machine. I only ever had one of those photos.
pose: center (dead zone)The idea came from dahbiahmed.com, which does the cursor-following portrait beautifully. The moment I saw it I knew I could build on it.
Nine poses from one photo#
The one usable picture of me went through Codex CLI with an identity-preserving prompt and came back as center. Every other pose is then generated from center, never from the original: the model is doing a small edit on an image it can see, so framing, lighting and shirt stay put. The prompt, per direction:
# gen-frames.sh — the whole trick is the edit clause
keep EVERYTHING identical to the reference — same person, hair, slight
smile, black shirt, identical crop and framing (head centered, eyes at
~42% from the top), same light-gray #e6e6e6 seamless background, same
soft lighting, same scale — and change ONLY this: he turns his head
about 30 degrees and looks $dir, with his eyes clearly directed there
so that at thumbnail size the face unmistakably reads as looking $name;
shoulders stay square to the camera and do not move.The clause that matters is the thumbnail one. At 120 px a realistic glance is invisible; the pose has to be overacted to survive the downscale.
After that it is plumbing. macOS Vision lifts the subject out of the background (30 lines of Swift, the same model as Finder’s “Remove Background”) so one transparent set serves both themes. Soft mask edges keep the gray studio wall mixed into them, which reads as a halo on dark. Compositing is c = a·fg + (1−a)·bg, and I know bg, so I solve back for fg. Then every frame is aligned to center by its alpha mask alone: shoulder line, body centre, shoulder width. The corrections came out under 8 px, which is exactly the size of error your eye reads as a twitch.
The blink is what makes it alive#
Nine more frames with the eyes closed, one per pose, each generated from its own pose so the head does not drift back to camera mid-blink.
The timing is copied from people rather than chosen:
blinkTimer = setTimeout(() => {
blink(); // 120 ms closed
if (Math.random() < 0.15) setTimeout(blink, 350); // occasional double
scheduleBlink();
}, 3000 + Math.random() * 3000);That 15% double-blink is the highest-value line in the file. A face blinking on a metronome is a machine; irregularity costs nothing and turns a photo that moves into something that seems to be waiting for you. It is also the cheapest part of the whole project: nine images and six lines.
every 3.0 s, exactly
3–6 s · 15% doublesA crossfade is not a head turn#
The first version crossfaded between poses over 80 ms. That is a dissolve: for 40 ms there are two translucent faces looking in different directions. Real turns have a middle.
So, three in-between frames for every adjacent pose pair, played at 50 ms a step. 16 pairs, each sequence replayed backwards for the opposite direction: 48 files, 32 transitions.

crossfade · 80 ms
3 in-betweens · 50 ms eachThe model cannot hit 0.25, so measure instead#
Asking for “the frame halfway between these two images” gave me frames about 0.9 of the way there. The model latches onto the last strong thing it saw, and halfway is not a quantity it can feel.
Anchoring the edit fixed the prompt: edit image 1, move the head and eyes a quarter of the way toward image 2 is an action, not a coordinate. The three-quarter frame is never asked for at all: it is a quarter frame anchored at B, moving toward A.
The rest is refusing to trust the prompt. Generate candidates, measure them, keep the ones that landed. Another 60 lines of Swift reads Vision face landmarks (nose tip offset from the eye line, pupil offset from the eye centre), projects each candidate onto the A→B pose vector, and reads progress off the position along it and drift off the distance from it. The selection is two constraints and a ranking:
# tween.py — a triple survives if the head advances
# and the eyes never flick back
if not (ts[0] < ts[1] < ts[2]): continue
if gz[0] > gz[1] + 0.25 or gz[1] > gz[2] + 0.25: continue
score = max(errs) + 0.1 * sum(errs) # rank by score, report the max
A · center88 candidates, 48 kept. Thirteen of the sixteen pairs landed inside tolerance:
| pair | status | t_head | candidates |
|---|---|---|---|
| center:right | ok | 0.23 · 0.48 · 0.84 | 5 |
| bottom-right:bottom | ok | 0.25 · 0.49 · 0.73 | 6 |
| bottom-left:left | best-effort | 0.06 · 0.52 · 0.94 | 12 |
| center:top | ok (manual) | 0.34 · 0.74 · 1.13 | 8 |
transitions/report.json, abridged (16 pairs total).
The two manual rows I picked by eye: looking straight up barely changes the nose-to-eye ratio, so there the metric is measuring noise; you can see it claim the head overshot to 1.13. Both are flagged in the report, since a number you overrode silently is worse than no number.
One rule came out of watching it fail: the eyes are allowed to lead the head. People move their eyes first and rotate after, so a frame where the gaze has arrived and the head is a third of the way there looks more real, not less. The scrubber has it in numbers: gaze at 1.00 while the head is at 0.23.
The face must not vibrate#
Angle to sector is one line of the runtime. What makes it usable is hysteresis: the dead-zone edge moves ±5 px depending on which side you are already on, and leaving a sector costs an extra 3° past its midline. Without it, a pointer resting on a boundary makes the face vibrate between two poses forever.
function dirFor(dx, dy) {
const r = Math.hypot(dx, dy);
const edge = cur === "center" ? DEAD_ZONE + 5 : DEAD_ZONE - 5; // sticky
if (r < edge) return "center";
const norm = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
const next = ORDER[Math.round(norm / 45) % 8];
if (cur !== "center" && cur !== next) {
const mid = ORDER.indexOf(cur) * 45;
// 22.5° would be the fair boundary; staying costs nothing, leaving 3°
if (Math.abs(((norm - mid + 540) % 360) - 180) < 25.5) return cur;
}
return next;
}
nearest sector · swaps: 0
3° to leave · swaps: 0Small print#
Everything is WebP, about 9 KB a frame, 600 KB for the whole 2× set, preloaded on mount. And Next serves public/ with max-age=0, so in production every frame swap fired a revalidation, one round trip per 50 ms frame. Perfect in dev, visibly stuttering on deploy. The frames are immutable by construction; now they say so.
None of this is specific to avatars. When a model has to hit a target it cannot feel, stop prompting harder: anchor an action, over-generate, put a cheap measurement between the model and the repo, keep what lands.
Pipeline, in scripts/avatar/: gen-frames.sh (poses), tween.py (in-betweens + selection), face-pose.swift (measurement), check-transitions.py (QA sheet), build.py (cut, align, crop, export). Runtime: src/components/avatar-engine.ts.