mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-01 13:36:43 +08:00
feat(glass): refine material response and rendering lifecycle (#596)
This commit is contained in:
@@ -3,7 +3,9 @@ import {
|
||||
GLASS_OPTICAL_MOTION_MAX_SCALE,
|
||||
GLASS_OPTICAL_REFLECTION_MAX_SCALE,
|
||||
getAvailableGlassOpticalPresets,
|
||||
getGlassCssFrostBlur,
|
||||
getGlassCoverScale,
|
||||
getGlassMaterialResponse,
|
||||
getGlassOpticalCssTransmissionBrightness,
|
||||
getGlassOpticalDecay,
|
||||
getGlassOpticalBufferSize,
|
||||
@@ -12,6 +14,8 @@ import {
|
||||
getGlassOpticalMotionExpansion,
|
||||
getGlassOpticalMotionStrengthScale,
|
||||
getGlassOpticalPresetParameters,
|
||||
getGlassOpticalPresetParametersWithOverrides,
|
||||
getGlassOpticalPresetKey,
|
||||
getGlassOpticalReflectionStrengthScale,
|
||||
getGlassOpticalRenderProfile,
|
||||
getGlassOpticalTransparency,
|
||||
@@ -57,7 +61,9 @@ describe('glass optics geometry', () => {
|
||||
expect(getGlassOpticalMotionStrengthScale(75) - getGlassOpticalMotionStrengthScale(50)).toBeGreaterThan(
|
||||
getGlassOpticalMotionStrengthScale(50) - getGlassOpticalMotionStrengthScale(25),
|
||||
)
|
||||
expect(getGlassOpticalMotionExpansion(50)).toBe(0)
|
||||
expect(getGlassOpticalMotionExpansion(0)).toBe(0)
|
||||
expect(getGlassOpticalMotionExpansion(50)).toBeGreaterThan(0.3)
|
||||
expect(getGlassOpticalMotionExpansion(50)).toBeLessThan(0.4)
|
||||
expect(getGlassOpticalMotionExpansion(80)).toBeGreaterThan(0.4)
|
||||
expect(getGlassOpticalMotionExpansion(100)).toBe(1)
|
||||
expect(getGlassOpticalMaxRefractionPixels(9, 50)).toBe(9)
|
||||
@@ -87,12 +93,12 @@ describe('glass optics geometry', () => {
|
||||
const liquid = getGlassOpticalPresetParameters('frosted', 'high', 'liquid')
|
||||
|
||||
expect(natural).toEqual({
|
||||
deformation: 50,
|
||||
flow: 50,
|
||||
deformation: 40,
|
||||
flow: 40,
|
||||
reflection: 35,
|
||||
transmission: 70,
|
||||
translation: 50,
|
||||
transparency: 70,
|
||||
transmission: 54,
|
||||
translation: 40,
|
||||
transparency: 46,
|
||||
})
|
||||
expect(glide.translation).toBeGreaterThan(glide.deformation)
|
||||
expect(liquid.deformation).toBeGreaterThan(glide.deformation)
|
||||
@@ -101,11 +107,166 @@ describe('glass optics geometry', () => {
|
||||
expect(getAvailableGlassOpticalPresets('balanced')).toEqual(['natural', 'glide', 'liquid'])
|
||||
})
|
||||
|
||||
it('keeps every preset dynamic parameter at the approved material calibration', () => {
|
||||
const expected = {
|
||||
clear: {
|
||||
css: { natural: { deformation: 40, flow: 40, translation: 40 } },
|
||||
balanced: {
|
||||
natural: { deformation: 40, flow: 40, translation: 40 },
|
||||
glide: { deformation: 24, flow: 35, translation: 58 },
|
||||
liquid: { deformation: 56, flow: 61, translation: 45 },
|
||||
},
|
||||
high: {
|
||||
natural: { deformation: 40, flow: 40, translation: 40 },
|
||||
glide: { deformation: 26, flow: 37, translation: 59 },
|
||||
liquid: { deformation: 59, flow: 64, translation: 46 },
|
||||
},
|
||||
},
|
||||
tinted: {
|
||||
css: { natural: { deformation: 40, flow: 40, translation: 40 } },
|
||||
balanced: {
|
||||
natural: { deformation: 42, flow: 40, translation: 40 },
|
||||
glide: { deformation: 26, flow: 35, translation: 56 },
|
||||
liquid: { deformation: 58, flow: 61, translation: 45 },
|
||||
},
|
||||
high: {
|
||||
natural: { deformation: 42, flow: 40, translation: 40 },
|
||||
glide: { deformation: 27, flow: 37, translation: 58 },
|
||||
liquid: { deformation: 61, flow: 64, translation: 46 },
|
||||
},
|
||||
},
|
||||
frosted: {
|
||||
css: { natural: { deformation: 40, flow: 40, translation: 40 } },
|
||||
balanced: {
|
||||
natural: { deformation: 46, flow: 42, translation: 38 },
|
||||
glide: { deformation: 30, flow: 35, translation: 54 },
|
||||
liquid: { deformation: 62, flow: 61, translation: 42 },
|
||||
},
|
||||
high: {
|
||||
natural: { deformation: 48, flow: 42, translation: 38 },
|
||||
glide: { deformation: 32, flow: 37, translation: 56 },
|
||||
liquid: { deformation: 66, flow: 64, translation: 43 },
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
for (const [appearance, qualities] of Object.entries(expected)) {
|
||||
for (const [quality, presets] of Object.entries(qualities)) {
|
||||
for (const [preset, parameters] of Object.entries(presets)) {
|
||||
expect(
|
||||
getGlassOpticalPresetParameters(
|
||||
appearance as 'clear' | 'frosted' | 'tinted',
|
||||
quality as 'balanced' | 'css' | 'high',
|
||||
preset as 'glide' | 'liquid' | 'natural',
|
||||
),
|
||||
).toMatchObject(parameters as object)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('restores per-combination overrides and keeps standard quality on natural', () => {
|
||||
const key = getGlassOpticalPresetKey('tinted', 'high', 'glide')
|
||||
const override = {
|
||||
deformation: 11,
|
||||
flow: 22,
|
||||
reflection: 33,
|
||||
transmission: 44,
|
||||
translation: 55,
|
||||
transparency: 66,
|
||||
}
|
||||
|
||||
expect(key).toBe('tinted:high:glide')
|
||||
expect(getGlassOpticalPresetKey('frosted', 'css', 'liquid')).toBe('frosted:css:natural')
|
||||
expect(getGlassOpticalPresetParametersWithOverrides('tinted', 'high', 'glide', { [key]: override })).toEqual(
|
||||
override,
|
||||
)
|
||||
expect(getGlassOpticalPresetParametersWithOverrides('clear', 'balanced', 'natural', {})).toEqual(
|
||||
getGlassOpticalPresetParameters('clear', 'balanced', 'natural'),
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the approved transparency and transmission matrix for all effective presets', () => {
|
||||
const expected = {
|
||||
clear: {
|
||||
css: { natural: [48, 56] },
|
||||
balanced: { natural: [46, 54], glide: [56, 58], liquid: [51, 51] },
|
||||
high: { natural: [45, 53], glide: [54, 56], liquid: [50, 50] },
|
||||
},
|
||||
tinted: {
|
||||
css: { natural: [34, 54] },
|
||||
balanced: { natural: [32, 56], glide: [40, 61], liquid: [36, 53] },
|
||||
high: { natural: [30, 54], glide: [38, 59], liquid: [34, 51] },
|
||||
},
|
||||
frosted: {
|
||||
css: { natural: [31, 50] },
|
||||
balanced: { natural: [29, 52], glide: [40, 56], liquid: [34, 49] },
|
||||
high: { natural: [27, 50], glide: [38, 54], liquid: [32, 47] },
|
||||
},
|
||||
} as const
|
||||
|
||||
for (const [appearance, qualities] of Object.entries(expected)) {
|
||||
for (const [quality, presets] of Object.entries(qualities)) {
|
||||
for (const [preset, values] of Object.entries(presets)) {
|
||||
const [transparency, transmission] = values as readonly [number, number]
|
||||
|
||||
expect(
|
||||
getGlassOpticalPresetParameters(
|
||||
appearance as 'clear' | 'frosted' | 'tinted',
|
||||
quality as 'balanced' | 'css' | 'high',
|
||||
preset as 'glide' | 'liquid' | 'natural',
|
||||
),
|
||||
).toMatchObject({ transmission, transparency })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('derives independent material responses from piecewise smooth transparency anchors', () => {
|
||||
expect(getGlassMaterialResponse('clear', 0)).toMatchObject({
|
||||
backgroundVisibility: 0.18,
|
||||
surfaceDensity: 1,
|
||||
})
|
||||
expect(getGlassMaterialResponse('tinted', 50)).toMatchObject({
|
||||
backgroundVisibility: 0.48,
|
||||
tintDensity: 0.65,
|
||||
})
|
||||
const frostedLow = getGlassMaterialResponse('frosted', 20)
|
||||
expect(frostedLow).toMatchObject({
|
||||
backgroundVisibility: 0.14,
|
||||
frostBlurScale: 1.48,
|
||||
surfaceDensity: 0.96,
|
||||
})
|
||||
expect(frostedLow.frostDetailLevel).toBeCloseTo(0.1)
|
||||
expect(getGlassMaterialResponse('frosted', 100)).toMatchObject({
|
||||
backgroundVisibility: 0.88,
|
||||
frostBlurScale: 0.52,
|
||||
frostDetailLevel: 0.9,
|
||||
surfaceDensity: 0.4,
|
||||
})
|
||||
expect(getGlassCssFrostBlur(0)).toEqual({ raised: 84, surface: 64 })
|
||||
expect(getGlassCssFrostBlur(50)).toEqual({ raised: 62, surface: 44 })
|
||||
expect(getGlassCssFrostBlur(100)).toEqual({ raised: 26, surface: 16 })
|
||||
|
||||
const samples = [0, 10, 20, 35, 50, 60, 70, 78, 85, 92, 100].map(value =>
|
||||
getGlassMaterialResponse('frosted', value),
|
||||
)
|
||||
expect(
|
||||
samples.every(
|
||||
(sample, index) =>
|
||||
index === 0 ||
|
||||
(sample.backgroundVisibility > samples[index - 1].backgroundVisibility &&
|
||||
sample.frostDetailLevel > samples[index - 1].frostDetailLevel &&
|
||||
sample.surfaceDensity < samples[index - 1].surfaceDensity),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns preset copies so previews cannot mutate the shared matrix', () => {
|
||||
const first = getGlassOpticalPresetParameters('tinted', 'high', 'glide')
|
||||
first.translation = 0
|
||||
|
||||
expect(getGlassOpticalPresetParameters('tinted', 'high', 'glide').translation).toBe(72)
|
||||
expect(getGlassOpticalPresetParameters('tinted', 'high', 'glide').translation).toBe(58)
|
||||
})
|
||||
|
||||
it('matches the monotonic CSS ease timeline used by wallpaper crossfades', () => {
|
||||
@@ -346,8 +507,8 @@ describe('glass optics geometry', () => {
|
||||
})
|
||||
|
||||
it('crossfades active surface weights monotonically', () => {
|
||||
expect(getGlassOpticalSurfaceTransitionWeights(0, 96)).toEqual({ incoming: 0.35, outgoing: 1 })
|
||||
expect(getGlassOpticalSurfaceTransitionWeights(48, 96)).toEqual({ incoming: 0.675, outgoing: 0.5 })
|
||||
expect(getGlassOpticalSurfaceTransitionWeights(0, 96)).toEqual({ incoming: 1, outgoing: 1 })
|
||||
expect(getGlassOpticalSurfaceTransitionWeights(48, 96)).toEqual({ incoming: 1, outgoing: 0.5 })
|
||||
expect(getGlassOpticalSurfaceTransitionWeights(96, 96)).toEqual({ incoming: 1, outgoing: 0 })
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { DEFAULT_GLASS_WALLPAPER_TONE_PROFILE, getGlassWallpaperToneProfile } from '@/utils/glassWallpaperTone'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_GLASS_WALLPAPER_TONE_PROFILE,
|
||||
getGlassWallpaperToneProfile,
|
||||
loadGlassWallpaperTone,
|
||||
takeGlassWallpaperDecodedSource,
|
||||
} from '@/utils/glassWallpaperTone'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('glass wallpaper tone profile', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
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])
|
||||
|
||||
@@ -32,4 +42,127 @@ describe('glass wallpaper tone profile', () => {
|
||||
it('falls back to the neutral profile when no valid samples exist', () => {
|
||||
expect(getGlassWallpaperToneProfile([Number.NaN])).toEqual(DEFAULT_GLASS_WALLPAPER_TONE_PROFILE)
|
||||
})
|
||||
|
||||
it('reuses the successful CORS image decode for readiness and tone analysis', async () => {
|
||||
const pixels = new Uint8ClampedArray(64 * 64 * 4)
|
||||
for (let offset = 0; offset < pixels.length; offset += 4) {
|
||||
pixels[offset] = 128
|
||||
pixels[offset + 1] = 128
|
||||
pixels[offset + 2] = 128
|
||||
pixels[offset + 3] = 255
|
||||
}
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
drawImage: vi.fn(),
|
||||
getImageData: vi.fn(() => ({ data: pixels })),
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
const fetchMock = vi.fn()
|
||||
let imageCount = 0
|
||||
class SuccessfulImage {
|
||||
crossOrigin = ''
|
||||
decoding = ''
|
||||
height = 64
|
||||
naturalHeight = 64
|
||||
naturalWidth = 64
|
||||
onerror: (() => void) | null = null
|
||||
onload: (() => void) | null = null
|
||||
width = 64
|
||||
|
||||
set src(_value: string) {
|
||||
imageCount += 1
|
||||
queueMicrotask(() => this.onload?.())
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
vi.stubGlobal('Image', SuccessfulImage)
|
||||
|
||||
const result = await loadGlassWallpaperTone('https://image.example/success.jpg')
|
||||
|
||||
expect(result.corsReady).toBe(true)
|
||||
expect(result.profile.medianLuminance).toBeCloseTo(128 / 255)
|
||||
expect(imageCount).toBe(1)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(takeGlassWallpaperDecodedSource('https://image.example/success.jpg')?.profile).toEqual(result.profile)
|
||||
expect(takeGlassWallpaperDecodedSource('https://image.example/success.jpg')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('repairs a polluted browser cache before retrying the CORS image decode', async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
drawImage: vi.fn(),
|
||||
getImageData: vi.fn(() => ({ data: new Uint8ClampedArray([128, 128, 128, 255]) })),
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||
.mockResolvedValueOnce({
|
||||
blob: vi.fn().mockResolvedValue(new Blob(['image'])),
|
||||
ok: true,
|
||||
})
|
||||
let imageCount = 0
|
||||
class RecoverableImage {
|
||||
crossOrigin = ''
|
||||
decoding = ''
|
||||
height = 64
|
||||
naturalHeight = 64
|
||||
naturalWidth = 64
|
||||
onerror: (() => void) | null = null
|
||||
onload: (() => void) | null = null
|
||||
width = 64
|
||||
|
||||
set src(_value: string) {
|
||||
imageCount += 1
|
||||
const succeeds = imageCount > 1
|
||||
queueMicrotask(() => (succeeds ? this.onload?.() : this.onerror?.()))
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
vi.stubGlobal('Image', RecoverableImage)
|
||||
|
||||
const result = await loadGlassWallpaperTone('https://image.example/recovered.jpg')
|
||||
|
||||
expect(result.corsReady).toBe(true)
|
||||
expect(imageCount).toBe(2)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
new URL('https://image.example/recovered.jpg'),
|
||||
expect.objectContaining({ cache: 'reload', credentials: 'omit', mode: 'cors' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('retries a transiently failed CORS decode instead of caching the fallback', async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
drawImage: vi.fn(),
|
||||
getImageData: vi.fn(() => ({ data: new Uint8ClampedArray([128, 128, 128, 255]) })),
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
blob: vi.fn().mockResolvedValue(new Blob()),
|
||||
ok: false,
|
||||
}),
|
||||
)
|
||||
let imageCount = 0
|
||||
class TransientImage {
|
||||
crossOrigin = ''
|
||||
decoding = ''
|
||||
height = 64
|
||||
naturalHeight = 64
|
||||
naturalWidth = 64
|
||||
onerror: (() => void) | null = null
|
||||
onload: (() => void) | null = null
|
||||
width = 64
|
||||
|
||||
set src(_value: string) {
|
||||
imageCount += 1
|
||||
queueMicrotask(() => (imageCount === 1 ? this.onerror?.() : this.onload?.()))
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('Image', TransientImage)
|
||||
|
||||
const failed = await loadGlassWallpaperTone('https://image.example/transient.jpg')
|
||||
const recovered = await loadGlassWallpaperTone('https://image.example/transient.jpg')
|
||||
|
||||
expect(failed.corsReady).toBe(false)
|
||||
expect(recovered.corsReady).toBe(true)
|
||||
expect(imageCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
+125
-40
@@ -13,6 +13,8 @@ export const GLASS_OPTICAL_REFERENCE_STRENGTH = 70
|
||||
export type GlassAppearance = 'clear' | 'frosted' | 'tinted'
|
||||
export type GlassOpticalCapability = 'balanced' | 'css' | 'high'
|
||||
export type GlassOpticalPreset = 'glide' | 'liquid' | 'natural'
|
||||
export type GlassOpticalPresetKey = `${GlassAppearance}:${GlassOpticalCapability}:${GlassOpticalPreset}`
|
||||
export type GlassOpticalPresetOverrides = Partial<Record<GlassOpticalPresetKey, GlassOpticalParameters>>
|
||||
export type GlassOpticalQuality = 'balanced' | 'high'
|
||||
export type GlassCornerRadii = [number, number, number, number]
|
||||
|
||||
@@ -31,6 +33,19 @@ export interface GlassOpticalParameters {
|
||||
transparency: number
|
||||
}
|
||||
|
||||
export interface GlassMaterialResponse {
|
||||
/** 真实壁纸在表面材质后的感知可见程度。 */
|
||||
backgroundVisibility: number
|
||||
/** 磨砂预滤纹理允许保留的高频细节比例。 */
|
||||
frostDetailLevel: number
|
||||
/** 标准档 CSS 磨砂半径相对既有 40px 基线的缩放。 */
|
||||
frostBlurScale: number
|
||||
/** 表面遮罩对真实壁纸的覆盖密度。 */
|
||||
surfaceDensity: number
|
||||
/** 色调材质的主体染色密度。 */
|
||||
tintDensity: number
|
||||
}
|
||||
|
||||
export interface GlassInteractionPoint {
|
||||
/** 指针或触点相对视口的横坐标。 */
|
||||
x: number
|
||||
@@ -128,59 +143,57 @@ export function normalizeGlassOpticalStrength(value: unknown) {
|
||||
return Math.min(GLASS_OPTICAL_STRENGTH_MAX, Math.max(GLASS_OPTICAL_STRENGTH_MIN, Math.round(value)))
|
||||
}
|
||||
|
||||
const GLASS_OPTICAL_PRESET_MATRIX: Record<
|
||||
GlassAppearance,
|
||||
Record<GlassOpticalCapability, Record<GlassOpticalPreset, GlassOpticalParameters>>
|
||||
> = {
|
||||
type GlassOpticalPresetSet = Record<GlassOpticalPreset, GlassOpticalParameters>
|
||||
type GlassOpticalCapabilityPresets = {
|
||||
balanced: GlassOpticalPresetSet
|
||||
css: Pick<GlassOpticalPresetSet, 'natural'>
|
||||
high: GlassOpticalPresetSet
|
||||
}
|
||||
|
||||
const GLASS_OPTICAL_PRESET_MATRIX: Record<GlassAppearance, GlassOpticalCapabilityPresets> = {
|
||||
clear: {
|
||||
css: {
|
||||
natural: { deformation: 50, flow: 50, reflection: 35, transmission: 70, translation: 50, transparency: 70 },
|
||||
glide: { deformation: 28, flow: 42, reflection: 29, transmission: 72, translation: 72, transparency: 78 },
|
||||
liquid: { deformation: 72, flow: 78, reflection: 38, transmission: 66, translation: 56, transparency: 74 },
|
||||
natural: { deformation: 40, flow: 40, reflection: 35, transmission: 56, translation: 40, transparency: 48 },
|
||||
},
|
||||
balanced: {
|
||||
natural: { deformation: 50, flow: 50, reflection: 35, transmission: 70, translation: 50, transparency: 70 },
|
||||
glide: { deformation: 30, flow: 44, reflection: 29, transmission: 72, translation: 72, transparency: 78 },
|
||||
liquid: { deformation: 70, flow: 76, reflection: 36, transmission: 66, translation: 56, transparency: 74 },
|
||||
natural: { deformation: 40, flow: 40, reflection: 35, transmission: 54, translation: 40, transparency: 46 },
|
||||
glide: { deformation: 24, flow: 35, reflection: 29, transmission: 58, translation: 58, transparency: 56 },
|
||||
liquid: { deformation: 56, flow: 61, reflection: 36, transmission: 51, translation: 45, transparency: 51 },
|
||||
},
|
||||
high: {
|
||||
natural: { deformation: 50, flow: 50, reflection: 32, transmission: 70, translation: 50, transparency: 70 },
|
||||
glide: { deformation: 32, flow: 46, reflection: 28, transmission: 74, translation: 74, transparency: 80 },
|
||||
liquid: { deformation: 74, flow: 80, reflection: 35, transmission: 68, translation: 58, transparency: 76 },
|
||||
natural: { deformation: 40, flow: 40, reflection: 32, transmission: 53, translation: 40, transparency: 45 },
|
||||
glide: { deformation: 26, flow: 37, reflection: 28, transmission: 56, translation: 59, transparency: 54 },
|
||||
liquid: { deformation: 59, flow: 64, reflection: 35, transmission: 50, translation: 46, transparency: 50 },
|
||||
},
|
||||
},
|
||||
tinted: {
|
||||
css: {
|
||||
natural: { deformation: 50, flow: 50, reflection: 38, transmission: 68, translation: 50, transparency: 46 },
|
||||
glide: { deformation: 30, flow: 42, reflection: 34, transmission: 74, translation: 70, transparency: 52 },
|
||||
liquid: { deformation: 70, flow: 76, reflection: 41, transmission: 64, translation: 54, transparency: 48 },
|
||||
natural: { deformation: 40, flow: 40, reflection: 38, transmission: 54, translation: 40, transparency: 34 },
|
||||
},
|
||||
balanced: {
|
||||
natural: { deformation: 52, flow: 50, reflection: 38, transmission: 72, translation: 50, transparency: 46 },
|
||||
glide: { deformation: 32, flow: 44, reflection: 34, transmission: 78, translation: 70, transparency: 52 },
|
||||
liquid: { deformation: 72, flow: 76, reflection: 39, transmission: 68, translation: 56, transparency: 48 },
|
||||
natural: { deformation: 42, flow: 40, reflection: 38, transmission: 56, translation: 40, transparency: 32 },
|
||||
glide: { deformation: 26, flow: 35, reflection: 34, transmission: 61, translation: 56, transparency: 40 },
|
||||
liquid: { deformation: 58, flow: 61, reflection: 39, transmission: 53, translation: 45, transparency: 36 },
|
||||
},
|
||||
high: {
|
||||
natural: { deformation: 52, flow: 50, reflection: 35, transmission: 76, translation: 50, transparency: 48 },
|
||||
glide: { deformation: 34, flow: 46, reflection: 32, transmission: 82, translation: 72, transparency: 54 },
|
||||
liquid: { deformation: 76, flow: 80, reflection: 38, transmission: 72, translation: 58, transparency: 50 },
|
||||
natural: { deformation: 42, flow: 40, reflection: 35, transmission: 54, translation: 40, transparency: 30 },
|
||||
glide: { deformation: 27, flow: 37, reflection: 32, transmission: 59, translation: 58, transparency: 38 },
|
||||
liquid: { deformation: 61, flow: 64, reflection: 38, transmission: 51, translation: 46, transparency: 34 },
|
||||
},
|
||||
},
|
||||
frosted: {
|
||||
css: {
|
||||
natural: { deformation: 50, flow: 50, reflection: 31, transmission: 54, translation: 50, transparency: 42 },
|
||||
glide: { deformation: 34, flow: 42, reflection: 27, transmission: 58, translation: 66, transparency: 46 },
|
||||
liquid: { deformation: 76, flow: 74, reflection: 34, transmission: 50, translation: 50, transparency: 44 },
|
||||
natural: { deformation: 40, flow: 40, reflection: 31, transmission: 50, translation: 40, transparency: 31 },
|
||||
},
|
||||
balanced: {
|
||||
natural: { deformation: 58, flow: 52, reflection: 31, transmission: 58, translation: 48, transparency: 42 },
|
||||
glide: { deformation: 38, flow: 44, reflection: 27, transmission: 62, translation: 68, transparency: 46 },
|
||||
liquid: { deformation: 78, flow: 76, reflection: 32, transmission: 54, translation: 52, transparency: 44 },
|
||||
natural: { deformation: 46, flow: 42, reflection: 31, transmission: 52, translation: 38, transparency: 29 },
|
||||
glide: { deformation: 30, flow: 35, reflection: 27, transmission: 56, translation: 54, transparency: 40 },
|
||||
liquid: { deformation: 62, flow: 61, reflection: 32, transmission: 49, translation: 42, transparency: 34 },
|
||||
},
|
||||
high: {
|
||||
natural: { deformation: 60, flow: 52, reflection: 29, transmission: 62, translation: 48, transparency: 44 },
|
||||
glide: { deformation: 40, flow: 46, reflection: 25, transmission: 66, translation: 70, transparency: 48 },
|
||||
liquid: { deformation: 82, flow: 80, reflection: 31, transmission: 58, translation: 54, transparency: 46 },
|
||||
natural: { deformation: 48, flow: 42, reflection: 29, transmission: 50, translation: 38, transparency: 27 },
|
||||
glide: { deformation: 32, flow: 37, reflection: 25, transmission: 54, translation: 56, transparency: 38 },
|
||||
liquid: { deformation: 66, flow: 64, reflection: 31, transmission: 47, translation: 43, transparency: 32 },
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -191,7 +204,10 @@ export function getGlassOpticalPresetParameters(
|
||||
quality: GlassOpticalCapability,
|
||||
preset: GlassOpticalPreset,
|
||||
): GlassOpticalParameters {
|
||||
return { ...GLASS_OPTICAL_PRESET_MATRIX[appearance][quality][preset] }
|
||||
const presets = GLASS_OPTICAL_PRESET_MATRIX[appearance][quality]
|
||||
const parameters = 'natural' === preset ? presets.natural : (presets as Partial<GlassOpticalPresetSet>)[preset]
|
||||
|
||||
return { ...(parameters ?? presets.natural) }
|
||||
}
|
||||
|
||||
/** 标准档只保留自然基线;实时档同时开放滑移与液态方案。 */
|
||||
@@ -199,6 +215,79 @@ export function getAvailableGlassOpticalPresets(quality: GlassOpticalCapability)
|
||||
return quality === 'css' ? ['natural'] : ['natural', 'glide', 'liquid']
|
||||
}
|
||||
|
||||
/** 生成持久化覆盖使用的稳定组合键;标准档始终归入自然方案。 */
|
||||
export function getGlassOpticalPresetKey(
|
||||
appearance: GlassAppearance,
|
||||
quality: GlassOpticalCapability,
|
||||
preset: GlassOpticalPreset,
|
||||
): GlassOpticalPresetKey {
|
||||
return `${appearance}:${quality}:${quality === 'css' ? 'natural' : preset}`
|
||||
}
|
||||
|
||||
/** 切换组合时优先恢复用户覆盖,没有覆盖才返回预设矩阵副本。 */
|
||||
export function getGlassOpticalPresetParametersWithOverrides(
|
||||
appearance: GlassAppearance,
|
||||
quality: GlassOpticalCapability,
|
||||
preset: GlassOpticalPreset,
|
||||
overrides: GlassOpticalPresetOverrides,
|
||||
) {
|
||||
const key = getGlassOpticalPresetKey(appearance, quality, preset)
|
||||
|
||||
return { ...(overrides[key] ?? getGlassOpticalPresetParameters(appearance, quality, preset)) }
|
||||
}
|
||||
|
||||
const GLASS_RESPONSE_STOPS = [0, 20, 50, 70, 85, 100] as const
|
||||
const GLASS_BACKGROUND_VISIBILITY: Record<GlassAppearance, readonly number[]> = {
|
||||
clear: [0.18, 0.3, 0.58, 0.77, 0.9, 0.96],
|
||||
tinted: [0.08, 0.2, 0.48, 0.7, 0.84, 0.92],
|
||||
frosted: [0.04, 0.14, 0.35, 0.6, 0.78, 0.88],
|
||||
}
|
||||
const GLASS_SURFACE_DENSITY: Record<GlassAppearance, readonly number[]> = {
|
||||
clear: [1, 0.88, 0.62, 0.42, 0.26, 0.18],
|
||||
tinted: [1, 0.92, 0.72, 0.52, 0.39, 0.3],
|
||||
frosted: [1, 0.96, 0.86, 0.68, 0.5, 0.4],
|
||||
}
|
||||
const GLASS_TINT_DENSITY = [1, 0.9, 0.65, 0.48, 0.36, 0.28] as const
|
||||
const GLASS_FROST_DENSITY = [1, 0.9, 0.7, 0.4, 0.18, 0.1] as const
|
||||
|
||||
/** 在相邻业务锚点之间使用零斜率边界插值,避免滑杆经过锚点时出现视觉折线。 */
|
||||
function interpolateGlassResponse(value: unknown, anchors: readonly number[]) {
|
||||
const normalized = normalizeGlassOpticalStrength(value)
|
||||
const upperIndex = GLASS_RESPONSE_STOPS.findIndex(stop => normalized <= stop)
|
||||
if (upperIndex <= 0) return anchors[0]
|
||||
|
||||
const lowerIndex = upperIndex - 1
|
||||
const lowerStop = GLASS_RESPONSE_STOPS[lowerIndex]
|
||||
const upperStop = GLASS_RESPONSE_STOPS[upperIndex]
|
||||
const linearProgress = (normalized - lowerStop) / (upperStop - lowerStop)
|
||||
const smoothProgress = linearProgress * linearProgress * (3 - 2 * linearProgress)
|
||||
|
||||
return anchors[lowerIndex] + (anchors[upperIndex] - anchors[lowerIndex]) * smoothProgress
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个通透度输入派生互不混用的材质响应;tone、曝光和透射亮度不在此处计算。
|
||||
*/
|
||||
export function getGlassMaterialResponse(appearance: GlassAppearance, value: unknown): GlassMaterialResponse {
|
||||
const frostDensity = interpolateGlassResponse(value, GLASS_FROST_DENSITY)
|
||||
|
||||
return {
|
||||
backgroundVisibility: interpolateGlassResponse(value, GLASS_BACKGROUND_VISIBILITY[appearance]),
|
||||
frostBlurScale: 0.4 + frostDensity * 1.2,
|
||||
frostDetailLevel: 1 - frostDensity,
|
||||
surfaceDensity: interpolateGlassResponse(value, GLASS_SURFACE_DENSITY[appearance]),
|
||||
tintDensity: interpolateGlassResponse(value, GLASS_TINT_DENSITY),
|
||||
}
|
||||
}
|
||||
|
||||
/** 标准档磨砂使用独立的 surface/raised 半径锚点,不借用背景亮度制造厚度。 */
|
||||
export function getGlassCssFrostBlur(value: unknown) {
|
||||
return {
|
||||
raised: interpolateGlassResponse(value, [84, 76, 62, 46, 34, 26]),
|
||||
surface: interpolateGlassResponse(value, [64, 58, 44, 30, 22, 16]),
|
||||
}
|
||||
}
|
||||
|
||||
/** 计算与 CSS `ease` 相同的交叉淡化进度,使 DOM 壁纸与 shader 双纹理保持同一时钟。 */
|
||||
export function getGlassWallpaperTransitionProgress(elapsed: number, duration: number) {
|
||||
if (duration <= 0 || elapsed >= duration) return 1
|
||||
@@ -258,15 +347,11 @@ export function getGlassOpticalFlowStrengthScale(value: unknown) {
|
||||
return normalizedRatio ** Math.log2(GLASS_OPTICAL_FLOW_MAX_SCALE)
|
||||
}
|
||||
|
||||
/** 高于默认值的流动强度逐步扩大作用范围,低区间不会意外改变既有空间尺度。 */
|
||||
/** 流动强度在完整滑杆区间连续控制轨迹范围,低值也能明显收紧空间足迹。 */
|
||||
export function getGlassOpticalMotionExpansion(value: unknown) {
|
||||
const normalized = normalizeGlassOpticalStrength(value)
|
||||
const highRangeProgress = Math.max(
|
||||
0,
|
||||
(normalized - GLASS_OPTICAL_STRENGTH_DEFAULT) / (GLASS_OPTICAL_STRENGTH_MAX - GLASS_OPTICAL_STRENGTH_DEFAULT),
|
||||
)
|
||||
|
||||
return highRangeProgress ** 1.55
|
||||
return (normalized / GLASS_OPTICAL_STRENGTH_MAX) ** 1.4
|
||||
}
|
||||
|
||||
/** 最大几何形变只由质量档约束;流动滑杆改变覆盖与连续性,不继续拉伸背景内容。 */
|
||||
@@ -442,13 +527,13 @@ export function getGlassOpticalWakeDirection(
|
||||
return directionDot < Math.cos((55 * Math.PI) / 180) ? nextDirection : currentDirection
|
||||
}
|
||||
|
||||
/** 活动表面立即可感知,随后与离场表面完成短时单调交叉过渡。 */
|
||||
/** 新活动表面立即接管输入,离场表面只做短时单调淡出。 */
|
||||
export function getGlassOpticalSurfaceTransitionWeights(elapsed: number, duration: number) {
|
||||
const progress = Math.min(1, Math.max(0, elapsed / Math.max(1, duration)))
|
||||
const eased = progress * progress * (3 - 2 * progress)
|
||||
|
||||
return {
|
||||
incoming: 0.35 + eased * 0.65,
|
||||
incoming: 1,
|
||||
outgoing: 1 - eased,
|
||||
}
|
||||
}
|
||||
|
||||
+103
-15
@@ -1,3 +1,5 @@
|
||||
import { preloadCorsImage } from '@/@core/utils/corsImage'
|
||||
|
||||
export interface GlassWallpaperToneProfile {
|
||||
/** 进入材质曲线前的有限整体曝光,避免壁纸明暗差异直接放大到操作表面。 */
|
||||
exposure: number
|
||||
@@ -7,7 +9,24 @@ export interface GlassWallpaperToneProfile {
|
||||
medianLuminance: number
|
||||
}
|
||||
|
||||
/** 一次图片解码同时给出 WebGL 可读性与壁纸曝光分析结果。 */
|
||||
export interface GlassWallpaperToneLoadResult {
|
||||
/** 当前 URL 已按匿名 CORS 模式完成解码,可安全交给 WebGL。 */
|
||||
corsReady: boolean
|
||||
/** 与 DOM 背景和 renderer 共用的有限曝光 profile。 */
|
||||
profile: GlassWallpaperToneProfile
|
||||
}
|
||||
|
||||
/** 交给 renderer 消费的一次性已解码壁纸源。 */
|
||||
export interface GlassWallpaperDecodedSource {
|
||||
/** 已完成匿名 CORS 解码的图片。 */
|
||||
image: HTMLImageElement
|
||||
/** 与该图片同一次解码得到的曝光 profile。 */
|
||||
profile: GlassWallpaperToneProfile
|
||||
}
|
||||
|
||||
const ANALYSIS_MAX_EDGE = 64
|
||||
const DECODED_SOURCE_CACHE_LIMIT = 3
|
||||
const PROFILE_CACHE_LIMIT = 32
|
||||
const PROFILE_LOAD_TIMEOUT_MS = 3000
|
||||
const EXPOSURE_MIN = 0.88
|
||||
@@ -21,7 +40,8 @@ export const DEFAULT_GLASS_WALLPAPER_TONE_PROFILE: GlassWallpaperToneProfile = {
|
||||
medianLuminance: MEDIAN_TARGET,
|
||||
}
|
||||
|
||||
const profileCache = new Map<string, Promise<GlassWallpaperToneProfile>>()
|
||||
const profileCache = new Map<string, Promise<GlassWallpaperToneLoadResult>>()
|
||||
const decodedSourceCache = new Map<string, GlassWallpaperDecodedSource>()
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
@@ -86,7 +106,7 @@ export function analyzeGlassWallpaperTone(image: CanvasImageSource, width: numbe
|
||||
}
|
||||
}
|
||||
|
||||
function rememberProfile(url: string, profile: Promise<GlassWallpaperToneProfile>) {
|
||||
function rememberProfile(url: string, profile: Promise<GlassWallpaperToneLoadResult>) {
|
||||
if (profileCache.size >= PROFILE_CACHE_LIMIT) {
|
||||
const oldestKey = profileCache.keys().next().value
|
||||
if (oldestKey) profileCache.delete(oldestKey)
|
||||
@@ -96,37 +116,105 @@ function rememberProfile(url: string, profile: Promise<GlassWallpaperToneProfile
|
||||
return profile
|
||||
}
|
||||
|
||||
/** 只保留 current/previous/prepared 三个候选,避免完整解码图片变成长期内存缓存。 */
|
||||
function rememberDecodedSource(url: string, source: GlassWallpaperDecodedSource) {
|
||||
decodedSourceCache.delete(url)
|
||||
decodedSourceCache.set(url, source)
|
||||
|
||||
while (decodedSourceCache.size > DECODED_SOURCE_CACHE_LIMIT) {
|
||||
const oldestKey = decodedSourceCache.keys().next().value
|
||||
if (oldestKey) decodedSourceCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 DOM 背景层加载可读像素。跨域来源不支持 CORS 时回落中性 profile,
|
||||
* 避免亮度分析阻断壁纸本身的 CSS 显示能力。
|
||||
* renderer 取得已完成 tone 分析的图片后立即移出缓存。
|
||||
* fixed 与 scroll context 后续通过 renderer 自身的 CPU source cache 共享缩放结果。
|
||||
*/
|
||||
export function loadGlassWallpaperToneProfile(url: string): Promise<GlassWallpaperToneProfile> {
|
||||
if (!url || typeof Image === 'undefined') return Promise.resolve({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE })
|
||||
export function takeGlassWallpaperDecodedSource(url: string): GlassWallpaperDecodedSource | undefined {
|
||||
const source = decodedSourceCache.get(url)
|
||||
if (source) decodedSourceCache.delete(url)
|
||||
|
||||
const cached = profileCache.get(url)
|
||||
if (cached) return cached
|
||||
return source
|
||||
}
|
||||
|
||||
const profile = new Promise<GlassWallpaperToneProfile>(resolve => {
|
||||
function loadCorsReadableToneProfile(url: string): Promise<GlassWallpaperToneLoadResult> {
|
||||
return new Promise(resolve => {
|
||||
const image = new Image()
|
||||
let settled = false
|
||||
const finish = (result: GlassWallpaperToneProfile) => {
|
||||
const finish = (result: GlassWallpaperToneLoadResult) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
window.clearTimeout(timeout)
|
||||
resolve(result)
|
||||
}
|
||||
const timeout = window.setTimeout(
|
||||
() => finish({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE }),
|
||||
() => finish({ corsReady: false, profile: { ...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.onload = () => {
|
||||
const profile = analyzeGlassWallpaperTone(
|
||||
image,
|
||||
image.naturalWidth || image.width,
|
||||
image.naturalHeight || image.height,
|
||||
)
|
||||
rememberDecodedSource(url, { image, profile })
|
||||
finish({
|
||||
corsReady: true,
|
||||
profile,
|
||||
})
|
||||
}
|
||||
image.onerror = () =>
|
||||
finish({
|
||||
corsReady: false,
|
||||
profile: { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE },
|
||||
})
|
||||
image.src = url
|
||||
})
|
||||
}
|
||||
|
||||
return rememberProfile(url, profile)
|
||||
/**
|
||||
* 以一次匿名图片解码同时完成 WebGL 可读性检查和曝光分析。
|
||||
* 只有旧的非 CORS 缓存污染读取时才强制重新验证,再进行一次解码。
|
||||
*/
|
||||
export function loadGlassWallpaperTone(url: string): Promise<GlassWallpaperToneLoadResult> {
|
||||
if (!url || typeof Image === 'undefined') {
|
||||
return Promise.resolve({
|
||||
corsReady: false,
|
||||
profile: { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE },
|
||||
})
|
||||
}
|
||||
|
||||
const cached = profileCache.get(url)
|
||||
if (cached) return cached
|
||||
|
||||
const profile = (async () => {
|
||||
const initial = await loadCorsReadableToneProfile(url)
|
||||
if (initial.corsReady || !(await preloadCorsImage(url))) return initial
|
||||
|
||||
return loadCorsReadableToneProfile(url)
|
||||
})()
|
||||
|
||||
rememberProfile(url, profile)
|
||||
void profile.then(
|
||||
result => {
|
||||
if (!result.corsReady && profileCache.get(url) === profile) profileCache.delete(url)
|
||||
},
|
||||
() => {
|
||||
if (profileCache.get(url) === profile) profileCache.delete(url)
|
||||
},
|
||||
)
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 DOM 背景层加载可读像素。跨域来源不支持 CORS 时回落中性 profile,
|
||||
* 避免亮度分析阻断壁纸本身的 CSS 显示能力。
|
||||
*/
|
||||
export async function loadGlassWallpaperToneProfile(url: string): Promise<GlassWallpaperToneProfile> {
|
||||
return (await loadGlassWallpaperTone(url)).profile
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user