nsfwapp/frontend/src/components/ui/previewSprite.ts
2026-04-28 11:02:08 +02:00

249 lines
6.4 KiB
TypeScript

// frontend\src\components\ui\previewSprite.ts
'use client'
import type { CSSProperties } from 'react'
export const DEFAULT_SPRITE_STEP_SECONDS = 5
export const DEFAULT_PREVIEW_SPRITE_COLS = 12
export const DEFAULT_PREVIEW_SPRITE_ROWS = 10
export const DEFAULT_PREVIEW_SPRITE_COUNT =
DEFAULT_PREVIEW_SPRITE_COLS * DEFAULT_PREVIEW_SPRITE_ROWS
function fixedPreviewSpriteGrid(count: number): [number, number] {
if (count <= 1) return [1, 1]
// Legacy-Fall, falls alte Sprites noch 80 Frames hatten
if (count === 80) return [10, 8]
// Neue feste Sprite-Layout-Logik aus dem Backend
return [DEFAULT_PREVIEW_SPRITE_COLS, DEFAULT_PREVIEW_SPRITE_ROWS]
}
export function firstNonEmptyString(...values: unknown[]): string | undefined {
for (const v of values) {
if (typeof v === 'string') {
const s = v.trim()
if (s) return s
}
}
return undefined
}
export function parseJobMeta(metaRaw: unknown): any | null {
if (!metaRaw) return null
if (typeof metaRaw === 'string') {
try {
return JSON.parse(metaRaw)
} catch {
return null
}
}
if (typeof metaRaw === 'object') return metaRaw
return null
}
export function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n))
}
export function chooseSpriteGrid(count: number): [number, number] {
if (count <= 1) return [1, 1]
const targetRatio = 16 / 9
let bestCols = 1
let bestRows = count
let bestWaste = Number.POSITIVE_INFINITY
let bestRatioScore = Number.POSITIVE_INFINITY
for (let c = 1; c <= count; c++) {
const r = Math.max(1, Math.ceil(count / c))
const waste = c * r - count
const ratio = c / r
const ratioScore = Math.abs(ratio - targetRatio)
if (
waste < bestWaste ||
(waste === bestWaste && ratioScore < bestRatioScore) ||
(waste === bestWaste && ratioScore === bestRatioScore && r < bestRows)
) {
bestWaste = waste
bestRatioScore = ratioScore
bestCols = c
bestRows = r
}
}
return [bestCols, bestRows]
}
export type PreviewSpriteInfo = {
pathRaw?: string
path?: string
url?: string
count: number
cols: number
rows: number
stepSeconds: number
version: number
hasScrubberUi: boolean
hasSpriteScrubber: boolean
}
export function readPreviewSpriteInfo(
metaRaw: unknown,
opts?: {
fallbackPath?: string
fallbackCount?: number
versionFallback?: number
}
): PreviewSpriteInfo {
const meta = parseJobMeta(metaRaw)
const pathRaw = firstNonEmptyString(
meta?.preview?.sprite?.path,
meta?.preview?.sprite?.url,
meta?.preview?.sprite?.src,
meta?.previewSprite?.path,
(meta as any)?.previewSpritePath,
opts?.fallbackPath
)
const path = pathRaw ? pathRaw.replace(/\/+$/, '') : undefined
const stepSecondsRaw =
meta?.preview?.sprite?.stepSeconds ??
meta?.previewSprite?.stepSeconds ??
(meta as any)?.previewSpriteStepSeconds
const stepSeconds =
typeof stepSecondsRaw === 'number' &&
Number.isFinite(stepSecondsRaw) &&
stepSecondsRaw > 0
? stepSecondsRaw
: DEFAULT_SPRITE_STEP_SECONDS
const countRaw =
meta?.preview?.sprite?.count ??
meta?.previewSprite?.count ??
(meta as any)?.previewSpriteCount ??
opts?.fallbackCount
const colsRaw =
meta?.preview?.sprite?.cols ??
meta?.previewSprite?.cols ??
(meta as any)?.previewSpriteCols
const rowsRaw =
meta?.preview?.sprite?.rows ??
meta?.previewSprite?.rows ??
(meta as any)?.previewSpriteRows
const count =
typeof countRaw === 'number' && Number.isFinite(countRaw)
? Math.max(0, Math.floor(countRaw))
: 0
const [fallbackCols, fallbackRows] = fixedPreviewSpriteGrid(count)
const explicitCols = Number(colsRaw)
const explicitRows = Number(rowsRaw)
const explicitGridLooksValid =
Number.isFinite(explicitCols) &&
Number.isFinite(explicitRows) &&
explicitCols > 0 &&
explicitRows > 0 &&
!(count > 1 && explicitCols === 1 && explicitRows === 1) &&
(count <= 1 || explicitCols * explicitRows >= count)
const cols = explicitGridLooksValid ? Math.floor(explicitCols) : fallbackCols
const rows = explicitGridLooksValid ? Math.floor(explicitRows) : fallbackRows
const version =
(typeof meta?.updatedAtUnix === 'number' && Number.isFinite(meta.updatedAtUnix)
? meta.updatedAtUnix
: undefined) ??
(typeof (meta as any)?.fileModUnix === 'number' && Number.isFinite((meta as any).fileModUnix)
? (meta as any).fileModUnix
: undefined) ??
(opts?.versionFallback ?? 0)
const url =
path && version
? `${path}?v=${encodeURIComponent(String(version))}`
: path || undefined
const hasScrubberUi =
Boolean(pathRaw) &&
Boolean(url) &&
count > 1
const hasSpriteScrubber =
hasScrubberUi &&
cols > 0 &&
rows > 0
return {
pathRaw,
path,
url,
count,
cols,
rows,
stepSeconds,
version,
hasScrubberUi,
hasSpriteScrubber,
}
}
export function buildSpriteFrameStyle(args: {
spriteUrl?: string
spriteCols: number
spriteRows: number
spriteCount: number
activeIndex?: number
}): CSSProperties | undefined {
const { spriteUrl, spriteCols, spriteRows, spriteCount, activeIndex } = args
if (!spriteUrl) return undefined
if (!(spriteCols > 0 && spriteRows > 0 && spriteCount > 1)) return undefined
if (typeof activeIndex !== 'number') return undefined
const idx = clamp(activeIndex, 0, Math.max(0, spriteCount - 1))
const col = idx % spriteCols
const row = Math.floor(idx / spriteCols)
const posX = spriteCols <= 1 ? 0 : (col / (spriteCols - 1)) * 100
const posY = spriteRows <= 1 ? 0 : (row / (spriteRows - 1)) * 100
return {
backgroundImage: `url("${spriteUrl}")`,
backgroundRepeat: 'no-repeat',
backgroundSize: `${spriteCols * 100}% ${spriteRows * 100}%`,
backgroundPosition: `${posX}% ${posY}%`,
}
}
export function scrubProgressRatioFromIndex(
activeIndex: number | undefined,
count: number
): number | undefined {
if (typeof activeIndex !== 'number') return undefined
if (!(count > 1)) return undefined
return clamp(activeIndex / (count - 1), 0, 1)
}
export function previewScrubberStepSeconds(metaRaw: unknown, fallback = DEFAULT_SPRITE_STEP_SECONDS): number {
const meta = parseJobMeta(metaRaw)
const raw =
meta?.preview?.sprite?.stepSeconds ??
meta?.previewSprite?.stepSeconds ??
(meta as any)?.previewSpriteStepSeconds
return typeof raw === 'number' && Number.isFinite(raw) && raw > 0
? raw
: fallback
}