I introduced this bug myself, and I did it with good intentions. The pre launch landing page was a classic WordPress theme built with CSS, HTML5 video, JavaScript and anime.js v4. I generated the hero background video myself, image-to-video with Seedance 1.5 Pro through the Higgsfield CLI, from a single architectural render. The result was an 8 second mp4, 1080p, 21:9, h264 with faststart, muted loop, and only 1.23 MB.
The first version of the hero felt too plain. So in the next pass, the one I called the immersive pass, I added ken burns: the slow CSS scale animation everyone uses to make a still image feel alive. In my head this was cheap and safe.
After that, the hero video felt juddery. Not an obvious frame drop, more a motion that never quite became smooth, as if every frame was held back a little before being released.
Two camera moves on one element
What I had forgotten: the project notes say image-to-video footage already carries its own camera motion, and that movement lives inside the frames themselves. A CSS ken burns means a second camera move stacked on top of the first.
The explanation I work with, and I want to be clear this is a working explanation rather than a measurement, is that the browser has to resample every decoded video frame through a transform whose value keeps changing. A frame coming out of the decoder can never be placed as is; each one passes through a slightly different scale than the frame before, and the combination of two motions that know nothing about each other reads to the eye as stutter. I have no frame time numbers to back this up, so I treat it as a hypothesis that happens to match the fix.
The wrong shape, with values simplified:
@keyframes ken-burns {
from { transform: scale(1); }
to { transform: scale(1.08); }
}
/* stacked on a video that already has its own camera motion */
.lp-visual video {
animation: ken-burns 14s ease-in-out infinite alternate;
}Nothing here is wrong syntactically, and on a still image it does exactly what you expect. The problem is the element it was attached to, not the animation.
The fix: ken burns belongs to still images only
The rule that came out of this bug is simple: never stack ken burns or a CSS scale on top of a video that already has its own camera motion. Ken burns may only live on the still image fallback, which in this project is marked with the class lp-visual--still. If the video fails to load and the still poster is what shows, the subtle drift is still there. If the video plays, it moves with its own motion and nothing else.
Two other changes also went into this landing page. The video got its own compositor layer through translateZ(0) and will-change, so it is painted separately from the elements around it. And the intro text characters, animated by anime.js when the hero comes in, were also layer promoted with will-change: transform, with the promotion released on onComplete so the layer does not live forever.
/* ken burns moves to the still image fallback */
.lp-visual--still img {
animation: ken-burns 14s ease-in-out infinite alternate;
}
/* the video gets its own compositor layer, no scale */
.lp-visual video {
transform: translateZ(0);
will-change: transform;
}const { animate } = window.anime;
const chars = hero.querySelectorAll('.lp-hero__char');
chars.forEach((el) => { el.style.willChange = 'transform'; });
animate(chars, {
translateY: ['105%', '0%'],
onComplete: () => {
// release the promotion as soon as the intro is done
chars.forEach((el) => { el.style.willChange = ''; });
},
});The project notes mark this fix as confirmed. I do not have a before and after frame time chart, so I will not pretend to; what I do have is a rule written down on 9 July 2026 so the same mistake does not come back in the next round.
Never judge smoothness from an occluded window
One trap nearly made me misjudge the fix. I verify the landing page through Playwright, and in an automation window that is occluded or unfocused, requestAnimationFrame can be throttled down to around 1 fps. JavaScript tweens look choppy in the test while being perfectly normal in a focused browser.
So I split the verification rule. Logic, meaning state, order and final values, can be checked from any window. Smoothness cannot. Before concluding there is a motion bug, check rafPerSecond in that window first.
// run inside page.evaluate before judging smoothness
async function rafPerSecond() {
let n = 0;
const t0 = performance.now();
await new Promise((done) => {
const tick = () => {
n += 1;
if (performance.now() - t0 < 1000) requestAnimationFrame(tick);
else done();
};
requestAnimationFrame(tick);
});
return n;
}If the number is close to one, the window is what stutters, not the animation.
That AI generated video was only ever a placeholder. On 26 August the client sent an opening clip and then a 4K master through a file transfer service, and the hero was replaced with a 2560x1440 webm for landscape and a 1440x2560 webm for portrait, with mp4 at the same resolutions as fallback. The rule did not change: footage that already moves does not need help moving.
What I took home
- Ken burns is a tool for still images. On top of a video with its own camera motion it becomes a second camera move colliding with the first.
- A background video deserves its own compositor layer, but that layer is there to separate painting, not as a licence to attach animation on top of it.
will-changeon the intro text goes on before the tween and comes off inonComplete. A layer promotion that is never revoked is a debt the visitor pays.- An occluded automation window can run at around 1 fps. Measure
rafPerSecondbefore trusting your own eyes on a test result.