aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorLibravatarLarge Libravatar memdmp <memdmpestrogenzone>2025-07-31 22:48:01 +0200
committerLibravatarLarge Libravatar memdmp <memdmpestrogenzone>2025-07-31 22:48:01 +0200
commite55f4c2fe8a6e1d62a0b005777b46c80e360d37e (patch)
tree1f9ad00b914f04677ffaf8b395a4c5d4ff659756 /src
downloadvideotool-e55f4c2fe8a6e1d62a0b005777b46c80e360d37e.tar.gz
videotool-e55f4c2fe8a6e1d62a0b005777b46c80e360d37e.tar.bz2
videotool-e55f4c2fe8a6e1d62a0b005777b46c80e360d37e.tar.lz
videotool-e55f4c2fe8a6e1d62a0b005777b46c80e360d37e.zip

feat: initial commit

Diffstat (limited to 'src')
-rw-r--r--src/app.css1
-rw-r--r--src/app.d.ts13
-rw-r--r--src/app.html14
-rw-r--r--src/lib/Player/FrameSlider.svelte67
-rw-r--r--src/lib/Player/Keybinds.svelte47
-rw-r--r--src/lib/Player/Player.svelte161
-rw-r--r--src/lib/Player/Video.ts55
-rw-r--r--src/lib/Renderer/Renderer.svelte146
-rw-r--r--src/lib/assets/favicon.svg1
-rw-r--r--src/lib/index.ts1
-rw-r--r--src/lib/vendor/svelte-range-slider/README1
-rw-r--r--src/lib/vendor/svelte-range-slider/range-pips.svelte303
-rw-r--r--src/lib/vendor/svelte-range-slider/range-slider.svelte1026
-rw-r--r--src/routes/+layout.svelte12
-rw-r--r--src/routes/+page.svelte6
-rw-r--r--src/routes/ffmpeg-test/+page.svelte45
-rw-r--r--src/routes/render/+page.svelte6
-rw-r--r--src/user/Sneky Snitch.mp4bin0 -> 1620945 bytes
-rw-r--r--src/user/index.ts21
19 files changed, 1926 insertions, 0 deletions
diff --git a/src/app.css b/src/app.css
new file mode 100644
index 0000000..d4b5078
--- /dev/null
+++ b/src/app.css
@@ -0,0 +1 @@
+@import 'tailwindcss';
diff --git a/src/app.d.ts b/src/app.d.ts
new file mode 100644
index 0000000..da08e6d
--- /dev/null
+++ b/src/app.d.ts
@@ -0,0 +1,13 @@
+// See https://svelte.dev/docs/kit/types#app.d.ts
+// for information about these interfaces
+declare global {
+ namespace App {
+ // interface Error {}
+ // interface Locals {}
+ // interface PageData {}
+ // interface PageState {}
+ // interface Platform {}
+ }
+}
+
+export {};
diff --git a/src/app.html b/src/app.html
new file mode 100644
index 0000000..d1e03cb
--- /dev/null
+++ b/src/app.html
@@ -0,0 +1,14 @@
+<!doctype html>
+<html lang="en" class='bg-[#0a0a0a] text-white'>
+
+<head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ %sveltekit.head%
+</head>
+
+<body data-sveltekit-preload-data="hover">
+ <div style="display: contents">%sveltekit.body%</div>
+</body>
+
+</html>
diff --git a/src/lib/Player/FrameSlider.svelte b/src/lib/Player/FrameSlider.svelte
new file mode 100644
index 0000000..a632aa9
--- /dev/null
+++ b/src/lib/Player/FrameSlider.svelte
@@ -0,0 +1,67 @@
+<script lang="ts">
+ import RangeSlider from '../vendor/svelte-range-slider/range-slider.svelte';
+ let {
+ frame = $bindable(),
+ frameCount,
+ playing = $bindable(),
+ playbackStarted = $bindable()
+ }: {
+ frame: number;
+ frameCount: number;
+ playing: boolean;
+ playbackStarted: number;
+ } = $props();
+</script>
+
+<div class="w-full flex items-center justify-center pt-2">
+ <div class="p-2 pr-1">
+ <button
+ onclick={() => {
+ playing = !playing;
+ }}
+ aria-label={playing ? 'pause' : 'play'}
+ class="flex items-center justify-center"
+ ><svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="size-6"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d={!playing
+ ? 'M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z'
+ : 'M15.75 5.25v13.5m-7.5-13.5v13.5'}
+ />
+ </svg></button
+ >
+ </div>
+ <div class="flex-1">
+ <RangeSlider bind:value={frame} min={0} max={frameCount} />
+ </div>
+ <div class="label p-2 pl-1">
+ <input
+ type="number"
+ bind:value={frame}
+ class="w-16 appearance-none text-right"
+ style="-moz-appearance:textfield;"
+ max={frameCount}
+ onkeypress={(e) => {
+ e.stopPropagation();
+ }}
+ onkeydown={(e) => {
+ e.stopPropagation();
+ }}
+ />
+ of {frameCount}
+ <!-- <input
+ type="number"
+ bind:value={frameCount}
+ class="w-8 appearance-none"
+ style="-moz-appearance:textfield;"
+ /> -->
+ </div>
+</div>
diff --git a/src/lib/Player/Keybinds.svelte b/src/lib/Player/Keybinds.svelte
new file mode 100644
index 0000000..756a865
--- /dev/null
+++ b/src/lib/Player/Keybinds.svelte
@@ -0,0 +1,47 @@
+<script lang="ts">
+ let {
+ frame = $bindable(),
+ frameCount,
+ fps,
+ playing = $bindable(),
+ playbackStarted = $bindable()
+ }: {
+ frame: number;
+ frameCount: number;
+ fps: number | undefined;
+ playing: boolean;
+ playbackStarted: number;
+ } = $props();
+</script>
+
+<svelte:window
+ onkeypress={(e) => {
+ switch (e.key) {
+ case ' ':
+ e.preventDefault();
+ playing = !playing;
+ if (playing) playbackStarted = performance.now();
+ break;
+
+ // default:
+ // if (dev) console.debug('Keypress:', e.key);
+ // break;
+ }
+ }}
+ onkeydown={(e) => {
+ switch (e.key) {
+ case 'ArrowLeft':
+ e.preventDefault();
+ frame = Math.max(frame - (e.ctrlKey ? (fps ?? 60) : 1), 0);
+ break;
+ case 'ArrowRight':
+ e.preventDefault();
+ frame = Math.min(frame + (e.ctrlKey ? (fps ?? 60) : 1), frameCount);
+ break;
+
+ // default:
+ // if (dev) console.debug('Keydown:', e.key);
+ // break;
+ }
+ }}
+/>
diff --git a/src/lib/Player/Player.svelte b/src/lib/Player/Player.svelte
new file mode 100644
index 0000000..f6df121
--- /dev/null
+++ b/src/lib/Player/Player.svelte
@@ -0,0 +1,161 @@
+<script lang="ts">
+ import FrameSlider from './FrameSlider.svelte';
+ import { type Video, type VideoConstructor } from '$/lib/Player/Video';
+ import Keybinds from './Keybinds.svelte';
+ import { onMount } from 'svelte';
+
+ let frame = $state(0);
+ let canvas = $state(null as HTMLCanvasElement | null);
+ let audio = $state(null as HTMLAudioElement | null);
+ let lastCanvas = null as typeof canvas;
+ let video = $state(undefined as Video | undefined);
+ let frameCount = $state(0);
+ let playing = $state(false);
+ let playbackStarted = $state(0);
+ let playbackFrameOffset = 0;
+ let renderPromise: Promise<void> | void = void 0;
+ let renderId = 0;
+ let audioSource = $state(null as null | string);
+ let {
+ Video: VideoImplementation
+ }: {
+ Video: VideoConstructor;
+ } = $props();
+ const newCanvas = async (canvas: HTMLCanvasElement, videoImplementation: VideoConstructor) => {
+ if (video) video.cleanup();
+ lastCanvas = canvas;
+ video = new VideoImplementation(canvas);
+ const audioSourcePromise = (async () => {
+ if (video?.audioUrl?.[1]) {
+ audioSource = await fetch(video.audioUrl[1])
+ .then(async (v) => {
+ if (v.status.toString().startsWith('2')) return URL.createObjectURL(await v.blob());
+ else throw new Error('non-2xx audio');
+ })
+ .catch((e) => {
+ console.warn('Failed to get audio', e);
+ return video?.audioUrl?.[1] ?? null;
+ });
+ }
+ })();
+ video['_isInit'] = true;
+ renderPromise = video.init();
+ await renderPromise;
+ video['_isInit'] = false;
+ frameCount = video.length;
+ await audioSourcePromise;
+ };
+ const renderPreviewFrame = async (video: Video, frame: number) => {
+ const ourId = ++renderId;
+ if (renderPromise) await renderPromise;
+ if (renderId !== ourId) return;
+ renderPromise =
+ video.renderFrame({
+ frames: frame,
+ milliseconds: (frame / video.fps) * 1000,
+ seconds: frame / video.fps
+ }) ?? Promise.resolve();
+ return renderPromise;
+ };
+ $effect(() => {
+ if (canvas && canvas !== lastCanvas) newCanvas(canvas, VideoImplementation);
+ });
+ $effect(() => {
+ if (video) renderPreviewFrame(video, frame);
+ });
+ let playbackLoopId = 0;
+ const startPlaybackLoop = (id = ++playbackLoopId) => {
+ if (video && id === playbackLoopId) {
+ const ms = performance.now() - playbackStarted;
+ let f = Math.floor((ms / 1000) * video.fps) + playbackFrameOffset;
+
+ if (f > frameCount) {
+ f = frameCount;
+ playing = false;
+ }
+ frame = f;
+ renderPreviewFrame(video, frame).then(() =>
+ requestAnimationFrame(() => (playing ? startPlaybackLoop(id) : void 0))
+ );
+ }
+ };
+ $effect(() => {
+ if (playing) {
+ playbackStarted = performance.now();
+ playbackFrameOffset = frame;
+ startPlaybackLoop();
+ }
+ });
+ let loadedFrameTimestamp = false;
+ onMount(() => {
+ const t = sessionStorage.getItem('timestamp');
+ const tI = t ? parseInt(t, 36) : null;
+ if (tI && !isNaN(tI)) {
+ frame = tI;
+ requestAnimationFrame(() => (frame = tI));
+ }
+ loadedFrameTimestamp = true;
+ });
+ // TODO: implement waitin a few seconds before saving
+ $effect(() => {
+ if (loadedFrameTimestamp)
+ try {
+ sessionStorage.setItem('timestamp', frame.toString(36));
+ } catch (_) {}
+ });
+ $effect(() => {
+ if (audio && video && !playing) {
+ try {
+ const f = frame;
+ audio.currentTime = frame / video.fps;
+ audio.play();
+ (async () => {
+ const targetTime = (frame + 1) / video.fps;
+ while (audio.currentTime <= targetTime) {
+ await new Promise((rs) => requestAnimationFrame(rs));
+ if (playing || frame !== f) return;
+ }
+ audio.pause();
+ audio.currentTime = frame / video.fps;
+ })();
+ } catch (error) {
+ console.warn(error);
+ }
+ }
+ });
+ $effect(() => {
+ if (audio) {
+ if (playing) audio.play();
+ else audio.pause();
+ }
+ });
+</script>
+
+<svelte:window
+ onresize={() => {
+ if (canvas && video) {
+ (async () => {
+ video['_isInit'] = true;
+ renderPromise = await video.init();
+ video['_isInit'] = false;
+ renderPreviewFrame(video, frame);
+ })();
+ }
+ }}
+/>
+
+<Keybinds bind:frame {frameCount} fps={video?.fps} bind:playing bind:playbackStarted />
+
+<div class="p-2 w-screen h-screen relative flex flex-col">
+ <div class="flex-1 relative">
+ <div class="absolute top-0 left-0 w-full h-full flex items-center justify-center">
+ <canvas class="pointer-events-none bg-black" bind:this={canvas}>
+ Your browser doesn't support the canvas API.
+ </canvas>
+ {#if audioSource}
+ <audio src={audioSource} bind:this={audio}></audio>
+ {/if}
+ </div>
+ </div>
+ <FrameSlider bind:frame {frameCount} bind:playing bind:playbackStarted />
+</div>
diff --git a/src/lib/Player/Video.ts b/src/lib/Player/Video.ts
new file mode 100644
index 0000000..78b3b8f
--- /dev/null
+++ b/src/lib/Player/Video.ts
@@ -0,0 +1,55 @@
+export type FrameTime = {
+ milliseconds: number,
+ seconds: number,
+ frames: number
+}
+export abstract class Video {
+ public constructor(public canvas: HTMLCanvasElement) { };
+ public abstract renderFrame(time: FrameTime): Promise<void> | void;
+ /** (re-)Initializes the Video object. Also called on window resizes. */
+ public abstract init(): void | Promise<void>;
+ private _isInit = false;
+ /** The frames per second to render at */
+ public abstract get fps(): number;
+ /** Length in frames */
+ public abstract get length(): number;
+ /** A URL (and matching filename) to an ffmpeg-compatible audio file */
+ public audioUrl?: readonly [filename: string, fileUrl: string];
+ /** Resizes the canvas to a predetermined render resolution - must only be called in init() - do not overwrite */
+ public resize(x: number, y: number) {
+ if (!this._isInit) throw new Error('Must only call resize() in init.')
+ this.canvas.width = x;
+ this.canvas.height = y;
+ const parentW = this.canvas.parentElement!.clientWidth,
+ parentH = this.canvas.parentElement!.clientHeight
+ if (x <= parentW && y <= parentH) {
+ this.canvas.style.width = `${x}px`;
+ this.canvas.style.height = `${y}px`;
+ } else if (x <= parentW && y > parentH) {
+ this.canvas.style.width = `${x / y * parentH}px`;
+ this.canvas.style.height = `${parentH}px`;
+ } else if (y <= parentH && x > parentW) {
+ this.canvas.style.width = `${parentW}px`;
+ this.canvas.style.height = `${y / x * parentW}px`;
+ } else {
+ if ((parentW / x) * y > parentH) {
+ this.canvas.style.width = `${(parentH / y) * x}px`
+ this.canvas.style.height = `${parentH}px`
+ } else {
+ this.canvas.style.width = `${parentW}px`
+ this.canvas.style.height = `${(parentW / x) * y}px`
+ }
+ }
+ }
+ /** The width of the video, in pixels */
+ public get w() {
+ return this.canvas.width;
+ }
+ /** The height of the video, in pixels */
+ public get h() {
+ return this.canvas.height;
+ }
+ /** Use to cleanup any mess you made - do not remove the canvas, it may be reused. */
+ public cleanup() { };
+}
+export type VideoConstructor = new (...params: ConstructorParameters<typeof Video>) => Video
diff --git a/src/lib/Renderer/Renderer.svelte b/src/lib/Renderer/Renderer.svelte
new file mode 100644
index 0000000..72c270b
--- /dev/null
+++ b/src/lib/Renderer/Renderer.svelte
@@ -0,0 +1,146 @@
+<script lang="ts">
+ import { onDestroy } from 'svelte';
+ import type { VideoConstructor } from '../Player/Video';
+ import { FFmpeg, type LogEvent } from '@ffmpeg/ffmpeg';
+ import { fetchFile, toBlobURL } from '@ffmpeg/util';
+
+ let {
+ Video: VideoImplementation
+ }: {
+ Video: VideoConstructor;
+ } = $props();
+ let canvas = $state(null as null | HTMLCanvasElement);
+
+ let frame = $state(0);
+ let frameCount = $state(0);
+ let active = false;
+
+ let lastAnimationFrame = 0;
+ let startedAt = 0;
+ let message = $state('Waiting');
+ let videoUrl = $state(null as null | string);
+
+ const ffmpegBaseURL = 'https://unpkg.com/@ffmpeg/core-mt@0.12.6/dist/esm';
+
+ const start = async (format = 'mp4') => {
+ active = true;
+ startedAt = performance.now();
+
+ const ffmpeg = new FFmpeg();
+ message = 'Loading ffmpeg-core.js';
+ ffmpeg.on('log', ({ message: msg }: LogEvent) => {
+ message = msg;
+ console.log(message);
+ });
+ await ffmpeg.load({
+ coreURL: await toBlobURL(`${ffmpegBaseURL}/ffmpeg-core.js`, 'text/javascript'),
+ wasmURL: await toBlobURL(`${ffmpegBaseURL}/ffmpeg-core.wasm`, 'application/wasm'),
+ workerURL: await toBlobURL(`${ffmpegBaseURL}/ffmpeg-core.worker.js`, 'text/javascript')
+ });
+
+ message = 'Making directory';
+ await ffmpeg.createDir('frames');
+ message = 'Preparing Canvas';
+ const c = canvas!;
+ const video = new VideoImplementation(c);
+ video['_isInit'] = true;
+ await video.init();
+ video['_isInit'] = false;
+ frameCount = video.length;
+ message = 'Rendering first few frames...';
+ for (frame = 0; frame <= frameCount; frame++) {
+ if (!active) return;
+ await video.renderFrame({
+ frames: frame,
+ milliseconds: (frame / video.fps) * 1000,
+ seconds: frame / video.fps
+ });
+ // TODO: see if we can pipe this into an active ffmpeg instead of writing a file then running a command after
+ const file = await fetchFile(c.toDataURL());
+ await ffmpeg.writeFile('frames/f' + frame.toString().padStart(10, '0') + '.png', file);
+ if (performance.now() > lastAnimationFrame + 33) {
+ await new Promise((rs) => requestAnimationFrame(rs));
+ lastAnimationFrame = performance.now();
+ message = `Rendering to pngs - ${frame}/${frameCount} frames | running for ${(
+ Math.floor(lastAnimationFrame - startedAt) / 1000
+ ).toFixed(3)} seconds`;
+ }
+ }
+
+ message = 'Start transcoding';
+ await ffmpeg.exec([
+ '-framerate',
+ video.fps.toString(),
+ '-pattern_type',
+ 'sequence',
+ '-start_number',
+ '0',
+ '-pattern_type',
+ 'glob',
+ '-i',
+ 'frames/f*.png',
+ // 'frames/f%04d.png',
+ 'middle.' + format
+ ]);
+ message = 'Disposing pngs';
+ for (const i of await ffmpeg.listDir('frames'))
+ if (!i.isDir) await ffmpeg.deleteFile('frames/' + i.name);
+ await ffmpeg.deleteDir('frames/');
+ if (video.audioUrl) {
+ message = 'Fetching Audio';
+ await ffmpeg.writeFile('audio.' + video.audioUrl[0], await fetchFile(video.audioUrl[1]));
+ message = 'Merging Audio';
+ await ffmpeg.exec([
+ '-i',
+ 'middle.' + format,
+ '-r',
+ video.fps.toString(),
+ '-i',
+ 'audio.' + video.audioUrl[0],
+ '-c:v',
+ 'copy',
+ '-map',
+ '0:v:0',
+ '-map',
+ '1:a:0',
+ '-frames:v',
+ video.length.toString(),
+ 'output.' + format
+ ]);
+ message = 'Removing videoless file';
+ await ffmpeg.deleteFile('middle.' + format);
+ } else await ffmpeg.rename('middle.' + format, 'output.' + format);
+ message = 'Reading File';
+ const data = await ffmpeg.readFile('output.' + format);
+ console.log('done');
+ videoUrl = URL.createObjectURL(
+ // @ts-ignore bufferlike is good enuf
+ new Blob([(data as Uint8Array).buffer], { type: 'video/' + format })
+ );
+ message = 'Disposing ffmpeg state';
+ await ffmpeg.deleteFile('output.' + format);
+ location.href = videoUrl;
+ };
+ $effect(() => {
+ if (canvas && VideoImplementation) start();
+ });
+ onDestroy(() => (active = false));
+</script>
+
+<div class="flex flex-col h-screen w-screen">
+ {#if videoUrl}
+ <!-- svelte-ignore a11y_media_has_caption -->
+ <video src={videoUrl} controls class="max-w-screen max-h-screen flex-1"></video>
+ {:else}
+ <div class="flex-1 relative">
+ <div class="absolute top-0 left-0 w-full h-full flex items-center justify-center">
+ <canvas class="pointer-events-none bg-black" bind:this={canvas}>
+ Your browser doesn't support the canvas API.
+ </canvas>
+ </div>
+ </div>
+ {/if}
+ <p class="p-4">
+ {message}
+ </p>
+</div>
diff --git a/src/lib/assets/favicon.svg b/src/lib/assets/favicon.svg
new file mode 100644
index 0000000..cc5dc66
--- /dev/null
+++ b/src/lib/assets/favicon.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg> \ No newline at end of file
diff --git a/src/lib/index.ts b/src/lib/index.ts
new file mode 100644
index 0000000..856f2b6
--- /dev/null
+++ b/src/lib/index.ts
@@ -0,0 +1 @@
+// place files you want to import through the `$lib` alias in this folder.
diff --git a/src/lib/vendor/svelte-range-slider/README b/src/lib/vendor/svelte-range-slider/README
new file mode 100644
index 0000000..b17797b
--- /dev/null
+++ b/src/lib/vendor/svelte-range-slider/README
@@ -0,0 +1 @@
+https://github.com/roycrippen4/svelte-range-slider/tree/master
diff --git a/src/lib/vendor/svelte-range-slider/range-pips.svelte b/src/lib/vendor/svelte-range-slider/range-pips.svelte
new file mode 100644
index 0000000..418fc7e
--- /dev/null
+++ b/src/lib/vendor/svelte-range-slider/range-pips.svelte
@@ -0,0 +1,303 @@
+<script lang="ts" module>
+ export interface PipsProps {
+ min?: number;
+ max?: number;
+ step?: number;
+ values?: number[];
+ vertical?: boolean;
+ reversed?: boolean;
+ hoverable?: boolean;
+ disabled?: boolean;
+ pipstep?: number;
+ prefix?: string;
+ suffix?: string;
+ focus?: boolean;
+ range?: undefined | boolean | 'min' | 'max';
+ all?: undefined | boolean | 'pip' | 'label';
+ first?: boolean | 'pip' | 'label';
+ last?: boolean | 'pip' | 'label';
+ rest?: boolean | 'pip' | 'label';
+ percentOf: (v: number) => number;
+ fixFloat: (v: number) => number;
+ orientationStart?: 'top' | 'bottom' | 'left' | 'right';
+ orientationEnd?: 'top' | 'bottom' | 'left' | 'right';
+ formatter?: (v: number, i: number, p: number) => string;
+ moveHandle: undefined | ((index: number | undefined, value: number) => number);
+ normalisedClient: (e: MouseEvent | TouchEvent) => { x: number; y: number };
+ }
+</script>
+
+<script lang="ts">
+ let {
+ range = false,
+ min = 0,
+ max = 100,
+ step = 1,
+ values = [(max + min) / 2],
+ vertical = false,
+ reversed = false,
+ hoverable = true,
+ disabled = false,
+ pipstep,
+ all = true,
+ first,
+ last,
+ rest,
+ prefix = '',
+ suffix = '',
+ focus,
+ orientationStart,
+ // eslint-disable-next-line no-unused-vars
+ formatter = (v, i, p) => v.toString(),
+ percentOf,
+ moveHandle,
+ fixFloat,
+ normalisedClient
+ }: PipsProps = $props();
+
+ let clientStart = $state({ x: 0, y: 0 });
+ let pipStep = $derived(
+ pipstep ||
+ ((max - min) / step >= (vertical ? 50 : 100) ? (max - min) / (vertical ? 10 : 20) : 1)
+ );
+ let pipCount = $derived(parseInt(((max - min) / (step * pipStep)).toString(), 10));
+ let pipVal = $derived((val: number) => fixFloat(min + val * step * pipStep));
+ let isSelected = $derived((val: number) => values.some((v) => fixFloat(v) === fixFloat(val)));
+ let inRange = $derived((val: number) => {
+ if (range === 'min') {
+ return values[0] > val;
+ }
+ if (range === 'max') {
+ return values[0] < val;
+ }
+ if (range) {
+ return values[0] < val && values[1] > val;
+ }
+ });
+
+ /**
+ * function to run when the user clicks on a label
+ * we store the original client position so we can check if the user has moved the mouse/finger
+ * @param {MouseEvent} e the event from browser
+ **/
+ const labelDown = (e: MouseEvent) => {
+ clientStart = { x: e.clientX, y: e.clientY };
+ };
+
+ /**
+ * function to run when the user releases the mouse/finger
+ * we check if the user has moved the mouse/finger, if not we "click" the label
+ * and move the handle it to the label position
+ * @param {number} val the value of the label
+ * @param {MouseEvent|TouchEvent} e the event from browser
+ */
+ function labelUp(val: number, e: MouseEvent | TouchEvent) {
+ if (disabled) {
+ return;
+ }
+
+ const clientPos = normalisedClient(e);
+ const distanceMoved = Math.sqrt(
+ Math.pow(clientStart.x - clientPos.x, 2) + Math.pow(clientStart.y - clientPos.y, 2)
+ );
+
+ if (clientStart && distanceMoved <= 5) {
+ moveHandle?.(undefined, val);
+ }
+ }
+</script>
+
+<div
+ class="rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6"
+ class:disabled
+ class:hoverable
+ class:vertical
+ class:reversed
+ class:focus
+>
+ {#if (all && first !== false) || first}
+ <span
+ class="pip-680f0f01-664b-43b5-9e1c-789449c63c62 first"
+ class:selected={isSelected(min)}
+ class:in-range={inRange(min)}
+ style="{orientationStart}: 0%;"
+ onpointerdown={labelDown}
+ onpointerup={(e) => labelUp(min, e)}
+ >
+ {#if all === 'label' || first === 'label'}
+ <span class="pipVal-c41e7185-de59-40a7-90f2-e3d98b1e844b">
+ {prefix}{formatter(fixFloat(min), 0, 0)}{suffix}
+ </span>
+ {/if}
+ </span>
+ {/if}
+
+ {#if (all && rest !== false) || rest}
+ <!-- eslint-disable-next-line no-unused-vars -->
+ {#each Array(pipCount + 1) as _, i}
+ {#if pipVal(i) !== min && pipVal(i) !== max}
+ <span
+ class="pip-680f0f01-664b-43b5-9e1c-789449c63c62"
+ class:selected={isSelected(pipVal(i))}
+ class:in-range={inRange(pipVal(i))}
+ style="{orientationStart}: {percentOf(pipVal(i))}%;"
+ onpointerdown={labelDown}
+ onpointerup={(e) => labelUp(pipVal(i), e)}
+ >
+ {#if all === 'label' || rest === 'label'}
+ <span class="pipVal-c41e7185-de59-40a7-90f2-e3d98b1e844b">
+ {prefix}{formatter(pipVal(i), i, percentOf(pipVal(i)))}{suffix}
+ </span>
+ {/if}
+ </span>
+ {/if}
+ {/each}
+ {/if}
+
+ {#if (all && last !== false) || last}
+ <span
+ class="pip last"
+ class:selected={isSelected(max)}
+ class:in-range={inRange(max)}
+ style="{orientationStart}: 100%;"
+ onpointerdown={labelDown}
+ onpointerup={(e) => labelUp(max, e)}
+ >
+ {#if all === 'label' || last === 'label'}
+ <span class="pipVal">
+ {prefix}{formatter(fixFloat(max), pipCount, 100)}{suffix}
+ </span>
+ {/if}
+ </span>
+ {/if}
+</div>
+
+<style>
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28) {
+ --pip: var(--range-pip, lightslategray);
+ --pip-text: var(--range-pip-text, var(--pip));
+ --pip-active: var(--range-pip-active, darkslategrey);
+ --pip-active-text: var(--range-pip-active-text, var(--pip-active));
+ --pip-hover: var(--range-pip-hover, darkslategrey);
+ --pip-hover-text: var(--range-pip-hover-text, var(--pip-hover));
+ --pip-in-range: var(--range-pip-in-range, var(--pip-active));
+ --pip-in-range-text: var(--range-pip-in-range-text, var(--pip-active-text));
+ }
+ :global(.rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6) {
+ position: absolute;
+ height: 1em;
+ left: 0;
+ right: 0;
+ bottom: -1em;
+ }
+ :global(.rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6.vertical) {
+ height: auto;
+ width: 1em;
+ left: 100%;
+ right: auto;
+ top: 0;
+ bottom: 0;
+ }
+ :global(
+ .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 .pip-680f0f01-664b-43b5-9e1c-789449c63c62
+ ) {
+ height: 0.4em;
+ position: absolute;
+ top: 0.25em;
+ width: 1px;
+ white-space: nowrap;
+ }
+ :global(
+ .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6.vertical
+ .pip-680f0f01-664b-43b5-9e1c-789449c63c62
+ ) {
+ height: 1px;
+ width: 0.4em;
+ left: 0.25em;
+ top: auto;
+ bottom: auto;
+ }
+ :global(
+ .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 .pipVal-c41e7185-de59-40a7-90f2-e3d98b1e844b
+ ) {
+ position: absolute;
+ top: 0.4em;
+ transform: translate(-50%, 25%);
+ }
+ :global(
+ .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6.vertical
+ .pipVal-c41e7185-de59-40a7-90f2-e3d98b1e844b
+ ) {
+ position: absolute;
+ top: 0;
+ left: 0.4em;
+ transform: translate(25%, -50%);
+ }
+ :global(
+ .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 .pip-680f0f01-664b-43b5-9e1c-789449c63c62
+ ) {
+ transition: all 0.15s ease;
+ }
+ :global(
+ .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 .pipVal-c41e7185-de59-40a7-90f2-e3d98b1e844b
+ ) {
+ transition:
+ all 0.15s ease,
+ font-weight 0s linear;
+ }
+ :global(
+ .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 .pip-680f0f01-664b-43b5-9e1c-789449c63c62
+ ) {
+ color: var(--pip-text, lightslategray);
+ background-color: var(--pip, lightslategray);
+ }
+ :global(.rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 .pip.selected) {
+ color: var(--pip-active-text, darkslategrey);
+ background-color: var(--pip-active, darkslategrey);
+ }
+ :global(.rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6.hoverable:not(.disabled) .pip:hover) {
+ color: var(--pip-hover-text, darkslategrey);
+ background-color: var(--pip-hover, darkslategrey);
+ }
+ :global(.rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 .pip.in-range) {
+ color: var(--pip-in-range-text, darkslategrey);
+ background-color: var(--pip-in-range, darkslategrey);
+ }
+ :global(.rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 .pip.selected) {
+ height: 0.75em;
+ }
+ :global(.rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6.vertical .pip.selected) {
+ height: 1px;
+ width: 0.75em;
+ }
+ :global(
+ .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6
+ .pip.selected
+ .pipVal-c41e7185-de59-40a7-90f2-e3d98b1e844b
+ ) {
+ font-weight: bold;
+ top: 0.75em;
+ }
+ :global(
+ .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6.vertical
+ .pip.selected
+ .pipVal-c41e7185-de59-40a7-90f2-e3d98b1e844b
+ ) {
+ top: 0;
+ left: 0.75em;
+ }
+ :global(
+ .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6.hoverable:not(.disabled)
+ .pip:not(.selected):hover
+ ) {
+ transition: none;
+ }
+ :global(
+ .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6.hoverable:not(.disabled)
+ .pip:not(.selected):hover
+ .pipVal-c41e7185-de59-40a7-90f2-e3d98b1e844b
+ ) {
+ transition: none;
+ font-weight: bold;
+ }
+</style>
diff --git a/src/lib/vendor/svelte-range-slider/range-slider.svelte b/src/lib/vendor/svelte-range-slider/range-slider.svelte
new file mode 100644
index 0000000..7f522e2
--- /dev/null
+++ b/src/lib/vendor/svelte-range-slider/range-slider.svelte
@@ -0,0 +1,1026 @@
+<script lang="ts" module>
+ export type ChangeEvent = {
+ activeHandle: number;
+ startValue: number;
+ previousValue: number;
+ value: number;
+ values: number[];
+ };
+
+ export type StartEvent = { activeHandle: number; value: number; values: number[] };
+
+ export type StopEvent = {
+ activeHandle: number;
+ startValue: number;
+ value: number;
+ values: number[];
+ };
+
+ export interface RangeSliderProps {
+ range?: boolean | 'min' | 'max';
+ onchange?: (event: ChangeEvent) => void;
+ onstart?: (event: StartEvent) => void;
+ onstop?: (event: StopEvent) => void;
+ pushy?: boolean;
+ min?: number;
+ max?: number;
+ ariaLabels?: string[];
+ precision?: number;
+ springOptions?: { stiffness: number; damping: number };
+ id?: string;
+ prefix?: string;
+ suffix?: string;
+ pips?: boolean;
+ pipstep?: number;
+ all?: boolean | 'pip' | 'label';
+ first?: boolean | 'pip' | 'label';
+ last?: boolean | 'pip' | 'label';
+ rest?: boolean | 'pip' | 'label';
+ step?: number;
+ value?: number;
+ values?: number[];
+ vertical?: boolean;
+ float?: boolean;
+ reversed?: boolean;
+ hoverable?: boolean;
+ disabled?: boolean;
+ formatter?: (value: number, index: number, percent: number) => string;
+ handleFormatter?: (value: number, index: number, percent: number) => string;
+ }
+</script>
+
+<script lang="ts">
+ import { spring } from 'svelte/motion';
+ import RangePips from './range-pips.svelte';
+
+ let {
+ range = false,
+ pushy = false,
+ min = 0,
+ max = 100,
+ ariaLabels = [],
+ precision = 2,
+ springOptions = { stiffness: 0.15, damping: 0.4 },
+ id = '',
+ prefix = '',
+ suffix = '',
+ pips = false,
+ pipstep,
+ all,
+ first,
+ last,
+ rest,
+ step = 1,
+ value = $bindable(0),
+ values = $bindable([(max + min) / 2]),
+ vertical = false,
+ float = false,
+ reversed = false,
+ hoverable = true,
+ disabled = false,
+ onchange,
+ onstart,
+ onstop,
+ formatter = (value: { toString: () => string }) => value.toString(),
+ handleFormatter = formatter
+ }: RangeSliderProps = $props();
+
+ if (value) {
+ values = [value];
+ }
+
+ let slider: Element | undefined = $state(undefined);
+ let valueLength = $state(0);
+ let focus = $state(false);
+ let handleActivated = $state(false);
+ let handlePressed = $state(false);
+ let keyboardActive = $state(false);
+ let activeHandle = $state(values.length - 1);
+
+ let startValue: number | undefined = $state();
+
+ let previousValue: number | undefined = $state();
+
+ /**
+ * make sure the value is coerced to a float value
+ * @param {number} v the value to fix
+ * @return {number} a float version of the input
+ **/
+ const fixFloat = (v: number): number => parseFloat((+v).toFixed(precision));
+
+ $effect(() => {
+ // check that "values" is an array, or set it as array to prevent any errors in springs, or range trimming
+ if (!Array.isArray(values)) {
+ values = [(max + min) / 2];
+ console.error(
+ "'values' prop should be an Array (https://github.com/simeydotme/svelte-range-slider-pips#slider-props)"
+ );
+ }
+
+ // trim the range so it remains as a min/max (only 2 handles)
+ // and also align the handles to the steps
+ const trimmedAlignedValues = trimRange(values.map((v) => alignValueToStep(v)));
+ if (
+ !(values.length === trimmedAlignedValues.length) ||
+ !values.every((element, index) => fixFloat(element) === trimmedAlignedValues[index])
+ ) {
+ values = trimmedAlignedValues;
+ }
+
+ // check if the valueLength (length of values[]) has changed,
+ // because if so we need to re-seed the spring function with the new values array.
+ if (valueLength !== values.length) {
+ // set the initial spring values when the slider initialises, or when values array length has changed
+ springPositions = spring(
+ values.map((v) => percentOf(v)),
+ springOptions
+ );
+ } else {
+ // update the value of the spring function for animated handles whenever the values has updated
+ springPositions.set(values.map((v) => percentOf(v)));
+ }
+ // set the valueLength for the next check
+ valueLength = values.length;
+
+ if (values.length > 1 && !Array.isArray(ariaLabels)) {
+ console.warn(
+ `'ariaLabels' prop should be an Array (https://github.com/simeydotme/svelte-range-slider-pips#slider-props)`
+ );
+ }
+ });
+
+ /**
+ * take in a value, and then calculate that value's percentage
+ * of the overall range (min-max);
+ * @param {number} val the value we're getting percent for
+ * @return {number} the percentage value
+ **/
+ const percentOf = (/** @type {number} */ val: number): number => {
+ let percent = ((val - min) / (max - min)) * 100;
+
+ if (isNaN(percent) || percent <= 0) {
+ return 0;
+ }
+
+ if (percent >= 100) {
+ return 100;
+ }
+
+ return fixFloat(percent);
+ };
+
+ /**
+ * clamp a value from the range so that it always
+ * falls within the min/max values
+ * @param {number} val the value to clamp
+ * @return {number} the value after it's been clamped
+ **/
+ const clampValue = (/** @type {number} */ val: number): number => {
+ // return the min/max if outside of that range
+ return val <= min ? min : val >= max ? max : val;
+ };
+
+ /**
+ * align the value with the steps so that it
+ * always sits on the closest (above/below) step
+ * @param {number} val the value to align
+ * @return {number} the value after it's been aligned
+ **/
+ const alignValueToStep = (/** @type {number} */ val: number): number => {
+ // sanity check for performance
+ if (val <= min) {
+ return fixFloat(min);
+ }
+
+ if (val >= max) {
+ return fixFloat(max);
+ }
+
+ val = fixFloat(val);
+
+ // find the middle-point between steps and see if the value is closer to the next step, or previous step
+ let remainder = (val - min) % step;
+ let aligned = val - remainder;
+
+ if (Math.abs(remainder) * 2 >= step) {
+ aligned += remainder > 0 ? step : -step;
+ }
+
+ aligned = clampValue(aligned); // make sure the value is within acceptable limits
+
+ // make sure the returned value is set to the precision desired
+ // this is also because javascript often returns weird floats
+ // when dealing with odd numbers and percentages
+ return fixFloat(aligned);
+ };
+
+ /**
+ * the orientation of the handles/pips based on the
+ * input values of vertical and reversed
+ * @type {"top"|"bottom"|"left"|"right"} orientationStart
+ **/
+ let orientationStart: 'top' | 'bottom' | 'left' | 'right' = $derived(
+ vertical ? (reversed ? 'top' : 'bottom') : reversed ? 'right' : 'left'
+ );
+ let orientationEnd = $derived(
+ vertical ? (reversed ? 'bottom' : 'top') : reversed ? 'left' : 'right'
+ );
+
+ /**
+ * helper function to get the index of an element in it's DOM container
+ * @param {Element|null} el dom object reference we want the index of
+ * @returns {number} the index of the input element
+ **/
+ function index(el: Element | null): number {
+ if (!el) {
+ return -1;
+ }
+
+ let i = 0;
+ while ((el = el.previousElementSibling)) {
+ i++;
+ }
+ return i;
+ }
+
+ /**
+ * normalise a mouse or touch event to return the
+ * client (x/y) object for that event
+ * @param {MouseEvent|TouchEvent} e a mouse/touch event to normalise
+ * @returns {{ x: number, y: number }} normalised event client object (x,y)
+ **/
+ function normalisedClient(e: MouseEvent | TouchEvent): { x: number; y: number } {
+ if (e.type.includes('touch')) {
+ const touchEvent = e as TouchEvent;
+ const touch = touchEvent.touches[0] || touchEvent.changedTouches[0];
+ return { x: touch.clientX, y: touch.clientY };
+ } else {
+ const mouseEvent = e as MouseEvent;
+ return { x: mouseEvent.clientX, y: mouseEvent.clientY };
+ }
+ }
+
+ /**
+ * check if an element is a handle on the slider
+ * @param {Element} el dom object reference we want to check
+ * @returns {boolean}
+ **/
+ function targetIsHandle(el: Element): boolean {
+ if (!slider) return false;
+ const handles = [...slider.querySelectorAll('.handle')];
+ const isHandle = handles.includes(el);
+ const isChild = handles.some((handle) => handle.contains(el));
+ return isHandle || isChild;
+ }
+
+ /**
+ * trim the values array based on whether the property
+ * for 'range' is 'min', 'max', or truthy. This is because we
+ * do not want more than one handle for a min/max range, and we do
+ * not want more than two handles for a true range.
+ * @param {number[]} values the input values for the rangeSlider
+ * @return {number[]} the range array for creating a rangeSlider
+ **/
+ function trimRange(values: number[]): number[] {
+ if (range === 'min' || range === 'max') {
+ return values.slice(0, 1);
+ }
+ if (range) {
+ return values.slice(0, 2);
+ }
+
+ return values;
+ }
+
+ /**
+ * helper to return the slider dimensions for finding
+ * the closest handle to user interaction
+ * @return {DOMRect} the range slider DOM client rect
+ **/
+ function getSliderDimensions(): DOMRect | undefined {
+ return slider?.getBoundingClientRect();
+ }
+
+ /**
+ * helper to return closest handle to user interaction
+ * @param {{ x: number, y: number }} clientPos the client{x,y} positions to check against
+ * @return {number} the index of the closest handle to clientPos
+ **/
+ function getClosestHandle(clientPos: { x: number; y: number }): number {
+ // first make sure we have the latest dimensions
+ // of the slider, as it may have changed size
+ const dims = getSliderDimensions();
+ if (!dims) throw new Error('No Slider Dimensions yet.');
+ // calculate the interaction position, percent and value
+ let handlePos = 0;
+ let handlePercent = 0;
+ let handleVal = 0;
+ if (vertical) {
+ handlePos = clientPos.y - dims.top;
+ handlePercent = (handlePos / dims.height) * 100;
+ handlePercent = reversed ? handlePercent : 100 - handlePercent;
+ } else {
+ handlePos = clientPos.x - dims.left;
+ handlePercent = (handlePos / dims.width) * 100;
+ handlePercent = reversed ? 100 - handlePercent : handlePercent;
+ }
+ handleVal = ((max - min) / 100) * handlePercent + min;
+
+ // if we have a range, and the handles are at the same
+ // position, we want a simple check if the interaction
+ // value is greater than return the second handle
+ if (range === true && values[0] === values[1]) {
+ if (handleVal > values[1]) {
+ return 1;
+ }
+
+ return 0;
+
+ // if there are multiple handles, and not a range, then
+ // we sort the handles values, and return the first one closest
+ // to the interaction value
+ }
+
+ return values.indexOf(
+ [...values].sort((a, b) => Math.abs(handleVal - a) - Math.abs(handleVal - b))[0]
+ );
+ }
+
+ /**
+ * take the interaction position on the slider, convert
+ * it to a value on the range, and then send that value
+ * through to the moveHandle() method to set the active
+ * handle's position
+ * @param {{ x: number, y: number }} clientPos the client{x,y} of the interaction
+ **/
+ function handleInteract(clientPos: { x: number; y: number }) {
+ // first make sure we have the latest dimensions
+ // of the slider, as it may have changed size
+ const dims = getSliderDimensions();
+ if (!dims) throw new Error('No Slider Dimensions yet.');
+ // calculate the interaction position, percent and value
+ let handlePos = 0;
+ let handlePercent = 0;
+ let handleVal = 0;
+ if (vertical) {
+ handlePos = clientPos.y - dims.top;
+ handlePercent = (handlePos / dims.height) * 100;
+ handlePercent = reversed ? handlePercent : 100 - handlePercent;
+ } else {
+ handlePos = clientPos.x - dims.left;
+ handlePercent = (handlePos / dims.width) * 100;
+ handlePercent = reversed ? 100 - handlePercent : handlePercent;
+ }
+ handleVal = ((max - min) / 100) * handlePercent + min;
+ // move handle to the value
+ moveHandle(activeHandle, handleVal);
+ }
+
+ let lastSetValue = NaN;
+ /**
+ * move a handle to a specific value, respecting the clamp/align rules
+ * @param {number} index the index of the handle we want to move
+ * @param {number} handleValue the value to move the handle to
+ * @return {number} the value that was moved to (after alignment/clamping)
+ **/
+ function moveHandle(index: number | undefined, handleValue: number): number {
+ // align & clamp the value so we're not doing extra
+ // calculation on an out-of-range value down below
+ handleValue = alignValueToStep(handleValue);
+ // use the active handle if handle index is not provided
+ if (typeof index === 'undefined') {
+ index = activeHandle;
+ }
+ // if this is a range slider perform special checks
+ if (range) {
+ // restrict the handles of a range-slider from
+ // going past one-another unless "pushy" is true
+ if (index === 0 && handleValue > values[1]) {
+ if (pushy) {
+ values[1] = handleValue;
+ } else {
+ handleValue = values[1];
+ }
+ } else if (index === 1 && handleValue < values[0]) {
+ if (pushy) {
+ values[0] = handleValue;
+ } else {
+ handleValue = values[0];
+ }
+ }
+ }
+
+ // if the value has changed, update it
+ if (values[index] !== handleValue) {
+ values[index] = handleValue;
+ }
+
+ // fire the change event when the handle moves,
+ // and store the previous value for the next time
+ if (previousValue !== handleValue) {
+ handleOnChange();
+ previousValue = handleValue;
+ }
+ lastSetValue = handleValue;
+ value = handleValue;
+ return handleValue;
+ }
+ $effect(() => {
+ if (value !== lastSetValue) moveHandle(undefined, value);
+ });
+
+ /**
+ * helper to find the beginning range value for use with css style
+ * @param {number[]} values the input values for the rangeSlider
+ * @return {number} the beginning of the range
+ **/
+ function rangeStart(values: number[]): number {
+ if (range === 'min') {
+ return 0;
+ }
+
+ return values[0];
+ }
+
+ /**
+ * helper to find the ending range value for use with css style
+ * @param {array} values the input values for the rangeSlider
+ * @return {number} the end of the range
+ **/
+ function rangeEnd(values: Array<any>): number {
+ if (range === 'max') {
+ return 0;
+ }
+
+ if (range === 'min') {
+ return 100 - values[0];
+ }
+
+ return 100 - values[1];
+ }
+
+ /**
+ * helper to take a string of html and return only the text
+ * @param {string} possibleHtml the string that may contain html
+ * @return {string} the text from the input
+ */
+ function pureText(possibleHtml: string): string {
+ return `${possibleHtml}`.replace(/<[^>]*>/g, '');
+ }
+
+ /**
+ * when the user has unfocussed (blurred) from the
+ * slider, deactivate all handles
+ **/
+ function sliderBlurHandle() {
+ if (!keyboardActive) {
+ return;
+ }
+
+ focus = false;
+ handleActivated = false;
+ handlePressed = false;
+ }
+
+ /**
+ * when the user focusses the handle of a slider
+ * set it to be active
+ * @param {Event} e the event from browser
+ **/
+ function sliderFocusHandle(e: Event) {
+ if (disabled) {
+ return;
+ }
+
+ const target = e.target as HTMLElement;
+ activeHandle = index(target);
+ focus = true;
+ }
+
+ /**
+ * handle the keyboard accessible features by checking the
+ * input type, and modfier key then moving handle by appropriate amount
+ * @param {KeyboardEvent} e the event from browser
+ **/
+ function sliderKeydown(e: KeyboardEvent) {
+ if (disabled) {
+ return;
+ }
+
+ const target = e.target as HTMLElement;
+ const handle = index(target);
+ let jump = e.ctrlKey || e.metaKey || e.shiftKey ? step * 10 : step;
+ let prevent = false;
+
+ if (e.key === 'PageDown' || e.key === 'PageUp') {
+ jump *= 10;
+ }
+
+ if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
+ moveHandle(handle, values[handle] + jump);
+ prevent = true;
+ }
+
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
+ moveHandle(handle, values[handle] - jump);
+ prevent = true;
+ }
+
+ if (e.key === 'Home') {
+ moveHandle(handle, min);
+ prevent = true;
+ }
+
+ if (e.key === 'End') {
+ moveHandle(handle, max);
+ prevent = true;
+ }
+
+ if (prevent) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ }
+
+ /**
+ * function to run when the user touches the slider element anywhere
+ * @param {MouseEvent|TouchEvent} e the event from browser
+ **/
+ function sliderInteractStart(e: MouseEvent | TouchEvent) {
+ if (disabled) {
+ return;
+ }
+
+ const element = e.target as HTMLElement;
+ const clientPos = normalisedClient(e);
+ // set the closest handle as active
+ focus = true;
+ handleActivated = true;
+ handlePressed = true;
+ activeHandle = getClosestHandle(clientPos);
+
+ // fire the start event
+ startValue = previousValue = alignValueToStep(values[activeHandle]);
+ handleOnStart();
+
+ // for touch devices we want the handle to instantly
+ // move to the position touched for more responsive feeling
+ if (e.type === 'touchstart' && !element.matches('.pipVal')) {
+ handleInteract(clientPos);
+ }
+ }
+
+ /**
+ * function to run when the user stops touching
+ * down on the slider element anywhere
+ * @param {Event} e the event from browser
+ **/
+ function sliderInteractEnd(e: Event) {
+ // fire the stop event for touch devices
+ if (e.type === 'touchend') {
+ handleOnStop();
+ }
+ handlePressed = false;
+ }
+
+ /**
+ * unfocus the slider if the user clicked off of
+ * it, somewhere else on the screen
+ * @param {MouseEvent|TouchEvent} e the event from browser
+ **/
+ function bodyInteractStart(e: MouseEvent | TouchEvent) {
+ keyboardActive = false;
+ const target = e.target as HTMLElement;
+ if (slider && focus && e.target !== slider && !slider.contains(target)) {
+ focus = false;
+ }
+ }
+
+ /**
+ * send the clientX through to handle the interaction
+ * whenever the user moves across screen while active
+ * @param {MouseEvent|TouchEvent} e the event from browser
+ **/
+ function bodyInteract(e: MouseEvent | TouchEvent) {
+ if (!disabled) {
+ if (handleActivated) {
+ handleInteract(normalisedClient(e));
+ }
+ }
+ }
+
+ /**
+ * if user triggers mouseup on the body while
+ * a handle is active (without moving) then we
+ * trigger an interact event there
+ * @param {MouseEvent|TouchEvent} e the event from browser
+ **/
+ function bodyMouseUp(e: MouseEvent | TouchEvent) {
+ if (!disabled) {
+ const el = e.target as HTMLElement;
+ // this only works if a handle is active, which can
+ // only happen if there was sliderInteractStart triggered
+ // on the slider, already
+ if (handleActivated) {
+ if (slider && (el === slider || slider.contains(el))) {
+ focus = true;
+ // don't trigger interact if the target is a handle (no need) or
+ // if the target is a label (we want to move to that value from rangePips)
+ if (!targetIsHandle(el) && !el.matches('.pipVal')) {
+ handleInteract(normalisedClient(e));
+ }
+ }
+ // fire the stop event for mouse device
+ // when the body is triggered with an active handle
+ handleOnStop();
+ }
+ }
+ handleActivated = false;
+ handlePressed = false;
+ }
+
+ /**
+ * @param {KeyboardEvent} e
+ */
+ function bodyKeyDown(e: KeyboardEvent) {
+ if (disabled) {
+ return;
+ }
+
+ const target = e.target as HTMLElement;
+
+ if (slider && (e.target === slider || slider.contains(target))) {
+ keyboardActive = true;
+ }
+ }
+
+ function handleOnStop() {
+ if (disabled || !onstop || typeof onstop !== 'function') {
+ return;
+ }
+
+ onstop({
+ activeHandle,
+ startValue: startValue ?? 0,
+ value: values[activeHandle],
+ values: values.map((v) => alignValueToStep(v))
+ });
+ }
+
+ function handleOnStart() {
+ if (disabled || !onstart || typeof onstart !== 'function') {
+ return;
+ }
+
+ onstart({
+ activeHandle,
+ value: startValue ?? 0,
+ values: values.map((v) => alignValueToStep(v))
+ });
+ }
+
+ function handleOnChange() {
+ if (disabled || !onchange || typeof onchange !== 'function') {
+ return;
+ }
+
+ onchange({
+ activeHandle,
+ startValue: startValue ?? 0,
+ previousValue: typeof previousValue === 'undefined' ? (startValue ?? 0) : previousValue,
+ value: values[activeHandle],
+ values: values.map((v) => alignValueToStep(v))
+ });
+ }
+
+ /** @type {import('svelte/motion').Spring<number[]>} */
+ let springPositions: import('svelte/motion').Spring<number[]> = spring(
+ values.map((v) => percentOf(v)),
+ springOptions
+ );
+</script>
+
+<div
+ {id}
+ bind:this={slider}
+ role="none"
+ class="_rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28"
+ class:range
+ class:disabled
+ class:hoverable
+ class:vertical
+ class:reversed
+ class:focus
+ class:min={range === 'min'}
+ class:max={range === 'max'}
+ class:pips
+ class:pip-labels={all === 'label' || first === 'label' || last === 'label' || rest === 'label'}
+ onmousedown={sliderInteractStart}
+ onmouseup={sliderInteractEnd}
+>
+ {#each values as value, index}
+ <span
+ role="slider"
+ class="rangeHandle"
+ class:active={focus && activeHandle === index}
+ class:press={handlePressed && activeHandle === index}
+ data-handle={index}
+ onblur={sliderBlurHandle}
+ onfocus={sliderFocusHandle}
+ onkeydown={sliderKeydown}
+ style="{orientationStart}: {$springPositions[index]}%; z-index: {activeHandle === index
+ ? 3
+ : 2};"
+ aria-label={ariaLabels[index]}
+ aria-valuemin={range === true && index === 1 ? values[0] : min}
+ aria-valuemax={range === true && index === 0 ? values[1] : max}
+ aria-valuenow={value}
+ aria-valuetext="{prefix}{pureText(handleFormatter(value, index, percentOf(value)))}{suffix}"
+ aria-orientation={vertical ? 'vertical' : 'horizontal'}
+ aria-disabled={disabled}
+ tabindex={disabled ? -1 : 0}
+ >
+ <span class="rangeNub"></span>
+ {#if float}
+ <span class="rangeFloat">
+ {prefix}{handleFormatter(value, index, percentOf(value))}{suffix}
+ </span>
+ {/if}
+ </span>
+ {/each}
+
+ {#if range}
+ <span
+ class="rangeBar"
+ style="{orientationStart}: {rangeStart($springPositions)}%;
+ {orientationEnd}: {rangeEnd($springPositions)}%;"
+ ></span>
+ {/if}
+
+ {#if pips}
+ <RangePips
+ {values}
+ {min}
+ {max}
+ {step}
+ {range}
+ {vertical}
+ {reversed}
+ {orientationStart}
+ {hoverable}
+ {disabled}
+ {all}
+ {first}
+ {last}
+ {rest}
+ {pipstep}
+ {prefix}
+ {suffix}
+ {formatter}
+ {focus}
+ {percentOf}
+ {moveHandle}
+ {fixFloat}
+ {normalisedClient}
+ />
+ {/if}
+</div>
+
+<svelte:window
+ onmousedown={bodyInteractStart}
+ onmousemove={bodyInteract}
+ onmouseup={bodyMouseUp}
+ onkeydown={bodyKeyDown}
+/>
+
+<style>
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28) {
+ --slider: var(--range-slider, #d7dada);
+ --handle-inactive: var(--range-handle-inactive, #99a2a2);
+ --handle: var(--range-handle, #838de7);
+ --handle-focus: var(--range-handle-focus, #4a40d4);
+ --handle-border: var(--range-handle-border, var(--handle));
+ --range-inactive: var(--range-range-inactive, var(--handle-inactive));
+ --range: var(--range-range, var(--handle-focus));
+ --float-inactive: var(--range-float-inactive, var(--handle-inactive));
+ --float: var(--range-float, var(--handle-focus));
+ --float-text: var(--range-float-text, white);
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28) {
+ position: relative;
+ border-radius: 100px;
+ height: 0.5em;
+ margin: 1em;
+ transition: opacity 0.2s ease;
+ user-select: none;
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28 *) {
+ user-select: none;
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.pips) {
+ margin-bottom: 1.8em;
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.pip-labels) {
+ margin-bottom: 2.8em;
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.vertical) {
+ display: inline-block;
+ border-radius: 100px;
+ width: 0.5em;
+ min-height: 200px;
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.vertical.pips) {
+ margin-right: 1.8em;
+ margin-bottom: 1em;
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.vertical.pip-labels) {
+ margin-right: 2.8em;
+ margin-bottom: 1em;
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28 .rangeHandle) {
+ position: absolute;
+ display: block;
+ height: 1.4em;
+ width: 1.4em;
+ top: 0.25em;
+ bottom: auto;
+ transform: translateY(-50%) translateX(-50%);
+ z-index: 2;
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.reversed .rangeHandle) {
+ transform: translateY(-50%) translateX(50%);
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.vertical .rangeHandle) {
+ left: 0.25em;
+ top: auto;
+ transform: translateY(50%) translateX(-50%);
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.vertical.reversed .rangeHandle) {
+ transform: translateY(-50%) translateX(-50%);
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28 .rangeNub),
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28 .rangeHandle:before) {
+ position: absolute;
+ left: 0;
+ top: 0;
+ display: block;
+ border-radius: 10em;
+ height: 100%;
+ width: 100%;
+ transition: box-shadow 0.2s ease;
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28 .rangeHandle:before) {
+ content: '';
+ left: 1px;
+ top: 1px;
+ bottom: 1px;
+ right: 1px;
+ height: auto;
+ width: auto;
+ box-shadow: 0 0 0 0px var(--handle-border);
+ opacity: 0;
+ }
+ :global(
+ ._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.hoverable:not(.disabled)
+ .rangeHandle:hover:before
+ ) {
+ box-shadow: 0 0 0 8px var(--handle-border);
+ opacity: 0.2;
+ }
+ :global(
+ ._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.hoverable:not(.disabled)
+ .rangeHandle.press:before
+ ),
+ :global(
+ ._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.hoverable:not(.disabled)
+ .rangeHandle.press:hover:before
+ ) {
+ box-shadow: 0 0 0 12px var(--handle-border);
+ opacity: 0.4;
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.range:not(.min):not(.max) .rangeNub) {
+ border-radius: 10em 10em 10em 1.6em;
+ }
+ :global(
+ ._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.range .rangeHandle:nth-of-type(1) .rangeNub
+ ) {
+ transform: rotate(-135deg);
+ }
+ :global(
+ ._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.range .rangeHandle:nth-of-type(2) .rangeNub
+ ) {
+ transform: rotate(45deg);
+ }
+ :global(
+ ._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.range.reversed
+ .rangeHandle:nth-of-type(1)
+ .rangeNub
+ ) {
+ transform: rotate(45deg);
+ }
+ :global(
+ ._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.range.reversed
+ .rangeHandle:nth-of-type(2)
+ .rangeNub
+ ) {
+ transform: rotate(-135deg);
+ }
+ :global(
+ ._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.range.vertical
+ .rangeHandle:nth-of-type(1)
+ .rangeNub
+ ) {
+ transform: rotate(135deg);
+ }
+ :global(
+ ._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.range.vertical
+ .rangeHandle:nth-of-type(2)
+ .rangeNub
+ ) {
+ transform: rotate(-45deg);
+ }
+ :global(
+ ._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.range.vertical.reversed
+ .rangeHandle:nth-of-type(1)
+ .rangeNub
+ ) {
+ transform: rotate(-45deg);
+ }
+ :global(
+ ._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.range.vertical.reversed
+ .rangeHandle:nth-of-type(2)
+ .rangeNub
+ ) {
+ transform: rotate(135deg);
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28 .rangeFloat) {
+ display: block;
+ position: absolute;
+ left: 50%;
+ top: -0.5em;
+ transform: translate(-50%, -100%);
+ font-size: 1em;
+ text-align: center;
+ opacity: 0;
+ pointer-events: none;
+ white-space: nowrap;
+ transition: all 0.2s ease;
+ font-size: 0.9em;
+ padding: 0.2em 0.4em;
+ border-radius: 0.2em;
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28 .rangeHandle.active .rangeFloat),
+ :global(
+ ._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.hoverable .rangeHandle:hover .rangeFloat
+ ) {
+ opacity: 1;
+ top: -0.2em;
+ transform: translate(-50%, -100%);
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28 .rangeBar) {
+ position: absolute;
+ display: block;
+ transition: background 0.2s ease;
+ border-radius: 1em;
+ height: 0.5em;
+ top: 0;
+ user-select: none;
+ z-index: 1;
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.vertical .rangeBar) {
+ width: 0.5em;
+ height: auto;
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28) {
+ background-color: var(--slider, #d7dada);
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28 .rangeBar) {
+ background-color: var(--range-inactive, #99a2a2);
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.focus .rangeBar) {
+ background-color: var(--range, #838de7);
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28 .rangeNub) {
+ background-color: var(--handle-inactive, #99a2a2);
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.focus .rangeNub) {
+ background-color: var(--handle, #838de7);
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28 .rangeHandle.active .rangeNub) {
+ background-color: var(--handle-focus, #4a40d4);
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28 .rangeFloat) {
+ color: white;
+ color: var(--float-text);
+ background-color: var(--float-inactive, #99a2a2);
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.focus .rangeFloat) {
+ background-color: var(--float, #4a40d4);
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.disabled) {
+ opacity: 0.5;
+ }
+ :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28.disabled .rangeNub) {
+ background-color: var(--slider, #d7dada);
+ }
+</style>
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
new file mode 100644
index 0000000..8c56a3c
--- /dev/null
+++ b/src/routes/+layout.svelte
@@ -0,0 +1,12 @@
+<script lang="ts">
+ import '../app.css';
+ import favicon from '$lib/assets/favicon.svg';
+
+ let { children } = $props();
+</script>
+
+<svelte:head>
+ <link rel="icon" href={favicon} />
+</svelte:head>
+
+{@render children?.()}
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
new file mode 100644
index 0000000..8d09da5
--- /dev/null
+++ b/src/routes/+page.svelte
@@ -0,0 +1,6 @@
+<script lang="ts">
+ import Editor from '$/lib/Player/Player.svelte';
+ import Video from '$/user';
+</script>
+
+<Editor {Video} />
diff --git a/src/routes/ffmpeg-test/+page.svelte b/src/routes/ffmpeg-test/+page.svelte
new file mode 100644
index 0000000..226fc3c
--- /dev/null
+++ b/src/routes/ffmpeg-test/+page.svelte
@@ -0,0 +1,45 @@
+<script lang="ts">
+ import { FFmpeg } from '@ffmpeg/ffmpeg';
+ // @ts-ignore
+ import type { LogEvent } from '@ffmpeg/ffmpeg/dist/esm/types';
+ import { fetchFile, toBlobURL } from '@ffmpeg/util';
+
+ let videoEl: HTMLVideoElement;
+
+ const baseURL = 'https://unpkg.com/@ffmpeg/core-mt@0.12.6/dist/esm';
+ const videoURL = 'https://raw.githubusercontent.com/ffmpegwasm/testdata/master/video-15s.avi';
+
+ let message = $state('Click Start to Transcode');
+
+ const transcode = async () => {
+ const ffmpeg = new FFmpeg();
+ message = 'Loading ffmpeg-core.js';
+ ffmpeg.on('log', ({ message: msg }: LogEvent) => {
+ message = msg;
+ console.log(message);
+ });
+ await ffmpeg.load({
+ coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
+ wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
+ workerURL: await toBlobURL(`${baseURL}/ffmpeg-core.worker.js`, 'text/javascript')
+ });
+ message = 'Start transcoding';
+ await ffmpeg.writeFile('test.avi', await fetchFile(videoURL));
+ await ffmpeg.exec(['-i', 'test.avi', 'test.mp4']);
+ message = 'Complete transcoding';
+ const data = await ffmpeg.readFile('test.mp4');
+ console.log('done');
+ videoEl.src = URL.createObjectURL(
+ // @ts-ignore bufferlike is good enuf
+ new Blob([(data as Uint8Array).buffer], { type: 'video/mp4' })
+ );
+ };
+</script>
+
+<div>
+ <!-- svelte-ignore a11y_media_has_caption -->
+ <video bind:this={videoEl} controls></video>
+ <br />
+ <button onclick={transcode}>Start</button>
+ <p>{message}</p>
+</div>
diff --git a/src/routes/render/+page.svelte b/src/routes/render/+page.svelte
new file mode 100644
index 0000000..e9ecd91
--- /dev/null
+++ b/src/routes/render/+page.svelte
@@ -0,0 +1,6 @@
+<script lang="ts">
+ import Renderer from '$/lib/Renderer/Renderer.svelte';
+ import Video from '$/user';
+</script>
+
+<Renderer {Video} />
diff --git a/src/user/Sneky Snitch.mp4 b/src/user/Sneky Snitch.mp4
new file mode 100644
index 0000000..b9d53b5
--- /dev/null
+++ b/src/user/Sneky Snitch.mp4
Binary files differ
diff --git a/src/user/index.ts b/src/user/index.ts
new file mode 100644
index 0000000..2caf88c
--- /dev/null
+++ b/src/user/index.ts
@@ -0,0 +1,21 @@
+import { Video as BaseVideo, type FrameTime } from '$/lib/Player/Video';
+import SneakySnitchUrl from './Sneky Snitch.mp4?url'
+
+export default class Video extends BaseVideo {
+ public ctx!: CanvasRenderingContext2D
+ public init(): void | Promise<void> {
+ // this.resize(this.canvas.clientWidth,this.canvas.clientHeight)
+ this.resize(1920, 1080)
+ this.ctx = this.canvas.getContext('2d')!
+ }
+ public renderFrame(time: FrameTime): Promise<void> | void {
+ this.ctx.fillStyle = '#000'
+ this.ctx.fillRect(0, 0, this.w, this.h)
+ this.ctx.font = "50px Nunito";
+ this.ctx.fillStyle = '#fff'
+ this.ctx.fillText(`${time.seconds.toFixed(3)}`, 0, 50)
+ }
+ public fps = 30;
+ public length = 3 * this.fps;
+ public audioUrl = ['sneakysnitch.mp4', SneakySnitchUrl] as const
+}