mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 07:28:37 +08:00
feat: add glass materials and quality profiles (#577)
This commit is contained in:
@@ -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] })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user