home-assistant.io/source/javascripts/connect/zwa-2/render-animations.js

333 lines
14 KiB
JavaScript

/**
* Using FFMPEG, we can convert 3D rendered video frames into a sequence of images.
* ffmpeg -c:v libvpx-vp9 -i {file.webm} -vf 'scale=1920:1080' -lossless 1 -c:v libwebp -y {output_dir}/frame-%03d.webp
*
* Alternatively, use the script `process.sh` to automate the conversion.
* e.g. `/workspaces/home-assistant.io/source/connect/zwa-2/source-video/process.sh ./hero.webm ../video-frames/hero`
*/
export class ZWA2RenderAnimation {
frameCount = null;
images = []; // sparse array: only buffered frames get Image objects
meta = { frame: 0 }; // current frame metadata
canvas = null;
context = null;
filename = null;
sections = [];
activeSection = 0;
bufferBackward = 50; // frames to keep behind current
bufferForward = 50; // frames to keep ahead of current
loadingFrames = new Set(); // track frames currently loading
frameGap = 5; // Controls staggered frame loading order (1 = sequential). Change after instantiation if desired.
smoothingFactor = 0.18; // LERP factor for scroll smoothing (0..1)
targetFrame = 0; // desired frame from scroll
_smoothingRaf = null;
animatingScroll = false;
currentFrame(index) {
return `/connect/zwa-2/video-frames/${this.filename}/${(Math.floor(index + 1)).toString().padStart(3, '0')}.webp`;
}
/**
* Ensure a single frame is loaded (idempotent / deduped).
*/
ensureFrameLoaded(index) {
if (index < 0 || index >= this.frameCount) return;
if (this.images[index] && this.images[index].complete) return;
if (this.loadingFrames.has(index)) return;
this.loadingFrames.add(index);
const img = new Image();
img.fetchPriority = 'high'; // prioritize loading for smooth scroll animation
img.src = this.currentFrame(index);
img._frameIndex = index;
img.onload = () => {
this.loadingFrames.delete(index);
// Re-render if this is the current frame
if (this.meta.frame === index) {
this.render();
}
};
this.images[index] = img;
}
/**
* Ensure buffer of frames around current frame are loaded.
* Optionally skew forward (e.g., during autoplay) by passing forwardExtra.
*/
ensureBufferLoaded(current, forwardExtra = 0) {
const start = Math.max(0, current - this.bufferBackward);
const end = Math.min(this.frameCount - 1, current + this.bufferForward + forwardExtra);
const needsLoad = [];
for (let i = start; i <= end; i++) {
if (!(this.images[i] && this.images[i].complete) && !this.loadingFrames.has(i)) {
needsLoad.push(i);
}
}
if (!needsLoad.length) return;
if (this.frameGap > 1) {
const ordered = [];
for (let offset = 0; offset < this.frameGap; offset++) {
for (let f = start + offset; f <= end; f += this.frameGap) {
if (needsLoad.includes(f)) ordered.push(f);
}
}
ordered.forEach(f => this.ensureFrameLoaded(f));
} else {
needsLoad.forEach(f => this.ensureFrameLoaded(f));
}
}
/**
* Initial image loading. Only load initial frame and its buffer.
* If autoplayRange provided and current frame inside, we skew forward prefetch.
*/
async loadImages(initialFrame, autoplayRange, onReady) {
this.ensureFrameLoaded(initialFrame);
// If we are going to autoplay, bias forward prefetch (no await needed)
const forwardExtra = (autoplayRange && initialFrame >= autoplayRange.start && initialFrame <= autoplayRange.end) ? 20 : 0;
this.ensureBufferLoaded(initialFrame, forwardExtra);
if (onReady) onReady();
this.render();
}
render() {
if (!this.context) return;
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
const frameIndex = Math.max(0, Math.min(this.frameCount - 1, Math.round(this.meta.frame)));
let img = this.images[frameIndex];
if (!img || !img.complete) {
let closestImg = null;
for (let offset = 1; offset < this.frameCount; offset++) {
const prevIndex = frameIndex - offset;
const nextIndex = frameIndex + offset;
const prevImg = prevIndex >= 0 ? this.images[prevIndex] : null;
const nextImg = nextIndex < this.frameCount ? this.images[nextIndex] : null;
const prevReady = prevImg && prevImg.complete;
const nextReady = nextImg && nextImg.complete;
if (prevReady || nextReady) {
closestImg = nextReady ? nextImg : prevImg;
break;
}
}
if (closestImg) img = closestImg; else img = this.images.find(im => im && im.complete) || null;
}
if (!img || !img.complete) return;
const aspectRatio = img.width / img.height;
const canvasAspectRatio = this.canvas.width / this.canvas.height;
let drawWidth, drawHeight;
if (canvasAspectRatio > aspectRatio) {
drawWidth = this.canvas.width;
drawHeight = this.canvas.width / aspectRatio;
} else {
drawHeight = this.canvas.height;
drawWidth = this.canvas.height * aspectRatio;
}
const x = (this.canvas.width - drawWidth) / 2;
const y = (this.canvas.height - drawHeight) / 2;
this.context.drawImage(img, x, y, drawWidth, drawHeight);
}
setupSectionAnimations(initialFrame) {
const autoplaySection = this.sections.find(s => s.autoplay);
let autoplayActive = false;
let autoplayEnd = autoplaySection?.autoplay?.end ?? null;
let autoplayDuration = autoplaySection?.autoplay?.duration ?? 2000; // ms
let autoplayRequest = null;
let autoplayStartTime = null;
const autoplayStart = autoplaySection?.autoplay?.start;
const startAutoplay = () => {
if (!autoplaySection) return;
autoplayActive = true;
autoplayStartTime = performance.now();
this.meta.frame = autoplayStart;
this.ensureBufferLoaded(this.meta.frame, 30); // prefetch ahead for smoothness
this.render();
autoplayRequest = requestAnimationFrame(autoplayStep);
};
const autoplayStep = (now) => {
if (!autoplayActive) return;
const elapsed = now - autoplayStartTime;
const progress = Math.min(1, elapsed / autoplayDuration);
const frame = Math.round(autoplayStart + progress * (autoplayEnd - autoplayStart));
this.meta.frame = frame;
this.ensureBufferLoaded(frame, 30);
const status = this.computeBufferStatus(frame);
this.render();
if (progress < 1) {
autoplayRequest = requestAnimationFrame(autoplayStep);
} else {
autoplayActive = false;
}
};
const interruptAutoplay = () => {
if (autoplayActive) {
autoplayActive = false;
if (autoplayRequest) cancelAnimationFrame(autoplayRequest);
}
};
const wrapper = this.sections.length > 0 ? document.querySelector(this.sections[0].selector)?.closest('.animation-wrapper') : null;
const triggerElem = wrapper || document.body;
const getSectionData = () => this.sections.map(section => {
const el = document.querySelector(section.selector);
const top = el?.offsetTop || 0;
const height = el?.offsetHeight || 0;
return { ...section, el, top, height };
});
let sectionData = getSectionData();
const computeFrameForScroll = (scrollY) => {
let prev = sectionData[0];
let next = sectionData[sectionData.length - 1];
for (let i = 0; i < sectionData.length - 1; i++) {
if (scrollY >= sectionData[i].top && scrollY < sectionData[i + 1].top) {
prev = sectionData[i];
next = sectionData[i + 1];
break;
}
}
if (scrollY < sectionData[0].top) prev = next = sectionData[0];
if (scrollY >= sectionData[sectionData.length - 1].top) prev = next = sectionData[sectionData.length - 1];
let progress = 0;
if (prev !== next) {
progress = (scrollY - prev.top) / (next.top - prev.top);
progress = Math.min(1, Math.max(0, progress));
}
return Math.floor(Math.round(prev.start + progress * (prev.end - prev.start)));
};
const handleScrollUpdate = () => {
sectionData = getSectionData();
interruptAutoplay();
const baseOffset = (triggerElem === document.body ? 0 : triggerElem.getBoundingClientRect().top + window.scrollY);
const scrollY = window.scrollY - baseOffset;
const targetFrame = computeFrameForScroll(scrollY);
if (targetFrame !== this.targetFrame) {
this.targetFrame = targetFrame;
this.ensureBufferLoaded(targetFrame);
if (!this.animatingScroll) this.startScrollSmoothing();
}
};
if (window.ScrollTrigger && window.ScrollTrigger.create) {
window.ScrollTrigger.create({
trigger: triggerElem,
start: "top top",
end: "bottom bottom",
onUpdate: handleScrollUpdate,
});
} else {
window.addEventListener('scroll', handleScrollUpdate, { passive: true });
}
this.meta.frame = initialFrame;
this.targetFrame = initialFrame;
this.ensureBufferLoaded(initialFrame);
const initStatus = this.computeBufferStatus(initialFrame);
this.render();
window.addEventListener('resize', () => {
sectionData = getSectionData();
if (window.ScrollTrigger && window.ScrollTrigger.refresh) {
window.ScrollTrigger.refresh();
}
this.ensureBufferLoaded(this.meta.frame);
});
if (autoplaySection && window.scrollY < 5) {
startAutoplay();
}
if (window.ScrollTrigger && window.ScrollTrigger.refresh) {
window.ScrollTrigger.refresh();
}
// Ensure initial scroll-derived target is set if user did not start at top.
handleScrollUpdate();
}
computeBufferStatus(frame) {
const maxAhead = Math.min(this.frameCount - 1, frame + this.bufferForward);
const maxBehind = Math.max(0, frame - this.bufferBackward);
let aheadLoaded = 0;
for (let i = frame + 1; i <= maxAhead; i++) {
const img = this.images[i];
if (img && img.complete) aheadLoaded++; else break;
}
let behindLoaded = 0;
for (let i = frame - 1; i >= maxBehind; i--) {
const img = this.images[i];
if (img && img.complete) behindLoaded++; else break;
}
return {
aheadLoaded,
behindLoaded,
aheadRemaining: this.bufferForward - aheadLoaded,
behindRemaining: this.bufferBackward - behindLoaded
};
}
/**
* @param {string} filename - The base filename for frames
* @param {string} elem - Canvas selector
* @param {Array} sections - Array of { selector, start, end } objects
* @param {number} frames - Total frame count
*/
constructor(filename, elem, sections, frames) {
this.filename = filename;
this.frameCount = frames;
this.sections = sections;
this.canvas = document.querySelector(elem);
this.context = this.canvas.getContext("2d");
this.canvas.width = 1920;
this.canvas.height = 1080;
// Determine initial frame (first section start or 0)
this._initialFrame = (sections[0] && typeof sections[0].start === 'number') ? sections[0].start : 0;
this.meta.frame = this._initialFrame;
this.targetFrame = this._initialFrame;
// Load only the very first frame (no buffers / listeners yet)
this.ensureFrameLoaded(this._initialFrame);
this.render();
this._started = false; // idempotent start flag
}
start() {
document.addEventListener('scroll', this.doStart.bind(this), { once: true, passive: true });
// all the other events, touchstart, mousemove, etc.
document.addEventListener('touchmove', this.doStart.bind(this), { once: true, passive: true });
document.addEventListener('touchstart', this.doStart.bind(this), { once: true, passive: true });
document.addEventListener('mousemove', this.doStart.bind(this), { once: true, passive: true });
}
doStart() {
if (this._started) return; // idempotent
this._started = true;
const autoplaySection = this.sections.find(s => s.autoplay);
const autoplayRange = autoplaySection ? { start: autoplaySection.autoplay.start, end: autoplaySection.autoplay.end } : null;
// Load remaining buffer & then set up scroll/section animations
this.loadImages(this._initialFrame, autoplayRange, () => {
this.setupSectionAnimations(this._initialFrame);
});
}
startScrollSmoothing() {
this.animatingScroll = true;
const step = () => {
const diff = this.targetFrame - this.meta.frame;
if (Math.abs(diff) < 0.02) {
this.meta.frame = this.targetFrame;
this.animatingScroll = false;
this.render();
return;
}
this.meta.frame += diff * this.smoothingFactor;
this.ensureBufferLoaded(Math.round(this.meta.frame));
this.render();
this._smoothingRaf = requestAnimationFrame(step);
};
step();
}
}