249 lines
6.4 KiB
TypeScript
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
|
|
} |