mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-04 15:10:56 +08:00
fix(glass): port material accents to v3 (#660)
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import { normalizePluginAccentColor, normalizeThemeMaterialAccent } from '@/utils/glassColor'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
interface OklchColor {
|
||||
lightness: number
|
||||
chroma: number
|
||||
hue: number
|
||||
}
|
||||
|
||||
function srgbToLinear(channel: number) {
|
||||
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
|
||||
function hexToOklch(color: string): OklchColor {
|
||||
const channels = [color.slice(1, 3), color.slice(3, 5), color.slice(5, 7)].map(channel =>
|
||||
srgbToLinear(Number.parseInt(channel, 16) / 255),
|
||||
)
|
||||
const [red, green, blue] = channels
|
||||
const l = Math.cbrt(0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue)
|
||||
const m = Math.cbrt(0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue)
|
||||
const s = Math.cbrt(0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue)
|
||||
const lightness = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s
|
||||
const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s
|
||||
const b = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
|
||||
|
||||
return { lightness, chroma: Math.hypot(a, b), hue: Math.atan2(b, a) }
|
||||
}
|
||||
|
||||
function hueDistanceDegrees(first: number, second: number) {
|
||||
const radians = Math.abs(Math.atan2(Math.sin(first - second), Math.cos(first - second)))
|
||||
|
||||
return (radians * 180) / Math.PI
|
||||
}
|
||||
|
||||
describe('normalizePluginAccentColor', () => {
|
||||
it.each(['#ff0000', '#00ff00', '#0000ff', '#00ffff', '#ff00ff', '#f5c400'])(
|
||||
'keeps chromatic %s within the plugin accent contract',
|
||||
sourceHex => {
|
||||
const normalized = normalizePluginAccentColor(sourceHex)
|
||||
|
||||
expect(normalized).toBeDefined()
|
||||
expect(normalized?.hex).toMatch(/^#[0-9a-f]{6}$/)
|
||||
expect(normalized?.rgb).toMatch(/^\d{1,3}, \d{1,3}, \d{1,3}$/)
|
||||
|
||||
const source = hexToOklch(sourceHex)
|
||||
const output = hexToOklch(normalized!.hex)
|
||||
expect(output.lightness).toBeGreaterThanOrEqual(0.475)
|
||||
expect(output.lightness).toBeLessThanOrEqual(0.765)
|
||||
expect(output.chroma).toBeLessThanOrEqual(Math.min(source.chroma, 0.18) + 0.005)
|
||||
if (source.chroma >= 0.03 && output.chroma >= 0.03)
|
||||
expect(hueDistanceDegrees(source.hue, output.hue)).toBeLessThanOrEqual(2)
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['#000000', '#ffffff', '#7d7d7d'])('keeps neutral %s neutral without inventing chroma', sourceHex => {
|
||||
const normalized = normalizePluginAccentColor(sourceHex)
|
||||
const source = hexToOklch(sourceHex)
|
||||
const output = hexToOklch(normalized!.hex)
|
||||
|
||||
expect(output.lightness).toBeGreaterThanOrEqual(0.475)
|
||||
expect(output.lightness).toBeLessThanOrEqual(0.765)
|
||||
expect(output.chroma).toBeLessThanOrEqual(source.chroma + 0.003)
|
||||
})
|
||||
|
||||
it('is deterministic, case-insensitive, and rejects non-contract inputs', () => {
|
||||
expect(normalizePluginAccentColor('#12ABef')).toEqual(normalizePluginAccentColor('#12abef'))
|
||||
expect(normalizePluginAccentColor('#fff')).toBeUndefined()
|
||||
expect(normalizePluginAccentColor('12abef')).toBeUndefined()
|
||||
expect(normalizePluginAccentColor('#gg0000')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeThemeMaterialAccent', () => {
|
||||
it.each([
|
||||
'#8D51F9',
|
||||
'#3F51B5',
|
||||
'#1976D2',
|
||||
'#00BCD4',
|
||||
'#009688',
|
||||
'#4CAF50',
|
||||
'#FFB400',
|
||||
'#FF9800',
|
||||
'#FF4C51',
|
||||
'#E91E63',
|
||||
'#16B1FF',
|
||||
'#607D8B',
|
||||
])('keeps preset %s within the material tone contract', sourceHex => {
|
||||
const normalized = normalizeThemeMaterialAccent(sourceHex)
|
||||
|
||||
expect(normalized).toBeDefined()
|
||||
expect(normalized?.hex).toMatch(/^#[0-9a-f]{6}$/)
|
||||
expect(normalized?.rgb).toMatch(/^\d{1,3}, \d{1,3}, \d{1,3}$/)
|
||||
|
||||
const source = hexToOklch(sourceHex)
|
||||
const output = hexToOklch(normalized!.hex)
|
||||
expect(output.lightness).toBeGreaterThanOrEqual(0.555)
|
||||
expect(output.lightness).toBeLessThanOrEqual(0.725)
|
||||
expect(output.chroma).toBeLessThanOrEqual(Math.min(source.chroma, 0.14) + 0.005)
|
||||
if (source.chroma >= 0.03 && output.chroma >= 0.03)
|
||||
expect(hueDistanceDegrees(source.hue, output.hue)).toBeLessThanOrEqual(2)
|
||||
})
|
||||
|
||||
it.each(['#000000', '#ffffff', '#7d7d7d', '#ffff00', '#00ffff', '#ff0000'])(
|
||||
'keeps extreme %s in gamut without inventing chroma',
|
||||
sourceHex => {
|
||||
const normalized = normalizeThemeMaterialAccent(sourceHex)
|
||||
const source = hexToOklch(sourceHex)
|
||||
const output = hexToOklch(normalized!.hex)
|
||||
|
||||
expect(output.lightness).toBeGreaterThanOrEqual(0.555)
|
||||
expect(output.lightness).toBeLessThanOrEqual(0.725)
|
||||
expect(output.chroma).toBeLessThanOrEqual(Math.min(source.chroma, 0.14) + 0.005)
|
||||
if (source.chroma >= 0.03 && output.chroma >= 0.03)
|
||||
expect(hueDistanceDegrees(source.hue, output.hue)).toBeLessThanOrEqual(2)
|
||||
},
|
||||
)
|
||||
|
||||
it('is deterministic, case-insensitive, and rejects non-contract inputs', () => {
|
||||
expect(normalizeThemeMaterialAccent('#12ABef')).toEqual(normalizeThemeMaterialAccent('#12abef'))
|
||||
expect(normalizeThemeMaterialAccent('#fff')).toBeUndefined()
|
||||
expect(normalizeThemeMaterialAccent('12abef')).toBeUndefined()
|
||||
expect(normalizeThemeMaterialAccent('#gg0000')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
interface OklabColor {
|
||||
lightness: number
|
||||
a: number
|
||||
b: number
|
||||
}
|
||||
|
||||
interface OklchColor {
|
||||
lightness: number
|
||||
chroma: number
|
||||
hue: number
|
||||
}
|
||||
|
||||
export interface GlassAccentColor {
|
||||
/** 可传给颜色输入或 WebGL 的六位十六进制色值。 */
|
||||
hex: string
|
||||
/** 可直接用于 `rgb()` / `rgba()` CSS 变量的通道值。 */
|
||||
rgb: string
|
||||
}
|
||||
|
||||
const PLUGIN_ACCENT_MIN_LIGHTNESS = 0.48
|
||||
const PLUGIN_ACCENT_MAX_LIGHTNESS = 0.76
|
||||
const PLUGIN_ACCENT_MAX_CHROMA = 0.18
|
||||
const THEME_MATERIAL_MIN_LIGHTNESS = 0.56
|
||||
const THEME_MATERIAL_MAX_LIGHTNESS = 0.72
|
||||
const THEME_MATERIAL_MAX_CHROMA = 0.14
|
||||
const NEUTRAL_CHROMA_THRESHOLD = 0.02
|
||||
const GAMUT_SEARCH_ITERATIONS = 24
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function parseHexColor(color: string) {
|
||||
if (!/^#[0-9a-f]{6}$/i.test(color)) return undefined
|
||||
|
||||
return [
|
||||
Number.parseInt(color.slice(1, 3), 16) / 255,
|
||||
Number.parseInt(color.slice(3, 5), 16) / 255,
|
||||
Number.parseInt(color.slice(5, 7), 16) / 255,
|
||||
] as const
|
||||
}
|
||||
|
||||
function srgbToLinear(channel: number) {
|
||||
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
|
||||
function linearToSrgb(channel: number) {
|
||||
return channel <= 0.0031308 ? 12.92 * channel : 1.055 * channel ** (1 / 2.4) - 0.055
|
||||
}
|
||||
|
||||
function srgbToOklab([red, green, blue]: readonly [number, number, number]): OklabColor {
|
||||
const linearRed = srgbToLinear(red)
|
||||
const linearGreen = srgbToLinear(green)
|
||||
const linearBlue = srgbToLinear(blue)
|
||||
const l = Math.cbrt(0.4122214708 * linearRed + 0.5363325363 * linearGreen + 0.0514459929 * linearBlue)
|
||||
const m = Math.cbrt(0.2119034982 * linearRed + 0.6806995451 * linearGreen + 0.1073969566 * linearBlue)
|
||||
const s = Math.cbrt(0.0883024619 * linearRed + 0.2817188376 * linearGreen + 0.6299787005 * linearBlue)
|
||||
|
||||
return {
|
||||
lightness: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
||||
a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
||||
b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
||||
}
|
||||
}
|
||||
|
||||
function oklabToLinearSrgb({ lightness, a, b }: OklabColor) {
|
||||
const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3
|
||||
const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3
|
||||
const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3
|
||||
|
||||
return [
|
||||
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
||||
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
||||
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
||||
] as const
|
||||
}
|
||||
|
||||
function oklabToOklch({ lightness, a, b }: OklabColor): OklchColor {
|
||||
return {
|
||||
lightness,
|
||||
chroma: Math.hypot(a, b),
|
||||
hue: Math.atan2(b, a),
|
||||
}
|
||||
}
|
||||
|
||||
function oklchToOklab({ lightness, chroma, hue }: OklchColor): OklabColor {
|
||||
return {
|
||||
lightness,
|
||||
a: chroma * Math.cos(hue),
|
||||
b: chroma * Math.sin(hue),
|
||||
}
|
||||
}
|
||||
|
||||
function isInSrgbGamut(color: OklchColor) {
|
||||
return oklabToLinearSrgb(oklchToOklab(color)).every(channel => channel >= 0 && channel <= 1)
|
||||
}
|
||||
|
||||
function mapChromaToSrgb(color: OklchColor): OklchColor {
|
||||
if (isInSrgbGamut(color)) return color
|
||||
|
||||
let lowerChroma = 0
|
||||
let upperChroma = color.chroma
|
||||
for (let iteration = 0; iteration < GAMUT_SEARCH_ITERATIONS; iteration += 1) {
|
||||
const candidateChroma = (lowerChroma + upperChroma) / 2
|
||||
if (isInSrgbGamut({ ...color, chroma: candidateChroma })) lowerChroma = candidateChroma
|
||||
else upperChroma = candidateChroma
|
||||
}
|
||||
|
||||
return { ...color, chroma: lowerChroma }
|
||||
}
|
||||
|
||||
function formatAccentColor(color: OklchColor): GlassAccentColor {
|
||||
const channels = oklabToLinearSrgb(oklchToOklab(color)).map(channel =>
|
||||
Math.round(clamp(linearToSrgb(channel), 0, 1) * 255),
|
||||
) as [number, number, number]
|
||||
|
||||
return {
|
||||
hex: `#${channels.map(channel => channel.toString(16).padStart(2, '0')).join('')}`,
|
||||
rgb: channels.join(', '),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAccentColor(color: string, minLightness: number, maxLightness: number, maxChroma: number) {
|
||||
const srgb = parseHexColor(color)
|
||||
if (!srgb) return undefined
|
||||
|
||||
const source = oklabToOklch(srgbToOklab(srgb))
|
||||
const chroma = source.chroma < NEUTRAL_CHROMA_THRESHOLD ? source.chroma : Math.min(source.chroma, maxChroma)
|
||||
const normalized = mapChromaToSrgb({
|
||||
lightness: clamp(source.lightness, minLightness, maxLightness),
|
||||
chroma,
|
||||
hue: source.hue,
|
||||
})
|
||||
|
||||
return formatAccentColor(normalized)
|
||||
}
|
||||
|
||||
/** 将插件 Logo 主色限制在可读范围内,同时保持品牌色相与中性色属性。 */
|
||||
export function normalizePluginAccentColor(color: string): GlassAccentColor | undefined {
|
||||
return normalizeAccentColor(color, PLUGIN_ACCENT_MIN_LIGHTNESS, PLUGIN_ACCENT_MAX_LIGHTNESS, PLUGIN_ACCENT_MAX_CHROMA)
|
||||
}
|
||||
|
||||
/** 派生大面积色调玻璃使用的材料色,不改变用户选择的真实主色。 */
|
||||
export function normalizeThemeMaterialAccent(color: string): GlassAccentColor | undefined {
|
||||
return normalizeAccentColor(
|
||||
color,
|
||||
THEME_MATERIAL_MIN_LIGHTNESS,
|
||||
THEME_MATERIAL_MAX_LIGHTNESS,
|
||||
THEME_MATERIAL_MAX_CHROMA,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user