This commit is contained in:
firestar5683
2026-09-10 15:22:36 -05:00
parent 91535cc086
commit ab9011c825
8 changed files with 169 additions and 16 deletions
@@ -118,7 +118,7 @@ export const AppShell = {
</button>
<span class="gx-appbar__home" role="button" tabindex="0"
:aria-label="tr('Galaxy home')" @click="goHome" @keydown.enter="goHome" @keydown.space.prevent="goHome">
<span class="gx-appbar__title">{{ tr("Galaxy") }}</span>
<span class="gx-appbar__title">Galaxy</span>
</span>
<div class="gx-searchwrap">
<input ref="searchInput" class="gx-search gx-appbar__search" type="search" :placeholder="tr('Search toggles...')"
@@ -4,6 +4,7 @@ import { GalaxyConfirm } from "./GalaxyModal.js"
import { GxNotice } from "./GxNotice.js"
const MAX_ROUTES = 250
const MAX_SEGMENTS = 5
export const LateralTuningPanel = {
name: "LateralTuningPanel",
@@ -20,6 +21,7 @@ export const LateralTuningPanel = {
laneCentering: false,
routes: [],
selectedRoutes: [],
segmentRanges: {},
report: null,
reportLoading: false,
loadedReportId: "",
@@ -29,6 +31,7 @@ export const LateralTuningPanel = {
feedbackNotes: "",
pending: null,
pendingName: "",
maxSegments: MAX_SEGMENTS,
}
},
created() {
@@ -102,7 +105,10 @@ export const LateralTuningPanel = {
return !!(rep && rep.car && rep.car.controlPath === "angle")
},
canAnalyze() {
return this.selectedRoutes.length > 0 && !this.isOnroad && !this.laneCentering
return this.selectedRoutes.length > 0 && this.selectedSegmentCount > 0 && this.selectedSegmentCount <= MAX_SEGMENTS && !this.isOnroad && !this.laneCentering
},
selectedSegmentCount() {
return this.selectedRoutes.reduce((total, routeName) => total + this.routeSelectedSegmentCount(routeName), 0)
},
canApplyTrial() {
const trial = this.activeTrial
@@ -147,6 +153,32 @@ export const LateralTuningPanel = {
const dur = min >= 60 ? `${Math.floor(min / 60)}h ${min % 60}m` : `~${min} min`
return `${seg} segment${seg === 1 ? "" : "s"} (${dur})`
},
routeSegmentBounds(routeName) {
const route = this.routes.find((item) => item && item.name === routeName) || {}
const count = Math.max(0, Math.round(this.num(route.segmentCount)))
const first = Math.max(0, Math.round(this.num(route.firstSegmentNum)))
return { first, last: first + Math.max(0, count - 1), count }
},
routeSelectedSegmentCount(routeName) {
const bounds = this.routeSegmentBounds(routeName)
if (!bounds.count) return 0
const selected = this.segmentRanges[routeName] || {}
const rawStart = String(selected.start ?? "").trim()
const rawEnd = String(selected.end ?? "").trim()
const start = Math.min(bounds.last, Math.max(bounds.first, rawStart === "" ? bounds.first : this.num(rawStart, bounds.first)))
const end = Math.max(bounds.first, Math.min(bounds.last, rawEnd === "" ? bounds.last : this.num(rawEnd, bounds.last)))
return end >= start ? end - start + 1 : 0
},
setSegmentRange(routeName, key, value) {
const cleaned = String(value ?? "").replace(/[^\d]/g, "")
this.segmentRanges = {
...this.segmentRanges,
[routeName]: {
...(this.segmentRanges[routeName] || {}),
[key]: cleaned,
},
}
},
syncFeedback(report) {
const fb = (report && report.feedback) || {}
this.feedbackAccepted = Array.isArray(fb.acceptedDimensions) ? [...fb.acceptedDimensions] : []
@@ -307,14 +339,65 @@ export const LateralTuningPanel = {
},
toggleRoute(name) {
const set = new Set(this.selectedRoutes)
if (set.has(name)) set.delete(name); else set.add(name)
const ranges = { ...this.segmentRanges }
if (set.has(name)) {
set.delete(name)
delete ranges[name]
} else {
const remaining = MAX_SEGMENTS - this.selectedSegmentCount
if (remaining <= 0) {
showSnackbar(`FLM is limited to ${MAX_SEGMENTS} segments at a time.`, "error")
return
}
const bounds = this.routeSegmentBounds(name)
if (!bounds.count) return
set.add(name)
if (bounds.count > remaining) {
ranges[name] = { start: String(bounds.first), end: String(bounds.first + remaining - 1) }
}
}
this.segmentRanges = ranges
this.selectedRoutes = [...set]
},
clearSelection() { this.selectedRoutes = [] },
selectFirstSegments() {
const selected = []
const ranges = {}
let remaining = MAX_SEGMENTS
for (const route of this.routes) {
if (remaining <= 0) break
const bounds = this.routeSegmentBounds(route.name)
if (!bounds.count) continue
const take = Math.min(bounds.count, remaining)
selected.push(route.name)
if (take < bounds.count) {
ranges[route.name] = { start: String(bounds.first), end: String(bounds.first + take - 1) }
}
remaining -= take
}
this.selectedRoutes = selected
this.segmentRanges = ranges
},
clearSelection() {
this.selectedRoutes = []
this.segmentRanges = {}
},
selectedSegmentRanges() {
const ranges = {}
for (const routeName of this.selectedRoutes) {
const selected = this.segmentRanges[routeName] || {}
const start = String(selected.start ?? "").trim()
const end = String(selected.end ?? "").trim()
if (start || end) ranges[routeName] = { start: start || null, end: end || null }
}
return ranges
},
async analyze() {
if (!this.canAnalyze || this.busy) return
const ok = await this.runWith(() => api.flmAnalyze(this.selectedRoutes, {}), "FLM analysis started.")
if (ok && this.selectedRoutes.length) this.selectedRoutes = []
const ok = await this.runWith(() => api.flmAnalyze(this.selectedRoutes, this.selectedSegmentRanges()), "FLM analysis started.")
if (ok && this.selectedRoutes.length) {
this.selectedRoutes = []
this.segmentRanges = {}
}
},
async stopAnalyze() {
await this.runWith(() => api.flmStopAnalyze(), "FLM analysis stopped.")
@@ -520,24 +603,33 @@ export const LateralTuningPanel = {
<span class="gx-section__title">Local Routes</span>
</div>
<div style="padding: var(--sp-4);">
<p style="color: var(--text-muted); line-height:1.6; margin:0 0 var(--sp-2);">Pick up to 8 routes to analyze. Whole routes are used.</p>
<p style="color: var(--text-muted); line-height:1.6; margin:0 0 var(--sp-2);">Pick routes and segment ranges to analyze. A run is limited to 5 segments total.</p>
<div v-if="loadingRoutes" class="gx-loading">Loading local routes...</div>
<div v-else-if="!routes.length" class="gx-empty">No local routes found.</div>
<div v-else>
<label class="gx-chip" style="cursor:pointer;" :style="'user-select:none;'">
<input type="checkbox" :checked="selectedRoutes.length === routes.length" style="margin-right:6px;" @change="selectedRoutes = (selectedRoutes.length === routes.length) ? [] : routes.map(r => r.name)" />
Select all
</label>
<span class="gx-chip" :style="selectedSegmentCount > maxSegments ? 'background:var(--error);color:var(--on-error);' : ''">
{{ selectedSegmentCount }}/{{ maxSegments }} segments selected
</span>
<button type="button" class="gx-btn gx-btn--text" style="font-size:var(--fs-xs);" @click="selectFirstSegments">Select first {{ maxSegments }}</button>
<button type="button" class="gx-btn gx-btn--text" style="font-size:var(--fs-xs);" @click="clearSelection">Clear</button>
<div v-for="route in routes" :key="route.name" style="border-top:1px solid var(--glass-border); padding: var(--sp-2) 0;">
<label style="display:flex; gap:10px; align-items:flex-start; cursor:pointer;">
<input type="checkbox" :checked="selectedRoutes.includes(route.name)" @change="toggleRoute(route.name)" style="margin-top:4px;" />
<input type="checkbox" :checked="selectedRoutes.includes(route.name)" :disabled="!selectedRoutes.includes(route.name) && selectedSegmentCount >= maxSegments" @change="toggleRoute(route.name)" style="margin-top:4px;" />
<span style="min-width:0;">
<strong>{{ fmtDate(route.timestamp) }}</strong>
<div class="gx-row__desc" style="word-break:break-all;">{{ route.name }}</div>
<div class="gx-row__desc">{{ fmtLen(route) }}</div>
<div class="gx-row__desc">{{ fmtLen(route) }} · {{ routeSelectedSegmentCount(route.name) }} selected</div>
</span>
</label>
<div v-if="selectedRoutes.includes(route.name)" style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:8px 0 0 28px;">
<span class="gx-row__desc">Segments</span>
<input class="gx-field" style="width:92px;" type="number" :min="routeSegmentBounds(route.name).first" :max="routeSegmentBounds(route.name).last" inputmode="numeric" placeholder="First"
:value="segmentRanges[route.name]?.start || ''" @input="setSegmentRange(route.name, 'start', $event.target.value)" />
<span class="gx-row__desc">to</span>
<input class="gx-field" style="width:92px;" type="number" :min="routeSegmentBounds(route.name).first" :max="routeSegmentBounds(route.name).last" inputmode="numeric" placeholder="Last"
:value="segmentRanges[route.name]?.end || ''" @input="setSegmentRange(route.name, 'end', $event.target.value)" />
<small class="gx-row__desc">Blank uses the whole route.</small>
</div>
</div>
</div>
</div>
@@ -42,6 +42,7 @@ FLM_STATUS_PATH = Path("/tmp/galaxy_flm_status.json")
FLM_LOG_PATH = Path("/tmp/galaxy_flm.log")
FLM_STATUS_MAX_AGE_SECONDS = 3600.0
FLM_ANALYZER_ROUTE_LIMIT = 8
FLM_MAX_ANALYSIS_SEGMENTS = 5
FLM_ANALYZER_PROCESS = None
FLM_ANALYZER_LOCK = threading.Lock()
FLM_PROGRESS_FILENAME = "progress.json"
@@ -553,6 +554,42 @@ def normalize_segment_ranges(route_names: list[str], segment_ranges: Any) -> dic
return normalized
def selected_segment_count(route_names: list[str], footage_paths: list[str],
segment_ranges: dict[str, dict[str, int | None]] | None = None) -> int:
normalized = normalize_segment_ranges(route_names, segment_ranges)
total = 0
for route in route_names[:FLM_ANALYZER_ROUTE_LIMIT]:
route = str(route).strip()
if not route:
continue
segments = []
for footage_path in footage_paths:
candidate = utilities.get_segments_in_route(route, footage_path)
if candidate:
segments = candidate
break
segment_range = normalized.get(route, {})
start = segment_range.get("start")
end = segment_range.get("end")
if segments:
numbers = [_parse_segment_num(segment) for segment in segments]
lower = start if start is not None else min(numbers)
upper = end if end is not None else max(numbers)
total += sum(lower <= number <= upper for number in numbers)
elif start is not None and end is not None:
total += max(0, end - start + 1)
return total
def enforce_segment_limit(route_names: list[str], footage_paths: list[str],
segment_ranges: dict[str, dict[str, int | None]] | None = None) -> int:
count = selected_segment_count(route_names, footage_paths, segment_ranges)
if count > FLM_MAX_ANALYSIS_SEGMENTS:
raise ValueError(f"FLM analysis is limited to {FLM_MAX_ANALYSIS_SEGMENTS} segments at a time (requested {count}).")
return count
def start_flm_background_analysis(route_names: list[str], footage_paths: list[str],
segment_ranges: dict[str, dict[str, int | None]] | None = None) -> bool:
global FLM_ANALYZER_PROCESS
@@ -567,6 +604,8 @@ def start_flm_background_analysis(route_names: list[str], footage_paths: list[st
except FLMAnalysisCancelled:
return False
enforce_segment_limit(route_names, footage_paths, segment_ranges)
ensure_flm_workspace()
process_to_watch = None
with FLM_ANALYZER_LOCK:
@@ -2424,6 +2463,7 @@ def analyze_routes(route_names: list[str], footage_paths: list[str], feedback: d
segment_ranges = normalize_segment_ranges(route_names, segment_ranges)
sources, warnings = resolve_route_sources(route_names, footage_paths, segment_ranges)
enforce_segment_limit(route_names, footage_paths, segment_ranges)
if not sources:
raise RuntimeError("No local routes with qlogs or rlogs were found for the selected routes.")
@@ -67,6 +67,7 @@ def test_process_route_is_metadata_only_and_retains_fields(monkeypatch, tmp_path
"isCustomName": True,
"is_preserved": True,
"segmentCount": 4,
"firstSegmentNum": 3,
"approxDurationSeconds": 240,
}
@@ -232,13 +232,23 @@ def test_segment_ranges_limit_resolved_route_sources(tmp_path, monkeypatch):
sources, warnings = module.resolve_route_sources(
[route],
[str(tmp_path)],
{route: {"start": 4, "end": 9}},
{route: {"start": 4, "end": 8}},
)
assert [source.segment_num for source in sources] == [4, 5, 6, 7, 8, 9]
assert [source.segment_num for source in sources] == [4, 5, 6, 7, 8]
assert warnings == []
def test_segment_limit_rejects_more_than_five_selected_segments(tmp_path, monkeypatch):
module, _ = _load_flm_workspace_module(tmp_path)
route = "00000001--abcdef1234"
segment_names = [f"{route}--{segment}" for segment in range(12)]
monkeypatch.setattr(module.utilities, "get_segments_in_route", lambda *_args: segment_names)
with pytest.raises(ValueError, match="limited to 5 segments"):
module.enforce_segment_limit([route], [str(tmp_path)], {route: {"start": 4, "end": 9}})
def test_segment_range_rejects_reversed_bounds(tmp_path):
module, _ = _load_flm_workspace_module(tmp_path)
with pytest.raises(ValueError, match="first segment"):
@@ -494,6 +494,12 @@ def test_ui_all_remaining_classic_tools_native_no_embed():
"js/components/LateralTuningPanel.js"]:
assert "fetch(" not in _read(rel), f"{rel} should not use raw fetch()"
lateral = _read("js/components/LateralTuningPanel.js")
assert "MAX_SEGMENTS = 5" in lateral
assert "segmentRanges" in lateral and "selectedSegmentRanges" in lateral
assert "flmAnalyze(this.selectedRoutes, this.selectedSegmentRanges())" in lateral
assert "routeSelectedSegmentCount" in lateral
def test_ui_cameras_hub_vasm_and_pip_native_no_embed():
app = _read("js/app.js")
+4 -1
View File
@@ -8296,7 +8296,10 @@ def setup(app):
except (TypeError, ValueError) as error:
return jsonify({"error": str(error)}), 400
started = flm_workspace.start_flm_background_analysis(route_names, FOOTAGE_PATHS, segment_ranges)
try:
started = flm_workspace.start_flm_background_analysis(route_names, FOOTAGE_PATHS, segment_ranges)
except (TypeError, ValueError) as error:
return jsonify({"error": str(error)}), 400
if not started:
return jsonify({"error": "Failed to start FLM analysis."}), 500
+1
View File
@@ -3260,6 +3260,7 @@ def process_route(footage_path, route_name, segment_count=0, first_segment_num=0
"isCustomName": custom_name is not None,
"is_preserved": has_preserve_attr(segment_path),
"segmentCount": max(0, int(segment_count)),
"firstSegmentNum": max(0, int(first_segment_num)),
"approxDurationSeconds": max(0, int(segment_count)) * 60,
}