feat: add glass materials and quality profiles (#577)

This commit is contained in:
InfinityPacer
2026-07-23 06:08:39 +08:00
committed by GitHub
parent 80519e6839
commit 1ed69ccc64
19 changed files with 2587 additions and 238 deletions
+107
View File
@@ -0,0 +1,107 @@
import {
canUseGlassWallpaperTexture,
getGlassCoverScale,
getGlassOpticalBufferSize,
getGlassOpticalRenderProfile,
normalizeGlassOpticalRect,
selectGlassOpticalRects,
type GlassOpticalRect,
} from '@/utils/glassOptics'
import { describe, expect, it } from 'vitest'
describe('glass optics geometry', () => {
it('caps the renderer buffer independently from device pixel ratio', () => {
expect(getGlassOpticalBufferSize(3456, 2234, false)).toEqual({ height: 621, width: 960 })
expect(getGlassOpticalBufferSize(390, 844, true)).toEqual({ height: 720, width: 333 })
expect(getGlassOpticalBufferSize(3456, 2234, false, 'high')).toEqual({ height: 931, width: 1440 })
expect(getGlassOpticalBufferSize(390, 844, true, 'high')).toEqual({ height: 844, width: 390 })
})
it('matches cover cropping on wide and tall images', () => {
expect(getGlassCoverScale(1600, 900, 2400, 1600)).toEqual({ x: 1, y: 0.84375 })
expect(getGlassCoverScale(900, 1600, 2400, 1600)).toEqual({ x: 0.375, y: 1 })
})
it('keeps high quality optics while reducing the media-dense recommendation budget', () => {
expect(getGlassOpticalRenderProfile('high', '/dashboard')).toEqual({
bufferQuality: 'high',
textureLimit: 3072,
textureSource: 'wallpaper',
})
expect(getGlassOpticalRenderProfile('high', '/recommend?source=tmdb')).toEqual({
bufferQuality: 'balanced',
textureLimit: 2048,
textureSource: 'wallpaper',
})
expect(getGlassOpticalRenderProfile('balanced', '/dashboard')).toEqual({
bufferQuality: 'balanced',
textureLimit: 2048,
textureSource: 'wallpaper',
})
expect(getGlassOpticalRenderProfile('high', '/login')).toEqual({
bufferQuality: 'high',
textureLimit: 3072,
textureSource: 'auto',
})
})
it('only uploads browser-readable login wallpapers to WebGL', () => {
const documentUrl = 'https://moviepilot.example/login'
expect(canUseGlassWallpaperTexture('/api/v1/login/wallpaper/0', documentUrl)).toBe(true)
expect(canUseGlassWallpaperTexture('https://moviepilot.example/assets/login.jpg', documentUrl)).toBe(true)
expect(canUseGlassWallpaperTexture('blob:https://moviepilot.example/texture', documentUrl)).toBe(true)
expect(canUseGlassWallpaperTexture('data:image/png;base64,AA==', documentUrl)).toBe(true)
expect(canUseGlassWallpaperTexture('https://image.tmdb.org/t/p/original/poster.jpg', documentUrl)).toBe(false)
expect(canUseGlassWallpaperTexture('', documentUrl)).toBe(false)
})
it('keeps high-value outer surfaces and removes nested repeats', () => {
const candidates: GlassOpticalRect[] = [
{ height: 900, radius: 0, rank: 2, width: 260, x: 0, y: 0 },
{ height: 300, radius: 16, rank: 4, width: 500, x: 300, y: 100 },
{ height: 100, radius: 12, rank: 5, width: 200, x: 320, y: 120 },
{ height: 120, radius: 12, rank: 1, width: 400, x: 500, y: 700 },
]
const selected = selectGlassOpticalRects(candidates, 1440, 900, false)
expect(selected).toHaveLength(3)
expect(selected.map(rect => rect.rank)).toEqual([1, 2, 4])
})
it('keeps original geometry while using the viewport intersection for visibility', () => {
const selected = selectGlassOpticalRects(
[{ height: 160, radius: 20, rank: 1, width: 180, x: -30, y: -40 }],
320,
240,
false,
)
expect(selected).toEqual([{ height: 160, radius: 20, rank: 1, width: 180, x: -30, y: -40 }])
})
it('budgets partially visible surfaces by their visible pixels', () => {
const selected = selectGlassOpticalRects(
[
{ height: 1000, radius: 20, rank: 1, width: 1000, x: -950, y: -900 },
{ height: 120, radius: 16, rank: 2, width: 200, x: 80, y: 80 },
],
320,
240,
false,
)
expect(selected.map(rect => rect.rank)).toEqual([1, 2])
})
it('converts DOM top-origin rectangles while preserving pixel radius', () => {
expect(
normalizeGlassOpticalRect({ height: 100, radius: 20, rank: 1, width: 200, x: 100, y: 50 }, 1000, 500),
).toEqual({ radius: 20, rect: [0.1, 0.7, 0.2, 0.2] })
expect(
normalizeGlassOpticalRect({ height: 100, radius: 80, rank: 1, width: 200, x: -20, y: 50 }, 1000, 500),
).toEqual({ radius: 50, rect: [-0.02, 0.7, 0.2, 0.2] })
})
})
+166
View File
@@ -0,0 +1,166 @@
export const GLASS_OPTICAL_MAX_SURFACES_DESKTOP = 8
export const GLASS_OPTICAL_MAX_SURFACES_MOBILE = 5
export type GlassOpticalQuality = 'balanced' | 'high'
export interface GlassOpticalRect {
/** 元素的实际高度,元素可部分位于视口外。 */
height: number
/** 元素圆角的 CSS 像素值。 */
radius: number
/** 表面的场景优先级,数值越小越优先。 */
rank: number
/** 元素的实际宽度,元素可部分位于视口外。 */
width: number
/** 元素左边缘相对视口的位置。 */
x: number
/** 元素上边缘相对视口的位置。 */
y: number
}
export interface GlassOpticalBufferSize {
height: number
width: number
}
export interface GlassOpticalRenderProfile {
/** 实际内部缓冲档位;媒体密集场景可保留高质量光学但降低合成分辨率。 */
bufferQuality: GlassOpticalQuality
/** 活动壁纸进入 GPU 前的最长边限制。 */
textureLimit: number
/** 登录页优先使用可读纹理,跨域外链自动退回程序化高光。 */
textureSource: 'auto' | 'procedural' | 'wallpaper'
}
/** 按质量与场景分配合成预算,避免推荐页海报解码与高分辨率光学层争抢资源。 */
export function getGlassOpticalRenderProfile(
quality: GlassOpticalQuality,
routeKey: string,
): GlassOpticalRenderProfile {
const mediaDenseRoute = routeKey.startsWith('/recommend')
return {
bufferQuality: quality === 'high' && !mediaDenseRoute ? 'high' : 'balanced',
textureLimit: quality === 'high' && !mediaDenseRoute ? 3072 : 2048,
textureSource: routeKey.startsWith('/login') ? 'auto' : 'wallpaper',
}
}
/** 只将同源及本地对象交给 WebGL,避免跨域纹理失败污染登录页控制台。 */
export function canUseGlassWallpaperTexture(url: string, documentUrl: string): boolean {
if (!url || !documentUrl) return false
try {
const source = new URL(url, documentUrl)
if (source.protocol === 'blob:' || source.protocol === 'data:') return true
return source.origin === new URL(documentUrl).origin
} catch {
return false
}
}
/** 按固定像素预算计算内部缓冲尺寸,避免高 DPI 屏幕线性放大 GPU 成本。 */
export function getGlassOpticalBufferSize(
viewportWidth: number,
viewportHeight: number,
mobile: boolean,
quality: GlassOpticalQuality = 'balanced',
): GlassOpticalBufferSize {
const safeWidth = Math.max(1, viewportWidth)
const safeHeight = Math.max(1, viewportHeight)
const highQuality = quality === 'high'
const maxWidth = mobile ? (highQuality ? 960 : 720) : highQuality ? 1440 : 960
const maxHeight = highQuality ? 960 : 720
const scale = Math.min(1, maxWidth / safeWidth, maxHeight / safeHeight)
return {
height: Math.max(1, Math.round(safeHeight * scale)),
width: Math.max(1, Math.round(safeWidth * scale)),
}
}
/** 计算与 CSS `background-size: cover` 一致的纹理缩放参数。 */
export function getGlassCoverScale(
viewportWidth: number,
viewportHeight: number,
imageWidth: number,
imageHeight: number,
) {
const viewportAspect = Math.max(1, viewportWidth) / Math.max(1, viewportHeight)
const imageAspect = Math.max(1, imageWidth) / Math.max(1, imageHeight)
return imageAspect > viewportAspect
? { x: viewportAspect / imageAspect, y: 1 }
: { x: 1, y: imageAspect / viewportAspect }
}
/** 用可见交集筛选和计费,但保留原始边界给 shader,避免在视口裁剪边生成伪圆角。 */
export function selectGlassOpticalRects(
candidates: GlassOpticalRect[],
viewportWidth: number,
viewportHeight: number,
mobile: boolean,
): GlassOpticalRect[] {
const viewportArea = Math.max(1, viewportWidth * viewportHeight)
const maxCount = mobile ? GLASS_OPTICAL_MAX_SURFACES_MOBILE : GLASS_OPTICAL_MAX_SURFACES_DESKTOP
const maxArea = viewportArea * (mobile ? 0.68 : 0.82)
const visible = candidates
.map(rect => {
const left = Math.max(0, rect.x)
const top = Math.max(0, rect.y)
const right = Math.min(viewportWidth, rect.x + rect.width)
const bottom = Math.min(viewportHeight, rect.y + rect.height)
const visibleHeight = Math.max(0, bottom - top)
const visibleWidth = Math.max(0, right - left)
return {
rect,
visibleArea: visibleWidth * visibleHeight,
visibleHeight,
visibleWidth,
}
})
.filter(candidate => candidate.visibleWidth >= 24 && candidate.visibleHeight >= 24)
.sort((left, right) => left.rect.rank - right.rect.rank || left.visibleArea - right.visibleArea)
const selected: typeof visible = []
let selectedArea = 0
for (const candidate of visible) {
if (selected.length >= maxCount) break
const { rect } = candidate
const nested = selected.some(
parent =>
rect.x >= parent.rect.x &&
rect.y >= parent.rect.y &&
rect.x + rect.width <= parent.rect.x + parent.rect.width &&
rect.y + rect.height <= parent.rect.y + parent.rect.height,
)
if (nested) continue
if (selected.length > 0 && selectedArea + candidate.visibleArea > maxArea) continue
selected.push(candidate)
selectedArea += candidate.visibleArea
}
return selected.map(candidate => candidate.rect)
}
/** 将视口矩形转换为 WebGL 底部原点的归一化参数。 */
export function normalizeGlassOpticalRect(rect: GlassOpticalRect, viewportWidth: number, viewportHeight: number) {
const safeWidth = Math.max(1, viewportWidth)
const safeHeight = Math.max(1, viewportHeight)
return {
radius: Math.min(Math.max(0, rect.radius), Math.max(0, Math.min(rect.width, rect.height) / 2)),
rect: [
rect.x / safeWidth,
1 - (rect.y + rect.height) / safeHeight,
rect.width / safeWidth,
rect.height / safeHeight,
] as const,
}
}