Refine fullscreen album color wash
This commit is contained in:
1 parent
a1e993facb
commit
29b6418e2c
3 files changed
+215
-38
No files matched your search
@@ -100,7 +100,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 {
|
||||||
|
|||||||
@@ -1,35 +1,83 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { extractPalette } from "./palette"
|
import {
|
||||||
|
extractPalette,
|
||||||
|
fromOkLab,
|
||||||
|
toOkLab,
|
||||||
|
type AlbumPalette,
|
||||||
|
} from "./palette"
|
||||||
|
|
||||||
const props = defineProps<{ src: string; active: boolean }>()
|
const props = defineProps<{ src: string; active: boolean }>()
|
||||||
const canvas = ref<HTMLCanvasElement | null>(null)
|
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||||
const palette = ref<[number, number, number][]>([])
|
const palette = ref<AlbumPalette | null>(null)
|
||||||
let frame = 0
|
let frame = 0
|
||||||
let startedAt = 0
|
let startedAt = 0
|
||||||
|
let lastFrameAt = 0
|
||||||
|
let random = [0, 0]
|
||||||
|
|
||||||
const fallback: [number, number, number][] = [
|
const fallback: AlbumPalette = { dominant: [30, 35, 55], accent: [105, 65, 45] }
|
||||||
[30, 35, 55], [70, 45, 75], [35, 75, 85], [105, 65, 45],
|
|
||||||
]
|
|
||||||
|
|
||||||
function loadArtwork() {
|
function loadArtwork() {
|
||||||
const image = new Image()
|
const image = new Image()
|
||||||
image.crossOrigin = "anonymous"
|
image.crossOrigin = "anonymous"
|
||||||
image.onload = () => {
|
image.onload = () => {
|
||||||
try {
|
try {
|
||||||
palette.value = extractPalette(image)
|
setPalette(extractPalette(image))
|
||||||
} catch {
|
} catch {
|
||||||
palette.value = fallback
|
setPalette(fallback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
image.onerror = () => { palette.value = fallback }
|
image.onerror = () => { setPalette(fallback) }
|
||||||
image.src = props.src
|
image.src = props.src
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setPalette(next: AlbumPalette) {
|
||||||
|
palette.value = next
|
||||||
|
random = Array.from({ length: 2 }, () => Math.random() * Math.PI * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 draw(now: number) {
|
function draw(now: number) {
|
||||||
const element = canvas.value
|
const element = canvas.value
|
||||||
const context = element?.getContext("2d")
|
const context = element?.getContext("2d")
|
||||||
if (!element || !context) return
|
if (!element || !context) return
|
||||||
if (!startedAt) startedAt = now
|
if (!startedAt) startedAt = now
|
||||||
|
if (now - lastFrameAt < 1000 / 24) {
|
||||||
|
frame = requestAnimationFrame(draw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastFrameAt = now
|
||||||
const time = (now - startedAt) / 1000
|
const time = (now - startedAt) / 1000
|
||||||
const width = 112
|
const width = 112
|
||||||
const height = Math.max(72, Math.round(width * element.clientHeight / Math.max(1, element.clientWidth)))
|
const height = Math.max(72, Math.round(width * element.clientHeight / Math.max(1, element.clientWidth)))
|
||||||
@@ -38,27 +86,29 @@ function draw(now: number) {
|
|||||||
element.height = height
|
element.height = height
|
||||||
}
|
}
|
||||||
const image = context.createImageData(width, height)
|
const image = context.createImageData(width, height)
|
||||||
const colors = palette.value.length ? palette.value : fallback
|
const colors = palette.value ?? fallback
|
||||||
const fields = colors.map((_, index) => {
|
const dominant = toOkLab(colors.dominant)
|
||||||
const angle = (index / colors.length) * Math.PI * 2
|
const accent = toOkLab(colors.accent)
|
||||||
return {
|
const motionTime = time * 0.2
|
||||||
x: 0.5 + Math.cos(angle + time * 0.13) * 0.34,
|
const gradientTime = motionTime * 0.1
|
||||||
y: 0.5 + Math.sin(angle + time * 0.11) * 0.32,
|
const centerX = 0.5 + 0.43 * Math.sin(motionTime * 0.22)
|
||||||
}
|
const centerY = 0.5 + 0.4 * Math.sin(motionTime * 0.31 + 1.4)
|
||||||
})
|
|
||||||
for (let y = 0; y < height; y++) {
|
for (let y = 0; y < height; y++) {
|
||||||
for (let x = 0; x < width; x++) {
|
for (let x = 0; x < width; x++) {
|
||||||
const px = x / width
|
const px = x / (width - 1)
|
||||||
const py = y / height
|
const py = y / (height - 1)
|
||||||
const weights = fields.map((field) => {
|
const degree = gradientNoise(
|
||||||
const dx = px - field.x
|
gradientTime + random[0] * 0.07,
|
||||||
const dy = py - field.y
|
px * py + random[1] * 0.07,
|
||||||
return 1 / (0.2 + dx * dx + dy * dy)
|
)
|
||||||
})
|
const distance = Math.hypot(px - centerX, py - centerY)
|
||||||
const total = weights.reduce((sum, weight) => sum + weight, 0)
|
const radius = distance + (degree - 0.5) * 0.12
|
||||||
const color = [0, 1, 2].map((channel) => Math.round(
|
const blend = 1 - smoothstep(0.3, 0.52, radius)
|
||||||
colors.reduce((sum, item, index) => sum + item[channel] * weights[index], 0) / total * 0.52,
|
const lab = dominant.map(
|
||||||
))
|
(value, channel) =>
|
||||||
|
value * (1 - blend) + accent[channel] * blend,
|
||||||
|
) as [number, number, number]
|
||||||
|
const color = fromOkLab(lab)
|
||||||
const offset = (y * width + x) * 4
|
const offset = (y * width + x) * 4
|
||||||
image.data[offset] = color[0]
|
image.data[offset] = color[0]
|
||||||
image.data[offset + 1] = color[1]
|
image.data[offset + 1] = color[1]
|
||||||
@@ -74,6 +124,7 @@ watch(() => props.src, loadArtwork)
|
|||||||
watch(() => props.active, (active) => {
|
watch(() => props.active, (active) => {
|
||||||
if (active) {
|
if (active) {
|
||||||
startedAt = 0
|
startedAt = 0
|
||||||
|
lastFrameAt = 0
|
||||||
frame = requestAnimationFrame(draw)
|
frame = requestAnimationFrame(draw)
|
||||||
} else {
|
} else {
|
||||||
cancelAnimationFrame(frame)
|
cancelAnimationFrame(frame)
|
||||||
@@ -98,9 +149,9 @@ onBeforeUnmount(() => {
|
|||||||
.background-wash {
|
.background-wash {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
width: 100%;
|
width: 50%;
|
||||||
height: 100%;
|
height: 50%;
|
||||||
filter: blur(42px) saturate(1.2);
|
filter: blur(28px) saturate(1.08);
|
||||||
transform: scale(1.08);
|
transform: scale(1.08);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,12 +1,73 @@
|
|||||||
type RGB = [number, number, number]
|
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.round(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. */
|
/** Sample a small CPU histogram and return the most common album colors. */
|
||||||
export function extractPalette(image: HTMLImageElement, size = 48): RGB[] {
|
export function extractPalette(image: HTMLImageElement, size = 48): AlbumPalette {
|
||||||
const canvas = document.createElement("canvas")
|
const canvas = document.createElement("canvas")
|
||||||
canvas.width = size
|
canvas.width = size
|
||||||
canvas.height = size
|
canvas.height = size
|
||||||
const context = canvas.getContext("2d", { willReadFrequently: true })
|
const context = canvas.getContext("2d", { willReadFrequently: true })
|
||||||
if (!context) return [[30, 35, 55], [70, 45, 75], [35, 75, 85], [105, 65, 45]]
|
if (!context) {
|
||||||
|
return { dominant: [30, 35, 55], accent: [105, 65, 45] }
|
||||||
|
}
|
||||||
|
|
||||||
context.drawImage(image, 0, 0, size, size)
|
context.drawImage(image, 0, 0, size, size)
|
||||||
const pixels = context.getImageData(0, 0, size, size).data
|
const pixels = context.getImageData(0, 0, size, size).data
|
||||||
@@ -24,9 +85,74 @@ export function extractPalette(image: HTMLImageElement, size = 48): RGB[] {
|
|||||||
bin.b += pixels[i + 2]
|
bin.b += pixels[i + 2]
|
||||||
bins.set(key, bin)
|
bins.set(key, bin)
|
||||||
}
|
}
|
||||||
const colors = [...bins.values()]
|
const entries = [...bins.values()].map((bin) => ({
|
||||||
.sort((a, b) => b.count - a.count)
|
count: bin.count,
|
||||||
.slice(0, 8)
|
color: [bin.r / bin.count, bin.g / bin.count, bin.b / bin.count] as RGB,
|
||||||
.map((bin) => [bin.r / bin.count, bin.g / bin.count, bin.b / bin.count] as RGB)
|
}))
|
||||||
return colors.length ? colors : [[30, 35, 55], [70, 45, 75], [35, 75, 85], [105, 65, 45]]
|
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 }
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user