phase 5: edge transforms, soleprint-ui rename, infra fixes
- pipeline edge transforms: stages can declare accepted_transforms, edges carry a transform dict, runner injects per-stage and nodes apply (e.g. invert_mask before edge detection); editable from UI via PUT /config/edge-transform - rename mpr-ui-framework -> soleprint-ui (now an external package synced via .spr from /home/mariano/wdir/spr); add @vue-flow/core and uplot to detection-app so linked package resolves them - Tiltfile guards kubectl context, k8s commands pin --context kind-mpr - kind-config: gateway on hostPort 30080 (Caddy fronts mpr.local.ar) - modelgen: pyproject.toml, .spr marker, dict default_factory support
This commit is contained in:
@@ -38,6 +38,14 @@ class StageOutputHintInfo(BaseModel):
|
||||
src_format: str = "png"
|
||||
|
||||
|
||||
class TransformOptionInfo(BaseModel):
|
||||
key: str
|
||||
type: str
|
||||
default: object = False
|
||||
label: str = ""
|
||||
description: str = ""
|
||||
|
||||
|
||||
class StageConfigInfo(BaseModel):
|
||||
name: str
|
||||
label: str
|
||||
@@ -45,6 +53,7 @@ class StageConfigInfo(BaseModel):
|
||||
category: str
|
||||
config_fields: list[dict]
|
||||
output_hints: list[StageOutputHintInfo] = []
|
||||
accepted_transforms: list[TransformOptionInfo] = []
|
||||
reads: list[str]
|
||||
writes: list[str]
|
||||
|
||||
@@ -87,6 +96,51 @@ def get_pipeline_config(profile_name: str):
|
||||
return profile["pipeline"]
|
||||
|
||||
|
||||
class UpdateEdgeTransformRequest(BaseModel):
|
||||
profile_name: str = "soccer_broadcast"
|
||||
source_stage: str
|
||||
target_stage: str
|
||||
transform: dict
|
||||
|
||||
|
||||
@router.put("/config/edge-transform")
|
||||
def update_edge_transform(req: UpdateEdgeTransformRequest):
|
||||
"""Update the transform on an edge in a profile's pipeline config."""
|
||||
from uuid import UUID
|
||||
from core.db.models import Profile
|
||||
from core.db.connection import get_session
|
||||
from sqlmodel import select
|
||||
from fastapi import HTTPException
|
||||
|
||||
with get_session() as session:
|
||||
stmt = select(Profile).where(Profile.name == req.profile_name)
|
||||
profile = session.exec(stmt).first()
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail=f"Profile not found: {req.profile_name}")
|
||||
|
||||
pipeline = dict(profile.pipeline)
|
||||
edges = pipeline.get("edges", [])
|
||||
|
||||
found = False
|
||||
for edge in edges:
|
||||
if edge.get("source") == req.source_stage and edge.get("target") == req.target_stage:
|
||||
edge["transform"] = req.transform
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Edge not found: {req.source_stage} → {req.target_stage}",
|
||||
)
|
||||
|
||||
pipeline["edges"] = edges
|
||||
profile.pipeline = pipeline
|
||||
session.commit()
|
||||
|
||||
return {"status": "updated", "edge": f"{req.source_stage} → {req.target_stage}", "transform": req.transform}
|
||||
|
||||
|
||||
@router.get("/config/stages", response_model=list[StageConfigInfo])
|
||||
def list_stage_configs():
|
||||
"""Return the stage palette with config field metadata for the editor."""
|
||||
@@ -137,6 +191,13 @@ def _stage_to_info(stage) -> StageConfigInfo:
|
||||
)
|
||||
for h in getattr(stage, "output_hints", [])
|
||||
],
|
||||
accepted_transforms=[
|
||||
TransformOptionInfo(
|
||||
key=t.key, type=t.type, default=t.default,
|
||||
label=t.label, description=t.description,
|
||||
)
|
||||
for t in getattr(stage, "accepted_transforms", [])
|
||||
],
|
||||
reads=stage.io.reads,
|
||||
writes=stage.io.writes,
|
||||
)
|
||||
|
||||
@@ -54,7 +54,8 @@
|
||||
},
|
||||
{
|
||||
"source": "field_segmentation",
|
||||
"target": "detect_edges"
|
||||
"target": "detect_edges",
|
||||
"transform": {"invert_mask": true}
|
||||
},
|
||||
{
|
||||
"source": "field_segmentation",
|
||||
|
||||
@@ -162,6 +162,16 @@ def node_detect_edges(state: DetectState) -> dict:
|
||||
field_masks = state.get("field_masks", {})
|
||||
job_id = state.get("job_id")
|
||||
|
||||
# Apply edge transforms from upstream connections
|
||||
edge_transforms = state.get("_edge_transforms", {})
|
||||
for source_stage, transform in edge_transforms.items():
|
||||
if transform.get("invert_mask") and field_masks:
|
||||
import numpy as np
|
||||
field_masks = {
|
||||
seq: np.bitwise_not(mask) if mask is not None else None
|
||||
for seq, mask in field_masks.items()
|
||||
}
|
||||
|
||||
regions = detect_edge_regions(
|
||||
frames, config, inference_url=INFERENCE_URL, job_id=job_id,
|
||||
field_masks=field_masks,
|
||||
|
||||
@@ -213,6 +213,13 @@ class PipelineRunner:
|
||||
self.config = config
|
||||
self.do_checkpoint = checkpoint
|
||||
self.stage_sequence = _flatten_config(config, start_from)
|
||||
# Build edge transform lookup: {target_stage: {source_stage: transform_dict}}
|
||||
self._edge_transforms: dict[str, dict[str, dict]] = {}
|
||||
for edge in config.edges:
|
||||
if edge.transform:
|
||||
if edge.target not in self._edge_transforms:
|
||||
self._edge_transforms[edge.target] = {}
|
||||
self._edge_transforms[edge.target][edge.source] = edge.transform
|
||||
|
||||
def invoke(self, state: DetectState) -> DetectState:
|
||||
"""Run the pipeline on the given state. Returns final state."""
|
||||
@@ -224,6 +231,14 @@ class PipelineRunner:
|
||||
if check and check():
|
||||
raise PipelineCancelled(f"Cancelled before {stage_name}")
|
||||
|
||||
# Inject edge transforms into state so the stage can read them.
|
||||
# Compatible with LangGraph — just a state dict key.
|
||||
transforms = self._edge_transforms.get(stage_name, {})
|
||||
if transforms:
|
||||
state["_edge_transforms"] = transforms
|
||||
elif "_edge_transforms" in state:
|
||||
del state["_edge_transforms"]
|
||||
|
||||
# 2. Run node function
|
||||
node_fn = _NODE_FN_MAP.get(stage_name)
|
||||
if node_fn is None:
|
||||
|
||||
@@ -25,6 +25,7 @@ from core.detect.stages.models import (
|
||||
StageDefinition,
|
||||
StageIO,
|
||||
StageOutputHint,
|
||||
TransformOption,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -54,6 +55,9 @@ class FieldSegmentationStage(Stage):
|
||||
output_hints=[
|
||||
StageOutputHint(key="mask_overlay_b64", type="overlay", label="Field mask", default_opacity=0.5, src_format="png"),
|
||||
],
|
||||
accepted_transforms=[
|
||||
TransformOption(key="invert_mask", type="bool", default=False, label="Invert selection", description="Invert the mask so downstream stages look outside the detected area"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -35,6 +35,14 @@ class StageOutputHint(BaseModel):
|
||||
default_opacity: float = 0.5
|
||||
src_format: str = "png"
|
||||
|
||||
class TransformOption(BaseModel):
|
||||
"""A transform the stage accepts on its incoming edges."""
|
||||
key: str
|
||||
type: str
|
||||
default: Any = False
|
||||
label: str = ""
|
||||
description: str = ""
|
||||
|
||||
class StageDefinition(BaseModel):
|
||||
"""Complete metadata for a pipeline stage."""
|
||||
name: str
|
||||
@@ -44,6 +52,7 @@ class StageDefinition(BaseModel):
|
||||
io: StageIO
|
||||
config_fields: List[StageConfigField] = Field(default_factory=list)
|
||||
output_hints: List[StageOutputHint] = Field(default_factory=list)
|
||||
accepted_transforms: List[TransformOption] = Field(default_factory=list)
|
||||
tracks_element: Optional[str] = None
|
||||
|
||||
class FrameExtractionConfig(BaseModel):
|
||||
@@ -105,6 +114,7 @@ class Edge(BaseModel):
|
||||
source: str
|
||||
target: str
|
||||
condition: str = ""
|
||||
transform: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
class PipelineConfig(BaseModel):
|
||||
"""Pipeline graph topology + routing rules."""
|
||||
@@ -112,4 +122,4 @@ class PipelineConfig(BaseModel):
|
||||
profile_name: str
|
||||
stages: List[StageRef] = Field(default_factory=list)
|
||||
edges: List[Edge] = Field(default_factory=list)
|
||||
routing_rules: Dict[str, Any]
|
||||
routing_rules: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@@ -35,6 +35,16 @@ class StageIO:
|
||||
optional_reads: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TransformOption:
|
||||
"""A transform the stage accepts on its incoming edges."""
|
||||
key: str
|
||||
type: str # "bool", "float", "int", "str"
|
||||
default: Any = False
|
||||
label: str = ""
|
||||
description: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class StageOutputHint:
|
||||
"""How to render a stage output in the compare/editor views."""
|
||||
@@ -55,6 +65,7 @@ class StageDefinition:
|
||||
io: StageIO = field(default_factory=StageIO)
|
||||
config_fields: List[StageConfigField] = field(default_factory=list)
|
||||
output_hints: List[StageOutputHint] = field(default_factory=list)
|
||||
accepted_transforms: List[TransformOption] = field(default_factory=list)
|
||||
tracks_element: Optional[str] = None
|
||||
|
||||
|
||||
@@ -129,10 +140,16 @@ class StageRef:
|
||||
|
||||
@dataclass
|
||||
class Edge:
|
||||
"""Connection between stages in the graph."""
|
||||
"""Connection between stages in the graph.
|
||||
|
||||
transform: per-edge data transformation spec. Flexible JSONB blob
|
||||
type-checked by the consuming stage. E.g. {"invert_mask": true}
|
||||
tells edge detection to invert the field segmentation mask.
|
||||
"""
|
||||
source: str
|
||||
target: str
|
||||
condition: str = ""
|
||||
transform: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -151,6 +168,7 @@ STAGE_VIEWS = [
|
||||
StageConfigField,
|
||||
StageIO,
|
||||
StageOutputHint,
|
||||
TransformOption,
|
||||
StageDefinition,
|
||||
FrameExtractionConfig,
|
||||
SceneFilterConfig,
|
||||
|
||||
@@ -4,9 +4,15 @@
|
||||
|
||||
allow_k8s_contexts('kind-mpr')
|
||||
|
||||
# Hard guard: Tilt deploys to whatever the shell's current kubectl context is,
|
||||
# and allow_k8s_contexts auto-permits any local-looking (kind-*) cluster.
|
||||
# Fail early instead of silently landing the workload in the wrong cluster.
|
||||
if k8s_context() != 'kind-mpr':
|
||||
fail("Wrong kubectl context: '%s'. Run: kubectl config use-context kind-mpr" % k8s_context())
|
||||
|
||||
# Create namespace first — kustomize includes it but Tilt may apply
|
||||
# all resources in parallel, causing "namespace not found" races
|
||||
local('kubectl create namespace mpr --dry-run=client -o yaml | kubectl apply -f -')
|
||||
# all resources in parallel, causing "namespace not found" races (pin cluster explicitly)
|
||||
local('kubectl --context kind-mpr create namespace mpr --dry-run=client -o yaml | kubectl --context kind-mpr apply -f -')
|
||||
|
||||
# Apply k8s manifests via kustomize (dev overlay)
|
||||
k8s_yaml(kustomize('k8s/overlays/dev'))
|
||||
|
||||
@@ -202,7 +202,7 @@ services:
|
||||
- ../ui/detection-app/src:/app/src
|
||||
- ../ui/detection-app/vite.config.ts:/app/vite.config.ts
|
||||
- ../ui/detection-app/index.html:/app/index.html
|
||||
- ../ui/framework:/app/node_modules/mpr-ui-framework
|
||||
- ../ui/framework:/app/node_modules/soleprint-ui
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
|
||||
@@ -4,10 +4,10 @@ name: mpr
|
||||
nodes:
|
||||
- role: control-plane
|
||||
extraPortMappings:
|
||||
# Gateway → http://k8s.mpr.local.ar (bind to 127.0.0.2 to avoid conflict with docker-compose on 127.0.0.1:80)
|
||||
# Gateway → routed via Caddy on port 80 (mpr.local.ar, k8s.mpr.local.ar)
|
||||
- containerPort: 30080
|
||||
hostPort: 80
|
||||
listenAddress: "127.0.0.2"
|
||||
hostPort: 30080
|
||||
listenAddress: "127.0.0.1"
|
||||
protocol: TCP
|
||||
# Redis
|
||||
- containerPort: 30379
|
||||
|
||||
7
modelgen/.spr
Normal file
7
modelgen/.spr
Normal file
@@ -0,0 +1,7 @@
|
||||
name=soleprint-modelgen
|
||||
version=0.2.0
|
||||
type=pip
|
||||
sha=0822761
|
||||
mode=published
|
||||
updated=2026-04-12T00:22:10Z
|
||||
source=/home/mariano/wdir/spr/soleprint/station/tools/modelgen
|
||||
@@ -328,7 +328,12 @@ class PydanticGenerator(BaseGenerator):
|
||||
if isinstance(default, Enum):
|
||||
return f" = {default.__class__.__name__}.{default.name}"
|
||||
if callable(default):
|
||||
return " = Field(default_factory=list)" if "list" in str(default) else ""
|
||||
default_str = str(default)
|
||||
if "list" in default_str:
|
||||
return " = Field(default_factory=list)"
|
||||
if "dict" in default_str:
|
||||
return " = Field(default_factory=dict)"
|
||||
return ""
|
||||
return f" = {default!r}"
|
||||
|
||||
def _generate_from_config(self, config) -> str:
|
||||
|
||||
16
modelgen/pyproject.toml
Normal file
16
modelgen/pyproject.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "soleprint-modelgen"
|
||||
version = "0.2.0"
|
||||
description = "Multi-source, multi-target model code generator"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
modelgen = "modelgen.__main__:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["modelgen*"]
|
||||
@@ -11,8 +11,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@techstark/opencv-js": "4.12.0-release.1",
|
||||
"mpr-ui-framework": "link:../framework",
|
||||
"@vue-flow/core": "^1.48.2",
|
||||
"soleprint-ui": "link:../framework",
|
||||
"pinia": "^2.2",
|
||||
"uplot": "^1.6",
|
||||
"vue": "^3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
6
ui/detection-app/pnpm-lock.yaml
generated
6
ui/detection-app/pnpm-lock.yaml
generated
@@ -11,12 +11,12 @@ importers:
|
||||
'@techstark/opencv-js':
|
||||
specifier: 4.12.0-release.1
|
||||
version: 4.12.0-release.1
|
||||
mpr-ui-framework:
|
||||
specifier: link:../framework
|
||||
version: link:../framework
|
||||
pinia:
|
||||
specifier: ^2.2
|
||||
version: 2.3.1(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))
|
||||
soleprint-ui:
|
||||
specifier: link:../framework
|
||||
version: link:../framework
|
||||
vue:
|
||||
specifier: ^3.5
|
||||
version: 3.5.30(typescript@5.9.3)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { Panel, ResizeHandle, SplitPane } from 'mpr-ui-framework'
|
||||
import 'mpr-ui-framework/src/tokens.css'
|
||||
import { Panel, ResizeHandle, SplitPane } from 'soleprint-ui'
|
||||
import 'soleprint-ui/src/tokens.css'
|
||||
import LogPanel from './panels/LogPanel.vue'
|
||||
import FunnelPanel from './panels/FunnelPanel.vue'
|
||||
import PipelineGraphPanel from './panels/PipelineGraphPanel.vue'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { FrameOverlay } from 'mpr-ui-framework'
|
||||
import type { FrameOverlay } from 'soleprint-ui'
|
||||
|
||||
defineProps<{
|
||||
overlays: FrameOverlay[]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ParameterEditor, useEditorExecution } from 'mpr-ui-framework'
|
||||
import type { ConfigField } from 'mpr-ui-framework'
|
||||
import { ParameterEditor, useEditorExecution } from 'soleprint-ui'
|
||||
import type { ConfigField } from 'soleprint-ui'
|
||||
import {
|
||||
runEdgeDetectionTs,
|
||||
runEdgeDetectionTsDebug,
|
||||
@@ -40,6 +40,9 @@ const emit = defineEmits<{
|
||||
|
||||
const fields = ref<ConfigField[]>([])
|
||||
const values = ref<Record<string, unknown>>({})
|
||||
const transforms = ref<{ key: string; type: string; default: unknown; label: string; description: string }[]>([])
|
||||
const transformValues = ref<Record<string, unknown>>({})
|
||||
const edgeTargets = ref<string[]>([])
|
||||
const statusText = ref<string | null>(null)
|
||||
const debugEnabled = ref(true)
|
||||
const processingIndex = ref<number | null>(null)
|
||||
@@ -159,6 +162,98 @@ const {
|
||||
apply: applyDetection, onParameterChange,
|
||||
} = useEditorExecution(executeDetection)
|
||||
|
||||
/**
|
||||
* Load cached overlays from DB stage output + S3 overlay cache.
|
||||
* Returns true if data was found and emitted, false if nothing cached.
|
||||
*/
|
||||
async function loadCachedOverlays(): Promise<boolean> {
|
||||
if (!props.jobId || !props.frames?.length) return false
|
||||
|
||||
try {
|
||||
// Load job detail to get stage outputs (inline overlays like field masks)
|
||||
const jobResp = await fetch(`/api/detect/jobs/${props.jobId}`)
|
||||
if (!jobResp.ok) return false
|
||||
const jobDetail = await jobResp.json()
|
||||
const stageOutput = jobDetail.stage_outputs?.[props.stage] ?? {}
|
||||
|
||||
// Check for inline overlay data (e.g. mask_overlays_by_frame)
|
||||
const overlays_by_frame: Record<string, Record<string, string>> = {}
|
||||
const regions_by_frame: Record<string, unknown[]> = {}
|
||||
let foundData = false
|
||||
|
||||
// Find box data
|
||||
for (const [key, val] of Object.entries(stageOutput)) {
|
||||
if (key.endsWith('_by_frame') && !key.includes('overlay') && typeof val === 'object') {
|
||||
for (const [seq, boxes] of Object.entries(val as Record<string, unknown>)) {
|
||||
if (Array.isArray(boxes)) {
|
||||
regions_by_frame[seq] = boxes
|
||||
foundData = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find inline overlay data
|
||||
for (const [key, val] of Object.entries(stageOutput)) {
|
||||
if (key.includes('overlay') && key.endsWith('_by_frame') && typeof val === 'object') {
|
||||
for (const [seq, b64] of Object.entries(val as Record<string, string>)) {
|
||||
if (!overlays_by_frame[seq]) overlays_by_frame[seq] = {}
|
||||
const overlayKey = key.replace('s_by_frame', '_b64')
|
||||
overlays_by_frame[seq][overlayKey] = b64
|
||||
foundData = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also try S3 overlay cache for each frame
|
||||
if (!foundData || Object.keys(overlays_by_frame).length === 0) {
|
||||
const { timelineId } = await import('../stores/pipeline').then(m => m.usePipelineStore())
|
||||
if (timelineId) {
|
||||
for (const frame of props.frames) {
|
||||
try {
|
||||
const oResp = await fetch(`/api/detect/overlays/${timelineId}/${props.jobId}/${props.stage}/${frame.seq}`)
|
||||
if (oResp.ok) {
|
||||
const oData = await oResp.json()
|
||||
if (oData.overlays && Object.keys(oData.overlays).length > 0) {
|
||||
overlays_by_frame[String(frame.seq)] = oData.overlays
|
||||
foundData = true
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundData) {
|
||||
emit('replay-result', {
|
||||
regions_by_frame: Object.keys(regions_by_frame).length > 0 ? regions_by_frame : undefined,
|
||||
overlays_by_frame: Object.keys(overlays_by_frame).length > 0 ? overlays_by_frame : undefined,
|
||||
})
|
||||
return true
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function onTransformChange(key: string, value: unknown) {
|
||||
transformValues.value[key] = value
|
||||
// Save to all outgoing edges from this stage
|
||||
const transform = { ...transformValues.value }
|
||||
for (const target of edgeTargets.value) {
|
||||
fetch('/api/detect/config/edge-transform', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
profile_name: 'soccer_broadcast',
|
||||
source_stage: props.stage,
|
||||
target_stage: target,
|
||||
transform,
|
||||
}),
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Init ---
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -167,15 +262,49 @@ onMounted(async () => {
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
fields.value = data.config_fields ?? []
|
||||
transforms.value = data.accepted_transforms ?? []
|
||||
}
|
||||
} catch { /* use empty fields */ }
|
||||
|
||||
for (const f of fields.value) {
|
||||
values.value[f.name] = f.default
|
||||
}
|
||||
for (const t of transforms.value) {
|
||||
transformValues.value[t.key] = t.default
|
||||
}
|
||||
|
||||
// Load current edge transform values from pipeline config
|
||||
// Transforms are on outgoing edges (source === this stage)
|
||||
if (transforms.value.length > 0) {
|
||||
try {
|
||||
const pResp = await fetch(`/api/detect/config/profiles/soccer_broadcast/pipeline`)
|
||||
if (pResp.ok) {
|
||||
const pipeline = await pResp.json()
|
||||
const edges = pipeline.edges ?? []
|
||||
// Find outgoing edges from this stage and merge their transforms
|
||||
for (const edge of edges) {
|
||||
if (edge.source === props.stage && edge.transform) {
|
||||
for (const [k, v] of Object.entries(edge.transform)) {
|
||||
if (k in transformValues.value) {
|
||||
transformValues.value[k] = v
|
||||
}
|
||||
}
|
||||
// Remember which targets we're connected to
|
||||
if (!edgeTargets.value.includes(edge.target)) {
|
||||
edgeTargets.value.push(edge.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (props.frameImage) {
|
||||
applyDetection()
|
||||
// Try loading cached overlays first (from stage output or S3)
|
||||
const loaded = await loadCachedOverlays()
|
||||
if (!loaded) {
|
||||
applyDetection()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -220,7 +349,10 @@ function getTargetFrames() {
|
||||
(props.selectionEnd ?? props.frames.length - 1) + 1,
|
||||
)
|
||||
}
|
||||
return [{ seq: props.frameRef ?? 0, jpeg_b64: props.frameImage! }]
|
||||
if (props.frameImage) {
|
||||
return [{ seq: props.frameRef ?? 0, jpeg_b64: props.frameImage }]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function edgeParams(): Partial<EdgeDetectionParams> {
|
||||
@@ -482,6 +614,20 @@ async function runGenericServer() {
|
||||
@update="onFieldUpdate"
|
||||
@reset="resetDefaults"
|
||||
/>
|
||||
|
||||
<div v-if="transforms.length > 0" class="transform-section">
|
||||
<div class="transform-header">Connection</div>
|
||||
<label v-for="t in transforms" :key="t.key" class="transform-option">
|
||||
<input
|
||||
v-if="t.type === 'bool'"
|
||||
type="checkbox"
|
||||
:checked="!!transformValues[t.key]"
|
||||
@change="onTransformChange(t.key, !transformValues[t.key])"
|
||||
/>
|
||||
<span class="transform-label">{{ t.label || t.key }}</span>
|
||||
<span v-if="t.description" class="transform-desc">{{ t.description }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sliders-footer">
|
||||
@@ -631,4 +777,43 @@ async function runGenericServer() {
|
||||
font-size: 9px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.transform-section {
|
||||
margin-top: var(--space-3);
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--surface-3);
|
||||
}
|
||||
|
||||
.transform-header {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.transform-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: var(--space-1) 0;
|
||||
}
|
||||
|
||||
.transform-option input[type="checkbox"] {
|
||||
accent-color: #00bcd4;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.transform-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.transform-desc {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import type { DataSource } from 'mpr-ui-framework'
|
||||
import type { DataSource } from 'soleprint-ui'
|
||||
import { usePipelineStore } from '../stores/pipeline'
|
||||
import { useStageRegistry } from './useStageRegistry'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import type { FrameOverlay, FrameBBox } from 'mpr-ui-framework'
|
||||
import type { FrameOverlay, FrameBBox } from 'soleprint-ui'
|
||||
import type { StageResult } from '@/components/StageConfig.vue'
|
||||
|
||||
export type RegionBox = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { SSEDataSource } from 'mpr-ui-framework'
|
||||
import type { DataSource } from 'mpr-ui-framework'
|
||||
import { SSEDataSource } from 'soleprint-ui'
|
||||
import type { DataSource } from 'soleprint-ui'
|
||||
import type { StatsUpdate, RunContext } from '../types/sse-contract'
|
||||
import { usePipelineStore } from '../stores/pipeline'
|
||||
|
||||
@@ -24,8 +24,12 @@ export function useSSEConnection() {
|
||||
const sseConnected = ref(false)
|
||||
|
||||
// No job selected and no hash route → open source selector
|
||||
if (!jobParam && !window.location.hash.replace(/^#\/?/, '')) {
|
||||
// Job selected but no hash route → go to compare (dashboard is for live runs)
|
||||
const hashPath = window.location.hash.replace(/^#\/?/, '')
|
||||
if (!jobParam && !hashPath) {
|
||||
pipeline.openSourceSelector()
|
||||
} else if (jobParam && !hashPath) {
|
||||
pipeline.openCompare()
|
||||
}
|
||||
|
||||
// Resolve timeline_id from job on init
|
||||
@@ -137,6 +141,10 @@ export function useSSEConnection() {
|
||||
if (!resp.ok) return
|
||||
const data = await resp.json()
|
||||
paused.value = data.status === 'paused'
|
||||
// Stop polling if the job is no longer active
|
||||
if (data.status === 'idle') {
|
||||
stopStatusPoll()
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRegistry } from 'mpr-ui-framework'
|
||||
import { useRegistry } from 'soleprint-ui'
|
||||
|
||||
export interface StageConfigField {
|
||||
name: string
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Panel, TableRenderer } from 'mpr-ui-framework'
|
||||
import type { TableColumn, DataSource } from 'mpr-ui-framework'
|
||||
import { Panel, TableRenderer } from 'soleprint-ui'
|
||||
import type { TableColumn, DataSource } from 'soleprint-ui'
|
||||
|
||||
const props = defineProps<{
|
||||
source: DataSource
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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 { Panel, SplitPane, FrameRenderer } from 'soleprint-ui'
|
||||
import type { FrameBBox, FrameOverlay } from 'soleprint-ui'
|
||||
import type { Job, Checkpoint } from '@common/types/generated'
|
||||
import { usePipelineStore } from '../stores/pipeline'
|
||||
import { useStageRegistry } from '../composables/useStageRegistry'
|
||||
@@ -222,12 +222,23 @@ function refreshOverlays() {
|
||||
watch([currentSeq, selectedStage], refreshOverlays)
|
||||
|
||||
// Sync overlay visibility/opacity from A controls → B overlays
|
||||
// Also share src if one side has it and the other doesn't
|
||||
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
|
||||
if (a.src && !b.src) b.src = a.src
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
watch(overlaysB, (bList) => {
|
||||
for (const b of bList) {
|
||||
const a = overlaysA.value.find(o => o.label === b.label)
|
||||
if (a) {
|
||||
if (b.src && !a.src) a.src = b.src
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Panel } from 'mpr-ui-framework'
|
||||
import type { DataSource } from 'mpr-ui-framework'
|
||||
import { Panel } from 'soleprint-ui'
|
||||
import type { DataSource } from 'soleprint-ui'
|
||||
import type { StatsUpdate, Detection } from '../types/sse-contract'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Panel, FrameRenderer } from 'mpr-ui-framework'
|
||||
import type { FrameBBox, FrameOverlay, DataSource } from 'mpr-ui-framework'
|
||||
import { Panel, FrameRenderer } from 'soleprint-ui'
|
||||
import type { FrameBBox, FrameOverlay, DataSource } from 'soleprint-ui'
|
||||
|
||||
const props = defineProps<{
|
||||
source: DataSource
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Panel, TimeSeriesRenderer } from 'mpr-ui-framework'
|
||||
import type { DataSource } from 'mpr-ui-framework'
|
||||
import { Panel, TimeSeriesRenderer } from 'soleprint-ui'
|
||||
import type { DataSource } from 'soleprint-ui'
|
||||
import type { StatsUpdate } from '../types/sse-contract'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Panel, LogRenderer } from 'mpr-ui-framework'
|
||||
import type { LogEntry, DataSource } from 'mpr-ui-framework'
|
||||
import { Panel, LogRenderer } from 'soleprint-ui'
|
||||
import type { LogEntry, DataSource } from 'soleprint-ui'
|
||||
import type { LogEvent } from '../types/sse-contract'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onMounted } from 'vue'
|
||||
import { Panel, GraphRenderer } from 'mpr-ui-framework'
|
||||
import type { GraphNode, GraphMode, DataSource } from 'mpr-ui-framework'
|
||||
import { Panel, GraphRenderer } from 'soleprint-ui'
|
||||
import type { GraphNode, GraphMode, DataSource } from 'soleprint-ui'
|
||||
import { usePipelineStore } from '../stores/pipeline'
|
||||
import { useStageRegistry } from '../composables/useStageRegistry'
|
||||
|
||||
@@ -43,20 +43,13 @@ const graphMode = computed<GraphMode>(() => {
|
||||
return 'observe'
|
||||
})
|
||||
|
||||
// Initialize nodes from pipeline config when it loads
|
||||
// Initialize nodes from pipeline config — single source of truth for stage order
|
||||
watch(pipelineConfig, (config) => {
|
||||
if (config && config.stages.length > 0 && nodes.value.length === 0) {
|
||||
nodes.value = config.stages.map((s) => ({ id: s.name, status: 'pending' }))
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Fallback: init from registry if no config loaded
|
||||
watch(stageNames, (names) => {
|
||||
if (names.length > 0 && nodes.value.length === 0) {
|
||||
nodes.value = names.map((id) => ({ id, status: 'pending' }))
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
props.source.on<{ nodes: GraphNode[] }>('graph_update', (e) => {
|
||||
nodes.value = e.nodes
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Panel } from 'mpr-ui-framework'
|
||||
import { Panel } from 'soleprint-ui'
|
||||
import { usePipelineStore } from '../stores/pipeline'
|
||||
import type { Timeline, Job } from '@common/types/generated'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Panel } from 'mpr-ui-framework'
|
||||
import type { DataSource } from 'mpr-ui-framework'
|
||||
import { Panel } from 'soleprint-ui'
|
||||
import type { DataSource } from 'soleprint-ui'
|
||||
import type { Detection } from '../types/sse-contract'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -86,9 +86,9 @@ export const usePipelineStore = defineStore('pipeline', () => {
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
// Reload to reinitialize panels with current job context
|
||||
// Go to compare view — the dashboard is for live runs only
|
||||
const url = new URL(window.location.href)
|
||||
url.hash = ''
|
||||
url.hash = '#/compare'
|
||||
if (jobId.value) {
|
||||
url.searchParams.set('job', jobId.value)
|
||||
}
|
||||
|
||||
7
ui/framework/.spr
Normal file
7
ui/framework/.spr
Normal file
@@ -0,0 +1,7 @@
|
||||
name=soleprint-ui
|
||||
version=0.1.0
|
||||
type=npm
|
||||
sha=0822761
|
||||
mode=published
|
||||
updated=2026-04-12T00:22:10Z
|
||||
source=/home/mariano/wdir/spr/soleprint/common/ui
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "mpr-ui-framework",
|
||||
"name": "soleprint-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
1692
ui/framework/pnpm-lock.yaml
generated
1692
ui/framework/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user