-
Notifications
You must be signed in to change notification settings - Fork 4
image sequence #212
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
image sequence #212
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| import { useMotionValueEvent, useScroll } from "motion/react"; | ||
| import { useRef } from "react"; | ||
| import type { ImageSequenceRef } from "../image-sequence"; | ||
| import { ImageSequence } from "../image-sequence"; | ||
|
|
||
| export const meta = { layout: "fullscreen", mode: "iframe" }; | ||
|
|
||
| const frames = [ | ||
| "/src/assets/sequence/001-frame.webp", | ||
| "/src/assets/sequence/002-frame.webp", | ||
| "/src/assets/sequence/003-frame.webp", | ||
| "/src/assets/sequence/004-frame.webp", | ||
| "/src/assets/sequence/005-frame.webp", | ||
| "/src/assets/sequence/006-frame.webp", | ||
| "/src/assets/sequence/007-frame.webp", | ||
| "/src/assets/sequence/008-frame.webp", | ||
| "/src/assets/sequence/009-frame.webp", | ||
| "/src/assets/sequence/010-frame.webp", | ||
| "/src/assets/sequence/011-frame.webp", | ||
| "/src/assets/sequence/012-frame.webp", | ||
| "/src/assets/sequence/013-frame.webp", | ||
| "/src/assets/sequence/014-frame.webp", | ||
| "/src/assets/sequence/015-frame.webp", | ||
| "/src/assets/sequence/016-frame.webp", | ||
| "/src/assets/sequence/017-frame.webp", | ||
| "/src/assets/sequence/018-frame.webp", | ||
| "/src/assets/sequence/019-frame.webp", | ||
| "/src/assets/sequence/020-frame.webp", | ||
| "/src/assets/sequence/021-frame.webp", | ||
| "/src/assets/sequence/022-frame.webp", | ||
| "/src/assets/sequence/023-frame.webp", | ||
| "/src/assets/sequence/024-frame.webp", | ||
| "/src/assets/sequence/025-frame.webp", | ||
| "/src/assets/sequence/026-frame.webp", | ||
| "/src/assets/sequence/027-frame.webp", | ||
| "/src/assets/sequence/028-frame.webp", | ||
| "/src/assets/sequence/029-frame.webp", | ||
| "/src/assets/sequence/030-frame.webp", | ||
| "/src/assets/sequence/031-frame.webp", | ||
| "/src/assets/sequence/032-frame.webp", | ||
| "/src/assets/sequence/033-frame.webp", | ||
| "/src/assets/sequence/034-frame.webp", | ||
| "/src/assets/sequence/035-frame.webp", | ||
| "/src/assets/sequence/036-frame.webp", | ||
| "/src/assets/sequence/037-frame.webp", | ||
| "/src/assets/sequence/038-frame.webp", | ||
| "/src/assets/sequence/039-frame.webp", | ||
| "/src/assets/sequence/040-frame.webp", | ||
| "/src/assets/sequence/041-frame.webp", | ||
| "/src/assets/sequence/042-frame.webp", | ||
| "/src/assets/sequence/043-frame.webp", | ||
| "/src/assets/sequence/044-frame.webp", | ||
| "/src/assets/sequence/045-frame.webp", | ||
| "/src/assets/sequence/046-frame.webp", | ||
| "/src/assets/sequence/047-frame.webp", | ||
| "/src/assets/sequence/048-frame.webp", | ||
| ]; | ||
|
|
||
| const ImageSequencePreview = () => { | ||
| const containerRef = useRef<HTMLDivElement>(null); | ||
| const sequenceRef = useRef<ImageSequenceRef>(null); | ||
| const { scrollYProgress } = useScroll({ | ||
| target: containerRef, | ||
| offset: ["start start", "end end"], | ||
| }); | ||
|
|
||
| useMotionValueEvent(scrollYProgress, "change", (progress) => { | ||
| sequenceRef.current?.scrub(progress); | ||
| }); | ||
|
|
||
| return ( | ||
| <div ref={containerRef}> | ||
| <div className="sticky top-0 h-screen w-full overflow-hidden"> | ||
| <ImageSequence frames={frames} ref={sequenceRef} /> | ||
| </div> | ||
| <div className="h-[400vh]" /> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default ImageSequencePreview; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| import type { ComponentPropsWithoutRef, Ref } from "react"; | ||
| import { useCallback, useEffect, useImperativeHandle, useRef } from "react"; | ||
| import { clamp } from "@/foundations/utils/math/clamp"; | ||
|
|
||
| interface ImageSequenceRef { | ||
| scrub: (progress: number) => void; | ||
| } | ||
|
|
||
| interface ImageSequenceProps extends ComponentPropsWithoutRef<"canvas"> { | ||
| frames: string[]; | ||
| ref?: Ref<ImageSequenceRef>; | ||
| } | ||
|
|
||
| const ImageSequence = ({ frames, ref, ...props }: ImageSequenceProps) => { | ||
| const canvasRef = useRef<HTMLCanvasElement>(null); | ||
| const framesRef = useRef<HTMLImageElement[]>([]); | ||
|
|
||
| if (!frames.length) throw new Error("[image-sequence]: frames must not be empty"); | ||
|
|
||
| const scrub = useCallback((progress: number) => { | ||
| const canvas = canvasRef.current; | ||
| const context = canvas?.getContext("2d"); | ||
| if (!context || !canvas) return; | ||
|
|
||
| const loaded = framesRef.current; | ||
| const index = clamp(0, Math.floor(progress * loaded.length), loaded.length - 1); | ||
| const image = loaded[index]; | ||
|
|
||
| if (image) { | ||
| context.drawImage(image, 0, 0, canvas.width, canvas.height); | ||
| } | ||
| }, []); | ||
|
|
||
| useImperativeHandle(ref, () => ({ scrub }), [scrub]); | ||
|
|
||
| useEffect(() => { | ||
| framesRef.current = []; | ||
|
|
||
| let abort: AbortController | undefined; | ||
|
|
||
| const start = () => { | ||
| abort = progressivelyLoadFrames(frames, [16, 8, 4, 2, 1], (frame, index) => { | ||
| framesRef.current[index] = frame; | ||
| if (index === 0) { | ||
| const canvas = canvasRef.current; | ||
| if (canvas) { | ||
| canvas.width = frame.naturalWidth; | ||
| canvas.height = frame.naturalHeight; | ||
| } | ||
| scrub(0); | ||
| } | ||
| }); | ||
| }; | ||
|
|
||
| if (document.readyState === "complete") { | ||
| start(); | ||
| } else { | ||
| window.addEventListener("load", start, { once: true }); | ||
| } | ||
|
|
||
| return () => { | ||
| window.removeEventListener("load", start); | ||
| abort?.abort(); | ||
| }; | ||
| }, [frames, scrub]); | ||
|
|
||
| return ( | ||
| <canvas | ||
| ref={canvasRef} | ||
| {...props} | ||
| style={{ width: "100%", height: "100%", objectFit: "cover", ...props.style }} | ||
| /> | ||
| ); | ||
| }; | ||
|
|
||
| // load frames progressively in steps | ||
| // examples: steps = [4,2,1] first renders every 4th frame, then every 2nd frame, then every 1st frame | ||
| function progressivelyLoadFrames( | ||
| images: string[], | ||
| steps: [...number[], 1], | ||
| onLoad: (image: HTMLImageElement, index: number) => void, | ||
| ): AbortController { | ||
| const abortController = new AbortController(); | ||
| const cache = new Map<string, HTMLImageElement>(); | ||
|
|
||
| function loadImage(src: string): Promise<HTMLImageElement> { | ||
| const cached = cache.get(src); | ||
| if (cached) return Promise.resolve(cached); | ||
|
|
||
| return new Promise((resolve, reject) => { | ||
| const img = new Image(); | ||
| img.onload = () => { | ||
| cache.set(src, img); | ||
| resolve(img); | ||
| }; | ||
| img.onerror = () => reject(new Error(`Failed to load image ${src}`)); | ||
| img.src = src; | ||
| }); | ||
| } | ||
|
|
||
| async function runStep(step: number, rendered: number[]) { | ||
| const batch: Promise<HTMLImageElement>[] = []; | ||
|
|
||
| for (let i = 0; i < images.length; i += step) { | ||
| // wait for previous batch to finish, before starting a new one | ||
| if (i !== 0) await Promise.all(batch); | ||
|
|
||
| const src = images[i]; | ||
| if (!src || rendered.includes(i) || abortController.signal.aborted) continue; | ||
|
|
||
| const imagePromise = loadImage(src); | ||
| batch.push(imagePromise); | ||
| void imagePromise.then((img) => { | ||
| onLoad(img, i); | ||
| rendered.push(i); | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| void (async () => { | ||
| const rendered: number[] = []; | ||
| for (const step of steps) { | ||
| if (abortController.signal.aborted) break; | ||
| await runStep(step, rendered); | ||
| } | ||
| })(); | ||
|
|
||
| return abortController; | ||
| } | ||
|
|
||
| export type { ImageSequenceRef }; | ||
| export { ImageSequence }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| --- | ||
| title: Image Sequence | ||
| description: A canvas-based component that renders image sequences driven by an external progress value. | ||
| preview: image-sequence | ||
| files: | ||
| - src/foundations/components/image-sequence/image-sequence.tsx | ||
|
|
||
| dependencies: | ||
| - name: clamp | ||
| href: /utils/math | ||
| folder: components | ||
| --- | ||
|
|
||
| ## Features | ||
|
|
||
| - **Canvas Rendering**: Draws frames onto a `<canvas>` element for efficient frame-by-frame playback | ||
| - **Progressive Loading**: Loads frames in passes of decreasing density (`[16, 8, 4, 2, 1]`) so a rough preview appears quickly before full resolution fills in | ||
| - **Deferred Start**: Waits for the window `load` event before fetching frames, avoiding competition with critical page assets | ||
| - **External Control**: Exposes a `scrub(progress)` method via ref so the caller decides what drives the animation — scroll position, pointer, timeline, or anything else | ||
| - **Cleanup**: Aborts in-flight frame loading on unmount or when `frames` changes | ||
|
|
||
| ## Usage | ||
|
|
||
| Frames must be defined at module level — outside the component — so the array reference stays stable across renders and does not restart frame loading on every render. | ||
|
|
||
| ```tsx | ||
| const frames = ["/frame-0001.webp", "/frame-0002.webp", "/frame-0003.webp"]; | ||
|
|
||
| const MyComponent = () => { | ||
| const containerRef = useRef<HTMLDivElement>(null); | ||
| const sequenceRef = useRef<ImageSequenceRef>(null); | ||
| const { scrollYProgress } = useScroll({ | ||
| target: containerRef, | ||
| offset: ["start start", "end end"], | ||
| }); | ||
|
|
||
| useMotionValueEvent(scrollYProgress, "change", (progress) => { | ||
| sequenceRef.current?.scrub(progress); | ||
| }); | ||
|
|
||
| return ( | ||
| <div ref={containerRef}> | ||
| <div className="sticky top-0 h-screen w-full overflow-hidden"> | ||
| <ImageSequence frames={frames} ref={sequenceRef} /> | ||
| </div> | ||
| <div className="h-[400vh]" /> | ||
| </div> | ||
| ); | ||
| }; | ||
| ``` | ||
|
Comment on lines
+26
to
+50
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should simplify this example. First, the should not use vite's glob import and instead pass in a "regular" array of images sources. Secondly, I think it'd be better to use motion's useScroll hook to drive the scroll progress. |
||
|
|
||
| ## API Reference | ||
|
|
||
| ### ImageSequence | ||
|
|
||
| <PropsTable | ||
| definition={{ | ||
| frames: { | ||
| type: "string[]", | ||
| description: | ||
| "The sequence frames to render. Must be defined at module level to keep the reference stable across renders.", | ||
| required: true, | ||
| }, | ||
| }} | ||
| /> | ||
|
|
||
| Accepts all standard `<canvas>` props. Default styles are `width: 100%`, `height: 100%`, `object-fit: cover` — override via the `style` prop. | ||
|
|
||
| ### ImageSequenceRef | ||
|
|
||
| ```ts | ||
| interface ImageSequenceRef { | ||
| scrub: (progress: number) => void; | ||
| } | ||
| ``` | ||
|
|
||
| `scrub` takes a progress value between `0` and `1` and draws the corresponding frame onto the canvas. | ||
|
|
||
| ## Examples | ||
|
|
||
| ### Scroll-driven | ||
|
|
||
| <Preview slug="image-sequence" /> | ||
|
|
||
| ## Frame Sorting | ||
|
|
||
| Frames are sorted numerically by the leading number in each filename before loading (e.g. `001-frame.webp`, `002-frame.webp`). Name your source files accordingly. | ||
|
|
||
| ## Limitations | ||
|
|
||
| - `import.meta.glob` patterns must be static string literals, so the glob cannot be passed as a prop. Define it in the consuming file and pass the resulting array as `frames`. | ||
|
Comment on lines
+89
to
+91
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I already alluded this in previous comments, but these limitations should not exist. Both are coupling the component to the build framework, which is something no foundations component should ever do. |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not a big fan of this. Binding methods to refs is somewhat anti-pattern in react. We should brainstorm a better way of doing this, maybe through a context provider.