349 lines
7.7 KiB
TypeScript
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
|
|
}
|