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;