a
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
"@/*": ["src/*"],
|
||||
"@common/*": ["../common/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
|
||||
@@ -8,6 +8,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
'@common': resolve(__dirname, '../common'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user