// 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 }