mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-04 15:10:56 +08:00
fix(glass): stabilize optical rendering and page presentation (#595)
This commit is contained in:
@@ -66,7 +66,7 @@ describe('glass optics geometry', () => {
|
||||
expect(getGlassOpticalReflectionStrengthScale(0)).toBe(0)
|
||||
expect(getGlassOpticalReflectionStrengthScale(50)).toBe(1)
|
||||
expect(getGlassOpticalReflectionStrengthScale(100)).toBeCloseTo(GLASS_OPTICAL_REFLECTION_MAX_SCALE)
|
||||
expect(getGlassOpticalTransparency(0)).toBeCloseTo(0.28)
|
||||
expect(getGlassOpticalTransparency(0)).toBe(0)
|
||||
expect(getGlassOpticalTransparency(50)).toBeGreaterThan(0.6)
|
||||
expect(getGlassOpticalTransparency(70)).toBeCloseTo(0.96)
|
||||
expect(getGlassOpticalTransparency(100)).toBeCloseTo(1.1)
|
||||
@@ -76,9 +76,9 @@ describe('glass optics geometry', () => {
|
||||
expect(getGlassOpticalTransmissionStrength(0)).toBe(0)
|
||||
expect(getGlassOpticalTransmissionStrength(70)).toBe(1)
|
||||
expect(getGlassOpticalTransmissionStrength(100)).toBe(1.3)
|
||||
expect(getGlassOpticalCssTransmissionBrightness(0)).toBeCloseTo(0.82)
|
||||
expect(getGlassOpticalCssTransmissionBrightness(70)).toBeCloseTo(1.28)
|
||||
expect(getGlassOpticalCssTransmissionBrightness(100)).toBeCloseTo(1.48)
|
||||
expect(getGlassOpticalCssTransmissionBrightness(0)).toBeCloseTo(0.84)
|
||||
expect(getGlassOpticalCssTransmissionBrightness(70)).toBeCloseTo(1)
|
||||
expect(getGlassOpticalCssTransmissionBrightness(100)).toBeCloseTo(1.08)
|
||||
})
|
||||
|
||||
it('keeps presets as concrete six-parameter values', () => {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { DEFAULT_GLASS_WALLPAPER_TONE_PROFILE, getGlassWallpaperToneProfile } from '@/utils/glassWallpaperTone'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('glass wallpaper tone profile', () => {
|
||||
it('keeps a representative mid-tone wallpaper near neutral exposure', () => {
|
||||
const profile = getGlassWallpaperToneProfile([0.18, 0.3, 0.36, 0.4, 0.52, 0.72, 0.82])
|
||||
|
||||
expect(profile.medianLuminance).toBeCloseTo(0.4)
|
||||
expect(profile.highlightLuminance).toBeCloseTo(0.72)
|
||||
expect(profile.exposure).toBeGreaterThan(0.98)
|
||||
expect(profile.exposure).toBeLessThan(1.04)
|
||||
})
|
||||
|
||||
it('uses bounded compensation without erasing bright and dark wallpaper character', () => {
|
||||
const dark = getGlassWallpaperToneProfile([0.01, 0.03, 0.06, 0.1, 0.14, 0.22, 0.32])
|
||||
const bright = getGlassWallpaperToneProfile([0.42, 0.58, 0.7, 0.78, 0.86, 0.94, 1])
|
||||
|
||||
expect(dark.exposure).toBe(1.14)
|
||||
expect(bright.exposure).toBeGreaterThanOrEqual(0.88)
|
||||
expect(bright.exposure).toBeLessThan(0.92)
|
||||
expect(dark.exposure).toBeGreaterThan(bright.exposure)
|
||||
})
|
||||
|
||||
it('uses the highlight percentile to lower exposure for locally overbright wallpapers', () => {
|
||||
const controlled = getGlassWallpaperToneProfile([0.2, 0.28, 0.34, 0.38, 0.42, 0.5, 0.58])
|
||||
const highlighted = getGlassWallpaperToneProfile([0.2, 0.28, 0.34, 0.38, 0.42, 0.95, 1])
|
||||
|
||||
expect(highlighted.medianLuminance).toBe(controlled.medianLuminance)
|
||||
expect(highlighted.exposure).toBeLessThan(controlled.exposure)
|
||||
})
|
||||
|
||||
it('falls back to the neutral profile when no valid samples exist', () => {
|
||||
expect(getGlassWallpaperToneProfile([Number.NaN])).toEqual(DEFAULT_GLASS_WALLPAPER_TONE_PROFILE)
|
||||
})
|
||||
})
|
||||
@@ -290,7 +290,7 @@ export function getGlassOpticalTransparency(value: unknown) {
|
||||
if (normalized <= GLASS_OPTICAL_REFERENCE_STRENGTH) {
|
||||
const progress = normalized / GLASS_OPTICAL_REFERENCE_STRENGTH
|
||||
|
||||
return 0.28 + 0.68 * progress ** 1.25
|
||||
return 0.96 * progress ** 0.83
|
||||
}
|
||||
|
||||
const highRangeProgress =
|
||||
@@ -299,19 +299,19 @@ export function getGlassOpticalTransparency(value: unknown) {
|
||||
return 0.96 + 0.14 * highRangeProgress ** 1.35
|
||||
}
|
||||
|
||||
/** 标准 CSS 材质使用受控亮度曲线,并在高区间保留有限余量。 */
|
||||
/** 标准 CSS 材质在壁纸归一化后只做窄幅目标亮度调整,避免重新放大原始明暗差异。 */
|
||||
export function getGlassOpticalCssTransmissionBrightness(value: unknown) {
|
||||
const transmission = getGlassOpticalTransmissionStrength(value)
|
||||
if (transmission <= 1) {
|
||||
return 0.82 + 0.46 * transmission ** 1.1
|
||||
return 0.84 + 0.16 * transmission ** 1.05
|
||||
}
|
||||
|
||||
const progress = (transmission - 1) / 0.3
|
||||
|
||||
return 1.28 + 0.2 * progress ** 1.2
|
||||
return 1 + 0.08 * progress ** 1.1
|
||||
}
|
||||
|
||||
/** 实时 renderer 接收透射响应,并在 shader 中按材质和质量执行高亮保护。 */
|
||||
/** 实时 renderer 以 70 为归一化目标亮度参考,并在 shader 中按材质和质量执行高亮保护。 */
|
||||
export function getGlassOpticalTransmissionStrength(value: unknown) {
|
||||
const normalized = normalizeGlassOpticalStrength(value)
|
||||
if (normalized <= GLASS_OPTICAL_REFERENCE_STRENGTH) {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
export interface GlassWallpaperToneProfile {
|
||||
/** 进入材质曲线前的有限整体曝光,避免壁纸明暗差异直接放大到操作表面。 */
|
||||
exposure: number
|
||||
/** 壁纸采样的高亮分位亮度,用于约束亮场曝光。 */
|
||||
highlightLuminance: number
|
||||
/** 壁纸采样的中位亮度,用于确定稳健的整体曝光。 */
|
||||
medianLuminance: number
|
||||
}
|
||||
|
||||
const ANALYSIS_MAX_EDGE = 64
|
||||
const PROFILE_CACHE_LIMIT = 32
|
||||
const PROFILE_LOAD_TIMEOUT_MS = 3000
|
||||
const EXPOSURE_MIN = 0.88
|
||||
const EXPOSURE_MAX = 1.14
|
||||
const MEDIAN_TARGET = 0.38
|
||||
const HIGHLIGHT_TARGET = 0.82
|
||||
|
||||
export const DEFAULT_GLASS_WALLPAPER_TONE_PROFILE: GlassWallpaperToneProfile = {
|
||||
exposure: 1,
|
||||
highlightLuminance: HIGHLIGHT_TARGET,
|
||||
medianLuminance: MEDIAN_TARGET,
|
||||
}
|
||||
|
||||
const profileCache = new Map<string, Promise<GlassWallpaperToneProfile>>()
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function readPercentile(sorted: number[], percentile: number) {
|
||||
if (!sorted.length) return 0
|
||||
|
||||
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.round((sorted.length - 1) * percentile)))]
|
||||
}
|
||||
|
||||
/**
|
||||
* 以中位亮度为主、高亮分位为辅计算有限曝光。
|
||||
* 指数响应只缩小跨壁纸差异,保留亮场与暗场各自的视觉性格。
|
||||
*/
|
||||
export function getGlassWallpaperToneProfile(luminances: number[]): GlassWallpaperToneProfile {
|
||||
const sorted = luminances
|
||||
.filter(Number.isFinite)
|
||||
.map(value => clamp(value, 0, 1))
|
||||
.sort((a, b) => a - b)
|
||||
if (!sorted.length) return { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE }
|
||||
|
||||
const medianLuminance = readPercentile(sorted, 0.5)
|
||||
const highlightLuminance = readPercentile(sorted, 0.9)
|
||||
const medianExposure = (MEDIAN_TARGET / Math.max(medianLuminance, 0.06)) ** 0.22
|
||||
const highlightExposure = (HIGHLIGHT_TARGET / Math.max(highlightLuminance, 0.15)) ** 0.16
|
||||
const exposure = clamp(medianExposure * 0.75 + highlightExposure * 0.25, EXPOSURE_MIN, EXPOSURE_MAX)
|
||||
|
||||
return {
|
||||
exposure,
|
||||
highlightLuminance,
|
||||
medianLuminance,
|
||||
}
|
||||
}
|
||||
|
||||
/** 从已解码图片生成与 renderer、DOM 背景层共用的稳健亮度 profile。 */
|
||||
export function analyzeGlassWallpaperTone(image: CanvasImageSource, width: number, height: number) {
|
||||
if (typeof document === 'undefined' || width <= 0 || height <= 0) {
|
||||
return { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE }
|
||||
}
|
||||
|
||||
try {
|
||||
const scale = Math.min(1, ANALYSIS_MAX_EDGE / Math.max(width, height))
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = Math.max(1, Math.round(width * scale))
|
||||
canvas.height = Math.max(1, Math.round(height * scale))
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true })
|
||||
if (!context) return { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE }
|
||||
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height)
|
||||
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data
|
||||
const luminances: number[] = []
|
||||
|
||||
for (let offset = 0; offset < pixels.length; offset += 4) {
|
||||
if (pixels[offset + 3] < 128) continue
|
||||
luminances.push((pixels[offset] * 0.2126 + pixels[offset + 1] * 0.7152 + pixels[offset + 2] * 0.0722) / 255)
|
||||
}
|
||||
|
||||
return getGlassWallpaperToneProfile(luminances)
|
||||
} catch {
|
||||
return { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE }
|
||||
}
|
||||
}
|
||||
|
||||
function rememberProfile(url: string, profile: Promise<GlassWallpaperToneProfile>) {
|
||||
if (profileCache.size >= PROFILE_CACHE_LIMIT) {
|
||||
const oldestKey = profileCache.keys().next().value
|
||||
if (oldestKey) profileCache.delete(oldestKey)
|
||||
}
|
||||
|
||||
profileCache.set(url, profile)
|
||||
return profile
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 DOM 背景层加载可读像素。跨域来源不支持 CORS 时回落中性 profile,
|
||||
* 避免亮度分析阻断壁纸本身的 CSS 显示能力。
|
||||
*/
|
||||
export function loadGlassWallpaperToneProfile(url: string): Promise<GlassWallpaperToneProfile> {
|
||||
if (!url || typeof Image === 'undefined') return Promise.resolve({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE })
|
||||
|
||||
const cached = profileCache.get(url)
|
||||
if (cached) return cached
|
||||
|
||||
const profile = new Promise<GlassWallpaperToneProfile>(resolve => {
|
||||
const image = new Image()
|
||||
let settled = false
|
||||
const finish = (result: GlassWallpaperToneProfile) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
window.clearTimeout(timeout)
|
||||
resolve(result)
|
||||
}
|
||||
const timeout = window.setTimeout(
|
||||
() => finish({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE }),
|
||||
PROFILE_LOAD_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
image.crossOrigin = 'anonymous'
|
||||
image.decoding = 'async'
|
||||
image.onload = () =>
|
||||
finish(analyzeGlassWallpaperTone(image, image.naturalWidth || image.width, image.naturalHeight || image.height))
|
||||
image.onerror = () => finish({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE })
|
||||
image.src = url
|
||||
})
|
||||
|
||||
return rememberProfile(url, profile)
|
||||
}
|
||||
Reference in New Issue
Block a user