Merge dynamic background
This commit is contained in:
commit
58338d0e8d
10 files changed
+1060
-106
No files matched your search
@@ -1,57 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { fullscreen, close: closeFullscreen } = useFullscreen()
|
const { fullscreen, close: closeFullscreen } = useFullscreen()
|
||||||
const song = useSong()
|
const song = useSong()
|
||||||
const backgroundColors = ref(["#24202d", "#302238", "#182b35"])
|
|
||||||
const backgroundPosition = ref("8% 14%, 88% 12%, 48% 76%, 0 0")
|
|
||||||
|
|
||||||
const randomBackgroundPosition = () => {
|
|
||||||
return [0, 1, 2]
|
|
||||||
.map(() => `${Math.round(Math.random() * 100)}% ${Math.round(Math.random() * 100)}%`)
|
|
||||||
.concat("0 0")
|
|
||||||
.join(", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => song.value?.album.file,
|
|
||||||
async (file) => {
|
|
||||||
if (!file || !import.meta.client) return
|
|
||||||
|
|
||||||
const image = new Image()
|
|
||||||
image.crossOrigin = "anonymous"
|
|
||||||
image.src = GET_FILE(file)
|
|
||||||
try {
|
|
||||||
await image.decode()
|
|
||||||
const canvas = document.createElement("canvas")
|
|
||||||
canvas.width = 3
|
|
||||||
canvas.height = 1
|
|
||||||
const context = canvas.getContext("2d", { willReadFrequently: true })
|
|
||||||
if (!context) return
|
|
||||||
context.drawImage(image, 0, 0, 3, 1)
|
|
||||||
const pixels = context.getImageData(0, 0, 3, 1).data
|
|
||||||
backgroundColors.value = [0, 1, 2].map((index) => {
|
|
||||||
const offset = index * 4
|
|
||||||
return `rgb(${pixels[offset]}, ${pixels[offset + 1]}, ${pixels[offset + 2]})`
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// Keep the default palette when the cover cannot be loaded or sampled.
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
const mouseMovedRecently = ref(true)
|
const mouseMovedRecently = ref(true)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
let timeoutId: NodeJS.Timeout
|
let timeoutId: NodeJS.Timeout
|
||||||
let backgroundInterval: NodeJS.Timeout | undefined
|
|
||||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)")
|
|
||||||
|
|
||||||
if (!reducedMotion.matches) {
|
|
||||||
backgroundPosition.value = randomBackgroundPosition()
|
|
||||||
backgroundInterval = setInterval(() => {
|
|
||||||
backgroundPosition.value = randomBackgroundPosition()
|
|
||||||
}, 9000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseMove = () => {
|
const handleMouseMove = () => {
|
||||||
mouseMovedRecently.value = true
|
mouseMovedRecently.value = true
|
||||||
@@ -73,7 +26,6 @@ onMounted(() => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener("mousemove", handleMouseMove)
|
window.removeEventListener("mousemove", handleMouseMove)
|
||||||
window.removeEventListener("keydown", handleKeydown)
|
window.removeEventListener("keydown", handleKeydown)
|
||||||
if (backgroundInterval) clearInterval(backgroundInterval)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -90,15 +42,12 @@ nuxtApp.hook("page:finish", () => {
|
|||||||
:class="{ showCursor: mouseMovedRecently }"
|
:class="{ showCursor: mouseMovedRecently }"
|
||||||
v-show="fullscreen"
|
v-show="fullscreen"
|
||||||
>
|
>
|
||||||
<div
|
<template v-if="song">
|
||||||
class="background"
|
<PlayerFullscreenBackgroundWash
|
||||||
:style="{
|
:src="GET_FILE(song.album.file)"
|
||||||
'--background-color-one': backgroundColors[0],
|
:active="fullscreen"
|
||||||
'--background-color-two': backgroundColors[1],
|
|
||||||
'--background-color-three': backgroundColors[2],
|
|
||||||
backgroundPosition,
|
|
||||||
}"
|
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<section class="screen-container" v-if="song">
|
<section class="screen-container" v-if="song">
|
||||||
<Icon
|
<Icon
|
||||||
@@ -131,8 +80,8 @@ nuxtApp.hook("page:finish", () => {
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
||||||
width: 200vw;
|
width: 100vw;
|
||||||
height: 200vh;
|
height: 100vh;
|
||||||
z-index: 150;
|
z-index: 150;
|
||||||
|
|
||||||
cursor: none;
|
cursor: none;
|
||||||
@@ -142,33 +91,6 @@ nuxtApp.hook("page:finish", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.background {
|
|
||||||
position: absolute;
|
|
||||||
inset: -25%;
|
|
||||||
background:
|
|
||||||
radial-gradient(ellipse, var(--background-color-one) 0%, transparent 74%),
|
|
||||||
radial-gradient(ellipse, var(--background-color-two) 0%, transparent 76%),
|
|
||||||
radial-gradient(ellipse, var(--background-color-three) 0%, transparent 72%),
|
|
||||||
#15131a;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-size: 48% 48%, 44% 44%, 46% 46%, auto;
|
|
||||||
filter: blur(28px);
|
|
||||||
transition: background-position 9s ease-in-out;
|
|
||||||
will-change: background-position;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-color: rgb(0 0 0 / 55%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.background { transition: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.screen-container {
|
.screen-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -177,7 +99,7 @@ nuxtApp.hook("page:finish", () => {
|
|||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
|
||||||
background-color: rgba(0, 0, 0, 0.45);
|
background-color: rgba(0, 0, 0, 0.34);
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn {
|
.close-btn {
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
extractPalette,
|
||||||
|
type AlbumPalette,
|
||||||
|
} from "./palette"
|
||||||
|
import { createCpuRenderer } from "./renderers/cpu"
|
||||||
|
import { createWebGpuRenderer } from "./renderers/webgpu"
|
||||||
|
import type { BackgroundFrame, BackgroundRenderer } from "./renderers/types"
|
||||||
|
import { shapeBeatPulse } from "~/utils/beat"
|
||||||
|
|
||||||
|
const props = defineProps<{ src: string; active: boolean }>()
|
||||||
|
const ANIMATION_SPEED = 0.3
|
||||||
|
const FRAME_RATE = 12
|
||||||
|
const FRAME_INTERVAL = 1000 / FRAME_RATE
|
||||||
|
const { beatEnergy } = usePlayer()
|
||||||
|
const cpuCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const webGpuCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const palette = ref<AlbumPalette | null>(null)
|
||||||
|
const backend = ref<"cpu" | "webgpu">("cpu")
|
||||||
|
let frameTimer: number | null = null
|
||||||
|
let startedAt = 0
|
||||||
|
let random: [number, number] = [0, 0]
|
||||||
|
let angleJitter = 0
|
||||||
|
let flowParams: [number, number, number, number] = [5, 25, 0.75, -0.08]
|
||||||
|
let cpuRenderer: BackgroundRenderer | null = null
|
||||||
|
let webGpuRenderer: BackgroundRenderer | null = null
|
||||||
|
let activeRenderer: BackgroundRenderer | null = null
|
||||||
|
let disposed = false
|
||||||
|
|
||||||
|
const fallback: AlbumPalette = { dominant: [30, 35, 55], accent: [105, 65, 45] }
|
||||||
|
|
||||||
|
function loadArtwork() {
|
||||||
|
const image = new Image()
|
||||||
|
image.crossOrigin = "anonymous"
|
||||||
|
image.onload = () => {
|
||||||
|
try {
|
||||||
|
palette.value = extractPalette(image)
|
||||||
|
} catch {
|
||||||
|
palette.value = fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
image.onerror = () => {
|
||||||
|
palette.value = fallback
|
||||||
|
}
|
||||||
|
image.src = props.src
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomizeMotion() {
|
||||||
|
random = [Math.random() * Math.PI * 2, Math.random() * Math.PI * 2]
|
||||||
|
angleJitter = (Math.random() - 0.5) * 0.3
|
||||||
|
flowParams = [
|
||||||
|
4.5 + Math.random(),
|
||||||
|
22 + Math.random() * 7,
|
||||||
|
(0.65 + Math.random() * 0.2) * (Math.random() < 0.5 ? -1 : 1),
|
||||||
|
((-5 + (Math.random() - 0.5) * 12) * Math.PI) / 180,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCpuFallback() {
|
||||||
|
if (!cpuRenderer) return
|
||||||
|
activeRenderer = cpuRenderer
|
||||||
|
backend.value = "cpu"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeRenderers() {
|
||||||
|
const cpuElement = cpuCanvas.value
|
||||||
|
const gpuElement = webGpuCanvas.value
|
||||||
|
if (!cpuElement || !gpuElement) return
|
||||||
|
cpuRenderer = createCpuRenderer(cpuElement)
|
||||||
|
activeRenderer = cpuRenderer
|
||||||
|
if (!cpuRenderer) {
|
||||||
|
console.warn("[background renderer] 2D canvas is unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let rendererLost = false
|
||||||
|
const renderer = await createWebGpuRenderer(gpuElement, () => {
|
||||||
|
rendererLost = true
|
||||||
|
webGpuRenderer = null
|
||||||
|
console.warn("[background renderer] WebGPU device lost; using CPU")
|
||||||
|
useCpuFallback()
|
||||||
|
})
|
||||||
|
if (!renderer) return
|
||||||
|
if (disposed || rendererLost) {
|
||||||
|
renderer.dispose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
webGpuRenderer = renderer
|
||||||
|
activeRenderer = renderer
|
||||||
|
backend.value = "webgpu"
|
||||||
|
console.info("[background renderer] using WebGPU")
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFrame(now: number, canvas: HTMLCanvasElement): BackgroundFrame {
|
||||||
|
if (!startedAt) startedAt = now
|
||||||
|
const width = 224
|
||||||
|
const height = Math.max(
|
||||||
|
72,
|
||||||
|
Math.round(
|
||||||
|
(width * canvas.clientHeight) / Math.max(1, canvas.clientWidth),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
time: ((now - startedAt) / 1000) * ANIMATION_SPEED,
|
||||||
|
beat: shapeBeatPulse(beatEnergy.value),
|
||||||
|
random,
|
||||||
|
angleJitter,
|
||||||
|
flowParams,
|
||||||
|
palette: palette.value ?? fallback,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCanvasScale(canvas: HTMLCanvasElement, beat: number) {
|
||||||
|
const transform = `scale(${1.08 + beat * 0.003})`
|
||||||
|
if (canvas.style.transform !== transform) canvas.style.transform = transform
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(now: number) {
|
||||||
|
if (!props.active || disposed) return
|
||||||
|
const canvas = backend.value === "webgpu" ? webGpuCanvas.value : cpuCanvas.value
|
||||||
|
if (canvas && activeRenderer) {
|
||||||
|
const renderFrame = makeFrame(now, canvas)
|
||||||
|
updateCanvasScale(canvas, renderFrame.beat)
|
||||||
|
try {
|
||||||
|
activeRenderer.render(renderFrame)
|
||||||
|
} catch (error) {
|
||||||
|
if (backend.value === "webgpu") {
|
||||||
|
console.warn(
|
||||||
|
"[background renderer] WebGPU render failed; using CPU",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
webGpuRenderer?.dispose()
|
||||||
|
webGpuRenderer = null
|
||||||
|
useCpuFallback()
|
||||||
|
if (cpuCanvas.value)
|
||||||
|
updateCanvasScale(cpuCanvas.value, renderFrame.beat)
|
||||||
|
try {
|
||||||
|
cpuRenderer?.render(renderFrame)
|
||||||
|
} catch (fallbackError) {
|
||||||
|
console.warn("[background renderer] CPU fallback failed", fallbackError)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("[background renderer] CPU render failed", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frameTimer = window.setTimeout(() => {
|
||||||
|
frameTimer = null
|
||||||
|
draw(performance.now())
|
||||||
|
}, FRAME_INTERVAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAnimation() {
|
||||||
|
if (frameTimer !== null) window.clearTimeout(frameTimer)
|
||||||
|
frameTimer = null
|
||||||
|
startedAt = 0
|
||||||
|
randomizeMotion()
|
||||||
|
draw(performance.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.src, loadArtwork)
|
||||||
|
watch(
|
||||||
|
() => props.active,
|
||||||
|
(active) => {
|
||||||
|
if (active) startAnimation()
|
||||||
|
else if (frameTimer !== null) {
|
||||||
|
window.clearTimeout(frameTimer)
|
||||||
|
frameTimer = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadArtwork()
|
||||||
|
void initializeRenderers()
|
||||||
|
if (props.active) startAnimation()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disposed = true
|
||||||
|
if (frameTimer !== null) window.clearTimeout(frameTimer)
|
||||||
|
webGpuRenderer?.dispose()
|
||||||
|
cpuRenderer?.dispose()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<canvas
|
||||||
|
ref="webGpuCanvas"
|
||||||
|
class="background-wash"
|
||||||
|
:class="{ 'background-wash--active': backend === 'webgpu' }"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<canvas
|
||||||
|
ref="cpuCanvas"
|
||||||
|
class="background-wash"
|
||||||
|
:class="{ 'background-wash--active': backend === 'cpu' }"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.background-wash {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
filter: blur(6px) saturate(1.08);
|
||||||
|
transform: scale(1.08);
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-wash--active {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
type RGB = [number, number, number]
|
||||||
|
type Lab = [number, number, number]
|
||||||
|
|
||||||
|
export interface AlbumPalette {
|
||||||
|
dominant: RGB
|
||||||
|
accent: RGB
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toOkLab([red, green, blue]: RGB): Lab {
|
||||||
|
const linear = (value: number) => {
|
||||||
|
const channel = value / 255
|
||||||
|
return channel <= 0.04045
|
||||||
|
? channel / 12.92
|
||||||
|
: ((channel + 0.055) / 1.055) ** 2.4
|
||||||
|
}
|
||||||
|
const r = linear(red)
|
||||||
|
const g = linear(green)
|
||||||
|
const b = linear(blue)
|
||||||
|
const l = Math.cbrt(
|
||||||
|
0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b,
|
||||||
|
)
|
||||||
|
const m = Math.cbrt(
|
||||||
|
0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b,
|
||||||
|
)
|
||||||
|
const s = Math.cbrt(
|
||||||
|
0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
||||||
|
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
||||||
|
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromOkLab([lightness, a, b]: Lab): RGB {
|
||||||
|
const lRoot = lightness + 0.3963377774 * a + 0.2158037573 * b
|
||||||
|
const mRoot = lightness - 0.1055613458 * a - 0.0638541728 * b
|
||||||
|
const sRoot = lightness - 0.0894841775 * a - 1.291485548 * b
|
||||||
|
const l = lRoot ** 3
|
||||||
|
const m = mRoot ** 3
|
||||||
|
const s = sRoot ** 3
|
||||||
|
const encode = (value: number) => {
|
||||||
|
const channel =
|
||||||
|
value <= 0.0031308
|
||||||
|
? 12.92 * value
|
||||||
|
: 1.055 * Math.max(value, 0) ** (1 / 2.4) - 0.055
|
||||||
|
return Math.max(0, Math.min(1, channel)) * 255
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
encode(
|
||||||
|
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
||||||
|
),
|
||||||
|
encode(
|
||||||
|
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
||||||
|
),
|
||||||
|
encode(
|
||||||
|
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sample a small CPU histogram and return the most common album colors. */
|
||||||
|
export function extractPalette(image: HTMLImageElement, size = 48): AlbumPalette {
|
||||||
|
const canvas = document.createElement("canvas")
|
||||||
|
canvas.width = size
|
||||||
|
canvas.height = size
|
||||||
|
const context = canvas.getContext("2d", { willReadFrequently: true })
|
||||||
|
if (!context) {
|
||||||
|
return { dominant: [30, 35, 55], accent: [105, 65, 45] }
|
||||||
|
}
|
||||||
|
|
||||||
|
context.drawImage(image, 0, 0, size, size)
|
||||||
|
const pixels = context.getImageData(0, 0, size, size).data
|
||||||
|
const bins = new Map<number, { count: number; r: number; g: number; b: number }>()
|
||||||
|
for (let i = 0; i < pixels.length; i += 4) {
|
||||||
|
if (pixels[i + 3] < 96) continue
|
||||||
|
const r = pixels[i] >> 3
|
||||||
|
const g = pixels[i + 1] >> 3
|
||||||
|
const b = pixels[i + 2] >> 3
|
||||||
|
const key = (r << 10) | (g << 5) | b
|
||||||
|
const bin = bins.get(key) ?? { count: 0, r: 0, g: 0, b: 0 }
|
||||||
|
bin.count++
|
||||||
|
bin.r += pixels[i]
|
||||||
|
bin.g += pixels[i + 1]
|
||||||
|
bin.b += pixels[i + 2]
|
||||||
|
bins.set(key, bin)
|
||||||
|
}
|
||||||
|
const entries = [...bins.values()].map((bin) => ({
|
||||||
|
count: bin.count,
|
||||||
|
color: [bin.r / bin.count, bin.g / bin.count, bin.b / bin.count] as RGB,
|
||||||
|
}))
|
||||||
|
if (!entries.length) {
|
||||||
|
return { dominant: [30, 35, 55], accent: [105, 65, 45] }
|
||||||
|
}
|
||||||
|
const distance = (a: RGB, b: RGB) => {
|
||||||
|
const first = toOkLab(a)
|
||||||
|
const second = toOkLab(b)
|
||||||
|
return (first[0] - second[0]) ** 2 + (first[1] - second[1]) ** 2 + (first[2] - second[2]) ** 2
|
||||||
|
}
|
||||||
|
const centers: RGB[] = [entries.reduce((best, entry) => entry.count > best.count ? entry : best).color]
|
||||||
|
while (centers.length < 4) {
|
||||||
|
let candidate = entries[0]
|
||||||
|
let bestScore = -1
|
||||||
|
for (const entry of entries) {
|
||||||
|
const nearestDistance = Math.min(...centers.map((center) => distance(entry.color, center)))
|
||||||
|
const score = nearestDistance * Math.sqrt(entry.count)
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score
|
||||||
|
candidate = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
centers.push(candidate.color)
|
||||||
|
}
|
||||||
|
|
||||||
|
let clusterSupport = centers.map(() => 0)
|
||||||
|
for (let iteration = 0; iteration < 8; iteration++) {
|
||||||
|
const clusters = centers.map(() => ({ count: 0, red: 0, green: 0, blue: 0 }))
|
||||||
|
for (const entry of entries) {
|
||||||
|
let nearest = 0
|
||||||
|
let nearestDistance = Infinity
|
||||||
|
for (let i = 0; i < centers.length; i++) {
|
||||||
|
const nextDistance = distance(entry.color, centers[i])
|
||||||
|
if (nextDistance < nearestDistance) {
|
||||||
|
nearest = i
|
||||||
|
nearestDistance = nextDistance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cluster = clusters[nearest]
|
||||||
|
cluster.count += entry.count
|
||||||
|
cluster.red += entry.color[0] * entry.count
|
||||||
|
cluster.green += entry.color[1] * entry.count
|
||||||
|
cluster.blue += entry.color[2] * entry.count
|
||||||
|
}
|
||||||
|
for (let i = 0; i < clusters.length; i++) {
|
||||||
|
const cluster = clusters[i]
|
||||||
|
if (cluster.count) centers[i] = [cluster.red / cluster.count, cluster.green / cluster.count, cluster.blue / cluster.count]
|
||||||
|
}
|
||||||
|
clusterSupport = clusters.map((cluster) => cluster.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dominantIndex = clusterSupport.reduce(
|
||||||
|
(best, count, index) => count > clusterSupport[best] ? index : best,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
const dominant = centers[dominantIndex]
|
||||||
|
const distinctCenters = centers.filter((center) => distance(center, dominant) > 0.002)
|
||||||
|
const candidates = distinctCenters.length ? distinctCenters : centers
|
||||||
|
const accent = candidates.reduce((best, color) => {
|
||||||
|
const index = centers.indexOf(color)
|
||||||
|
const [_, a, b] = toOkLab(color)
|
||||||
|
const chroma = Math.hypot(a, b)
|
||||||
|
const score = chroma * Math.sqrt(clusterSupport[index] || 1)
|
||||||
|
const [__, bestA, bestB] = toOkLab(best)
|
||||||
|
const bestScore = Math.hypot(bestA, bestB) * Math.sqrt(clusterSupport[centers.indexOf(best)] || 1)
|
||||||
|
return score > bestScore ? color : best
|
||||||
|
})
|
||||||
|
return { dominant, accent }
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { fromOkLab, toOkLab } from "../palette"
|
||||||
|
import type { BackgroundFrame, BackgroundRenderer } from "./types"
|
||||||
|
|
||||||
|
function gradientHash(x: number, y: number): [number, number] {
|
||||||
|
const hash = (a: number, b: number, xScale: number, yScale: number) => {
|
||||||
|
const value = Math.sin(a * xScale + b * yScale) * 43758.5453
|
||||||
|
return (value - Math.floor(value)) * 2 - 1
|
||||||
|
}
|
||||||
|
return [hash(x, y, 127.1, 311.7), hash(x, y, 269.5, 183.3)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function gradientNoise(x: number, y: number) {
|
||||||
|
const cellX = Math.floor(x)
|
||||||
|
const cellY = Math.floor(y)
|
||||||
|
const offsetX = x - cellX
|
||||||
|
const offsetY = y - cellY
|
||||||
|
const easeX = offsetX * offsetX * (3 - 2 * offsetX)
|
||||||
|
const easeY = offsetY * offsetY * (3 - 2 * offsetY)
|
||||||
|
const dot = (cx: number, cy: number, ox: number, oy: number) => {
|
||||||
|
const [gx, gy] = gradientHash(cx, cy)
|
||||||
|
return gx * ox + gy * oy
|
||||||
|
}
|
||||||
|
const lower =
|
||||||
|
dot(cellX, cellY, offsetX, offsetY) * (1 - easeX) +
|
||||||
|
dot(cellX + 1, cellY, offsetX - 1, offsetY) * easeX
|
||||||
|
const upper =
|
||||||
|
dot(cellX, cellY + 1, offsetX, offsetY - 1) * (1 - easeX) +
|
||||||
|
dot(cellX + 1, cellY + 1, offsetX - 1, offsetY - 1) * easeX
|
||||||
|
return 0.5 + 0.5 * (lower * (1 - easeY) + upper * easeY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function smoothstep(edge0: number, edge1: number, value: number) {
|
||||||
|
const amount = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0)))
|
||||||
|
return amount * amount * (3 - 2 * amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ditherNoise(x: number, y: number, channel: number) {
|
||||||
|
const value = Math.sin(
|
||||||
|
(x + channel * 17) * 127.1 + (y + channel * 59) * 311.7,
|
||||||
|
) * 43758.5453
|
||||||
|
return value - Math.floor(value) - 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
function flowPoint(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
frame: BackgroundFrame,
|
||||||
|
): [number, number] {
|
||||||
|
const { random, angleJitter, flowParams, time } = frame
|
||||||
|
const degree = gradientNoise(
|
||||||
|
time * 0.1 + random[0] * 0.07,
|
||||||
|
x * y + random[1] * 0.07,
|
||||||
|
)
|
||||||
|
const angle = (((degree - 0.5) * 720 + 180) * Math.PI) / 180 + angleJitter
|
||||||
|
const sine = Math.sin(angle)
|
||||||
|
const cosine = Math.cos(angle)
|
||||||
|
let flowX = x * cosine - y * sine
|
||||||
|
let flowY = x * sine + y * cosine
|
||||||
|
const speed = time * flowParams[2]
|
||||||
|
flowX += Math.sin(flowY * flowParams[0] + speed) / flowParams[1]
|
||||||
|
flowY +=
|
||||||
|
Math.sin(flowX * flowParams[0] * 1.5 + speed) / (flowParams[1] * 0.5)
|
||||||
|
const tiltSine = Math.sin(flowParams[3])
|
||||||
|
const tiltCosine = Math.cos(flowParams[3])
|
||||||
|
return [
|
||||||
|
flowX * tiltCosine - flowY * tiltSine,
|
||||||
|
flowX * tiltSine + flowY * tiltCosine,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCpuRenderer(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
): BackgroundRenderer | null {
|
||||||
|
const context = canvas.getContext("2d")
|
||||||
|
if (!context) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
render(frame) {
|
||||||
|
if (canvas.width !== frame.width || canvas.height !== frame.height) {
|
||||||
|
canvas.width = frame.width
|
||||||
|
canvas.height = frame.height
|
||||||
|
}
|
||||||
|
const image = context.createImageData(frame.width, frame.height)
|
||||||
|
const dominant = toOkLab(frame.palette.dominant)
|
||||||
|
const accent = toOkLab(frame.palette.accent)
|
||||||
|
const maxPaletteLightness = Math.max(dominant[0], accent[0])
|
||||||
|
for (let y = 0; y < frame.height; y++) {
|
||||||
|
for (let x = 0; x < frame.width; x++) {
|
||||||
|
const px = x / (frame.width - 1)
|
||||||
|
const py = y / (frame.height - 1)
|
||||||
|
const point = flowPoint(px - 0.5, py - 0.5, frame)
|
||||||
|
const blend = smoothstep(-0.34, 0.16, point[0])
|
||||||
|
const transition = 1 - Math.abs(blend * 2 - 1)
|
||||||
|
const lab: [number, number, number] = [
|
||||||
|
dominant[0] * (1 - blend) + accent[0] * blend,
|
||||||
|
dominant[1] * (1 - blend) + accent[1] * blend,
|
||||||
|
dominant[2] * (1 - blend) + accent[2] * blend,
|
||||||
|
]
|
||||||
|
lab[0] = Math.min(maxPaletteLightness, lab[0] + frame.beat * 0.01)
|
||||||
|
lab[1] *= 1 + frame.beat * 0.02
|
||||||
|
lab[2] *= 1 + frame.beat * 0.02
|
||||||
|
const color = fromOkLab(lab)
|
||||||
|
const offset = (y * frame.width + x) * 4
|
||||||
|
image.data[offset] = color[0] + ditherNoise(x, y, 0) * transition * 3
|
||||||
|
image.data[offset + 1] =
|
||||||
|
color[1] + ditherNoise(x, y, 1) * transition * 3
|
||||||
|
image.data[offset + 2] =
|
||||||
|
color[2] + ditherNoise(x, y, 2) * transition * 3
|
||||||
|
image.data[offset + 3] = 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.putImageData(image, 0, 0)
|
||||||
|
},
|
||||||
|
dispose() {},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
struct Params {
|
||||||
|
resolutionTimeBeat: vec4f,
|
||||||
|
randomAndJitter: vec4f,
|
||||||
|
flowParams: vec4f,
|
||||||
|
dominant: vec4f,
|
||||||
|
accent: vec4f,
|
||||||
|
}
|
||||||
|
|
||||||
|
@group(0) @binding(0) var<uniform> params: Params;
|
||||||
|
|
||||||
|
fn hash(a: f32, b: f32, xScale: f32, yScale: f32) -> f32 {
|
||||||
|
let value = sin(a * xScale + b * yScale) * 43758.5453;
|
||||||
|
return fract(value) * 2.0 - 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gradientHash(cell: vec2f) -> vec2f {
|
||||||
|
return vec2f(
|
||||||
|
hash(cell.x, cell.y, 127.1, 311.7),
|
||||||
|
hash(cell.x, cell.y, 269.5, 183.3),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gradientDot(cell: vec2f, offset: vec2f) -> f32 {
|
||||||
|
return dot(gradientHash(cell), offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gradientNoise(point: vec2f) -> f32 {
|
||||||
|
let cell = floor(point);
|
||||||
|
let offset = fract(point);
|
||||||
|
let ease = offset * offset * (3.0 - 2.0 * offset);
|
||||||
|
let lower = mix(
|
||||||
|
gradientDot(cell, offset),
|
||||||
|
gradientDot(cell + vec2f(1.0, 0.0), offset - vec2f(1.0, 0.0)),
|
||||||
|
ease.x,
|
||||||
|
);
|
||||||
|
let upper = mix(
|
||||||
|
gradientDot(cell + vec2f(0.0, 1.0), offset - vec2f(0.0, 1.0)),
|
||||||
|
gradientDot(cell + vec2f(1.0, 1.0), offset - vec2f(1.0, 1.0)),
|
||||||
|
ease.x,
|
||||||
|
);
|
||||||
|
return 0.5 + 0.5 * mix(lower, upper, ease.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flowPoint(point: vec2f, time: f32) -> vec2f {
|
||||||
|
let degree = gradientNoise(vec2f(
|
||||||
|
time * 0.1 + params.randomAndJitter.x * 0.07,
|
||||||
|
point.x * point.y + params.randomAndJitter.y * 0.07,
|
||||||
|
));
|
||||||
|
let angle = ((degree - 0.5) * 720.0 + 180.0) * 0.017453292519943295
|
||||||
|
+ params.randomAndJitter.z;
|
||||||
|
let sine = sin(angle);
|
||||||
|
let cosine = cos(angle);
|
||||||
|
var flow = vec2f(
|
||||||
|
point.x * cosine - point.y * sine,
|
||||||
|
point.x * sine + point.y * cosine,
|
||||||
|
);
|
||||||
|
let speed = time * params.flowParams.z;
|
||||||
|
flow.x += sin(flow.y * params.flowParams.x + speed) / params.flowParams.y;
|
||||||
|
flow.y += sin(flow.x * params.flowParams.x * 1.5 + speed)
|
||||||
|
/ (params.flowParams.y * 0.5);
|
||||||
|
let tiltSine = sin(params.flowParams.w);
|
||||||
|
let tiltCosine = cos(params.flowParams.w);
|
||||||
|
return vec2f(
|
||||||
|
flow.x * tiltCosine - flow.y * tiltSine,
|
||||||
|
flow.x * tiltSine + flow.y * tiltCosine,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn linearToSrgb(value: f32) -> f32 {
|
||||||
|
if (value <= 0.0031308) {
|
||||||
|
return 12.92 * value;
|
||||||
|
}
|
||||||
|
return 1.055 * pow(max(value, 0.0), 1.0 / 2.4) - 0.055;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fromOkLab(lab: vec3f) -> vec3f {
|
||||||
|
let lRoot = lab.x + 0.3963377774 * lab.y + 0.2158037573 * lab.z;
|
||||||
|
let mRoot = lab.x - 0.1055613458 * lab.y - 0.0638541728 * lab.z;
|
||||||
|
let sRoot = lab.x - 0.0894841775 * lab.y - 1.291485548 * lab.z;
|
||||||
|
let l = lRoot * lRoot * lRoot;
|
||||||
|
let m = mRoot * mRoot * mRoot;
|
||||||
|
let s = sRoot * sRoot * sRoot;
|
||||||
|
let red = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
|
||||||
|
let green = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
||||||
|
let blue = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
|
||||||
|
return clamp(vec3f(
|
||||||
|
linearToSrgb(red),
|
||||||
|
linearToSrgb(green),
|
||||||
|
linearToSrgb(blue),
|
||||||
|
), vec3f(0.0), vec3f(1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ditherNoise(x: f32, y: f32, channel: f32) -> f32 {
|
||||||
|
let value = sin(
|
||||||
|
(x + channel * 17.0) * 127.1 + (y + channel * 59.0) * 311.7,
|
||||||
|
) * 43758.5453;
|
||||||
|
return fract(value) - 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vertexMain(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f {
|
||||||
|
var positions = array<vec2f, 3>(
|
||||||
|
vec2f(-1.0, -1.0),
|
||||||
|
vec2f(3.0, -1.0),
|
||||||
|
vec2f(-1.0, 3.0),
|
||||||
|
);
|
||||||
|
return vec4f(positions[index], 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fragmentMain(@builtin(position) position: vec4f) -> @location(0) vec4f {
|
||||||
|
let dimensions = params.resolutionTimeBeat.xy;
|
||||||
|
let time = params.resolutionTimeBeat.z;
|
||||||
|
let beat = params.resolutionTimeBeat.w;
|
||||||
|
let uv = position.xy / dimensions;
|
||||||
|
let point = flowPoint(uv - vec2f(0.5), time);
|
||||||
|
let blend = smoothstep(-0.34, 0.16, point.x);
|
||||||
|
let transition = 1.0 - abs(blend * 2.0 - 1.0);
|
||||||
|
var lab = mix(params.dominant.xyz, params.accent.xyz, blend);
|
||||||
|
lab.x = min(params.dominant.w, lab.x + beat * 0.01);
|
||||||
|
lab.y = lab.y * (1.0 + beat * 0.02);
|
||||||
|
lab.z = lab.z * (1.0 + beat * 0.02);
|
||||||
|
let color = fromOkLab(lab);
|
||||||
|
let pixel = floor(position.xy);
|
||||||
|
let noise = vec3f(
|
||||||
|
ditherNoise(pixel.x, pixel.y, 0.0),
|
||||||
|
ditherNoise(pixel.x, pixel.y, 1.0),
|
||||||
|
ditherNoise(pixel.x, pixel.y, 2.0),
|
||||||
|
);
|
||||||
|
let dithered = clamp(color + noise * transition * (3.0 / 255.0),
|
||||||
|
vec3f(0.0), vec3f(1.0));
|
||||||
|
return vec4f(dithered, 1.0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { AlbumPalette } from "../palette"
|
||||||
|
|
||||||
|
export interface BackgroundFrame {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
time: number
|
||||||
|
beat: number
|
||||||
|
random: [number, number]
|
||||||
|
angleJitter: number
|
||||||
|
flowParams: [number, number, number, number]
|
||||||
|
palette: AlbumPalette
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackgroundRenderer {
|
||||||
|
render(frame: BackgroundFrame): void
|
||||||
|
dispose(): void
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { toOkLab } from "../palette"
|
||||||
|
import shader from "./shader.wgsl?raw"
|
||||||
|
import type { BackgroundFrame, BackgroundRenderer } from "./types"
|
||||||
|
|
||||||
|
const uniformUsage = 0x0040 | 0x0008
|
||||||
|
|
||||||
|
interface GpuBuffer {
|
||||||
|
destroy(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GpuPass {
|
||||||
|
setPipeline(pipeline: unknown): void
|
||||||
|
setBindGroup(index: number, bindGroup: unknown): void
|
||||||
|
draw(vertexCount: number, instanceCount?: number): void
|
||||||
|
end(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GpuDevice {
|
||||||
|
queue: {
|
||||||
|
writeBuffer(buffer: GpuBuffer, offset: number, data: Float32Array): void
|
||||||
|
submit(commands: unknown[]): void
|
||||||
|
}
|
||||||
|
lost: Promise<{ message: string }>
|
||||||
|
createBuffer(descriptor: { size: number; usage: number }): GpuBuffer
|
||||||
|
createShaderModule(descriptor: { code: string }): {
|
||||||
|
getCompilationInfo(): Promise<{
|
||||||
|
messages: Array<{ type: string; message: string }>
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
createRenderPipeline(descriptor: unknown): {
|
||||||
|
getBindGroupLayout(index: number): unknown
|
||||||
|
}
|
||||||
|
createBindGroup(descriptor: unknown): unknown
|
||||||
|
createCommandEncoder(): {
|
||||||
|
beginRenderPass(descriptor: unknown): GpuPass
|
||||||
|
finish(): unknown
|
||||||
|
}
|
||||||
|
destroy(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GpuAdapter {
|
||||||
|
requestDevice(): Promise<GpuDevice>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GpuApi {
|
||||||
|
requestAdapter(options?: {
|
||||||
|
powerPreference?: "low-power" | "high-performance"
|
||||||
|
}): Promise<GpuAdapter | null>
|
||||||
|
getPreferredCanvasFormat(): string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GpuCanvasContext {
|
||||||
|
configure(options: {
|
||||||
|
device: GpuDevice
|
||||||
|
format: string
|
||||||
|
alphaMode: "opaque"
|
||||||
|
}): void
|
||||||
|
getCurrentTexture(): { createView(): unknown }
|
||||||
|
unconfigure(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWebGpuRenderer(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
onDeviceLost: () => void,
|
||||||
|
): Promise<BackgroundRenderer | null> {
|
||||||
|
let device: GpuDevice | null = null
|
||||||
|
let context: GpuCanvasContext | null = null
|
||||||
|
let uniformBuffer: GpuBuffer | null = null
|
||||||
|
try {
|
||||||
|
const gpu = (navigator as unknown as { gpu?: GpuApi }).gpu
|
||||||
|
if (!gpu) {
|
||||||
|
console.info("[background renderer] WebGPU API unavailable; using CPU")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const adapter = await gpu.requestAdapter({
|
||||||
|
powerPreference: "high-performance",
|
||||||
|
})
|
||||||
|
if (!adapter) {
|
||||||
|
console.info("[background renderer] no WebGPU adapter; using CPU")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
device = await adapter.requestDevice()
|
||||||
|
const rawContext = (canvas.getContext as (kind: string) => unknown).call(
|
||||||
|
canvas,
|
||||||
|
"webgpu",
|
||||||
|
)
|
||||||
|
if (!rawContext) {
|
||||||
|
device.destroy()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
context = rawContext as GpuCanvasContext
|
||||||
|
const format = gpu.getPreferredCanvasFormat()
|
||||||
|
context.configure({ device, format, alphaMode: "opaque" })
|
||||||
|
|
||||||
|
const shaderModule = device.createShaderModule({ code: shader })
|
||||||
|
const compilation = await shaderModule.getCompilationInfo()
|
||||||
|
const shaderErrors = compilation.messages.filter(
|
||||||
|
(message) => message.type === "error",
|
||||||
|
)
|
||||||
|
if (shaderErrors.length) {
|
||||||
|
throw new Error(shaderErrors.map((message) => message.message).join("\n"))
|
||||||
|
}
|
||||||
|
const pipeline = device.createRenderPipeline({
|
||||||
|
layout: "auto",
|
||||||
|
vertex: { module: shaderModule, entryPoint: "vertexMain" },
|
||||||
|
fragment: {
|
||||||
|
module: shaderModule,
|
||||||
|
entryPoint: "fragmentMain",
|
||||||
|
targets: [{ format }],
|
||||||
|
},
|
||||||
|
primitive: { topology: "triangle-list" },
|
||||||
|
})
|
||||||
|
uniformBuffer = device.createBuffer({ size: 80, usage: uniformUsage })
|
||||||
|
const bindGroup = device.createBindGroup({
|
||||||
|
layout: pipeline.getBindGroupLayout(0),
|
||||||
|
entries: [{ binding: 0, resource: { buffer: uniformBuffer } }],
|
||||||
|
})
|
||||||
|
const activeDevice = device
|
||||||
|
const activeContext = context
|
||||||
|
const activeBuffer = uniformBuffer
|
||||||
|
const uniforms = new Float32Array(20)
|
||||||
|
let lastPalette: BackgroundFrame["palette"] | null = null
|
||||||
|
let dominant: [number, number, number] = [0, 0, 0]
|
||||||
|
let accent: [number, number, number] = [0, 0, 0]
|
||||||
|
let maxPaletteLightness = 0
|
||||||
|
let disposed = false
|
||||||
|
void device.lost.then(() => {
|
||||||
|
if (!disposed) onDeviceLost()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
render(frame: BackgroundFrame) {
|
||||||
|
if (canvas.width !== frame.width || canvas.height !== frame.height) {
|
||||||
|
canvas.width = frame.width
|
||||||
|
canvas.height = frame.height
|
||||||
|
}
|
||||||
|
if (lastPalette !== frame.palette) {
|
||||||
|
dominant = toOkLab(frame.palette.dominant)
|
||||||
|
accent = toOkLab(frame.palette.accent)
|
||||||
|
maxPaletteLightness = Math.max(dominant[0], accent[0])
|
||||||
|
lastPalette = frame.palette
|
||||||
|
}
|
||||||
|
uniforms[0] = frame.width
|
||||||
|
uniforms[1] = frame.height
|
||||||
|
uniforms[2] = frame.time
|
||||||
|
uniforms[3] = frame.beat
|
||||||
|
uniforms[4] = frame.random[0]
|
||||||
|
uniforms[5] = frame.random[1]
|
||||||
|
uniforms[6] = frame.angleJitter
|
||||||
|
uniforms[7] = 0
|
||||||
|
uniforms.set(frame.flowParams, 8)
|
||||||
|
uniforms.set(dominant, 12)
|
||||||
|
uniforms[15] = maxPaletteLightness
|
||||||
|
uniforms.set(accent, 16)
|
||||||
|
uniforms[19] = 0
|
||||||
|
activeDevice.queue.writeBuffer(activeBuffer, 0, uniforms)
|
||||||
|
const encoder = activeDevice.createCommandEncoder()
|
||||||
|
const pass = encoder.beginRenderPass({
|
||||||
|
colorAttachments: [
|
||||||
|
{
|
||||||
|
view: activeContext.getCurrentTexture().createView(),
|
||||||
|
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
||||||
|
loadOp: "clear",
|
||||||
|
storeOp: "store",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
pass.setPipeline(pipeline)
|
||||||
|
pass.setBindGroup(0, bindGroup)
|
||||||
|
pass.draw(3, 1)
|
||||||
|
pass.end()
|
||||||
|
activeDevice.queue.submit([encoder.finish()])
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
disposed = true
|
||||||
|
activeContext.unconfigure()
|
||||||
|
activeBuffer.destroy()
|
||||||
|
activeDevice.destroy()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[background renderer] WebGPU unavailable; using CPU", error)
|
||||||
|
uniformBuffer?.destroy()
|
||||||
|
context?.unconfigure()
|
||||||
|
device?.destroy()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,18 +51,32 @@ const height = ref<number>()
|
|||||||
const top = ref(0)
|
const top = ref(0)
|
||||||
const syncedHeight = ref<number>()
|
const syncedHeight = ref<number>()
|
||||||
const layoutVersion = ref(0)
|
const layoutVersion = ref(0)
|
||||||
|
const lyricOffsets = ref<number[]>([])
|
||||||
const topFadeExtension = 75
|
const topFadeExtension = 75
|
||||||
|
|
||||||
const updateLyricsLayout = () => {
|
const updateLyricsLayout = () => {
|
||||||
const trackDisplay = document.querySelector(".track-display")
|
const trackDisplay = document.querySelector(".track-display")
|
||||||
|
|
||||||
if (!trackDisplay) return
|
if (trackDisplay) {
|
||||||
|
|
||||||
height.value = trackDisplay.clientHeight
|
height.value = trackDisplay.clientHeight
|
||||||
top.value = trackDisplay.getBoundingClientRect().top
|
top.value = trackDisplay.getBoundingClientRect().top
|
||||||
syncedHeight.value = window.innerHeight - top.value - 100
|
syncedHeight.value = window.innerHeight - top.value - 100
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lines = containerRef.value?.children
|
||||||
|
if (!lines) {
|
||||||
|
lyricOffsets.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let accumulatedHeight = 0
|
||||||
|
lyricOffsets.value = Array.from(lines, (line) => {
|
||||||
|
const offset = accumulatedHeight
|
||||||
|
accumulatedHeight += line.getBoundingClientRect().height
|
||||||
|
return offset
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
fullscreen,
|
fullscreen,
|
||||||
async () => {
|
async () => {
|
||||||
@@ -87,18 +101,20 @@ const outerRef = useTemplateRef("outer-ref")
|
|||||||
|
|
||||||
const transformY = computed(() => {
|
const transformY = computed(() => {
|
||||||
layoutVersion.value
|
layoutVersion.value
|
||||||
const lines = containerRef.value?.children
|
const offset = lyricOffsets.value[scrollActiveIndex.value]
|
||||||
if (scrollActiveIndex.value < 0 || !lines?.length) return 0
|
return offset === undefined ? 0 : -offset
|
||||||
|
|
||||||
let accumulatedHeight = 0
|
|
||||||
for (let i = 0; i < scrollActiveIndex.value; i++) {
|
|
||||||
const line = lines[i] as HTMLElement | undefined
|
|
||||||
if (line) accumulatedHeight += line.getBoundingClientRect().height
|
|
||||||
}
|
|
||||||
|
|
||||||
return -accumulatedHeight
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
parsedLyrics,
|
||||||
|
async () => {
|
||||||
|
await nextTick()
|
||||||
|
updateLyricsLayout()
|
||||||
|
layoutVersion.value++
|
||||||
|
},
|
||||||
|
{ flush: "post" },
|
||||||
|
)
|
||||||
|
|
||||||
const manualOffset = ref(0)
|
const manualOffset = ref(0)
|
||||||
const isScrolling = ref(false)
|
const isScrolling = ref(false)
|
||||||
|
|
||||||
|
|||||||
+185
-8
@@ -1,10 +1,27 @@
|
|||||||
import { Howl } from "howler"
|
import { Howl, Howler } from "howler"
|
||||||
|
import { shapeBeatPulse } from "~/utils/beat"
|
||||||
|
|
||||||
export default defineNuxtPlugin((nuxtApp) => {
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
const mediaBaseUrl = useRuntimeConfig().public.mediaBaseUrl
|
const mediaBaseUrl = useRuntimeConfig().public.mediaBaseUrl
|
||||||
const { close: closeFullscreen } = useFullscreen()
|
const { close: closeFullscreen } = useFullscreen()
|
||||||
|
if (import.meta.client) {
|
||||||
|
const howlerInternal = Howler as unknown as {
|
||||||
|
_obtainHtml5Audio: () => HTMLAudioElement
|
||||||
|
_musicV2CorsEnabled?: boolean
|
||||||
|
}
|
||||||
|
if (!howlerInternal._musicV2CorsEnabled) {
|
||||||
|
const obtainHtml5Audio = howlerInternal._obtainHtml5Audio
|
||||||
|
howlerInternal._obtainHtml5Audio = function () {
|
||||||
|
const audio = obtainHtml5Audio.call(Howler)
|
||||||
|
audio.crossOrigin = "anonymous"
|
||||||
|
return audio
|
||||||
|
}
|
||||||
|
howlerInternal._musicV2CorsEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
const VOLUME_STORAGE_KEY = "music-v2-player-volume"
|
const VOLUME_STORAGE_KEY = "music-v2-player-volume"
|
||||||
const DEFAULT_VOLUME = 0.5
|
const DEFAULT_VOLUME = 0.5
|
||||||
|
const DEBUG_BEAT_ANALYSIS = false
|
||||||
|
|
||||||
const sound = ref<Howl | null>(null)
|
const sound = ref<Howl | null>(null)
|
||||||
const queue = ref<Track[]>([])
|
const queue = ref<Track[]>([])
|
||||||
@@ -13,7 +30,16 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
const currentSong = ref<Track | null>(null)
|
const currentSong = ref<Track | null>(null)
|
||||||
const repeat = ref(false)
|
const repeat = ref(false)
|
||||||
const isPlaying = ref(false)
|
const isPlaying = ref(false)
|
||||||
|
const beatEnergy = ref(0)
|
||||||
const storedVolume = ref(DEFAULT_VOLUME)
|
const storedVolume = ref(DEFAULT_VOLUME)
|
||||||
|
type HowlWithMediaNode = Howl & {
|
||||||
|
_sounds?: Array<{ _node?: HTMLMediaElement }>
|
||||||
|
}
|
||||||
|
let analysisContext: AudioContext | null = null
|
||||||
|
let analysisSource: MediaStreamAudioSourceNode | null = null
|
||||||
|
let analysisNode: AnalyserNode | null = null
|
||||||
|
let analysisFrame = 0
|
||||||
|
let isAnalyzing = false
|
||||||
|
|
||||||
const clampVolume = (value: number) => Math.min(1, Math.max(0, value))
|
const clampVolume = (value: number) => Math.min(1, Math.max(0, value))
|
||||||
|
|
||||||
@@ -64,9 +90,149 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
})
|
})
|
||||||
const duration = ref(0)
|
const duration = ref(0)
|
||||||
let lastPositionUpdate = 0
|
let lastPositionUpdate = 0
|
||||||
|
let trackingTimer: number | null = null
|
||||||
let mediaSessionSongId: string | null = null
|
let mediaSessionSongId: string | null = null
|
||||||
|
|
||||||
const aniFrame = ref<number | null>(null)
|
const stopBeatAnalysis = (reason = "stopped") => {
|
||||||
|
if (isAnalyzing && DEBUG_BEAT_ANALYSIS)
|
||||||
|
console.info(`[beat analyzer] stopped: ${reason}`)
|
||||||
|
isAnalyzing = false
|
||||||
|
if (analysisFrame) cancelAnimationFrame(analysisFrame)
|
||||||
|
analysisFrame = 0
|
||||||
|
analysisSource?.disconnect()
|
||||||
|
analysisSource = null
|
||||||
|
analysisNode = null
|
||||||
|
beatEnergy.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const startBeatAnalysis = (howl: Howl) => {
|
||||||
|
stopBeatAnalysis("restarted")
|
||||||
|
const mediaElement = (howl as HowlWithMediaNode)._sounds?.[0]?._node
|
||||||
|
if (!mediaElement) {
|
||||||
|
if (DEBUG_BEAT_ANALYSIS)
|
||||||
|
console.warn(
|
||||||
|
"[beat analyzer] failed: Howler media element unavailable",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const elementWithCapture = mediaElement as HTMLMediaElement & {
|
||||||
|
captureStream?: () => MediaStream
|
||||||
|
mozCaptureStream?: () => MediaStream
|
||||||
|
webkitCaptureStream?: () => MediaStream
|
||||||
|
}
|
||||||
|
const capture =
|
||||||
|
elementWithCapture.captureStream ??
|
||||||
|
elementWithCapture.mozCaptureStream ??
|
||||||
|
elementWithCapture.webkitCaptureStream
|
||||||
|
if (!capture) {
|
||||||
|
if (DEBUG_BEAT_ANALYSIS)
|
||||||
|
console.warn(
|
||||||
|
"[beat analyzer] failed: media capture unsupported",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = capture.call(mediaElement)
|
||||||
|
if (stream.getAudioTracks().length === 0) {
|
||||||
|
if (DEBUG_BEAT_ANALYSIS)
|
||||||
|
console.warn(
|
||||||
|
"[beat analyzer] failed: capture has no audio tracks",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
analysisContext ??= new AudioContext()
|
||||||
|
if (analysisContext.state === "suspended")
|
||||||
|
void analysisContext.resume().catch(() => {})
|
||||||
|
analysisNode = analysisContext.createAnalyser()
|
||||||
|
analysisNode.fftSize = 2048
|
||||||
|
analysisNode.smoothingTimeConstant = 0.05
|
||||||
|
analysisSource = analysisContext.createMediaStreamSource(stream)
|
||||||
|
analysisSource.connect(analysisNode)
|
||||||
|
|
||||||
|
const samples = new Uint8Array(analysisNode.frequencyBinCount)
|
||||||
|
let previousBass: number | null = null
|
||||||
|
let riseAverage = 0.01
|
||||||
|
let riseVariance = 0.0004
|
||||||
|
let pulse = 0
|
||||||
|
let lastBeatLogAt = 0
|
||||||
|
let lastLevelLogAt = 0
|
||||||
|
const sample = () => {
|
||||||
|
if (!analysisNode) return
|
||||||
|
analysisNode.getByteFrequencyData(samples)
|
||||||
|
const sampleRate = analysisContext!.sampleRate
|
||||||
|
const firstBin = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor((45 * 2048) / sampleRate),
|
||||||
|
)
|
||||||
|
const lastBin = Math.min(
|
||||||
|
samples.length - 1,
|
||||||
|
Math.ceil((130 * 2048) / sampleRate),
|
||||||
|
)
|
||||||
|
let bass = 0
|
||||||
|
for (let bin = firstBin; bin <= lastBin; bin++)
|
||||||
|
bass += samples[bin]
|
||||||
|
bass /= Math.max(1, lastBin - firstBin + 1) * 255
|
||||||
|
if (previousBass === null) previousBass = bass
|
||||||
|
const rise = Math.max(0, bass - previousBass)
|
||||||
|
previousBass = previousBass * 0.985 + bass * 0.015
|
||||||
|
const riseDeviation = Math.sqrt(riseVariance)
|
||||||
|
const strongThumpThreshold = Math.min(
|
||||||
|
0.1,
|
||||||
|
Math.max(0.045, riseAverage + riseDeviation * 2),
|
||||||
|
)
|
||||||
|
const attack = Math.max(0, rise - strongThumpThreshold)
|
||||||
|
const thumpCooldown = Math.min(
|
||||||
|
500,
|
||||||
|
Math.max(300, 300 + riseDeviation * 4000),
|
||||||
|
)
|
||||||
|
const now = performance.now()
|
||||||
|
const isStrongThump =
|
||||||
|
rise >= strongThumpThreshold &&
|
||||||
|
now - lastBeatLogAt > thumpCooldown
|
||||||
|
const riseDelta = rise - riseAverage
|
||||||
|
riseAverage += riseDelta * 0.01
|
||||||
|
riseVariance += (riseDelta * riseDelta - riseVariance) * 0.01
|
||||||
|
if (isStrongThump) {
|
||||||
|
const thumpStrength = Math.min(0.38, 0.1 + attack * 2.5)
|
||||||
|
pulse = Math.max(pulse, thumpStrength)
|
||||||
|
if (DEBUG_BEAT_ANALYSIS) {
|
||||||
|
console.log(
|
||||||
|
"[fullscreen beat]",
|
||||||
|
`detected=${thumpStrength.toFixed(2)}`,
|
||||||
|
`visual=${shapeBeatPulse(thumpStrength).toFixed(2)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else pulse *= 0.94
|
||||||
|
beatEnergy.value = Math.min(1, pulse)
|
||||||
|
if (DEBUG_BEAT_ANALYSIS && now - lastLevelLogAt > 1000) {
|
||||||
|
lastLevelLogAt = now
|
||||||
|
console.info(
|
||||||
|
"[beat level]",
|
||||||
|
`bass=${bass.toFixed(3)}`,
|
||||||
|
`attack=${attack.toFixed(3)}`,
|
||||||
|
`gate=${strongThumpThreshold.toFixed(3)}`,
|
||||||
|
`cooldown=${Math.round(thumpCooldown)}`,
|
||||||
|
`pulse=${beatEnergy.value.toFixed(2)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isStrongThump) lastBeatLogAt = now
|
||||||
|
analysisFrame = requestAnimationFrame(sample)
|
||||||
|
}
|
||||||
|
analysisFrame = requestAnimationFrame(sample)
|
||||||
|
isAnalyzing = true
|
||||||
|
if (DEBUG_BEAT_ANALYSIS)
|
||||||
|
console.info("[beat analyzer] analysis started")
|
||||||
|
} catch (error) {
|
||||||
|
stopBeatAnalysis()
|
||||||
|
if (DEBUG_BEAT_ANALYSIS)
|
||||||
|
console.warn(
|
||||||
|
"[beat analyzer] failed while setting up audio capture",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
const updateMediaSession = () => {
|
const updateMediaSession = () => {
|
||||||
@@ -88,7 +254,10 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
album: currentSong.value.album.name,
|
album: currentSong.value.album.name,
|
||||||
artwork: [
|
artwork: [
|
||||||
{
|
{
|
||||||
src: GET_FILE(currentSong.value.album.file, mediaBaseUrl),
|
src: GET_FILE(
|
||||||
|
currentSong.value.album.file,
|
||||||
|
mediaBaseUrl,
|
||||||
|
),
|
||||||
sizes: "512x512",
|
sizes: "512x512",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -164,6 +333,7 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startTracking = () => {
|
const startTracking = () => {
|
||||||
|
if (trackingTimer !== null) window.clearTimeout(trackingTimer)
|
||||||
const update = () => {
|
const update = () => {
|
||||||
tick.value++
|
tick.value++
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@@ -171,15 +341,15 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
lastPositionUpdate = now
|
lastPositionUpdate = now
|
||||||
updateMediaSession()
|
updateMediaSession()
|
||||||
}
|
}
|
||||||
aniFrame.value = requestAnimationFrame(update)
|
trackingTimer = window.setTimeout(update, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
update()
|
update()
|
||||||
}
|
}
|
||||||
|
|
||||||
const stopTracking = () => {
|
const stopTracking = () => {
|
||||||
if (aniFrame.value) cancelAnimationFrame(aniFrame.value)
|
if (trackingTimer !== null) window.clearTimeout(trackingTimer)
|
||||||
aniFrame.value = null
|
trackingTimer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const createHowl = async (song: Track) => {
|
const createHowl = async (song: Track) => {
|
||||||
@@ -197,11 +367,13 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
updateMediaSession()
|
updateMediaSession()
|
||||||
},
|
},
|
||||||
onend: () => {
|
onend: () => {
|
||||||
|
stopBeatAnalysis("track ended")
|
||||||
void closeFullscreen()
|
void closeFullscreen()
|
||||||
next()
|
next()
|
||||||
stopTracking()
|
stopTracking()
|
||||||
},
|
},
|
||||||
onstop: () => {
|
onstop: () => {
|
||||||
|
stopBeatAnalysis("playback stopped")
|
||||||
isPlaying.value = false
|
isPlaying.value = false
|
||||||
stopTracking()
|
stopTracking()
|
||||||
if (navigator.mediaSession)
|
if (navigator.mediaSession)
|
||||||
@@ -209,11 +381,13 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
},
|
},
|
||||||
onplay: () => {
|
onplay: () => {
|
||||||
isPlaying.value = true
|
isPlaying.value = true
|
||||||
|
startBeatAnalysis(newSound)
|
||||||
startTracking()
|
startTracking()
|
||||||
if (navigator.mediaSession)
|
if (navigator.mediaSession)
|
||||||
navigator.mediaSession.playbackState = "playing"
|
navigator.mediaSession.playbackState = "playing"
|
||||||
},
|
},
|
||||||
onpause: () => {
|
onpause: () => {
|
||||||
|
stopBeatAnalysis("paused")
|
||||||
isPlaying.value = false
|
isPlaying.value = false
|
||||||
stopTracking()
|
stopTracking()
|
||||||
if (navigator.mediaSession)
|
if (navigator.mediaSession)
|
||||||
@@ -317,7 +491,8 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
event.ctrlKey ||
|
event.ctrlKey ||
|
||||||
event.metaKey ||
|
event.metaKey ||
|
||||||
event.altKey
|
event.altKey
|
||||||
) return
|
)
|
||||||
|
return
|
||||||
|
|
||||||
const target = event.target
|
const target = event.target
|
||||||
if (
|
if (
|
||||||
@@ -325,7 +500,8 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
target.closest(
|
target.closest(
|
||||||
"input, textarea, select, button, [contenteditable='true'], [role='slider']",
|
"input, textarea, select, button, [contenteditable='true'], [role='slider']",
|
||||||
)
|
)
|
||||||
) return
|
)
|
||||||
|
return
|
||||||
|
|
||||||
switch (event.code) {
|
switch (event.code) {
|
||||||
case "Space":
|
case "Space":
|
||||||
@@ -392,6 +568,7 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
provide: {
|
provide: {
|
||||||
player: {
|
player: {
|
||||||
isPlaying,
|
isPlaying,
|
||||||
|
beatEnergy,
|
||||||
currentSong,
|
currentSong,
|
||||||
repeat,
|
repeat,
|
||||||
play,
|
play,
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export function shapeBeatPulse(value: number) {
|
||||||
|
const strength = Math.max(0, Math.min(0.4, value))
|
||||||
|
if (strength <= 0.2) return strength * 0.4
|
||||||
|
if (strength <= 0.35) {
|
||||||
|
const progress = (strength - 0.2) / 0.15
|
||||||
|
return 0.08 + (1 - (1 - progress) ** 3) * 0.42
|
||||||
|
}
|
||||||
|
return 0.5 + ((strength - 0.35) / 0.05) * 0.05
|
||||||
|
}
|
||||||
Reference in new issue
Block a user