diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/AppShell.js b/starpilot/system/the_galaxy/assets/mobile/js/components/AppShell.js index 11d5217a0b..577c00fa99 100644 --- a/starpilot/system/the_galaxy/assets/mobile/js/components/AppShell.js +++ b/starpilot/system/the_galaxy/assets/mobile/js/components/AppShell.js @@ -118,7 +118,7 @@ export const AppShell = { - {{ tr("Galaxy") }} + Galaxy
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 = { Local Routes
-

Pick up to 8 routes to analyze. Whole routes are used.

+

Pick routes and segment ranges to analyze. A run is limited to 5 segments total.

Loading local routes...
No local routes found.
- + + {{ selectedSegmentCount }}/{{ maxSegments }} segments selected + +
+
+ Segments + + to + + Blank uses the whole route. +
diff --git a/starpilot/system/the_galaxy/flm_workspace.py b/starpilot/system/the_galaxy/flm_workspace.py index 9af8a148de..d3be0d72fd 100644 --- a/starpilot/system/the_galaxy/flm_workspace.py +++ b/starpilot/system/the_galaxy/flm_workspace.py @@ -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.") diff --git a/starpilot/system/the_galaxy/tests/test_dashcam_routes.py b/starpilot/system/the_galaxy/tests/test_dashcam_routes.py index 13864037e0..c7573c64cb 100644 --- a/starpilot/system/the_galaxy/tests/test_dashcam_routes.py +++ b/starpilot/system/the_galaxy/tests/test_dashcam_routes.py @@ -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, } diff --git a/starpilot/system/the_galaxy/tests/test_flm_workspace.py b/starpilot/system/the_galaxy/tests/test_flm_workspace.py index 46d313b4a6..3ae0a3e160 100644 --- a/starpilot/system/the_galaxy/tests/test_flm_workspace.py +++ b/starpilot/system/the_galaxy/tests/test_flm_workspace.py @@ -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"): diff --git a/starpilot/system/the_galaxy/tests/test_ui_vue_frontend.py b/starpilot/system/the_galaxy/tests/test_ui_vue_frontend.py index 3964508e97..d01bdefe5b 100644 --- a/starpilot/system/the_galaxy/tests/test_ui_vue_frontend.py +++ b/starpilot/system/the_galaxy/tests/test_ui_vue_frontend.py @@ -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") diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index 9f2176aa0d..289feb378a 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -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 diff --git a/starpilot/system/the_galaxy/utilities.py b/starpilot/system/the_galaxy/utilities.py index e24c1894da..0f603d855f 100644 --- a/starpilot/system/the_galaxy/utilities.py +++ b/starpilot/system/the_galaxy/utilities.py @@ -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, }