// 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 { return !!value && typeof value === 'object' && !Array.isArray(value) } export function parseHeatmapMeta(metaRaw: unknown): Record | 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 | null): Record | 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 }