Skip to content

Commit 56aedd4

Browse files
committed
feat: cache frames in video knob
1 parent e4b36e8 commit 56aedd4

File tree

5 files changed

+122
-54
lines changed

5 files changed

+122
-54
lines changed

src/lib/ImageKnob.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
colors = {}
3737
}: Props = $props();
3838
39+
// TODO rewrite base knob logic
3940
const rotationDegrees = spring(normalize(value, param) * 270 - 135, { stiffness });
4041
4142
function draw() {

src/lib/VideoKnob.svelte

Lines changed: 105 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
<script lang="ts" module>
2+
/**
3+
Removes cache created by <VideoKnob /> component.
4+
*/
5+
export async function cleanVideoKnobCache() {
6+
await caches.delete('svelte-knobs/cache');
7+
}
8+
</script>
9+
110
<script lang="ts">
211
import { normalize } from './params.js';
312
import { onMount } from 'svelte';
@@ -12,6 +21,7 @@
1221
numberOfFrames: number;
1322
width?: number;
1423
height?: number;
24+
cacheFrames?: boolean;
1525
} & SharedKnobProps;
1626
1727
let {
@@ -33,6 +43,7 @@
3343
height = 80,
3444
disabled = false,
3545
draggable = true,
46+
cacheFrames = false,
3647
colors = {}
3748
}: Props = $props();
3849
@@ -63,71 +74,115 @@
6374
ctx?.fillText('Loading...', 0, canvas.height / 2);
6475
});
6576
66-
onMount(() => {
67-
if (!source) return;
68-
69-
frames.splice(0);
70-
71-
const video = document.createElement('video');
72-
video.src = source;
77+
async function loadImage(src: string) {
78+
return new Promise<HTMLImageElement>((resolve, reject) => {
79+
const image = new Image();
80+
image.src = src;
81+
image.onerror = reject;
82+
image.onload = () => resolve(image);
83+
});
84+
}
7385
74-
video.onloadeddata = async () => {
75-
if (video.duration === 0) throw Error('Video is empty');
76-
duration = video.duration;
86+
async function loadFromCache() {
87+
const cache = await caches.open('svelte-knobs/cache');
7788
78-
const fps = duration / numberOfFrames;
79-
const decoderCanvas = document.createElement('canvas');
80-
const dctx = decoderCanvas.getContext('2d');
89+
let i = 0;
90+
let hasReadCache = false;
8191
82-
decoderCanvas.width = width;
83-
decoderCanvas.height = height;
92+
while (i < numberOfFrames) {
93+
const response = await cache.match(`${source}:${i}`);
94+
if (!response) break;
8495
85-
console.time('Decoding video');
96+
const url = URL.createObjectURL(await response.blob());
97+
const image = await loadImage(url);
98+
frames.push(image);
8699
87-
let i = -1;
100+
if (i === 0) {
101+
ctx?.clearRect(0, 0, canvas.width, canvas.height);
102+
ctx?.drawImage(image, 0, 0);
103+
}
88104
89-
async function decodeFrame() {
90-
if (video.currentTime >= video.duration) return;
91-
if (!dctx) throw Error('Failed to create canvas context');
105+
if (i === numberOfFrames - 1) hasReadCache = true;
106+
i++;
107+
}
92108
93-
dctx.clearRect(0, 0, decoderCanvas.width, decoderCanvas.height);
94-
dctx.drawImage(video, 0, 0, decoderCanvas.width, decoderCanvas.height);
109+
return hasReadCache;
110+
}
95111
96-
return new Promise<void>((resolve, reject) => {
97-
decoderCanvas.toBlob((blob) => {
98-
if (!blob) return reject('Failed to create canvas blob');
112+
async function decodeVideo() {
113+
const video = document.createElement('video');
114+
video.src = source;
99115
100-
const url = URL.createObjectURL(blob);
101-
const image = new Image();
116+
const decoderCanvas = document.createElement('canvas');
117+
const dctx = decoderCanvas.getContext('2d');
118+
if (!dctx) throw new Error('Failed to create canvas context');
119+
120+
return new Promise<void>((resolve, reject) => {
121+
video.onerror = reject;
122+
video.onloadeddata = async () => {
123+
if (video.duration === 0) throw new Error('Video is empty');
124+
duration = video.duration;
125+
const fps = video.duration / numberOfFrames;
126+
decoderCanvas.width = width;
127+
decoderCanvas.height = height;
128+
129+
let i = -1;
130+
while (++i < numberOfFrames) {
131+
video.currentTime = i * fps;
132+
await new Promise((resolve) => video.requestVideoFrameCallback(resolve));
133+
134+
dctx.clearRect(0, 0, decoderCanvas.width, decoderCanvas.height);
135+
dctx.drawImage(video, 0, 0, decoderCanvas.width, decoderCanvas.height);
136+
137+
const blob = await new Promise<Blob>((resolve, reject) => {
138+
decoderCanvas.toBlob(
139+
(blob) => (blob ? resolve(blob) : reject('Failed to create canvas blob')),
140+
'image/webp'
141+
);
142+
});
143+
144+
const url = URL.createObjectURL(blob);
145+
const image = await loadImage(url);
146+
frames.push(image);
147+
148+
if (i === 0) {
149+
ctx?.clearRect(0, 0, canvas.width, canvas.height);
150+
ctx?.drawImage(image, 0, 0);
151+
}
152+
153+
const response = new Response(blob, { headers: { 'Content-Type': 'image/webp' } });
154+
155+
if (cacheFrames) {
156+
const cache = await caches.open('svelte-knobs/cache');
157+
await cache.put(`${source}:${i}`, response);
158+
}
159+
}
160+
161+
resolve();
162+
};
163+
});
164+
}
102165
103-
image.src = url;
104-
image.onerror = reject;
105-
image.onload = () => {
106-
if (i === 0) {
107-
ctx?.clearRect(0, 0, canvas.width, canvas.height);
108-
ctx?.drawImage(image, 0, 0);
109-
}
166+
// @ts-expect-error oops
167+
onMount(async () => {
168+
ctx = canvas.getContext('2d');
169+
if (ctx) ctx.fillText('Loading...', 0, canvas.height / 2);
110170
111-
frames.push(image);
112-
resolve();
113-
};
114-
}, 'image/webp');
115-
});
116-
}
171+
console.time('Decoding video');
117172
118-
async function waitForFrame(video: HTMLVideoElement) {
119-
return new Promise((resolve) => video.requestVideoFrameCallback(resolve));
173+
if (cacheFrames) {
174+
const hasReadCache = await loadFromCache();
175+
if (hasReadCache) {
176+
console.log('cache hit');
177+
console.timeEnd('Decoding video');
178+
return;
120179
}
180+
}
121181
122-
while (i < numberOfFrames) {
123-
video.currentTime = ++i * fps;
124-
125-
await waitForFrame(video);
126-
await decodeFrame();
127-
}
182+
frames.splice(0);
183+
await decodeVideo();
128184
129-
console.timeEnd('Decoding video');
130-
};
185+
console.timeEnd('Decoding video');
131186
132187
return () => {
133188
for (const { src } of frames) {

src/lib/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Reexport your entry components here
22
export * from './params.js';
33
import Knob from './Knob.svelte';
4-
import VideoKnob from './VideoKnob.svelte';
4+
import VideoKnob, { cleanVideoKnobCache } from './VideoKnob.svelte';
55
import ImageKnob from './ImageKnob.svelte';
66

7-
export { Knob, ImageKnob, VideoKnob };
7+
export { Knob, ImageKnob, VideoKnob, cleanVideoKnobCache };

src/routes/+page.svelte

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@
106106
<LazyComponent component={() => import('./examples/ImageStrip.svelte')} />
107107

108108
<p>
109-
You can also create interactive knobs with a image slice.<br />
109+
You can also create interactive knobs with an image strip.<br />
110110
There's 2 ways of doing that here.
111111
<code>{'<ImageKnob />'}</code> and <code>{'<VideoKnob />'}</code>
112112
</p>
@@ -122,7 +122,7 @@
122122
<tbody>
123123
<tr>
124124
<td>file size</td>
125-
<td>tiny (20K)</td>
125+
<td>tiny (18K)</td>
126126
<td>small - large (264K - 408K)</td>
127127
</tr>
128128
<tr>
@@ -135,6 +135,11 @@
135135
<td>very slow (~5.2s)</td>
136136
<td>fast (~395ms)</td>
137137
</tr>
138+
<tr>
139+
<td>load time (from cache)</td>
140+
<td>fast (~180ms)</td>
141+
<td>-</td>
142+
</tr>
138143
<tr>
139144
<td>initial responsiveness</td>
140145
<td>delayed (only after video processing)</td>
@@ -155,6 +160,12 @@
155160
for image knob. It will help calculate the frames better and avoid flickering.
156161
</p>
157162

163+
<p>
164+
For the video knob, you can enable the <code>cleanVideoKnobCache</code> parameter by setting
165+
it to <code>true</code>, which will sped up initial load in later uses. Additionally, a
166+
function called <code>cleanVideoKnobCache</code> is provided to clear the cache afterward.
167+
</p>
168+
158169
<CopyPaste>%import('./examples/ImageStrip.svelte')%</CopyPaste>
159170
</div>
160171
</div>

src/routes/examples/ImageStrip.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
unit="%"
1515
source={videoSource}
1616
numberOfFrames={79}
17+
cacheFrames
1718
/>
1819

1920
<ImageKnob

0 commit comments

Comments
 (0)