| (function () { |
| const app = window.avtApp; |
| if (!app) return; |
|
|
| const MODEL_ROOT = "/static/models/browser/"; |
| const DEFAULT_FPS = 25; |
| let manifest = null; |
| let featureSession = null; |
| let classifierSession = null; |
| let activeProvider = null; |
|
|
| function providers() { |
| return navigator.gpu ? ["webgpu", "wasm"] : ["wasm"]; |
| } |
|
|
| function softmax2(a, b) { |
| const max = Math.max(a, b); |
| const ea = Math.exp(a - max); |
| const eb = Math.exp(b - max); |
| const total = ea + eb; |
| return [ea / total, eb / total]; |
| } |
|
|
| async function loadManifest() { |
| if (manifest) return manifest; |
| const response = await fetch(`${MODEL_ROOT}manifest.json`); |
| if (!response.ok) throw new Error("Could not load browser model manifest"); |
| manifest = await response.json(); |
| return manifest; |
| } |
|
|
| async function loadModels() { |
| if (!window.ort) throw new Error("ONNX Runtime Web is not available"); |
| const config = await loadManifest(); |
| const selectedProviders = providers(); |
| activeProvider = selectedProviders[0]; |
| app.els.clientInferenceBtn.disabled = true; |
| app.els.clientInferenceStatus.textContent = `Loading browser models with ${activeProvider}...`; |
| app.appendTerminalLine(`[browser] loading ONNX models via ${selectedProviders.join(" -> ")}`); |
|
|
| try { |
| ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/"; |
| featureSession = featureSession || (await ort.InferenceSession.create(`${MODEL_ROOT}${config.models.videomaeFeatureExtractor}`, { |
| executionProviders: selectedProviders, |
| })); |
| app.appendTerminalLine("[browser] VideoMAE feature extractor ready"); |
| classifierSession = classifierSession || (await ort.InferenceSession.create(`${MODEL_ROOT}${config.models.anomalyClassifier}`, { |
| executionProviders: selectedProviders, |
| })); |
| app.appendTerminalLine("[browser] anomaly classifier ready"); |
|
|
| const test = new Float32Array(1 * config.maxFrames * config.featureDim); |
| const started = performance.now(); |
| await classifierSession.run({ features: new ort.Tensor("float32", test, [1, config.maxFrames, config.featureDim]) }); |
| const elapsed = performance.now() - started; |
| app.els.clientInferenceStatus.textContent = `Browser models loaded on ${activeProvider}. Classifier test ${elapsed.toFixed(1)} ms.`; |
| } finally { |
| app.els.clientInferenceBtn.disabled = false; |
| } |
| } |
|
|
| function videoReady(video) { |
| return new Promise((resolve, reject) => { |
| video.onloadedmetadata = () => resolve(); |
| video.onerror = () => reject(new Error("Could not load video metadata")); |
| }); |
| } |
|
|
| function seek(video, time) { |
| return new Promise((resolve, reject) => { |
| const done = () => { |
| video.removeEventListener("seeked", done); |
| resolve(); |
| }; |
| video.addEventListener("seeked", done, { once: true }); |
| video.onerror = () => reject(new Error("Could not seek uploaded video")); |
| video.currentTime = Math.min(Math.max(time, 0), Math.max(video.duration - 0.02, 0)); |
| }); |
| } |
|
|
| async function captureFrame(video, canvas, time, size) { |
| await seek(video, time); |
| const ctx = canvas.getContext("2d", { willReadFrequently: true }); |
| canvas.width = size; |
| canvas.height = size; |
| ctx.drawImage(video, 0, 0, size, size); |
| return ctx.getImageData(0, 0, size, size); |
| } |
|
|
| async function clipTensor(video, canvas, startTime, config) { |
| const size = config.imageSize; |
| const data = new Float32Array(1 * config.clipLen * 3 * size * size); |
| const frameStep = config.frameSkip / DEFAULT_FPS; |
|
|
| for (let frame = 0; frame < config.clipLen; frame += 1) { |
| const image = await captureFrame(video, canvas, startTime + frame * frameStep, size); |
| const pixels = image.data; |
| const frameOffset = frame * 3 * size * size; |
| for (let y = 0; y < size; y += 1) { |
| for (let x = 0; x < size; x += 1) { |
| const pixelIndex = (y * size + x) * 4; |
| const outIndex = y * size + x; |
| data[frameOffset + outIndex] = (pixels[pixelIndex] / 255 - config.imageMean[0]) / config.imageStd[0]; |
| data[frameOffset + size * size + outIndex] = (pixels[pixelIndex + 1] / 255 - config.imageMean[1]) / config.imageStd[1]; |
| data[frameOffset + 2 * size * size + outIndex] = (pixels[pixelIndex + 2] / 255 - config.imageMean[2]) / config.imageStd[2]; |
| } |
| } |
| } |
|
|
| return new ort.Tensor("float32", data, [1, config.clipLen, 3, size, size]); |
| } |
|
|
| function buildClipStarts(duration, config) { |
| const span = (config.clipLen * config.frameSkip) / DEFAULT_FPS; |
| const stride = config.clipStride / DEFAULT_FPS; |
| const starts = []; |
| for (let start = 0; start + span <= duration; start += stride) { |
| starts.push(start); |
| } |
| if (!starts.length) starts.push(0); |
| return starts; |
| } |
|
|
| async function extractFeatures(video, canvas, config) { |
| const starts = buildClipStarts(video.duration, config); |
| const features = []; |
| const started = performance.now(); |
| for (let i = 0; i < starts.length; i += 1) { |
| app.setWorkflowStep("features", "running", `Browser feature ${i + 1}/${starts.length}`); |
| app.appendTerminalLine(`[browser-features] extracting feature ${i + 1}/${starts.length} t=${starts[i].toFixed(2)}s`); |
| const tensor = await clipTensor(video, canvas, starts[i], config); |
| const outputs = await featureSession.run({ pixel_values: tensor }); |
| features.push(new Float32Array(outputs.features.data)); |
| app.appendTerminalLine(`[browser-features] feature ${i + 1} ready shape=(768,)`); |
| await new Promise((resolve) => setTimeout(resolve, 0)); |
| } |
| app.appendTerminalLine(`[browser-features] completed ${features.length} features in ${((performance.now() - started) / 1000).toFixed(2)}s`); |
| return { features, starts }; |
| } |
|
|
| async function predictFeatureWindow(featureRows, threshold, config, label) { |
| const input = new Float32Array(1 * config.maxFrames * config.featureDim); |
| const rows = featureRows.length > config.maxFrames ? featureRows.slice(-config.maxFrames) : featureRows; |
| for (let row = 0; row < rows.length; row += 1) { |
| input.set(rows[row], row * config.featureDim); |
| } |
| const outputs = await classifierSession.run({ |
| features: new ort.Tensor("float32", input, [1, config.maxFrames, config.featureDim]), |
| }); |
| const logits = outputs.logits.data; |
| const [probNormal, probAnomaly] = softmax2(logits[0], logits[1]); |
| const prediction = probAnomaly >= threshold ? "ANOMALY" : "NORMAL"; |
| app.appendTerminalLine(`[browser-calc] ${label}: normal=${probNormal.toFixed(4)} threat=${probAnomaly.toFixed(4)} => ${prediction}`); |
| return { |
| prob_normal: probNormal, |
| prob_anomaly: probAnomaly, |
| prediction, |
| confidence: Math.max(probNormal, probAnomaly), |
| }; |
| } |
|
|
| async function scoreTimeline(features, starts, duration, threshold, config) { |
| const timeline = []; |
| const segmentClips = config.segmentClips; |
| const step = Math.max(1, Math.floor(segmentClips / 2)); |
| for (let start = 0; start < Math.max(1, features.length - segmentClips + 1); start += step) { |
| const end = Math.min(start + segmentClips, features.length); |
| const result = await predictFeatureWindow(features.slice(start, end), threshold, config, `segment_${timeline.length + 1}`); |
| timeline.push({ |
| start: Number(starts[start].toFixed(2)), |
| end: Number(Math.min(starts[end - 1] + (config.clipLen * config.frameSkip) / DEFAULT_FPS, duration).toFixed(2)), |
| prob_anomaly: result.prob_anomaly, |
| prediction: result.prediction, |
| }); |
| } |
| return timeline; |
| } |
|
|
| function segmentUnionSeconds(segments) { |
| const intervals = segments.map((seg) => [Number(seg.start), Number(seg.end)]).sort((a, b) => a[0] - b[0]); |
| const merged = []; |
| for (const [start, end] of intervals) { |
| if (!merged.length || start > merged[merged.length - 1][1]) merged.push([start, end]); |
| else merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], end); |
| } |
| return merged.reduce((sum, [start, end]) => sum + Math.max(0, end - start), 0); |
| } |
|
|
| function scoreAtTime(timeline, time) { |
| return ( |
| timeline.find((segment) => Number(segment.start) <= time && time <= Number(segment.end)) || |
| timeline.reduce((best, segment) => { |
| const current = Math.min(Math.abs(time - segment.start), Math.abs(time - segment.end)); |
| const previous = Math.min(Math.abs(time - best.start), Math.abs(time - best.end)); |
| return current < previous ? segment : best; |
| }, timeline[0]) |
| ); |
| } |
|
|
| async function frameSamples(video, timeline) { |
| if (!timeline.length) return []; |
| const canvas = document.createElement("canvas"); |
| const samples = []; |
| const duration = video.duration; |
| const times = [0, duration * 0.25, duration * 0.5, duration * 0.75]; |
| const peak = timeline.reduce((best, segment) => (segment.prob_anomaly > best.prob_anomaly ? segment : best), timeline[0]); |
| times.unshift((peak.start + peak.end) / 2); |
| const used = new Set(); |
| for (const time of times) { |
| const key = time.toFixed(2); |
| if (used.has(key)) continue; |
| used.add(key); |
| await captureFrame(video, canvas, time, 360); |
| const score = scoreAtTime(timeline, time); |
| samples.push({ |
| time: Number(time.toFixed(2)), |
| score: score.prob_anomaly, |
| prediction: score.prediction, |
| image: canvas.toDataURL("image/jpeg", 0.74), |
| }); |
| if (samples.length >= 6) break; |
| } |
| return samples.sort((a, b) => a.time - b.time); |
| } |
|
|
| async function analyzeInBrowser() { |
| const file = app.els.videoFile.files[0]; |
| if (!file) return; |
| app.startWorkflow(file); |
| app.els.uploadResult.textContent = "Starting browser-side analysis..."; |
| app.els.timeline.innerHTML = ""; |
| app.els.visualSummary.innerHTML = ""; |
| app.els.analysisMetrics.innerHTML = ""; |
| app.els.frameGallery.innerHTML = ""; |
|
|
| const processingStarted = performance.now(); |
| let url = null; |
| try { |
| const config = await loadManifest(); |
| await loadModels(); |
| app.appendTerminalLine("[browser] decoding video locally"); |
| app.setWorkflowStep("frames", "running", "Reading browser video metadata"); |
|
|
| url = URL.createObjectURL(file); |
| const video = document.createElement("video"); |
| video.muted = true; |
| video.playsInline = true; |
| video.preload = "auto"; |
| video.src = url; |
| await videoReady(video); |
| app.appendTerminalLine(`[browser-video] duration=${video.duration.toFixed(2)}s assumed_fps=${DEFAULT_FPS}`); |
|
|
| const canvas = document.createElement("canvas"); |
| const featureStarted = performance.now(); |
| const { features, starts } = await extractFeatures(video, canvas, config); |
| const featureSeconds = (performance.now() - featureStarted) / 1000; |
|
|
| app.setWorkflowStep("scoring", "running", "Scoring in browser"); |
| const threshold = Number(app.els.thresholdInput.value); |
| const scoringStarted = performance.now(); |
| const overall = await predictFeatureWindow(features, threshold, config, "overall"); |
| const timeline = await scoreTimeline(features, starts, video.duration, threshold, config); |
| const scoringSeconds = (performance.now() - scoringStarted) / 1000; |
| const anomalySegments = timeline.filter((segment) => segment.prob_anomaly >= threshold); |
| const peakScore = Math.max(...timeline.map((segment) => segment.prob_anomaly), overall.prob_anomaly); |
| const peakSegment = timeline.reduce((best, segment) => (segment.prob_anomaly > best.prob_anomaly ? segment : best), timeline[0]); |
| const averageScore = timeline.reduce((sum, segment) => sum + segment.prob_anomaly, 0) / Math.max(timeline.length, 1); |
| const anomalySeconds = segmentUnionSeconds(anomalySegments); |
| const samples = await frameSamples(video, timeline); |
| const operationalScore = Math.max(overall.prob_anomaly, peakScore); |
| const operational = { |
| prob_anomaly: operationalScore, |
| prob_normal: 1 - operationalScore, |
| prediction: operationalScore >= threshold ? "ANOMALY" : "NORMAL", |
| confidence: Math.max(operationalScore, 1 - operationalScore), |
| basis: peakScore >= overall.prob_anomaly ? "peak_segment" : "whole_video", |
| }; |
| const result = { |
| filename: file.name, |
| duration: video.duration, |
| fps: DEFAULT_FPS, |
| clips: features.length, |
| threshold, |
| overall, |
| operational, |
| timeline, |
| anomaly_segments: anomalySegments, |
| peak_score: peakScore, |
| peak_segment: peakSegment, |
| frame_samples: samples, |
| metrics: { |
| frames: Math.round(video.duration * DEFAULT_FPS), |
| fps: DEFAULT_FPS, |
| duration_seconds: Number(video.duration.toFixed(2)), |
| clips: features.length, |
| features: features.length, |
| feature_dim: config.featureDim, |
| timeline_segments: timeline.length, |
| anomaly_segments: anomalySegments.length, |
| anomaly_seconds: Number(anomalySeconds.toFixed(2)), |
| anomaly_coverage: anomalySeconds / Math.max(video.duration, 0.001), |
| average_score: averageScore, |
| peak_score: peakScore, |
| threshold, |
| processing_seconds: Number(((performance.now() - processingStarted) / 1000).toFixed(3)), |
| phase_times: { |
| read_video_seconds: 0, |
| feature_extraction_seconds: Number(featureSeconds.toFixed(3)), |
| scoring_seconds: Number(scoringSeconds.toFixed(3)), |
| }, |
| }, |
| }; |
|
|
| app.appendTerminalLine(`[browser-done] completed prediction=${operational.prediction} threat=${operationalScore.toFixed(4)}`, "complete"); |
| app.setWorkflowStep("completed", "complete", "Browser scoring completed"); |
| app.completeWorkflow(result); |
| app.renderUpload(result); |
| } catch (err) { |
| app.appendTerminalLine(`[browser-error] ${err.message}`, "error"); |
| app.failWorkflow(err.message); |
| app.els.uploadResult.textContent = err.message; |
| } finally { |
| if (url) URL.revokeObjectURL(url); |
| } |
| } |
|
|
| const browserAnalyzeBtn = document.getElementById("browserAnalyzeBtn"); |
| if (browserAnalyzeBtn) { |
| browserAnalyzeBtn.addEventListener("click", analyzeInBrowser); |
| } |
|
|
| window.avtBrowserInference = { |
| analyzeInBrowser, |
| loadModels, |
| }; |
| })(); |
|
|