nsfwapp/frontend/src/components/ui/videoHeatmap.ts
2026-06-13 23:22:15 +02:00

349 lines
7.7 KiB
TypeScript

// frontend\src\components\ui\videoHeatmap.ts
import type {
RecordJob,
VideoHeatmapSegment,
VideoHeatmapSource,
} from '../../types'
import {
aiLabelGroup,
normalizeAiLabel,
prettyAiLabel,
} from '../../aiLabels'
function isPlainObject(value: unknown): value is Record<string, any> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
export function parseHeatmapMeta(metaRaw: unknown): Record<string, any> | null {
if (!metaRaw) return null
if (typeof metaRaw === 'string') {
try {
const parsed = JSON.parse(metaRaw)
return isPlainObject(parsed) ? parsed : null
} catch {
return null
}
}
return isPlainObject(metaRaw) ? metaRaw : null
}
function normalizeDurationSeconds(value: unknown): number {
const n = Number(value)
if (!Number.isFinite(n) || n <= 0) return 0
return n > 24 * 60 * 60 ? n / 1000 : n
}
export function heatmapDurationForJob(
job: RecordJob,
fallbackDurationSeconds?: number
): number {
const meta = parseHeatmapMeta((job as any)?.meta)
return normalizeDurationSeconds(
meta?.media?.durationSeconds ??
meta?.file?.durationSeconds ??
meta?.durationSeconds ??
(job as any)?.durationSeconds ??
fallbackDurationSeconds
)
}
function readAiNode(meta: Record<string, any> | null): Record<string, any> | null {
if (!meta) return null
const ai =
meta?.analysis?.highlights ??
meta?.analysis?.ai ??
meta?.ai ??
null
return isPlainObject(ai) ? ai : null
}
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n))
}
function readScore(value: unknown): number {
const n = Number(value)
if (!Number.isFinite(n)) return 0.35
if (n >= 0 && n <= 1) return clamp(n, 0, 1)
if (n > 1 && n <= 100) return clamp(n / 100, 0, 1)
return 0.35
}
function readOptionalScore(value: unknown): number | null {
if (value == null || value === '') return null
const n = Number(value)
if (!Number.isFinite(n)) return null
if (n >= 0 && n <= 1) return clamp(n, 0, 1)
if (n > 1 && n <= 100) return clamp(n / 100, 0, 1)
return null
}
type WeightedDetectedLabel = {
label: string
scoreWeight: number
priority: number
source: VideoHeatmapSource
}
function weightedLabelFromValue(value: unknown): WeightedDetectedLabel | null {
const raw = String(value ?? '').trim()
if (!raw) return null
const lower = raw.toLowerCase()
if (lower.startsWith('combo:')) {
const parts = lower.slice('combo:'.length).split('+')
let best: WeightedDetectedLabel | null = null
for (const part of parts) {
const found = weightedLabelFromValue(part)
if (!found) continue
if (!best || found.priority > best.priority) {
best = found
}
}
return best
}
const normalized = normalizeAiLabel(raw)
if (!normalized || normalized === 'unknown') return null
const group = aiLabelGroup(normalized)
if (group === 'position') {
return {
label: prettyAiLabel(normalized),
scoreWeight: 0.82,
priority: 4,
source: 'position',
}
}
if (group === 'body') {
return {
label: prettyAiLabel(normalized),
scoreWeight: 0.50,
priority: 3,
source: 'other',
}
}
if (group === 'object') {
return {
label: prettyAiLabel(normalized),
scoreWeight: 0.15,
priority: 2,
source: 'other',
}
}
if (group === 'clothing') {
return {
label: prettyAiLabel(normalized),
scoreWeight: 0.28,
priority: 1,
source: 'clothing',
}
}
return null
}
function readWeightedLabel(item: any): WeightedDetectedLabel | null {
const directValues = [
item?.position,
item?.Position,
item?.label,
item?.Label,
item?.title,
item?.name,
item?.category,
item?.type,
item?.kind,
]
let best: WeightedDetectedLabel | null = null
for (const value of directValues) {
const found = weightedLabelFromValue(value)
if (!found) continue
if (!best || found.priority > best.priority) {
best = found
}
}
const arrayValues = [
item?.tags,
item?.labels,
item?.classes,
item?.flags,
]
for (const values of arrayValues) {
if (!Array.isArray(values)) continue
for (const value of values) {
const found = weightedLabelFromValue(value)
if (!found) continue
if (!best || found.priority > best.priority) {
best = found
}
}
}
return best
}
function addSegment(
out: VideoHeatmapSegment[],
startRaw: unknown,
endRaw: unknown,
markerRaw: unknown,
durationSec: number,
label: string,
scoreRaw: unknown,
source: VideoHeatmapSource = 'other'
) {
if (!Number.isFinite(durationSec) || durationSec <= 0) return
let start = Number(startRaw)
let end = Number(endRaw)
if (!Number.isFinite(start) || start < 0) {
const marker = Number(markerRaw)
if (!Number.isFinite(marker) || marker < 0) return
start = marker - 3
end = marker + 3
}
if (!Number.isFinite(end) || end <= start) {
end = start + 6
}
start = clamp(start, 0, durationSec)
end = clamp(end, 0, durationSec)
if (end <= start) return
out.push({
startSec: start,
endSec: end,
intensity: readScore(scoreRaw),
label,
source,
})
}
export function buildVideoHeatmapSegments(
job: RecordJob,
fallbackDurationSeconds?: number
): VideoHeatmapSegment[] {
const meta = parseHeatmapMeta((job as any)?.meta)
const ai = readAiNode(meta)
const durationSec = heatmapDurationForJob(job, fallbackDurationSeconds)
if (!ai || durationSec <= 0) return []
const out: VideoHeatmapSegment[] = []
const segments = Array.isArray(ai?.segments ?? ai?.Segments)
? (ai?.segments ?? ai?.Segments)
: []
const appendItems = (items: unknown[]) => {
for (const item of items) {
if (!isPlainObject(item)) continue
const detected = readWeightedLabel(item)
if (!detected) continue
const ratingIntensity = readOptionalScore(
item.ratingIntensity ?? item.RatingIntensity
)
const rawScore = readScore(
item.score ?? item.Score ?? item.confidence
)
const intensity =
ratingIntensity ??
clamp(rawScore * detected.scoreWeight, 0, 1)
addSegment(
out,
item.startSeconds ?? item.StartSeconds ?? item.start ?? item.Start,
item.endSeconds ?? item.EndSeconds ?? item.end ?? item.End,
item.time ?? item.Time ?? item.at ?? item.timestamp,
durationSec,
detected.label,
intensity,
detected.source
)
}
}
appendItems(segments)
// Raw hits are the source of the condensed segments and would otherwise
// paint the same activity twice. Keep them only for legacy metadata.
if (out.length === 0) {
const hits = Array.isArray(ai?.hits ?? ai?.Hits)
? (ai?.hits ?? ai?.Hits)
: []
appendItems(hits)
}
return mergeHeatmapSegments(out, durationSec)
}
function mergeHeatmapSegments(
input: VideoHeatmapSegment[],
durationSec: number
): VideoHeatmapSegment[] {
if (input.length <= 1) return input
const sorted = [...input]
.filter((s) => s.endSec > s.startSec)
.sort((a, b) => a.startSec - b.startSec || b.endSec - a.endSec)
const out: VideoHeatmapSegment[] = []
for (const seg of sorted) {
const last = out[out.length - 1]
// Nur sehr nahe/überlappende Bereiche zusammenfassen.
if (
!last ||
last.source !== seg.source ||
seg.startSec > last.endSec + 1.5
) {
out.push({ ...seg })
continue
}
last.endSec = clamp(Math.max(last.endSec, seg.endSec), 0, durationSec)
last.intensity = Math.max(last.intensity, seg.intensity)
if (!last.label && seg.label) {
last.label = seg.label
}
}
return out
}