compare view
This commit is contained in:
@@ -10,7 +10,9 @@ import BrandTablePanel from './panels/BrandTablePanel.vue'
|
||||
import TimelinePanel from './panels/TimelinePanel.vue'
|
||||
import CostStatsPanel from './panels/CostStatsPanel.vue'
|
||||
import SourceSelector from './panels/SourceSelector.vue'
|
||||
import CompareView from './panels/CompareView.vue'
|
||||
import StageConfig from './components/StageConfig.vue'
|
||||
import OverlayControls from './components/OverlayControls.vue'
|
||||
import FrameStrip from './components/FrameStrip.vue'
|
||||
import { usePipelineStore } from './stores/pipeline'
|
||||
import { useSSEConnection } from './composables/useSSEConnection'
|
||||
@@ -41,9 +43,14 @@ const {
|
||||
// Editor overlays + CV result accumulation
|
||||
const {
|
||||
editorOverlays, editorBoxes,
|
||||
updateDisplayForFrame, onReplayResult, setActiveStage,
|
||||
updateDisplayForFrame, onReplayResult: _onReplayResult, setActiveStage,
|
||||
saveOverlaysToCache,
|
||||
} = useEditorState(currentFrameRef)
|
||||
|
||||
function onReplayResult(result: any) {
|
||||
_onReplayResult(result)
|
||||
}
|
||||
|
||||
// Set active stage when editor opens
|
||||
watch(() => pipeline.editorStage, (stage) => {
|
||||
if (stage) setActiveStage(stage)
|
||||
@@ -56,6 +63,13 @@ function setCheckpointFrame(index: number) {
|
||||
if (frame) updateDisplayForFrame(frame.seq)
|
||||
}
|
||||
|
||||
// Save overlays to S3 cache then close editor
|
||||
function closeEditorWithSave() {
|
||||
saveOverlaysToCache(pipeline.timelineId, pipeline.jobId)
|
||||
// Small delay to let the save requests fire before navigation
|
||||
setTimeout(() => pipeline.closeEditor(), 100)
|
||||
}
|
||||
|
||||
// Wire job start to clear log panel
|
||||
function onJobStarted(newJobId: string, opts?: { pauseAfterStage?: boolean }) {
|
||||
logPanel.value?.clear()
|
||||
@@ -74,6 +88,11 @@ function onJobStarted(newJobId: string, opts?: { pauseAfterStage?: boolean }) {
|
||||
<path d="M2 4h4l2 2h6v8H2V4z"/><path d="M2 4V2h12v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="header-btn" title="Compare jobs" @click="pipeline.openCompare()">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="1" y="3" width="6" height="10" rx="1"/><rect x="9" y="3" width="6" height="10" rx="1"/><path d="M8 5v6"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Transport controls — visible when a pipeline is running -->
|
||||
<div v-if="sseConnected && (status === 'live' || status === 'processing' || paused)" class="transport">
|
||||
@@ -210,21 +229,8 @@ function onJobStarted(newJobId: string, opts?: { pauseAfterStage?: boolean }) {
|
||||
/>
|
||||
|
||||
<div class="editor-bottom">
|
||||
<div class="overlay-controls">
|
||||
<label v-for="(overlay, idx) in editorOverlays" :key="idx" class="overlay-toggle">
|
||||
<input type="checkbox" v-model="overlay.visible" />
|
||||
<span class="overlay-label">{{ overlay.label }}</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0" max="1" step="0.05"
|
||||
:value="overlay.opacity ?? 0.5"
|
||||
@input="(e: Event) => overlay.opacity = Number((e.target as HTMLInputElement).value)"
|
||||
class="opacity-slider"
|
||||
/>
|
||||
<span class="opacity-value">{{ Math.round((overlay.opacity ?? 0.5) * 100) }}%</span>
|
||||
</label>
|
||||
</div>
|
||||
<button class="editor-close" @click="pipeline.closeEditor()">✕ Close</button>
|
||||
<OverlayControls :overlays="editorOverlays" />
|
||||
<button class="editor-close" @click="closeEditorWithSave()">✕ Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -242,6 +248,9 @@ function onJobStarted(newJobId: string, opts?: { pauseAfterStage?: boolean }) {
|
||||
<!-- === SOURCE SELECTOR MODE === -->
|
||||
<SourceSelector v-else-if="pipeline.layoutMode === 'source_selector'" @job-started="(id: string, opts: any) => onJobStarted(id, opts)" />
|
||||
|
||||
<!-- === COMPARE MODE === -->
|
||||
<CompareView v-else-if="pipeline.layoutMode === 'compare'" />
|
||||
|
||||
</template>
|
||||
</SplitPane>
|
||||
|
||||
@@ -457,41 +466,6 @@ header h1 { font-size: var(--font-size-lg); font-weight: 600; }
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.overlay-controls {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.overlay-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.overlay-toggle input[type="checkbox"] {
|
||||
accent-color: #00bcd4;
|
||||
}
|
||||
|
||||
.overlay-label {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.opacity-slider {
|
||||
width: 80px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.opacity-value {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
/* Source selector */
|
||||
.source-selector {
|
||||
|
||||
62
ui/detection-app/src/components/OverlayControls.vue
Normal file
62
ui/detection-app/src/components/OverlayControls.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import type { FrameOverlay } from 'mpr-ui-framework'
|
||||
|
||||
defineProps<{
|
||||
overlays: FrameOverlay[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overlay-controls">
|
||||
<label v-for="(overlay, idx) in overlays" :key="idx" class="overlay-toggle">
|
||||
<input type="checkbox" v-model="overlay.visible" />
|
||||
<span class="overlay-label">{{ overlay.label }}</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0" max="1" step="0.05"
|
||||
:value="overlay.opacity ?? 0.5"
|
||||
@input="(e: Event) => overlay.opacity = Number((e.target as HTMLInputElement).value)"
|
||||
class="opacity-slider"
|
||||
/>
|
||||
<span class="opacity-value">{{ Math.round((overlay.opacity ?? 0.5) * 100) }}%</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overlay-controls {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.overlay-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.overlay-toggle input[type="checkbox"] {
|
||||
accent-color: #00bcd4;
|
||||
}
|
||||
|
||||
.overlay-label {
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.opacity-slider {
|
||||
width: 80px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.opacity-value {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
min-width: 30px;
|
||||
}
|
||||
</style>
|
||||
@@ -71,10 +71,11 @@ export function useCheckpointLoader(
|
||||
const { stages, checkpointStageFor } = useStageRegistry()
|
||||
|
||||
// Auto-load checkpoint when entering editor mode.
|
||||
// Also watches timelineId — it resolves async on page load.
|
||||
watch(
|
||||
() => [pipeline.layoutMode, pipeline.editorStage, jobId.value, stages.value.length] as const,
|
||||
() => [pipeline.layoutMode, pipeline.editorStage, jobId.value, stages.value.length, pipeline.timelineId] as const,
|
||||
([mode, stage, job]) => {
|
||||
if (mode === 'bbox_editor' && stage && job) {
|
||||
if (mode === 'bbox_editor' && stage && job && pipeline.timelineId) {
|
||||
const cpStage = checkpointStageFor(stage)
|
||||
if (cpStage) {
|
||||
loadCheckpoint(job, cpStage)
|
||||
|
||||
@@ -69,7 +69,7 @@ export function useEditorState(currentFrameRef: Ref<number | null>) {
|
||||
}
|
||||
}
|
||||
|
||||
function onReplayResult(result: StageResult) {
|
||||
function onReplayResult(result: StageResult, jobId?: string) {
|
||||
if (result.frameWidth && result.frameHeight) {
|
||||
frameDimensions.value = { w: result.frameWidth, h: result.frameHeight }
|
||||
}
|
||||
@@ -115,6 +115,28 @@ export function useEditorState(currentFrameRef: Ref<number | null>) {
|
||||
}))
|
||||
}
|
||||
|
||||
async function saveOverlaysToCache(timelineId: string, jobId: string) {
|
||||
const stage = activeStage.value
|
||||
if (!stage) return
|
||||
|
||||
const entries = Object.entries(allFrameOverlays.value)
|
||||
if (entries.length === 0) return
|
||||
|
||||
for (const [seqStr, overlayMap] of entries) {
|
||||
fetch('/api/detect/overlays', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
timeline_id: timelineId,
|
||||
job_id: jobId,
|
||||
stage,
|
||||
seq: Number(seqStr),
|
||||
overlays: overlayMap,
|
||||
}),
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function resetEditorState() {
|
||||
activeStage.value = null
|
||||
allFrameRegions.value = {}
|
||||
@@ -136,6 +158,7 @@ export function useEditorState(currentFrameRef: Ref<number | null>) {
|
||||
updateDisplayForFrame,
|
||||
onReplayResult,
|
||||
setActiveStage,
|
||||
saveOverlaysToCache,
|
||||
resetEditorState,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ export function useHashRouter() {
|
||||
if (configMatch) {
|
||||
return { mode: 'stage_editor', stage: configMatch[1] }
|
||||
}
|
||||
if (path === 'compare') {
|
||||
return { mode: 'compare', stage: null as string | null }
|
||||
}
|
||||
return { mode: 'normal', stage: null as string | null }
|
||||
}
|
||||
|
||||
@@ -49,6 +52,8 @@ export function useHashRouter() {
|
||||
hash = `#/config/${pipeline.editorStage}`
|
||||
} else if (pipeline.layoutMode === 'source_selector') {
|
||||
hash = '#/source'
|
||||
} else if (pipeline.layoutMode === 'compare') {
|
||||
hash = '#/compare'
|
||||
}
|
||||
if (window.location.hash !== hash) {
|
||||
window.history.pushState(null, '', hash)
|
||||
|
||||
@@ -28,6 +28,19 @@ export function useSSEConnection() {
|
||||
pipeline.openSourceSelector()
|
||||
}
|
||||
|
||||
// Resolve timeline_id from job on init
|
||||
if (jobParam) {
|
||||
pipeline.setJob(jobParam)
|
||||
fetch(`/api/detect/jobs/${jobParam}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (data?.timeline_id) {
|
||||
pipeline.setTimelineId(data.timeline_id)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const source = new SSEDataSource({
|
||||
id: 'detect-stream',
|
||||
url: jobId.value ? `/api/detect/stream/${jobId.value}` : '',
|
||||
|
||||
@@ -11,12 +11,21 @@ export interface StageConfigField {
|
||||
options: string[] | null
|
||||
}
|
||||
|
||||
export interface StageOutputHint {
|
||||
key: string
|
||||
type: string // "boxes_by_frame" | "overlay"
|
||||
label: string
|
||||
default_opacity: number
|
||||
src_format: string
|
||||
}
|
||||
|
||||
export interface StageInfo {
|
||||
name: string
|
||||
label: string
|
||||
description: string
|
||||
category: string
|
||||
config_fields: StageConfigField[]
|
||||
output_hints: StageOutputHint[]
|
||||
reads: string[]
|
||||
writes: string[]
|
||||
}
|
||||
|
||||
573
ui/detection-app/src/panels/CompareView.vue
Normal file
573
ui/detection-app/src/panels/CompareView.vue
Normal file
@@ -0,0 +1,573 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { Panel, SplitPane, FrameRenderer } from 'mpr-ui-framework'
|
||||
import type { FrameBBox, FrameOverlay } from 'mpr-ui-framework'
|
||||
import type { Job, Checkpoint } from '@common/types/generated'
|
||||
import { usePipelineStore } from '../stores/pipeline'
|
||||
import { useStageRegistry } from '../composables/useStageRegistry'
|
||||
import type { StageOutputHint } from '../composables/useStageRegistry'
|
||||
import FrameStrip from '../components/FrameStrip.vue'
|
||||
import OverlayControls from '../components/OverlayControls.vue'
|
||||
import { getOverlays } from '../composables/useOverlayCache'
|
||||
|
||||
const pipeline = usePipelineStore()
|
||||
const { editableStages, getStage } = useStageRegistry()
|
||||
|
||||
function isEditable(stage: string): boolean {
|
||||
return editableStages.value.includes(stage)
|
||||
}
|
||||
|
||||
function openEditor(jobId: string, stage: string) {
|
||||
// Navigate to editor for this job + stage
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('job', jobId)
|
||||
url.hash = `#/editor/${stage}`
|
||||
window.location.href = url.toString()
|
||||
}
|
||||
|
||||
interface JobDetail {
|
||||
id: string
|
||||
timeline_id: string | null
|
||||
profile_name: string
|
||||
status: string
|
||||
config_overrides: Record<string, unknown>
|
||||
checkpoints: { stage_name: string; stats: Record<string, unknown> }[]
|
||||
stage_outputs: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
interface CheckpointFrame {
|
||||
seq: number
|
||||
timestamp: number
|
||||
jpeg_b64: string
|
||||
}
|
||||
|
||||
// Two sides
|
||||
const jobA = ref<JobDetail | null>(null)
|
||||
const jobB = ref<JobDetail | null>(null)
|
||||
|
||||
// Available jobs for this timeline
|
||||
const jobs = ref<Job[]>([])
|
||||
|
||||
// Shared frames from timeline cache
|
||||
const frames = ref<CheckpointFrame[]>([])
|
||||
const frameIndex = ref(0)
|
||||
|
||||
// Only show stages that have visual editors
|
||||
const stages = computed(() => {
|
||||
if (!jobA.value) return []
|
||||
const editable = new Set(editableStages.value)
|
||||
return jobA.value.checkpoints
|
||||
.filter(c => c.stage_name && editable.has(c.stage_name))
|
||||
.map(c => c.stage_name)
|
||||
})
|
||||
|
||||
// Persist selections in localStorage
|
||||
const STORAGE_KEY = 'mpr:compare'
|
||||
|
||||
function loadSaved(): { jobA?: string; jobB?: string; stage?: string } {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
return raw ? JSON.parse(raw) : {}
|
||||
} catch { return {} }
|
||||
}
|
||||
|
||||
function saveSelection() {
|
||||
const data = {
|
||||
jobA: jobA.value?.id ?? '',
|
||||
jobB: jobB.value?.id ?? '',
|
||||
stage: selectedStage.value,
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
|
||||
}
|
||||
|
||||
const saved = loadSaved()
|
||||
const selectedStage = ref(saved.stage ?? '')
|
||||
|
||||
// Current frame image
|
||||
const currentFrame = computed(() => frames.value[frameIndex.value] ?? null)
|
||||
const currentImage = computed(() => currentFrame.value?.jpeg_b64 ?? '')
|
||||
const currentSeq = computed(() => currentFrame.value?.seq ?? 0)
|
||||
|
||||
// Generic box extraction from stage outputs using output_hints
|
||||
function boxesForJob(job: JobDetail | null): FrameBBox[] {
|
||||
if (!job || !selectedStage.value) return []
|
||||
const output = job.stage_outputs[selectedStage.value]
|
||||
if (!output) return []
|
||||
|
||||
const stage = getStage(selectedStage.value)
|
||||
if (!stage) return []
|
||||
|
||||
const boxHints = stage.output_hints.filter(h => h.type === 'boxes_by_frame')
|
||||
const allBoxes: FrameBBox[] = []
|
||||
|
||||
for (const hint of boxHints) {
|
||||
const regions = output[hint.key] as Record<string, unknown[]> | undefined
|
||||
if (!regions) continue
|
||||
|
||||
const seq = String(currentSeq.value)
|
||||
const boxes = regions[seq] as { x: number; y: number; w: number; h: number; confidence: number; label: string }[] | undefined
|
||||
if (boxes) {
|
||||
for (const b of boxes) {
|
||||
allBoxes.push({
|
||||
x: b.x, y: b.y, w: b.w, h: b.h,
|
||||
confidence: b.confidence,
|
||||
label: b.label || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allBoxes
|
||||
}
|
||||
|
||||
const boxesA = computed(() => boxesForJob(jobA.value))
|
||||
const boxesB = computed(() => boxesForJob(jobB.value))
|
||||
|
||||
|
||||
// Debug overlays from S3 cache — driven by stage registry output_hints
|
||||
const overlaysA = ref<FrameOverlay[]>([])
|
||||
const overlaysB = ref<FrameOverlay[]>([])
|
||||
|
||||
function overlayHintsForStage(): StageOutputHint[] {
|
||||
const stage = getStage(selectedStage.value)
|
||||
if (!stage) return []
|
||||
return stage.output_hints.filter(h => h.type === 'overlay')
|
||||
}
|
||||
|
||||
async function loadOverlaysForFrame(
|
||||
job: JobDetail | null,
|
||||
seq: number,
|
||||
target: typeof overlaysA,
|
||||
) {
|
||||
if (!job || !selectedStage.value || !pipeline.timelineId) {
|
||||
target.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const hints = overlayHintsForStage()
|
||||
if (hints.length === 0) {
|
||||
target.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// First check stage outputs for inline overlay data (e.g. mask_overlays_by_frame)
|
||||
const stageOutput = job.stage_outputs[selectedStage.value] ?? {}
|
||||
const prev = new Map(target.value.map(o => [o.label, o]))
|
||||
|
||||
// Try inline overlays from stage output (keyed by frame seq)
|
||||
let foundInline = false
|
||||
const result: FrameOverlay[] = []
|
||||
for (const h of hints) {
|
||||
const existing = prev.get(h.label)
|
||||
// Convention: overlay data in stage output uses key with _by_frame suffix
|
||||
const byFrameKey = h.key.replace('_b64', 's_by_frame').replace('overlay', 'overlay')
|
||||
// Try common patterns: mask_overlays_by_frame, etc.
|
||||
const possibleKeys = [
|
||||
`${h.key.replace('_b64', '')}_by_frame`,
|
||||
`${h.key.replace('_b64', 's')}_by_frame`,
|
||||
`mask_overlays_by_frame`,
|
||||
]
|
||||
let src = ''
|
||||
for (const k of possibleKeys) {
|
||||
const byFrame = stageOutput[k] as Record<string, string> | undefined
|
||||
if (byFrame && byFrame[String(seq)]) {
|
||||
src = byFrame[String(seq)]
|
||||
foundInline = true
|
||||
break
|
||||
}
|
||||
}
|
||||
result.push({
|
||||
src,
|
||||
label: h.label,
|
||||
visible: existing?.visible ?? true,
|
||||
opacity: existing?.opacity ?? h.default_opacity,
|
||||
srcFormat: h.src_format as 'jpeg' | 'png',
|
||||
})
|
||||
}
|
||||
|
||||
if (foundInline) {
|
||||
target.value = result
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to S3 overlay cache
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/detect/overlays/${pipeline.timelineId}/${job.id}/${selectedStage.value}/${seq}`
|
||||
)
|
||||
const data = resp.ok ? await resp.json() : {}
|
||||
const cached = data.overlays ?? {}
|
||||
|
||||
target.value = hints.map(h => {
|
||||
const existing = prev.get(h.label)
|
||||
return {
|
||||
src: cached[h.key] ?? '',
|
||||
label: h.label,
|
||||
visible: existing?.visible ?? true,
|
||||
opacity: existing?.opacity ?? h.default_opacity,
|
||||
srcFormat: h.src_format as 'jpeg' | 'png',
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
target.value = result // use whatever we have (possibly empty)
|
||||
}
|
||||
}
|
||||
|
||||
// Reload overlays when frame or stage changes
|
||||
function refreshOverlays() {
|
||||
loadOverlaysForFrame(jobA.value, currentSeq.value, overlaysA)
|
||||
loadOverlaysForFrame(jobB.value, currentSeq.value, overlaysB)
|
||||
}
|
||||
|
||||
watch([currentSeq, selectedStage], refreshOverlays)
|
||||
|
||||
// Sync overlay visibility/opacity from A controls → B overlays
|
||||
watch(overlaysA, (aList) => {
|
||||
for (const a of aList) {
|
||||
const b = overlaysB.value.find(o => o.label === a.label)
|
||||
if (b) {
|
||||
b.visible = a.visible
|
||||
b.opacity = a.opacity
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// Resolve timeline from a saved job if store doesn't have it
|
||||
async function ensureTimelineId(): Promise<string> {
|
||||
if (pipeline.timelineId) return pipeline.timelineId
|
||||
|
||||
// Try to resolve from saved job selection
|
||||
const savedJobId = saved.jobA || saved.jobB
|
||||
if (savedJobId) {
|
||||
try {
|
||||
const resp = await fetch(`/api/detect/jobs/${savedJobId}`)
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
if (data.timeline_id) {
|
||||
pipeline.setTimelineId(data.timeline_id)
|
||||
return data.timeline_id
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// Load jobs — prefer current timeline, fall back to all jobs
|
||||
async function loadJobs() {
|
||||
const tid = await ensureTimelineId()
|
||||
const url = tid
|
||||
? `/api/detect/jobs?timeline_id=${tid}`
|
||||
: '/api/detect/jobs'
|
||||
try {
|
||||
const resp = await fetch(url)
|
||||
if (!resp.ok) return
|
||||
const result = await resp.json()
|
||||
// If only one job on this timeline, show all jobs for comparison
|
||||
if (tid && result.length < 2) {
|
||||
const allResp = await fetch('/api/detect/jobs')
|
||||
if (allResp.ok) {
|
||||
jobs.value = await allResp.json()
|
||||
return
|
||||
}
|
||||
}
|
||||
jobs.value = result
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Load a job's detail
|
||||
async function loadJobDetail(jobId: string): Promise<JobDetail | null> {
|
||||
try {
|
||||
const resp = await fetch(`/api/detect/jobs/${jobId}`)
|
||||
if (!resp.ok) return null
|
||||
return await resp.json()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Load frames from timeline cache via checkpoint endpoint
|
||||
async function loadFrames(stage: string) {
|
||||
const tid = pipeline.timelineId
|
||||
if (!tid) return
|
||||
try {
|
||||
const resp = await fetch(`/api/detect/checkpoints/${tid}/${stage}`)
|
||||
if (!resp.ok) return
|
||||
const data = await resp.json()
|
||||
frames.value = data.frames ?? []
|
||||
frameIndex.value = 0
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function selectJobA(job: Job) {
|
||||
jobA.value = await loadJobDetail(job.id)
|
||||
if (jobA.value && stages.value.length > 0 && !selectedStage.value) {
|
||||
selectedStage.value = stages.value[0]
|
||||
}
|
||||
// Load frames if stage is already selected (e.g. restored from localStorage)
|
||||
if (selectedStage.value && frames.value.length === 0) {
|
||||
await loadFrames(selectedStage.value)
|
||||
}
|
||||
refreshOverlays()
|
||||
saveSelection()
|
||||
}
|
||||
|
||||
async function selectJobB(job: Job) {
|
||||
jobB.value = await loadJobDetail(job.id)
|
||||
refreshOverlays()
|
||||
saveSelection()
|
||||
}
|
||||
|
||||
function jobLabel(j: Job): string {
|
||||
const id = j.id.slice(0, 8)
|
||||
const date = j.created_at ? new Date(j.created_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) : ''
|
||||
const overrides = Object.keys(j.config_overrides || {}).length
|
||||
const extra = overrides ? ` +${overrides} overrides` : ''
|
||||
return `${id} ${j.run_type} ${j.status} ${date}${extra}`
|
||||
}
|
||||
|
||||
function setFrame(index: number) {
|
||||
if (index >= 0 && index < frames.value.length) {
|
||||
frameIndex.value = index
|
||||
}
|
||||
}
|
||||
|
||||
// Load frames when stage selection changes
|
||||
watch(selectedStage, (stage) => {
|
||||
if (stage) {
|
||||
loadFrames(stage)
|
||||
saveSelection()
|
||||
}
|
||||
})
|
||||
|
||||
// Auto-select last two jobs (most recent first in the list)
|
||||
watch(jobs, (list) => {
|
||||
if (list.length === 0) return
|
||||
|
||||
if (list.length >= 2 && !jobA.value) selectJobA(list[0])
|
||||
if (list.length >= 2 && !jobB.value) selectJobB(list[1])
|
||||
else if (list.length === 1 && !jobA.value) selectJobA(list[0])
|
||||
}, { immediate: true })
|
||||
|
||||
// Load on mount, and also when timelineId becomes available (async from SSE init)
|
||||
onMounted(loadJobs)
|
||||
watch(() => pipeline.timelineId, (tid) => {
|
||||
if (tid && jobs.value.length === 0) loadJobs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="compare-view">
|
||||
<!-- Header: job selectors + stage selector -->
|
||||
<div class="compare-header">
|
||||
<div class="job-selector">
|
||||
<label>A</label>
|
||||
<select @change="(e: Event) => { const id = (e.target as HTMLSelectElement).value; const j = jobs.find(j => j.id === id); if (j) selectJobA(j) }">
|
||||
<option value="">select job...</option>
|
||||
<option v-for="j in jobs" :key="j.id" :value="j.id" :selected="jobA?.id === j.id">
|
||||
{{ jobLabel(j) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="stage-selector">
|
||||
<select v-model="selectedStage">
|
||||
<option v-for="s in stages" :key="s" :value="s">{{ s.replace(/_/g, ' ') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="job-selector">
|
||||
<label>B</label>
|
||||
<select @change="(e: Event) => { const id = (e.target as HTMLSelectElement).value; const j = jobs.find(j => j.id === id); if (j) selectJobB(j) }">
|
||||
<option value="">select job...</option>
|
||||
<option v-for="j in jobs" :key="j.id" :value="j.id" :selected="jobB?.id === j.id">
|
||||
{{ jobLabel(j) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button class="close-btn" @click="pipeline.closeEditor()">Close</button>
|
||||
</div>
|
||||
|
||||
<!-- Two frame panels side by side -->
|
||||
<div class="compare-frames">
|
||||
<SplitPane direction="horizontal" :initial-size="1" size-mode="ratio" :min="0.3" :max="3">
|
||||
<template #first>
|
||||
<Panel :title="jobA ? `A: ${jobA.id.slice(0, 8)}` : 'A'" status="idle">
|
||||
<div class="compare-frame-content">
|
||||
<FrameRenderer :image-src="currentImage" :boxes="boxesA" :overlays="overlaysA" />
|
||||
<button
|
||||
v-if="jobA && selectedStage && isEditable(selectedStage)"
|
||||
class="edit-btn"
|
||||
title="Edit this stage"
|
||||
@click="openEditor(jobA.id, selectedStage)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="5" cy="5" r="3.5"/><line x1="7.5" y1="7.5" x2="11" y2="11"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
</template>
|
||||
<template #second>
|
||||
<Panel :title="jobB ? `B: ${jobB.id.slice(0, 8)}` : 'B'" status="idle">
|
||||
<div class="compare-frame-content">
|
||||
<FrameRenderer :image-src="currentImage" :boxes="boxesB" :overlays="overlaysB" />
|
||||
<button
|
||||
v-if="jobB && selectedStage && isEditable(selectedStage)"
|
||||
class="edit-btn"
|
||||
title="Edit this stage"
|
||||
@click="openEditor(jobB.id, selectedStage)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="5" cy="5" r="3.5"/><line x1="7.5" y1="7.5" x2="11" y2="11"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
</template>
|
||||
</SplitPane>
|
||||
</div>
|
||||
|
||||
<!-- Shared frame strip -->
|
||||
<FrameStrip
|
||||
v-if="frames.length > 0"
|
||||
:frames="frames"
|
||||
:current-index="frameIndex"
|
||||
:selection-start="0"
|
||||
:selection-end="frames.length - 1"
|
||||
@frame-click="setFrame"
|
||||
/>
|
||||
|
||||
<!-- Overlay controls + stats -->
|
||||
<div class="compare-bottom">
|
||||
<OverlayControls :overlays="overlaysA" />
|
||||
<div class="compare-stats" v-if="selectedStage">
|
||||
<span class="stat-side">A: <strong>{{ boxesA.length }}</strong></span>
|
||||
<span class="stat-side">B: <strong>{{ boxesB.length }}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.compare-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.compare-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--surface-2);
|
||||
border-bottom: var(--panel-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.job-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.job-selector label {
|
||||
font-weight: 700;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
min-width: 14px;
|
||||
}
|
||||
|
||||
.job-selector select,
|
||||
.stage-selector select {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--surface-3);
|
||||
border-radius: 3px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.stage-selector {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--surface-3);
|
||||
border-radius: 4px;
|
||||
padding: 3px 10px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--status-error);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.compare-frames {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.compare-frame-content {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.compare-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--surface-2);
|
||||
border-top: var(--panel-border);
|
||||
flex-shrink: 0;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.compare-stats {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-dim);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.compare-frame-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid var(--surface-3);
|
||||
border-radius: 4px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -210,6 +210,36 @@ async function runPipeline() {
|
||||
}
|
||||
}
|
||||
|
||||
async function runOnTimeline(timelineId: string) {
|
||||
running.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const resp = await fetch('/api/detect/run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
timeline_id: timelineId,
|
||||
profile_name: selectedProfile.value,
|
||||
checkpoint: checkpoint.value,
|
||||
skip_vlm: skipVlm.value,
|
||||
skip_cloud: skipCloud.value,
|
||||
log_level: logLevel.value,
|
||||
pause_after_stage: pauseAfterStage.value,
|
||||
}),
|
||||
})
|
||||
if (!resp.ok) {
|
||||
const detail = await resp.text()
|
||||
throw new Error(`${resp.status}: ${detail}`)
|
||||
}
|
||||
const data = await resp.json()
|
||||
pipeline.setTimelineId(timelineId)
|
||||
emit('job-started', data.job_id, { pauseAfterStage: pauseAfterStage.value })
|
||||
} catch (e: any) {
|
||||
error.value = `Failed to start pipeline: ${e.message}`
|
||||
running.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`
|
||||
@@ -320,7 +350,8 @@ onMounted(loadSources)
|
||||
:class="['source-item', { selected: selectedTimeline === tl.id }]"
|
||||
@click="selectTimeline(tl)"
|
||||
>
|
||||
<span class="source-id">{{ tl.name || tl.id.slice(0, 12) }}</span>
|
||||
<span class="source-id">{{ tl.id.slice(0, 8) }}</span>
|
||||
<span class="timeline-name" v-if="tl.name">{{ tl.name }}</span>
|
||||
<span class="source-meta">
|
||||
<span class="source-type-badge">{{ tl.status.toUpperCase() }}</span>
|
||||
<span v-if="tl.frame_count" class="source-count">{{ tl.frame_count }} frames</span>
|
||||
@@ -344,6 +375,14 @@ onMounted(loadSources)
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="selectedTimeline" class="source-empty">No jobs yet</div>
|
||||
<button
|
||||
v-if="selectedTimeline"
|
||||
class="run-again-btn"
|
||||
@click="runOnTimeline(selectedTimeline)"
|
||||
:disabled="running"
|
||||
>
|
||||
{{ running ? 'Starting...' : 'Run again on this timeline' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="source-actions">
|
||||
@@ -441,7 +480,8 @@ onMounted(loadSources)
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.source-id { font-family: var(--font-mono); }
|
||||
.source-id { font-family: var(--font-mono); color: var(--text-dim); font-size: 10px; }
|
||||
.timeline-name { font-size: var(--font-size-sm); }
|
||||
|
||||
.source-meta {
|
||||
display: flex;
|
||||
@@ -535,6 +575,20 @@ onMounted(loadSources)
|
||||
.run-btn:hover { opacity: 0.9; }
|
||||
.run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.run-again-btn {
|
||||
background: var(--surface-3);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--surface-3);
|
||||
border-radius: 4px;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
.run-again-btn:hover { background: var(--status-live); color: #000; }
|
||||
.run-again-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.source-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
|
||||
@@ -80,10 +80,19 @@ export const usePipelineStore = defineStore('pipeline', () => {
|
||||
editorStage.value = stage
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
layoutMode.value = 'normal'
|
||||
function openCompare() {
|
||||
layoutMode.value = 'compare'
|
||||
editorStage.value = null
|
||||
sourceHasSelection.value = false
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
// Reload to reinitialize panels with current job context
|
||||
const url = new URL(window.location.href)
|
||||
url.hash = ''
|
||||
if (jobId.value) {
|
||||
url.searchParams.set('job', jobId.value)
|
||||
}
|
||||
window.location.href = url.toString()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
@@ -105,6 +114,6 @@ export const usePipelineStore = defineStore('pipeline', () => {
|
||||
checkpoints, error, layoutMode, editorStage, sourceHasSelection,
|
||||
isRunning, isPaused, canReplay, isEditing,
|
||||
setJob, setTimelineId, setStatus, updateNodes, setRunContext, setCheckpoints, setError,
|
||||
openSourceSelector, openBBoxEditor, openStageEditor, closeEditor, reset,
|
||||
openSourceSelector, openBBoxEditor, openStageEditor, openCompare, closeEditor, reset,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user