aboutsummaryrefslogtreecommitdiffstats
path: root/src/lib/Player
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/lib/Player
downloadvideotool-e55f4c2fe8a6e1d62a0b005777b46c80e360d37e.tar.gz
videotool-e55f4c2fe8a6e1d62a0b005777b46c80e360d37e.tar.bz2
videotool-e55f4c2fe8a6e1d62a0b005777b46c80e360d37e.tar.lz
videotool-e55f4c2fe8a6e1d62a0b005777b46c80e360d37e.zip

feat: initial commit

Diffstat (limited to 'src/lib/Player')
-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
4 files changed, 330 insertions, 0 deletions
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