compare view

This commit is contained in:
2026-03-30 13:05:28 -03:00
parent aac27b8504
commit 55e83e4203
23 changed files with 1321 additions and 201 deletions

View File

@@ -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 {

View 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>

View File

@@ -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)

View File

@@ -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,
}
}

View File

@@ -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)

View File

@@ -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}` : '',

View File

@@ -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[]
}

View 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>

View File

@@ -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;

View File

@@ -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,
}
})