This commit is contained in:
2026-03-27 00:01:54 -03:00
parent 65814b5b9e
commit df6bcb01e8
14 changed files with 1246 additions and 203 deletions

View File

@@ -78,6 +78,18 @@ function onTimelineResize(delta: number) {
tableFlex.value = Math.max(0.3, Math.min(3, tableFlex.value - shift))
}
// Editor sliders sidebar width — drag right = shrink sliders (grow frame)
const slidersWidth = ref(210)
function onSlidersResize(delta: number) {
slidersWidth.value = Math.max(210, Math.min(350, slidersWidth.value - delta))
}
// Editor bottom height (overlays bar)
const editorBottomHeight = ref(50)
function onEditorBottomResize(delta: number) {
editorBottomHeight.value = Math.max(36, Math.min(120, editorBottomHeight.value - delta))
}
const statusMap: Record<string, 'idle' | 'live' | 'processing' | 'error'> = {
idle: 'idle',
connecting: 'processing',
@@ -110,31 +122,107 @@ async function stopPipeline() {
const currentFrameImage = ref<string | null>(null)
const currentFrameRef = ref<number | null>(null)
// All checkpoint frames (for scenario mode — scrubbing)
const checkpointFrames = ref<{ seq: number; timestamp: number; jpeg_b64: string }[]>([])
const checkpointFrameIndex = ref(0)
const checkpointStage = ref<string | null>(null) // which stage the checkpoint is at
source.on<{ frame_ref: number; jpeg_b64: string }>('frame_update', (e) => {
currentFrameImage.value = e.jpeg_b64
currentFrameRef.value = e.frame_ref
})
// Load checkpoint data when in scenario mode
async function loadCheckpoint(job: string, stage: string) {
try {
const resp = await fetch(`/api/detect/checkpoints/${job}/${stage}`)
if (!resp.ok) return
const data = await resp.json()
checkpointFrames.value = data.frames ?? []
checkpointStage.value = stage
// Show first frame
if (checkpointFrames.value.length > 0) {
checkpointFrameIndex.value = 0
const first = checkpointFrames.value[0]
currentFrameImage.value = first.jpeg_b64
currentFrameRef.value = first.seq
}
status.value = 'idle'
} catch (e) {
console.error('Failed to load checkpoint:', e)
}
}
function setCheckpointFrame(index: number) {
if (index < 0 || index >= checkpointFrames.value.length) return
checkpointFrameIndex.value = index
const frame = checkpointFrames.value[index]
currentFrameImage.value = frame.jpeg_b64
currentFrameRef.value = frame.seq
}
// Load checkpoint when in editor mode with a job (scenario URL)
// Uses watch to handle both initial load and navigation
import { watch as vueWatch } from 'vue'
vueWatch(
() => [pipeline.layoutMode, pipeline.editorStage, jobId.value] as const,
([mode, stage, job]) => {
if (mode === 'bbox_editor' && stage && job) {
const stageMap: Record<string, string> = {
detect_edges: 'filter_scenes',
detect_contours: 'detect_edges',
detect_color: 'detect_contours',
merge_regions: 'detect_color',
}
const cpStage = stageMap[stage] ?? 'filter_scenes'
loadCheckpoint(job, cpStage)
}
},
{ immediate: true },
)
// Debug overlays from replay-stage results
const editorOverlays = ref<FrameOverlay[]>([])
// Boxes from edge detection (local or server)
const editorBoxes = ref<import('mpr-ui-framework/src/renderers/FrameRenderer.vue').FrameBBox[]>([])
function onReplayResult(result: {
regions_by_frame?: Record<string, unknown[]>
debug?: Record<string, { edge_overlay_b64: string; lines_overlay_b64: string; horizontal_count: number; pair_count: number }>
}) {
const overlays: FrameOverlay[] = []
// Update boxes
if (result.regions_by_frame) {
const firstRegions = Object.values(result.regions_by_frame)[0] as any[] ?? []
editorBoxes.value = firstRegions.map((r: any) => ({
x: r.x, y: r.y, w: r.w, h: r.h,
confidence: r.confidence,
label: r.label ?? 'edge_region',
stage: 'detect_edges',
}))
}
// Update overlays — only when debug data is present, preserve existing otherwise
if (result.debug) {
// Take first frame's debug data (editor shows one frame at a time)
const firstDebug = Object.values(result.debug)[0]
if (firstDebug) {
const overlays: FrameOverlay[] = []
if (firstDebug.edge_overlay_b64) {
overlays.push({ src: firstDebug.edge_overlay_b64, label: 'Canny edges', visible: true, opacity: 0.4 })
// Preserve visibility/opacity from existing overlays if they exist
const existing = editorOverlays.value.find(o => o.label === 'Canny edges')
overlays.push({ src: firstDebug.edge_overlay_b64, label: 'Canny edges', visible: existing?.visible ?? true, opacity: existing?.opacity ?? 0.25 })
}
if (firstDebug.lines_overlay_b64) {
overlays.push({ src: firstDebug.lines_overlay_b64, label: 'Hough lines', visible: true, opacity: 0.5 })
const existing = editorOverlays.value.find(o => o.label === 'Hough lines')
overlays.push({ src: firstDebug.lines_overlay_b64, label: 'Hough lines', visible: existing?.visible ?? true, opacity: existing?.opacity ?? 0.25 })
}
editorOverlays.value = overlays
}
}
editorOverlays.value = overlays
}
function onJobStarted(newJobId: string) {
@@ -236,12 +324,14 @@ function onJobStarted(newJobId: string) {
<!-- === BBOX EDITOR MODE === -->
<template v-else-if="pipeline.layoutMode === 'bbox_editor'">
<Panel :title="`Region Editor — ${pipeline.editorStage?.replace(/_/g, ' ')}`" :status="status">
<div class="editor-placeholder">
<div class="editor-layout">
<!-- Top: frame + sliders side by side -->
<div class="editor-top">
<div class="editor-frame">
<FramePanel :source="source" :status="status" :overlays="editorOverlays" />
<FramePanel :source="source" :status="status" :overlays="editorOverlays" :frame-image="currentFrameImage" :editor-boxes="editorBoxes" />
</div>
<div class="editor-tools">
<ResizeHandle direction="horizontal" @resize="onSlidersResize" />
<div class="editor-sliders" :style="{ width: slidersWidth + 'px' }">
<StageConfigSliders
v-if="pipeline.editorStage"
:stage="pipeline.editorStage"
@@ -250,10 +340,29 @@ function onJobStarted(newJobId: string) {
:frame-ref="currentFrameRef"
@replay-result="onReplayResult"
/>
<button class="editor-close" @click="pipeline.closeEditor()">✕ Close</button>
</div>
</div>
</Panel>
<!-- Bottom: debug overlays + close -->
<div class="editor-bottom">
<div class="overlay-controls">
<template v-if="editorOverlays.length > 0">
<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>
</template>
</div>
<button class="editor-close" @click="pipeline.closeEditor()">✕ Close</button>
</div>
</div>
</template>
<!-- === STAGE EDITOR MODE === -->
@@ -279,28 +388,8 @@ function onJobStarted(newJobId: string) {
<!-- Bottom bar: Log or Blob viewer depending on mode -->
<div class="log-row">
<template v-if="pipeline.layoutMode === 'bbox_editor'">
<Panel :title="`Debug Overlays — ${pipeline.editorStage?.replace(/_/g, ' ')}`" :status="status">
<div class="overlay-controls">
<template v-if="editorOverlays.length > 0">
<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) => overlay.opacity = Number((e.target as HTMLInputElement).value)"
class="opacity-slider"
/>
<span class="opacity-value">{{ Math.round((overlay.opacity ?? 0.5) * 100) }}%</span>
</label>
</template>
<div v-else class="blob-placeholder">
Run analysis with debug enabled to see edge and line overlays
</div>
</div>
</Panel>
<template v-if="pipeline.layoutMode === 'source_selector'">
<!-- no log in source selector -->
</template>
<template v-else>
<LogPanel ref="logPanel" :source="source" :status="status" />
@@ -478,34 +567,82 @@ header h1 { font-size: var(--font-size-lg); font-weight: 600; }
/* Log: full width bottom */
.log-row {
flex-shrink: 0;
height: 200px;
height: 150px;
}
.empty { color: var(--text-dim); padding: var(--space-6); text-align: center; }
/* Editor placeholders */
.editor-placeholder {
/* Editor layout — frame maximized, sliders right, overlays bottom */
.editor-layout {
display: flex;
flex-direction: column;
height: 100%;
gap: var(--space-2);
min-height: 0;
overflow: hidden;
}
.editor-top {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.editor-frame {
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
display: flex;
}
.editor-frame > * {
flex: 1;
min-height: 0;
overflow: hidden;
}
.editor-tools {
width: 200px;
.editor-sliders {
flex-shrink: 0;
padding: var(--space-3);
min-width: 210px;
padding: var(--space-2);
background: var(--surface-2);
border-radius: var(--panel-radius);
overflow-y: auto;
overflow-x: hidden;
}
.editor-bottom {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
font-size: var(--font-size-sm);
align-items: center;
gap: var(--space-4);
padding: var(--space-2) var(--space-3);
background: var(--surface-2);
border-top: var(--panel-border);
height: 36px;
}
.editor-close {
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;
}
.editor-close:hover {
background: var(--status-error);
color: #000;
}
/* Stage config editor (placeholder) */
.editor-placeholder {
display: flex;
height: 100%;
gap: var(--space-2);
}
.editor-config {
@@ -517,23 +654,6 @@ header h1 { font-size: var(--font-size-lg); font-weight: 600; }
gap: var(--space-2);
}
.editor-close {
background: var(--surface-3);
border: 1px solid var(--surface-3);
border-radius: 4px;
padding: var(--space-2) var(--space-3);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
cursor: pointer;
margin-top: auto;
}
.editor-close:hover {
background: var(--status-error);
color: #000;
}
.blob-placeholder {
padding: var(--space-4);
color: var(--text-dim);