From 9ab2e2884b956bc79c01d6edfe867a85c2870796 Mon Sep 17 00:00:00 2001 From: memdmp Date: Sat, 2 Aug 2025 13:25:30 +0200 Subject: feat: more things --- deno.lock | 4 + package.json | 1 + pnpm-lock.yaml | 51 ++++++++++ src/user/ThreeVideo.ts | 47 +++++++++- src/user/index.ts | 246 +++++++++++++++++++++++++++++++++++++++++-------- 5 files changed, 309 insertions(+), 40 deletions(-) diff --git a/deno.lock b/deno.lock index df9d81e..b88a794 100644 --- a/deno.lock +++ b/deno.lock @@ -1018,16 +1018,20 @@ ], "packageJson": { "dependencies": [ + "npm:@ffmpeg/ffmpeg@~0.12.15", + "npm:@ffmpeg/util@~0.12.2", "npm:@sveltejs/adapter-static@3.0.8", "npm:@sveltejs/kit@2.19.0", "npm:@sveltejs/vite-plugin-svelte@5.0.3", "npm:@tailwindcss/vite@4.1.11", + "npm:@types/three@~0.178.1", "npm:esbuild@0.25.1", "npm:prettier-plugin-svelte@3.3.3", "npm:prettier@3.5.3", "npm:svelte-check@4.1.5", "npm:svelte@5.23.0", "npm:tailwindcss@4.0.13", + "npm:three@0.178", "npm:typescript@5.8.2", "npm:vite@6.2.1" ] diff --git a/package.json b/package.json index 72507e8..1a48225 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "@sveltejs/kit": "2.19.0", "@sveltejs/vite-plugin-svelte": "5.0.3", "@tailwindcss/vite": "4.1.11", + "@types/three": "^0.178.1", "esbuild": "0.25.1", "prettier": "3.5.3", "prettier-plugin-svelte": "3.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae7f10c..7ca5702 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,9 @@ importers: '@tailwindcss/vite': specifier: 4.1.11 version: 4.1.11(vite@6.2.1(jiti@2.4.2)(lightningcss@1.30.1)) + '@types/three': + specifier: ^0.178.1 + version: 0.178.1 esbuild: specifier: 0.25.1 version: 0.25.1 @@ -61,6 +64,9 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@esbuild/aix-ppc64@0.25.1': resolution: {integrity: sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==} engines: {node: '>=18'} @@ -467,12 +473,27 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.178.1': + resolution: {integrity: sha512-WSabew1mgWgRx2RfLfKY+9h4wyg6U94JfLbZEGU245j/WY2kXqU0MUfghS+3AYMV5ET1VlILAgpy77cB6a3Itw==} + + '@types/webxr@0.5.22': + resolution: {integrity: sha512-Vr6Stjv5jPRqH690f5I5GLjVk8GSsoQSYJ2FVd/3jJF7KaqfwPi3ehfBS96mlQ2kPCwZaX6U0rG2+NGHBKkA/A==} + + '@webgpu/types@0.1.64': + resolution: {integrity: sha512-84kRIAGV46LJTlJZWxShiOrNL30A+9KokD7RB3dRCIqODFjodS5tCD5yyiZ8kIReGVZSDfA3XkkwyyOIF6K62A==} + acorn@8.14.1: resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==} engines: {node: '>=0.4.0'} @@ -545,6 +566,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -637,6 +661,9 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + meshoptimizer@0.18.1: + resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==} + minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} @@ -808,6 +835,8 @@ snapshots: '@jridgewell/gen-mapping': 0.3.8 '@jridgewell/trace-mapping': 0.3.25 + '@dimforge/rapier3d-compat@0.12.0': {} + '@esbuild/aix-ppc64@0.25.1': optional: true @@ -1089,10 +1118,28 @@ snapshots: tailwindcss: 4.1.11 vite: 6.2.1(jiti@2.4.2)(lightningcss@1.30.1) + '@tweenjs/tween.js@23.1.3': {} + '@types/cookie@0.6.0': {} '@types/estree@1.0.6': {} + '@types/stats.js@0.17.4': {} + + '@types/three@0.178.1': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.22 + '@webgpu/types': 0.1.64 + fflate: 0.8.2 + meshoptimizer: 0.18.1 + + '@types/webxr@0.5.22': {} + + '@webgpu/types@0.1.64': {} + acorn@8.14.1: {} aria-query@5.3.2: {} @@ -1160,6 +1207,8 @@ snapshots: fdir@6.4.3: {} + fflate@0.8.2: {} + fsevents@2.3.3: optional: true @@ -1226,6 +1275,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + meshoptimizer@0.18.1: {} + minipass@7.1.2: {} minizlib@3.0.2: diff --git a/src/user/ThreeVideo.ts b/src/user/ThreeVideo.ts index bbce038..92615f1 100644 --- a/src/user/ThreeVideo.ts +++ b/src/user/ThreeVideo.ts @@ -1,8 +1,53 @@ import { Video as BaseVideo, type FrameTime, type InitConfig } from '$/lib/Player/Video'; import * as THREE from 'three'; +import type { OrbitControls } from 'three/examples/jsm/Addons.js'; + +export const OnceCell = (create: () => T) => { + let called = false, + cached = null as unknown as T + return () => { + if (called) return cached + else { cached = create(); called = true; return cached } + } +} export default abstract class ThreeVideo extends BaseVideo { - public async init(config: InitConfig): Promise { + protected abstract ctx: CanvasRenderingContext2D + protected scene!: THREE.Scene; + protected camera!: THREE.PerspectiveCamera; + protected renderer!: THREE.WebGLRenderer; + protected threeCanvas!: HTMLCanvasElement; + protected orbitControls?: OrbitControls; + public async init(_config: InitConfig): Promise { + this.scene = new THREE.Scene(); + this.camera = new THREE.PerspectiveCamera(75, this.w / this.h, 0.1, 1000); + const canvas = this.threeCanvas = document.createElement('canvas'); + canvas.width = this.canvas.width + canvas.height = this.canvas.height + canvas.style.opacity = "0" + canvas.style.position = "fixed"; + canvas.style.top = "1000vh" + canvas.style.left = "1000vw" + document.body.appendChild(canvas) + + this.renderer = new THREE.WebGLRenderer({ + canvas: canvas, + alpha: true, + powerPreference: 'high-performance', + }); + this.renderer.setSize(this.w, this.h); + this.renderer.setAnimationLoop(() => this.renderScene()); + } + public renderScene(ctx?: CanvasRenderingContext2D) { + if (this.orbitControls) + this.orbitControls.update() + this.renderer.render(this.scene, this.camera) + if (ctx) + ctx.drawImage(this.threeCanvas, 0, 0) + } + public cleanup(): void { + this.renderer.dispose() + this.threeCanvas.remove() } } diff --git a/src/user/index.ts b/src/user/index.ts index fb0001e..541cd94 100644 --- a/src/user/index.ts +++ b/src/user/index.ts @@ -1,6 +1,8 @@ import { type FrameTime, type InitConfig } from '$/lib/Player/Video'; -import ThreeVideo from './ThreeVideo'; -import AudioURL from './03. Lemaitre, Jennie A. - Closer - 2min version.flac?url' +import ThreeVideo, { OnceCell } from './ThreeVideo'; +import AudioURL from './03. Lemaitre, Jennie A. - Closer - 40sec version.flac?url' +import * as THREE from 'three'; +import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js'; type FontInfo = { family: string, size: number, weight?: number @@ -16,7 +18,21 @@ const getTextSize = (ctx: CanvasRenderingContext2D, text: string, fontInfo: Font return ctx.measureText(text) } +const lerp = (t: number, initial: number, final: number) => { + return initial + ((final - initial) * t) +} +const bezier = (t: number, initial: number, p1: number, p2: number, final: number) => { + return (1 - t) * (1 - t) * (1 - t) * initial + + + 3 * (1 - t) * (1 - t) * t * p1 + + + 3 * (1 - t) * t * t * p2 + + + t * t * t * final; +} + export default class Video extends ThreeVideo { + protected ctx!: CanvasRenderingContext2D protected isPreview = false; protected px(pixels: number) { return this.isPreview ? pixels / 1.5 : pixels @@ -25,6 +41,10 @@ export default class Video extends ThreeVideo { const { isPreview } = config this.isPreview = isPreview this.resize(this.px(1920), this.px(1080)) + this.ctx = this.canvas.getContext('2d', { + willReadFrequently: !isPreview, + desynchronized: isPreview, + })! const threeInit = super.init(config).catch(e => ([1, e] as const)) // const v = document.createElement('video') @@ -44,47 +64,195 @@ export default class Video extends ThreeVideo { const rs = await threeInit if (rs && rs[0] === 1) { console.error(rs[1]); throw new Error('Failed to initialize ThreeJS!'); } } + protected uiGeometry = OnceCell(() => new THREE.BoxGeometry(0.1, 5, 7)); + protected uiDarkMaterial = OnceCell(() => new THREE.MeshStandardMaterial({ + roughness: 0.8, + color: 0xffffff, + metalness: 0.2, + bumpScale: 1 + })); + protected uiDark = OnceCell(() => new THREE.Mesh(this.uiGeometry(), this.uiDarkMaterial())) + protected uiLightMaterial = OnceCell(() => new THREE.MeshBasicMaterial({ color: 0x000000 })) + protected uiLight = OnceCell(() => new THREE.Mesh(this.uiGeometry(), this.uiLightMaterial())) + protected lighting = OnceCell(() => { + const dirLight = new THREE.DirectionalLight(0xffffff, 3); + dirLight.castShadow = true; + dirLight.shadow.camera.top = 0; + dirLight.shadow.camera.bottom = 0; + dirLight.shadow.camera.left = 0; + dirLight.shadow.camera.right = 0; + dirLight.shadow.camera.near = 0.1; + dirLight.shadow.camera.far = 90; + + const cam = dirLight.shadow.camera; + cam.top = cam.right = 0; + cam.bottom = cam.left = 0; + cam.near = 3; + cam.far = 8; + dirLight.shadow.mapSize.set(1024, 1024); + + return dirLight; + }) + protected uiCanvas = OnceCell(() => { + const c = document.createElement('canvas'); + c.width = this.px(1000); + c.height = c.width / 5 * 7; + return c; + }); + protected uiCanvasCtx = OnceCell(() => this.uiCanvas().getContext('2d', { + alpha: true, + })) + protected drawUiCanvas() { } public renderFrame(time: FrameTime): Promise | void { const beat = 1 + ((time.seconds - 0.098) * (92 / 60)) const center = [this.w / 2, this.h / 2] as const - // if (beat < 13 || beat >= 16) { - // this.ctx.fillStyle = '#fff'; - // this.ctx.fillRect(0, 0, this.w, this.h); - // } else { - // this.ctx.fillStyle = '#000'; - // this.ctx.fillRect(0, 0, this.w, this.h); - // } - // const AdDefault: FontInfo = { - // family: 'Inter Variable', - // size: this.px(52), - // weight: 450 - // } - // if (this.isPreview) - // renderText(this.ctx, `${(Math.floor(beat * 100) / 100).toFixed(1)}`, '#646663', { ...AdDefault, size: this.px(12), weight: 400 }, { x: this.w - this.px(4), y: this.h - this.px(4) }, 'end') - // switch (true) { - // case beat >= 1 && beat < 4.3: - // renderText(this.ctx, `Need a new AI assistant?`, '#646663', AdDefault, { x: center[0], y: center[1] }, 'center') - // break; - // case beat >= 4.3 && beat < 8.4: { - // const text = `Like${beat >= 4.8 ? ' new' : ''}${beat >= 5.02 ? ' new' : ''}`; - // const longTextWidth = getTextSize(this.ctx, 'Like new new', AdDefault) - // renderText(this.ctx, text, '#646663', AdDefault, { x: center[0] - longTextWidth.width / 2, y: center[1] }, 'start') - // break; - // } - // case beat >= 8.4 && beat < 13: { - // const text = `Like${beat >= 9 ? ` runs random shit in your terminal` : ''}${beat >= 11 ? ' new' : ''}`; - // const longTextWidth = getTextSize(this.ctx, `Like runs random shit in your terminal new`, AdDefault) - // renderText(this.ctx, text, '#646663', AdDefault, { x: center[0] - longTextWidth.width / 2, y: center[1] }, 'start') - // break; - // } - // // TODO: + this.ctx.fillStyle = '#fff'; + this.ctx.fillRect(0, 0, this.w, this.h); + + this.scene.background = null; + + const AdDefault: FontInfo = { + // family: 'Inter Variable', + family: 'Adwaita Sans', + size: this.px(58), + weight: 450 + } + switch (true) { + case beat < 1: + break; + case beat >= 1 && beat < 4.3: + renderText(this.ctx, `Need a new AI assistant?`, '#646663', AdDefault, { x: center[0], y: center[1] }, 'center') + break; + case beat >= 4.3 && beat < 8.4: { + const text = `Like${beat >= 4.8 ? ' new' : ''}${beat >= 5.02 ? ' new?' : ''}`; + const longTextWidth = getTextSize(this.ctx, 'Like new new?', AdDefault) + renderText(this.ctx, text, '#646663', AdDefault, { x: center[0] - longTextWidth.width / 2, y: center[1] }, 'start') + break; + } + case beat >= 8.4 && beat < 13: { + const text = `Like${beat >= 9 ? ` has a hyprminimal design` : ''}${beat >= 11 ? ' new' : ''}`; + const longTextWidth = getTextSize(this.ctx, `Like has a hyprminimal design new`, AdDefault) + renderText(this.ctx, text, '#646663', AdDefault, { x: center[0] - longTextWidth.width / 2, y: center[1] }, 'start') + break; + } + case beat >= 13 && beat < 15: { + this.scene.background = new THREE.Color(0x000000); - // default: - // break; - // } + this.scene.add(this.lighting()) + + this.scene.add(this.uiDark()); + const progress = (beat - 13) / 3.5 + this.uiDark().rotation.x = bezier(progress, 0.4, 0.6, 0.6, 1.1); + this.uiDark().rotation.y = bezier(progress, 2, 1.7, 1.6, bezier(progress, 1, 0.8, 0.3, -0.5)); + this.camera.position.z = bezier(progress, 6, 4, 4, bezier(progress, 7, 9, 15, 25)); + this.lighting().position.set(0, 0, this.camera.position.z); + this.renderScene(this.ctx) + this.scene.remove(this.uiDark()); + this.scene.remove(this.lighting()) + break; + } + case beat >= 15 && beat < 16: { + this.scene.background = new THREE.Color(0x000000); + + this.scene.add(this.lighting()) + + this.scene.add(this.uiDark()); + const progress = (beat - 15) + this.uiDark().rotation.x = bezier(progress, -0.5, -0.5, -0.5, -0.5); + this.uiDark().rotation.y = bezier(progress, 0.7, 0.6, 0.4, 0.4); + this.camera.position.z = bezier(progress, 4, 5, 5, 8); + this.lighting().position.set(-0.5, 0, this.camera.position.z); + this.renderScene(this.ctx) + this.scene.remove(this.uiDark()); + this.scene.remove(this.lighting()) + break; + } + case beat >= 16 && beat < 18.4: { + this.scene.add(this.uiLight()); + const progress = (beat - 16) / 3.5 + this.uiLight().rotation.x = bezier(progress, -0.5, 0.6, 0.6, 1.1) * (beat < 17 ? 1 : -1); + this.uiLight().rotation.y = bezier(progress, 0.4, 1.7, 1.8, 2) * (beat < 17 ? 1 : -1); + this.camera.position.z = bezier(progress, 8, 4, 4, 12); + + this.renderScene(this.ctx) + this.scene.remove(this.uiLight()); + break; + } + case beat >= 18.4 && beat < 22.8: { + const text = `Efficiency ${beat >= 19 ? `so hyprefficient, ` : ''}${beat >= 20 ? 'we created it' : ''}${beat >= 21 ? ' hypr' + (beat >= 22.1 ? 'new' : '') : ''}`; + const longTextWidth = getTextSize(this.ctx, `Efficiency so hyprefficient, we created it hyprnew`, AdDefault) + renderText(this.ctx, text, '#646663', AdDefault, { x: center[0] - longTextWidth.width / 2, y: center[1] }, 'start') + break; + } + case beat >= 22.8 && beat < 25: + // TODO: add animation for hyprefficient + renderText(this.ctx, `TODO`, '#ff000099', AdDefault, { x: center[0], y: center[1] }, 'center') + break; + case beat >= 25 && beat < 31 || beat < 33: { + const text = `1 month free ${beat >= 26.5 ? 'for a hypr' : ''}${beat >= 26.7 ? 'local Mistral 7b' : ''}${beat >= 29 ? ' new' : ''}`; + const longTextWidth = getTextSize(this.ctx, `1 month free for a hyprlocal Mistral 7b new`, AdDefault) + renderText(this.ctx, text, '#646663', AdDefault, { x: center[0] - longTextWidth.width / 2, y: center[1] }, 'start') + renderText(this.ctx, '*Not guaranteed to work. Subscriptions start at 69.99CHF/mo and get billed on the first of the next month automatically.', '#64666366', { ...AdDefault, size: 12, weight: 400 }, { x: center[0], y: this.h - this.px(44) }, 'center') + renderText(this.ctx, 'Cancellable only within 12 hours of the first day of the month.', '#64666366', { ...AdDefault, size: 12, weight: 400 }, { x: center[0], y: this.h - this.px(24) }, 'center') + break; + } + case beat >= 31 && beat < 33://&& beat < 33: + // TODO: add animation for hyprsubscription + renderText(this.ctx, `i've already dumped too much time into this pls contribute`, '#ff000099', AdDefault, { x: center[0], y: center[1] }, 'center') + break; + case beat >= 33 && beat < 41: { + if (beat >= 34.75) { + this.ctx.fillStyle = '#23f'; + this.ctx.fillRect(0, 0, this.w, this.h); + // renderText(this.ctx, `Need a new AI assistant?`, '#646663', AdDefault, { x: center[0], y: center[1] }, 'center') + } + const text = `Accents ${beat >= 34.5 ? `so ` : ''}${beat >= 34.75 ? 'hyprblue, ' : ''}${beat >= 35.8 ? 'we created them' : ''}${beat >= 37 ? ' hypr' + (beat >= 38.1 ? 'new' : '') : ''}`; + const longTextWidth = getTextSize(this.ctx, `Accents so hyprblue, we created them hyprnew`, AdDefault) + renderText(this.ctx, text, beat >= 34.75 ? '#ffffff' : '#646663', AdDefault, { x: center[0] - longTextWidth.width / 2, y: center[1] }, 'start') + break; + } + + case beat >= 49 && beat < 56.8: { + const opacity1 = Math.min(Math.max(Math.floor(255 - lerp((beat - 50) * 1.1, 255, 0)), 0), 255).toString(16).padStart(2, '0') + const opacity2 = Math.min(Math.max(Math.floor(255 - lerp((beat - 52.7) * 1.1, 255, 0)), 0), 255).toString(16).padStart(2, '0') + const text1 = `Introducing `; + const text2 = `HyprAI`; + const longTextWidth = getTextSize(this.ctx, `Introducing HyprAI`, AdDefault) + const shortTextWidth = getTextSize(this.ctx, `Introducing `, AdDefault) + renderText(this.ctx, text1, '#646663' + opacity1, AdDefault, { x: center[0] - longTextWidth.width / 2, y: center[1] }, 'start') + renderText(this.ctx, text2, '#646663' + opacity2, AdDefault, { x: (center[0] - longTextWidth.width / 2) + shortTextWidth.width, y: center[1] }, 'start') + break; + } + case beat >= 56.8 && beat < 67: { + renderText(this.ctx, beat < 61 ? `AI assistant by Hyprland` : 'Who knew?', '#646663', AdDefault, { x: center[0], y: center[1] }, 'center') + if (beat < 61) + renderText(this.ctx, `*This ad is satire vaxry please don't sue me`, '#64666366', { + ...AdDefault, + size: 12, + weight: 400 + }, { x: center[0], y: this.h - this.px(24) }, 'center') + // fall-thru + } + case beat >= 64: { + if (beat >= 64) { + // needed due to fallthru + // TODO: animate ui going up over text + } + break; + } + default: + break; + } + if (this.isPreview) + renderText(this.ctx, `${(Math.floor(beat * 10) / 10).toFixed(1)}`, '#646663', { ...AdDefault, size: this.px(12), weight: 400 }, { x: this.w - this.px(4), y: this.h - this.px(4) }, 'end') + } + public cleanup(): void { + this.uiCanvas().remove() + super.cleanup() } - // public fps = 59.94; - public fps = 30; - public length = Math.ceil(2 * 60 * this.fps); + public fps = 59.94; + // public fps = 119.88; + // public fps = 30; + public length = Math.ceil(43.5 * this.fps); public audioUrl = ['03. Lemaitre, Jennie A. - Closer.flac', AudioURL] as const } -- cgit v1.2.3