Adam Fręśko

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.

the gaze machineAdam Fręśkopose: center (dead zone)
The same machine as the header, in a box. Move the pointer around it, and wait a few seconds if you want to catch a blink.

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.

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.

blink timing
Adam Fręśko, blinking on a timerevery 3.0 s, exactlyAdam Fręśko, blinking like a person3–6 s · 15% doubles
Same face, two clocks. The left one fires every 3.0 s exactly; the right one runs the snippet above. Give them half a minute.

A 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 vs in-betweens
Adam Fręśko, crossfading between two posescrossfade · 80 msAdam Fręśko, turning frame by frame3 in-betweens · 50 ms each
The same turn, both ways of faking it. ×8 slow motion stretches 80 ms to 640; watch the left disc hold two faces at once.

The 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
the shipped tweens, measuredAdam Fręśko, A · centerA · center
head0.00gaze0.00
The three center → right frames that shipped, with their measured positions along the pose vector. The ticks are the targets, 0.25 / 0.50 / 0.75. Note the gaze row.

88 candidates, 48 kept. Thirteen of the sixteen pairs landed inside tolerance:

pairstatust_headcandidates
center:rightok0.23 · 0.48 · 0.845
bottom-right:bottomok0.25 · 0.49 · 0.736
bottom-left:leftbest-effort0.06 · 0.52 · 0.9412
center:topok (manual)0.34 · 0.74 · 1.138

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;
}
boundary hysteresis
Adam Fręśko, pose flickering at a sector boundarynearest sector · swaps: 0Adam Fręśko, pose held steady by hysteresis3° to leave · swaps: 0
Both machines get the same pointer: a wander across the right / bottom-right boundary with a tremor on top. The hairline marks the boundary; on the right disc, the two lines are the ±3° band it must clear to swap. Transitions are off so every raw switch shows.

Small 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.