This commit is contained in:
2026-03-30 09:53:10 -03:00
parent 4220b0418e
commit aac27b8504
32 changed files with 1068 additions and 329 deletions

View File

@@ -77,13 +77,14 @@ export interface Job {
export interface Timeline {
id: string;
name: string;
source_asset_id: string | null;
source_video: string;
chunk_paths: string[];
profile_name: string;
status: string;
fps: number;
frames_prefix: string;
frames_manifest: Record<string, unknown>;
frames_meta: string[];
frame_count: number;
source_ephemeral: boolean;
created_at: string | null;
}
@@ -92,7 +93,7 @@ export interface Checkpoint {
timeline_id: string;
job_id: string | null;
parent_id: string | null;
stage_outputs: Record<string, unknown>;
stage_name: string;
config_overrides: Record<string, unknown>;
stats: Record<string, unknown>;
is_scenario: boolean;
@@ -100,6 +101,16 @@ export interface Checkpoint {
created_at: string | null;
}
export interface StageOutput {
id: string;
job_id: string;
timeline_id: string;
stage_name: string;
checkpoint_id: string | null;
output: Record<string, unknown>;
created_at: string | null;
}
export interface Brand {
id: string;
canonical_name: string;

View File

@@ -29,35 +29,15 @@ export function useCheckpointLoader(
stripSelEndOverride.value ?? Math.max(0, checkpointFrames.value.length - 1),
)
// Cache job_id → timeline_id mappings
const timelineCache = new Map<string, string>()
// Track current frame from SSE
source.on<{ frame_ref: number; jpeg_b64: string }>('frame_update', (e) => {
currentFrameImage.value = e.jpeg_b64
currentFrameRef.value = e.frame_ref
})
async function resolveTimelineId(job: string): Promise<string | null> {
if (timelineCache.has(job)) return timelineCache.get(job)!
try {
const resp = await fetch(`/api/detect/timeline/${job}`)
if (!resp.ok) return null
const data = await resp.json()
const tid = data.timeline_id
if (tid) timelineCache.set(job, tid)
return tid
} catch {
return null
}
}
async function loadCheckpoint(job: string, stage: string) {
try {
// Resolve timeline_id from job_id
const timelineId = await resolveTimelineId(job)
const lookupId = timelineId ?? job
const lookupId = pipeline.timelineId || job
const resp = await fetch(`/api/detect/checkpoints/${lookupId}/${stage}`)
if (!resp.ok) return

View File

@@ -56,10 +56,13 @@ export function useStageRegistry() {
}
/**
* Stages that have config fields (and thus can open a parameter editor).
* Stages that have a visual stage editor (canvas + overlays + sliders).
* Add stage names here when a visual editor is implemented for them.
*/
const STAGE_EDITORS = new Set(['detect_edges', 'field_segmentation'])
const editableStages = computed(() =>
stages.value.filter(s => s.config_fields.length > 0).map(s => s.name)
stages.value.filter(s => STAGE_EDITORS.has(s.name)).map(s => s.name)
)
function getStage(name: string): StageInfo | undefined {

View File

@@ -2,6 +2,7 @@
import { ref, onMounted } from 'vue'
import { Panel } from 'mpr-ui-framework'
import { usePipelineStore } from '../stores/pipeline'
import type { Timeline, Job } from '@common/types/generated'
const pipeline = usePipelineStore()
@@ -32,6 +33,9 @@ interface ProfileInfo {
const sources = ref<SourceInfo[]>([])
const chunks = ref<ChunkInfo[]>([])
const profiles = ref<ProfileInfo[]>([])
const timelines = ref<Timeline[]>([])
const timelineJobs = ref<Job[]>([])
const selectedTimeline = ref<string | null>(null)
const selectedSource = ref<string | null>(null)
const selectedChunks = ref<Set<string>>(new Set())
const selectedProfile = ref('soccer_broadcast')
@@ -48,9 +52,10 @@ async function loadSources() {
loading.value = true
error.value = null
try {
const [srcResp, profResp] = await Promise.all([
const [srcResp, profResp, tlResp] = await Promise.all([
fetch('/api/detect/sources'),
fetch('/api/detect/config/profiles'),
fetch('/api/detect/timeline'),
])
if (!srcResp.ok) throw new Error(`${srcResp.status} ${srcResp.statusText}`)
sources.value = await srcResp.json()
@@ -61,6 +66,10 @@ async function loadSources() {
selectedProfile.value = profiles.value[0].name
}
}
if (tlResp.ok) {
timelines.value = await tlResp.json()
}
} catch (e: any) {
error.value = `Failed to load sources: ${e.message}`
} finally {
@@ -81,6 +90,32 @@ async function loadChunks(jobId: string) {
}
}
async function selectTimeline(tl: Timeline) {
selectedTimeline.value = tl.id
timelineJobs.value = []
try {
const resp = await fetch(`/api/detect/jobs?timeline_id=${tl.id}`)
if (!resp.ok) throw new Error(`${resp.status}`)
timelineJobs.value = await resp.json()
} catch (e: any) {
error.value = `Failed to load jobs: ${e.message}`
}
}
function loadJob(job: Job) {
// Navigate to the job — full page load so all panels initialize with the job context
const url = new URL(window.location.href)
url.searchParams.set('job', job.id)
url.hash = ''
window.location.href = url.toString()
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return ''
const d = new Date(dateStr)
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
function toggleChunk(chunk: ChunkInfo) {
const s = new Set(selectedChunks.value)
if (s.has(chunk.key)) {
@@ -125,15 +160,34 @@ async function runPipeline() {
running.value = true
error.value = null
// Run first selected chunk (multi-run queuing is future work)
const videoPath = [...selectedChunks.value][0]
const chunkPaths = [...selectedChunks.value]
try {
const resp = await fetch('/api/detect/run', {
// 1. Create timeline from chunk selection
const tlResp = await fetch('/api/detect/timeline', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
video_path: videoPath,
chunk_paths: chunkPaths,
profile_name: selectedProfile.value,
source_asset_id: selectedSource.value || '',
name: chunkPaths.length === 1
? chunkPaths[0].split('/').pop() ?? ''
: `${chunkPaths.length} chunks`,
}),
})
if (!tlResp.ok) {
const detail = await tlResp.text()
throw new Error(`Timeline creation failed: ${tlResp.status}: ${detail}`)
}
const timeline = await tlResp.json()
// 2. Run pipeline on the timeline
const runResp = await fetch('/api/detect/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
timeline_id: timeline.id,
profile_name: selectedProfile.value,
checkpoint: checkpoint.value,
skip_vlm: skipVlm.value,
@@ -142,12 +196,13 @@ async function runPipeline() {
pause_after_stage: pauseAfterStage.value,
}),
})
if (!resp.ok) {
const detail = await resp.text()
throw new Error(`${resp.status}: ${detail}`)
if (!runResp.ok) {
const detail = await runResp.text()
throw new Error(`Pipeline start failed: ${runResp.status}: ${detail}`)
}
const data = await resp.json()
const data = await runResp.json()
pipeline.setTimelineId(timeline.id)
emit('job-started', data.job_id, { pauseAfterStage: pauseAfterStage.value })
} catch (e: any) {
error.value = `Failed to start pipeline: ${e.message}`
@@ -255,6 +310,42 @@ onMounted(loadSources)
</button>
</div>
<!-- Recent timelines + jobs -->
<div class="source-section" v-if="timelines.length > 0">
<h3>Recent Timelines</h3>
<div class="source-list">
<div
v-for="tl in timelines"
:key="tl.id"
: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-meta">
<span class="source-type-badge">{{ tl.status.toUpperCase() }}</span>
<span v-if="tl.frame_count" class="source-count">{{ tl.frame_count }} frames</span>
<span class="source-size">{{ formatDate(tl.created_at) }}</span>
</span>
</div>
</div>
<!-- Jobs for selected timeline -->
<div v-if="selectedTimeline && timelineJobs.length > 0" class="job-list">
<div
v-for="job in timelineJobs"
:key="job.id"
class="job-item"
@click="loadJob(job)"
>
<span class="job-id">{{ job.id.slice(0, 8) }}</span>
<span :class="['job-status', job.status]">{{ job.status }}</span>
<span v-if="job.current_stage" class="job-stage">{{ job.current_stage.replace(/_/g, ' ') }}</span>
<span class="job-date">{{ formatDate(job.created_at) }}</span>
</div>
</div>
<div v-else-if="selectedTimeline" class="source-empty">No jobs yet</div>
</div>
<div class="source-actions">
<button class="editor-close" @click="pipeline.closeEditor()"> Close</button>
</div>
@@ -465,4 +556,56 @@ onMounted(loadSources)
background: var(--status-error);
color: #000;
}
/* Job list */
.job-list {
background: var(--surface-2);
border-radius: var(--panel-radius);
padding: var(--space-1);
margin-top: var(--space-1);
}
.job-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-2);
border-radius: 3px;
cursor: pointer;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.job-item:hover {
background: var(--surface-3);
}
.job-id {
font-family: var(--font-mono);
color: var(--text-dim);
}
.job-status {
font-size: 10px;
font-weight: 700;
padding: 1px 4px;
border-radius: 2px;
}
.job-status.completed { color: var(--status-live); background: rgba(0, 255, 128, 0.1); }
.job-status.failed { color: var(--status-error); background: rgba(224, 82, 82, 0.1); }
.job-status.running { color: var(--status-processing); background: rgba(255, 213, 79, 0.1); }
.job-status.cancelled { color: var(--text-dim); background: var(--surface-3); }
.job-status.pending { color: var(--text-dim); }
.job-stage {
color: var(--text-dim);
font-size: 10px;
}
.job-date {
color: var(--text-dim);
font-size: 10px;
margin-left: auto;
}
</style>

View File

@@ -12,6 +12,7 @@ import type { CheckpointInfo } from '../types/sse-contract'
export const usePipelineStore = defineStore('pipeline', () => {
const jobId = ref('')
const timelineId = ref('')
const status = ref<string>('idle')
const nodes = ref<NodeState[]>([])
const currentStage = ref<string | null>(null)
@@ -35,6 +36,10 @@ export const usePipelineStore = defineStore('pipeline', () => {
jobId.value = id
}
function setTimelineId(id: string) {
timelineId.value = id
}
function setStatus(s: string) {
status.value = s
}
@@ -92,13 +97,14 @@ export const usePipelineStore = defineStore('pipeline', () => {
parentJobId.value = null
runType.value = 'initial'
error.value = null
timelineId.value = ''
}
return {
jobId, status, nodes, currentStage, runId, parentJobId, runType,
jobId, timelineId, status, nodes, currentStage, runId, parentJobId, runType,
checkpoints, error, layoutMode, editorStage, sourceHasSelection,
isRunning, isPaused, canReplay, isEditing,
setJob, setStatus, updateNodes, setRunContext, setCheckpoints, setError,
setJob, setTimelineId, setStatus, updateNodes, setRunContext, setCheckpoints, setError,
openSourceSelector, openBBoxEditor, openStageEditor, closeEditor, reset,
}
})

View File

@@ -11,7 +11,8 @@
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
"@/*": ["src/*"],
"@common/*": ["../common/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.vue"]

View File

@@ -8,6 +8,7 @@ export default defineConfig({
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'@common': resolve(__dirname, '../common'),
},
},
server: {

View File

@@ -116,8 +116,7 @@ const flowNodes = computed(() =>
status: n.status,
...appearance,
hasCheckpoint: n.hasCheckpoint ?? false,
hasRegionEditor: regionStageSet.value.has(n.id),
hasEditors: (n.availableEditors?.length ?? 0) > 0,
hasStageEditor: regionStageSet.value.has(n.id),
isRunning: n.status === 'running',
isActive: n.id === props.activeStage,
},
@@ -190,9 +189,9 @@ function onNodeClick(id: string) {
<span class="stage-actions">
<button
v-if="data.hasRegionEditor"
class="stage-btn region-btn"
title="Region editor"
v-if="data.hasStageEditor"
class="stage-btn editor-btn"
title="Stage editor"
@click.stop="emit('open-region-editor', id)"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">