diff --git a/crates/infinity_host/src/runtime.rs b/crates/infinity_host/src/runtime.rs index 07a5d51..5951d44 100644 --- a/crates/infinity_host/src/runtime.rs +++ b/crates/infinity_host/src/runtime.rs @@ -22,7 +22,7 @@ impl Default for TickSchedule { logic_hz: 120, frame_synthesis_hz: 60, network_send_hz: 60, - preview_hz: 15, + preview_hz: 60, } } } @@ -54,12 +54,12 @@ impl RealtimeEngine { mode: ValidationMode, ) -> ValidationReport { let mut report = project.validate(mode); - if self.schedule.preview_hz >= self.schedule.frame_synthesis_hz { + if self.schedule.preview_hz > self.schedule.frame_synthesis_hz { report.issues.push(ValidationIssue { severity: ValidationSeverity::Warning, code: "preview_rate_too_high", path: "runtime.schedule.preview_hz".to_string(), - message: "preview rate should stay below frame synthesis rate".to_string(), + message: "preview rate should not exceed frame synthesis rate".to_string(), }); } report diff --git a/crates/infinity_host/src/scene.rs b/crates/infinity_host/src/scene.rs index 05c87f2..89ececa 100644 --- a/crates/infinity_host/src/scene.rs +++ b/crates/infinity_host/src/scene.rs @@ -317,7 +317,7 @@ fn render_pattern_leds( let speed = scene_scalar(scene, "speed", 0.45).max(0.05); let tempo_multiplier = scene_scalar(scene, "tempo_multiplier", 1.0).clamp(0.25, 8.0); let intensity = scene_scalar(scene, "intensity", 1.0).clamp(0.0, 1.0); - let fade = scene_scalar(scene, "fade", 0.35).clamp(0.0, 1.0); + let fade = scene_scalar(scene, "fade", 0.0).clamp(0.0, 1.0); let pattern_brightness = scene_scalar(scene, "brightness", 1.0).clamp(0.0, 2.0); let block_size = scene_scalar(scene, "block_size", 1.0).max(0.1); let on_width = scene_scalar(scene, "on_width", 1.0).clamp(0.1, 2.0); @@ -543,16 +543,31 @@ fn render_pattern_leds( _ => vec![primary; led_count], }; - if fade > 0.0 && colors.len() > 2 { + if fade > 0.0 && colors.len() > 2 && smoothing_allowed_for_pattern(pattern_id) { colors = smooth_led_sequence(colors, fade); } colors .into_iter() - .map(|color| color.scale((intensity * pattern_brightness).clamp(0.0, 1.0))) + .map(|color| { + color + .scale(quantize_intensity( + (intensity * pattern_brightness).clamp(0.0, 1.0), + )) + .quantize_10_percent() + }) .collect() } +fn smoothing_allowed_for_pattern(pattern_id: &str) -> bool { + matches!(pattern_id, "breathing") +} + +fn quantize_intensity(value: f32) -> f32 { + let clamped = value.clamp(0.0, 1.0); + (clamped * 10.0).round() / 10.0 +} + fn canonical_pattern_id(pattern_id: &str) -> &str { match pattern_id { "solid_color" => "solid", @@ -715,7 +730,7 @@ fn choose_pair( ) -> (RgbColor, RgbColor) { let primary = scene_text(scene, "primary_color", "#4D7CFF"); let secondary = scene_text(scene, "secondary_color", "#0E1630"); - let palette_name = scene_text(scene, "palette", "Laser Club"); + let palette_name = scene_text(scene, "palette", "Deep Blue"); let seed_amount = amount + panel_row as f32 * 0.13 + panel_col as f32 * 0.07; match color_mode { "palette" => ( @@ -770,159 +785,129 @@ fn sample_palette_list(colors: &[RgbColor], amount: f32) -> RgbColor { } fn named_palette(name: &str) -> Option<&'static [RgbColor]> { - const LASER_CLUB: [RgbColor; 4] = [ - RgbColor { - r: 0, - g: 240, - b: 255, - }, - RgbColor { - r: 0, - g: 140, - b: 255, - }, - RgbColor { - r: 106, - g: 0, - b: 255, - }, - RgbColor { r: 6, g: 8, b: 20 }, - ]; - const AFTERHOURS: [RgbColor; 4] = [ - RgbColor { - r: 247, - g: 37, - b: 133, - }, - RgbColor { - r: 181, - g: 23, - b: 158, - }, - RgbColor { - r: 114, - g: 9, - b: 183, - }, - RgbColor { r: 20, g: 3, b: 26 }, - ]; - const VOLTAGE: [RgbColor; 4] = [ - RgbColor { - r: 0, - g: 229, - b: 255, - }, - RgbColor { - r: 0, - g: 179, - b: 255, - }, - RgbColor { - r: 58, - g: 134, - b: 255, - }, - RgbColor { r: 5, g: 10, b: 20 }, - ]; - const MAGENTA_DRIVE: [RgbColor; 4] = [ - RgbColor { - r: 255, - g: 0, - b: 110, - }, - RgbColor { - r: 255, - g: 77, - b: 166, - }, - RgbColor { - r: 122, - g: 0, - b: 255, - }, - RgbColor { r: 18, g: 3, b: 24 }, - ]; - const WAREHOUSE_HEAT: [RgbColor; 4] = [ - RgbColor { - r: 255, - g: 90, - b: 31, - }, - RgbColor { - r: 255, - g: 158, - b: 0, - }, - RgbColor { - r: 255, - g: 208, - b: 0, - }, - RgbColor { r: 20, g: 6, b: 0 }, - ]; - const UV_RIOT: [RgbColor; 4] = [ - RgbColor { - r: 122, - g: 0, - b: 255, - }, - RgbColor { - r: 177, - g: 0, - b: 255, - }, - RgbColor { - r: 255, - g: 0, - b: 168, - }, - RgbColor { r: 16, g: 0, b: 20 }, - ]; - const REDLINE: [RgbColor; 4] = [ - RgbColor { - r: 255, - g: 45, - b: 85, - }, - RgbColor { - r: 255, - g: 106, - b: 0, - }, - RgbColor { - r: 255, - g: 176, - b: 0, - }, - RgbColor { r: 22, g: 4, b: 6 }, - ]; - const SODIUM_HAZE: [RgbColor; 4] = [ - RgbColor { - r: 255, - g: 122, - b: 0, - }, - RgbColor { - r: 255, - g: 176, - b: 0, - }, - RgbColor { - r: 255, - g: 216, - b: 107, - }, - RgbColor { r: 18, g: 7, b: 0 }, - ]; + const CANDLE: [RgbColor; 1] = [RgbColor { + r: 255, + g: 147, + b: 41, + }]; + const TUNGSTEN_40W: [RgbColor; 1] = [RgbColor { + r: 255, + g: 197, + b: 143, + }]; + const TUNGSTEN_100W: [RgbColor; 1] = [RgbColor { + r: 255, + g: 214, + b: 170, + }]; + const HALOGEN: [RgbColor; 1] = [RgbColor { + r: 255, + g: 241, + b: 224, + }]; + const CARBON_ARC: [RgbColor; 1] = [RgbColor { + r: 255, + g: 250, + b: 244, + }]; + const HIGH_NOON_SUN: [RgbColor; 1] = [RgbColor { + r: 255, + g: 255, + b: 251, + }]; + const OVERCAST_SKY: [RgbColor; 1] = [RgbColor { + r: 201, + g: 226, + b: 255, + }]; + const CLEAR_BLUE_SKY: [RgbColor; 1] = [RgbColor { + r: 64, + g: 156, + b: 255, + }]; + + const DEEP_BLUE: [RgbColor; 1] = [RgbColor { + r: 0, + g: 71, + b: 255, + }]; + const ELECTRIC_CYAN: [RgbColor; 1] = [RgbColor { + r: 0, + g: 216, + b: 255, + }]; + const EMERALD: [RgbColor; 1] = [RgbColor { + r: 0, + g: 200, + b: 83, + }]; + const ACID_LIME: [RgbColor; 1] = [RgbColor { + r: 174, + g: 234, + b: 0, + }]; + const AMBER: [RgbColor; 1] = [RgbColor { + r: 255, + g: 157, + b: 0, + }]; + const SIGNAL_RED: [RgbColor; 1] = [RgbColor { + r: 255, + g: 45, + b: 45, + }]; + const HOT_MAGENTA: [RgbColor; 1] = [RgbColor { + r: 255, + g: 0, + b: 168, + }]; + const VIOLET: [RgbColor; 1] = [RgbColor { + r: 122, + g: 60, + b: 255, + }]; + const WARM_WHITE: [RgbColor; 1] = [RgbColor { + r: 255, + g: 214, + b: 170, + }]; + const NEUTRAL_WHITE: [RgbColor; 1] = [RgbColor { + r: 255, + g: 241, + b: 224, + }]; + const COOL_WHITE: [RgbColor; 1] = [RgbColor { + r: 201, + g: 226, + b: 255, + }]; + const BLACKLIGHT_VIOLET: [RgbColor; 1] = [RgbColor { + r: 167, + g: 0, + b: 255, + }]; + match name { - "Magenta Drive" => Some(&MAGENTA_DRIVE), - "Warehouse Heat" => Some(&WAREHOUSE_HEAT), - "UV Riot" => Some(&UV_RIOT), - "Redline" => Some(&REDLINE), - "Sodium Haze" => Some(&SODIUM_HAZE), - "Afterhours" => Some(&AFTERHOURS), - "Voltage" => Some(&VOLTAGE), - "Laser Club" => Some(&LASER_CLUB), + "Candle" => Some(&CANDLE), + "Tungsten 40W" => Some(&TUNGSTEN_40W), + "Tungsten 100W" => Some(&TUNGSTEN_100W), + "Halogen" => Some(&HALOGEN), + "Carbon Arc" => Some(&CARBON_ARC), + "High Noon Sun" => Some(&HIGH_NOON_SUN), + "Overcast Sky" => Some(&OVERCAST_SKY), + "Clear Blue Sky" => Some(&CLEAR_BLUE_SKY), + "Deep Blue" => Some(&DEEP_BLUE), + "Electric Cyan" => Some(&ELECTRIC_CYAN), + "Emerald" => Some(&EMERALD), + "Acid Lime" => Some(&ACID_LIME), + "Amber" => Some(&AMBER), + "Signal Red" => Some(&SIGNAL_RED), + "Hot Magenta" => Some(&HOT_MAGENTA), + "Violet" => Some(&VIOLET), + "Warm White" => Some(&WARM_WHITE), + "Neutral White" => Some(&NEUTRAL_WHITE), + "Cool White" => Some(&COOL_WHITE), + "Blacklight Violet" => Some(&BLACKLIGHT_VIOLET), _ => None, } } @@ -1853,12 +1838,12 @@ fn common_motion_parameters(extra_keys: Vec<&str>) -> Vec { scalar_spec("speed", "Speed", 0.05, 8.0, 0.05, 0.45), scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 1.0), scalar_spec("brightness", "Brightness", 0.0, 2.0, 0.01, 1.0), - scalar_spec("fade", "Smoothing", 0.0, 1.0, 0.01, 0.35), + scalar_spec("fade", "Smoothing", 0.0, 1.0, 0.01, 0.0), scalar_spec("tempo_multiplier", "Tempo Multiplier", 0.25, 8.0, 0.05, 1.0), text_spec("color_mode", "Color Mode", "dual"), text_spec("primary_color", "Primary Color", "#4D7CFF"), text_spec("secondary_color", "Secondary Color", "#0E1630"), - text_spec("palette", "Palette", "Laser Club"), + text_spec("palette", "Palette", "Deep Blue"), ]; for key in extra_keys { @@ -1960,6 +1945,14 @@ impl RgbColor { } } + fn quantize_10_percent(self) -> Self { + Self { + r: quantize_channel_10_percent(self.r), + g: quantize_channel_10_percent(self.g), + b: quantize_channel_10_percent(self.b), + } + } + fn complementary(self) -> Self { Self { r: 255u8.saturating_sub(self.r), @@ -1998,6 +1991,12 @@ impl RgbColor { } } +fn quantize_channel_10_percent(value: u8) -> u8 { + (((value as f32 / 255.0) * 10.0).round() * 25.5) + .round() + .clamp(0.0, 255.0) as u8 +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/infinity_host_api/src/server.rs b/crates/infinity_host_api/src/server.rs index acd64c4..0917684 100644 --- a/crates/infinity_host_api/src/server.rs +++ b/crates/infinity_host_api/src/server.rs @@ -277,6 +277,7 @@ fn handle_websocket( let mut sequence = 1u64; let mut last_event_millis = None::; let mut last_event_signatures = Vec::<(Option, String)>::new(); + let mut last_streamed_preview = None::; loop { let snapshot = service.snapshot(); send_stream_message( @@ -286,13 +287,22 @@ fn handle_websocket( ApiStreamMessage::Snapshot(ApiStateSnapshot::from_snapshot(&snapshot)), )?; sequence += 1; - send_stream_message( - &mut stream, - sequence, - snapshot.generated_at_millis, - ApiStreamMessage::Preview(crate::dto::ApiPreviewSnapshot::from_snapshot(&snapshot)), - )?; - sequence += 1; + + let preview_payload = crate::dto::ApiPreviewSnapshot::from_snapshot(&snapshot); + if last_streamed_preview + .as_ref() + .map(|previous| previous != &preview_payload) + .unwrap_or(true) + { + send_stream_message( + &mut stream, + sequence, + snapshot.generated_at_millis, + ApiStreamMessage::Preview(preview_payload.clone()), + )?; + sequence += 1; + last_streamed_preview = Some(preview_payload); + } let mut new_events = snapshot .recent_events diff --git a/crates/infinity_host_api/tests/contract.rs b/crates/infinity_host_api/tests/contract.rs index 892336c..7dc9048 100644 --- a/crates/infinity_host_api/tests/contract.rs +++ b/crates/infinity_host_api/tests/contract.rs @@ -147,6 +147,28 @@ fn web_ui_browser_smoke_serves_shell_assets_and_stream_bootstrap() { server.shutdown(); } +#[test] +fn technical_surface_script_guards_missing_recent_events_in_state_snapshot() { + let server = start_server(); + let technical_js = send_http_request(server.local_addr(), "GET", "/technical.js", None); + let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None); + let state_body: Value = serde_json::from_str(&state.body).expect("state json"); + + assert_eq!(technical_js.status_code, 200); + assert!(technical_js + .body + .contains("function snapshotRecentEvents(snapshot)")); + assert!(technical_js + .body + .contains("Array.isArray(snapshot?.recent_events) ? snapshot.recent_events : []")); + assert!(technical_js + .body + .contains("const recentEvents = snapshotRecentEvents(appState.snapshot);")); + assert!(state_body["state"].get("recent_events").is_none()); + + server.shutdown(); +} + #[test] fn state_preview_and_snapshot_endpoints_are_versioned_and_separated() { let server = start_server(); @@ -268,6 +290,40 @@ fn technical_surface_commands_update_backend_node_targets_and_panel_mapping() { server.shutdown(); } +#[test] +fn technical_surface_can_disable_output_again_after_enabling_it() { + let server = start_server(); + + let enable_mode = send_command_json( + server.local_addr(), + r#"{"command":{"type":"set_output_backend_mode","payload":{"mode":"ddp_wled"}}}"#, + ); + let enable_output = send_command_json( + server.local_addr(), + r#"{"command":{"type":"set_live_output_enabled","payload":{"enabled":true}}}"#, + ); + let disable_output = send_command_json( + server.local_addr(), + r#"{"command":{"type":"set_live_output_enabled","payload":{"enabled":false}}}"#, + ); + + assert_eq!(enable_mode.status_code, 200); + assert_eq!(enable_output.status_code, 200); + assert_eq!(disable_output.status_code, 200); + + let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None); + let state_body: Value = serde_json::from_str(&state.body).expect("state json"); + + assert_eq!(state_body["state"]["technical"]["backend_mode"], "ddp_wled"); + assert_eq!(state_body["state"]["technical"]["output_enabled"], false); + assert_eq!( + state_body["state"]["technical"]["live_status"], + "DDP (WLED) selected - output disabled" + ); + + server.shutdown(); +} + #[test] fn discovery_scan_endpoint_returns_structured_results_and_rejects_invalid_subnets() { let server = start_server(); diff --git a/docs/architecture.md b/docs/architecture.md index fe62b76..6f09e4d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,7 +42,7 @@ The current delivery order is intentionally software-first: - Logic tick target: 120 Hz - Frame synthesis target: 60 Hz - Network send target: 40-60 Hz, profile dependent -- Preview target: 10-15 Hz +- Preview target: up to 60 Hz when the active surface can render it cleanly, otherwise 30 Hz fallback Preview and telemetry are explicitly degradable. Realtime output is not. diff --git a/docs/codex_worklog.md b/docs/codex_worklog.md new file mode 100644 index 0000000..32b5ebb --- /dev/null +++ b/docs/codex_worklog.md @@ -0,0 +1,121 @@ +# Codex Worklog + +## 2026-04-20 - Creative Surface kompakter und Preview-FPS-Pruefung + +- Geaenderte Dateien: + - `web/v1/styles.css` + - `web/v1/app.js` + - `crates/infinity_host/src/runtime.rs` + - `docs/architecture.md` +- Fachliche Aenderungen: + - Creative Surface kompakter aufgestellt, damit die 18 Preview-Panels im 6x3-Raster besser vollstaendig sichtbar bleiben. + - Frontend-Preview-Drosselung von ca. 11 fps entfernt und auf eine ruhige `requestAnimationFrame`-basierte Taktung mit Host-orientiertem Ziel-FPS umgestellt. + - Host-Preview-Schedule von 15 Hz auf 60 Hz angehoben, ohne neue Architektur einzuziehen. + - Dokumentation des Preview-Ziels auf den aktuellen Stand gebracht. +- Gelaufene Tests: + - `cargo fmt --check` + - `cargo test -q -p infinity_host` + - `cargo test -q -p infinity_host_api --test contract` + - lokaler API-Check gegen temporaeren Host auf `127.0.0.1:9002` mit bestaetigtem `engine.preview_hz = 60` + - kein echter Browser-Handtest in dieser CLI-Umgebung moeglich +- Bewusste Abweichungen zur Python-Version: + - Kein Canvas-Preview-Rewrite in diesem Schritt; die bestehende DOM/CSS-Preview bleibt erhalten und wird nur verschlankt sowie render-seitig entdrosselt. + +## 2026-04-20 - Diskretere Preview-Wirkung und weniger Preview-Clutter + +- Geaenderte Dateien: + - `crates/infinity_host/src/scene.rs` + - `crates/infinity_host_api/src/server.rs` + - `web/v1/app.js` + - `web/v1/index.html` +- Fachliche Aenderungen: + - Normale Pattern-Intensitaet vor Preview-Ausgabe auf 10%-Stufen quantisiert. + - Normales Preview-Smoothing standardmaessig stark reduziert; nur `breathing` darf weiterhin ueber den bestehenden `fade`-Pfad weich bleiben. + - Preview-Stream im WebSocket sendet Preview nur noch bei geaendertem Payload; der bestehende Snapshot-Heartbeat bleibt fuer den API-Vertrag erhalten. + - Master Brightness in den Header verschoben; linke Brightness-Sektion zeigt nur noch pattern-spezifische Helligkeitsparameter. + - Den sichtbaren `speed`-Slider aus der Creative Surface entfernt; BPM oben plus `tempo_multiplier` bleiben die Geschwindigkeitsbedienung. +- Gelaufene Tests: + - `cargo fmt --check` + - `cargo test -q -p infinity_host` + - `cargo test -q -p infinity_host_api --test contract` +- Bewusste Abweichungen zur Python-Version: + - Keine vollstaendige Python-Pattern-Engine-Portierung; stattdessen gezielte Diskretisierung und Stream-Beruhigung innerhalb der bestehenden Rust-Host-/Web-Architektur. + - Kein echter Browser-Handtest in dieser CLI-Umgebung moeglich. + +## 2026-04-20 - Creative Surface Redesign und Control-Fix (gezielt) + +- Geaenderte Dateien: + - `web/v1/index.html` + - `web/v1/app.js` + - `web/v1/styles.css` + - `crates/infinity_host/src/scene.rs` +- Fachliche Aenderungen: + - Redundante globale Brightness-Steuerung in der linken Spalte entfernt; Master Brightness bleibt oben im Header. + - Sichtbaren `speed`-Slider in der Creative Surface entfernt; Bedienung ueber BPM oben plus `tempo_multiplier` in den Pattern-Parametern. + - `palette`, `color_mode`, `direction`, `mirror` als feste Controls (keine freien Textfelder) in der Web-UI beibehalten/abgesichert. + - Palette auf zwei feste Gruppen mit den vorgegebenen Namen und Hex-Werten umgestellt; Host-Palette-Mapping auf dieselben IDs umgestellt. + - Layout gezielt entkapselt: weniger harte Rahmen, leichtere Rails, kompaktere Topbar, mehr zusammenhaengende Arbeitsflaeche. + - Preview-Grid auf feste 6 Spalten gesetzt und Tiles quadratisch gemacht (`aspect-ratio: 1 / 1`) fuer ein klares 3x6-Raster. +- Gelaufene Tests: + - `cargo fmt --check` + - `cargo test -q -p infinity_host` + - `cargo test -q -p infinity_host_api --test contract` +- Browser-Handtest: + - Versuch via lokalem Headless-Firefox auf `127.0.0.1:9002` ist in dieser Umgebung fehlgeschlagen (`cannot open display: :0` / Firefox Headless-Crash). +- Bewusste Abweichungen zur Python-Version: + - Die neue, fest vorgegebene Palette-Liste ersetzt die alten Python-Palettenamen; das ist eine inhaltliche Vorgabe dieses Arbeitsschritts. + +## 2026-04-20 - Palette ersetzen, linken Brightness-Slider entfernen, linke Rail entzerren + +- Geaenderte Dateien: + - `web/v1/index.html` + - `web/v1/app.js` + - `web/v1/styles.css` +- Fachliche Aenderungen: + - Die Creative-Surface-Palette bleibt auf die kuratierte WS2812-Liste mit exakt vorgegebenen Namen und Hex-Werten beschraenkt. + - Den unteren Brightness-Bereich in der linken Spalte vollstaendig entfernt; globale Helligkeit bleibt ausschliesslich im Header. + - Den dynamisch aus Host-Parametern kommenden `brightness`-Control in der linken Rail ausgefiltert. + - Die linke Rail moderat verbreitert und Parameterfelder so nachgezogen, dass Dropdowns, Labels und Farbwerte weniger gequetscht sind. +- Gelaufene Tests: + - `cargo fmt --check` + - `cargo test -q -p infinity_host` + - `cargo test -q -p infinity_host_api --test contract` + - lokaler CLI-Smoke gegen temporaeren Host auf `127.0.0.1:9002` via `/api/v1/state` und `/api/v1/preview` +- Bewusste Abweichungen zur Python-Version: + - Keine; dieser Schritt korrigiert nur die Web-Darstellung und blendet den global redundanten Brightness-Slider links aus. + +## 2026-04-20 - Technical Surface null-safe gegen fehlende recent_events + +- Geaenderte Dateien: + - `web/v1/technical.js` + - `crates/infinity_host_api/tests/contract.rs` + - `docs/codex_worklog.md` +- Fachliche Aenderungen: + - Technical Surface gegen unvollstaendige oder startende Snapshot-Daten robuster gemacht. + - `recent_events`, `system`, `technical`, `nodes` und `panels` werden in der UI jetzt ueber null-safe Helper mit Fallbacks gelesen. + - Direkter Zugriff auf `snapshot.recent_events.map(...)` entfernt, damit `/technical` nicht mehr crasht, wenn das Feld fehlt. + - Contract-/UI-Smoke-Test ergaenzt, der absichert, dass das ausgelieferte `technical.js` den Guard fuer fehlende `recent_events` enthaelt, waehrend `/api/v1/state` das Feld weiterhin weglassen darf. +- Gelaufene Tests: + - `cargo fmt --check` + - `cargo test -q -p infinity_host_api --test contract` + - lokaler Host-Smoke auf `127.0.0.1:9011` mit erfolgreichem Abruf von `/` und `/api/v1/state` +- Bewusste Abweichungen zur Python-Version: + - Keine; das ist ein reiner Stabilitaets-/Robustheitsfix in der bestehenden Web-Oberflaeche. + +## 2026-04-20 - Browser-Smoke-Runner und Output-Disable-Fix + +- Geaenderte Dateien: + - `web/v1/technical.js` + - `crates/infinity_host_api/tests/contract.rs` + - `scripts/codex_browser_smoke.sh` + - `docs/codex_worklog.md` +- Fachliche Aenderungen: + - Technical-Output-Controls gegen Polling-/Apply-Rennen stabilisiert, indem waehrend `saveOutputSettings()` ein `outputSaving`-Zustand gesetzt wird. + - Output-Controls werden waehrend des Speicherns kurz deaktiviert und der Draft wird in dieser Zeit nicht von Polling-Snapshots ueberschrieben. + - Contract-Test ergaenzt, der absichert, dass `output_enabled` nach vorherigem Aktivieren wieder sauber auf `false` gesetzt werden kann. + - Kleinen lokalen Browser-/Route-Smoke-Runner fuer Codex angelegt, der eine Kurzinstanz startet und `/`, `/technical` sowie `/technical.js` selbst prueft. +- Gelaufene Tests: + - `cargo test -q -p infinity_host_api --test contract` + - `scripts/codex_browser_smoke.sh 9012` +- Bewusste Abweichungen zur Python-Version: + - Keine; der Schritt behebt einen Web-Workflow-Bug und verbessert nur den lokalen Debug-Pfad. diff --git a/docs/qwen14b_handoff.md b/docs/qwen14b_handoff.md index 19d7b0a..e1febc3 100644 --- a/docs/qwen14b_handoff.md +++ b/docs/qwen14b_handoff.md @@ -9,11 +9,27 @@ Diese Datei ist die schnelle Uebergabe fuer ein kleineres Modell wie Qwen 14B. S - Host-Core ist die zentrale Runtime und bleibt die einzige Kernarchitektur. - API v1 ist die verbindliche Aussenkante fuer State, Preview, Snapshot, Catalog, Commands und Event-Stream. - Die Creative Surface lebt in `web/v1/` und ist die operatorische Web-Oberflaeche. -- Die Desktop-GUI in `crates/infinity_host_ui/` bleibt die technische Engineering-/Diagnoseoberflaeche. +- Die Technical Surface in `web/v1/technical.html` plus `web/v1/technical.js` ist die aktuelle technische Web-Oberflaeche. +- Die Desktop-GUI in `crates/infinity_host_ui/` existiert weiter als technische Engineering-/Diagnoseflaeche, ist aber nicht der primaere aktuelle UI-Arbeitspfad. - Persistenz, Recovery und Runtime-Show-Store sind vorhanden. - Show-Control-v1-Primitive sind faktisch eingefroren und dokumentiert. - Ein generischer externer Control-Pfad ist vorhanden, aber bewusst nicht grandMA-spezifisch. +## Frisch umgesetzt in dieser Arbeitsphase + +- Creative Surface wurde kompakter gemacht, damit alle 18 Panels im 3x6-Raster gleichzeitig sichtbar bleiben. +- Preview- und normale Pattern-Wirkung wurden diskreter gemacht: + - Preview-Ziel im Host liegt jetzt bei bis zu 60 Hz. + - Normale Preview-/Pattern-Helligkeit ist auf 10%-Stufen quantisiert. + - Preview-Updates und WebSocket-Preview-Frames werden nur noch bei inhaltlicher Aenderung weitergeschoben. +- Die feste WS2812-Palette wurde in Host und Web-UI hinterlegt; alte freie/abweichende Palettennamen wurden ersetzt. +- Globaler Master-Brightness sitzt nur noch im Header; redundante globale Brightness-Bedienung links ist entfernt. +- Die Technical Surface ist robuster gegen unvollstaendige Snapshots, insbesondere fehlende `recent_events`. +- Fuer Codex gibt es jetzt einen lokalen Route-/Browser-Smoke-Runner: + - `scripts/codex_browser_smoke.sh` + - prueft `/`, `/technical` und `/technical.js` gegen eine frische Kurzinstanz + - ersetzt noch keinen vollgerenderten Headless-Browser mit DOM-Interaktion + ## Lokale Umgebung auf diesem Rechner - Arbeitsverzeichnis des Rust-Projekts: `/home/jan/Documents/RFP/Infinity_Vis_Rust` @@ -77,6 +93,8 @@ Die wichtigsten Fortschrittsanker liegen hier: Konflikt- und Ownership-Regeln zwischen Web-UI, technischer GUI und externen Control-Quellen. - [docs/local_software_only_runbook.md](/home/jan/Documents/RFP/Infinity_Vis_Rust/docs/local_software_only_runbook.md:1) Reproduzierbarer software-only Startpfad. +- [docs/codex_worklog.md](/home/jan/Documents/RFP/Infinity_Vis_Rust/docs/codex_worklog.md:1) + Knapper Verlauf der letzten Codex-Arbeitspakete inklusive Tests, Abweichungen und lokalen Smoke-Checks. - [crates/infinity_host/tests/show_control_v1_golden.rs](/home/jan/Documents/RFP/Infinity_Vis_Rust/crates/infinity_host/tests/show_control_v1_golden.rs:1) Golden-Trace- und Replay-Schutz fuer die zentrale v1-Semantik. - Git-Historie: @@ -129,6 +147,7 @@ Die wichtigsten Fortschrittsanker liegen hier: - [styles.css](/home/jan/Documents/RFP/Infinity_Vis_Rust/web/v1/styles.css:1) - [technical.html](/home/jan/Documents/RFP/Infinity_Vis_Rust/web/v1/technical.html:1) - [technical.js](/home/jan/Documents/RFP/Infinity_Vis_Rust/web/v1/technical.js:1) +- [codex_browser_smoke.sh](/home/jan/Documents/RFP/Infinity_Vis_Rust/scripts/codex_browser_smoke.sh:1) ## Was aktuell als stabil gelten soll @@ -139,6 +158,7 @@ Die wichtigsten Fortschrittsanker liegen hier: - `Show/Event` bleibt staged plus Commit ueber `Go` oder `Fade Go`. - Preview-Only und Offline-Status ehrlich anzeigen, keine Fake-Nodes. - Creative Surface nicht mit technischen Mapping-Details ueberladen. +- Technical Surface muss gegen partielle/startende State-Snapshots robust bleiben. ## Sichere Arbeitsreihenfolge fuer weitere Aenderungen @@ -156,6 +176,7 @@ Die wichtigsten Fortschrittsanker liegen hier: . "$HOME/.cargo/env" cargo test -q -p infinity_host cargo test -q -p infinity_host_api --test contract +scripts/codex_browser_smoke.sh 9012 ``` Fuer lokalen software-only Start: @@ -170,6 +191,7 @@ cargo run -p infinity_host_api -- --config config/project.example.toml --bind 12 - Das alte Python-Projekt ist derzeit nicht lokal neben dem Repo ausgecheckt. Fuer echte 1:1-Conformance-Vergleiche sollte es bewusst lokal daneben geklont werden. - Das Runbook enthaelt noch einen veralteten Hinweis aus der frueheren Arbeitsbaum-Phase; aktuell ist das Repo wieder ein echter Git-Clone mit `.git`. - Die grosse Pattern-Conformance gegen das alte Python-Projekt ist noch nicht vollstaendig als separater, systematischer Abgleich abgeschlossen. +- Ein echter gerenderter Headless-Browser-Runner mit DOM-Interaktion ist lokal noch nicht sauber eingerichtet; vorhanden ist aktuell nur der Route-/Asset-Smoke ueber `scripts/codex_browser_smoke.sh`. ## Kurzbriefing fuer das naechste Modell diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 8546a37..73cb934 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,3 @@ [toolchain] channel = "stable" components = ["rustfmt", "clippy"] - diff --git a/scripts/codex_browser_smoke.sh b/scripts/codex_browser_smoke.sh new file mode 100755 index 0000000..092543e --- /dev/null +++ b/scripts/codex_browser_smoke.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_DIR="/home/jan/Documents/RFP/Infinity_Vis_Rust" +PORT="${1:-9011}" +BASE_URL="http://127.0.0.1:${PORT}" +RUNTIME_STATE="/tmp/infinity_vis_runtime_codex_browser_${PORT}.json" + +cd "$REPO_DIR" +. "$HOME/.cargo/env" + +cargo run -q -p infinity_host_api -- \ + --config config/project.example.toml \ + --bind "127.0.0.1:${PORT}" \ + --runtime-state "$RUNTIME_STATE" & +SERVER_PID=$! + +cleanup() { + if kill -0 "$SERVER_PID" >/dev/null 2>&1; then + kill "$SERVER_PID" >/dev/null 2>&1 || true + wait "$SERVER_PID" >/dev/null 2>&1 || true + fi +} + +trap cleanup EXIT INT TERM + +for _attempt in $(seq 1 40); do + if curl -fsS "${BASE_URL}/api/v1/state" >/dev/null 2>&1; then + break + fi + sleep 0.25 +done + +echo "Smoke-checking ${BASE_URL}/" +curl -fsS "${BASE_URL}/" >/tmp/infinity_vis_creative_${PORT}.html >/dev/null +echo "Smoke-checking ${BASE_URL}/technical" +curl -fsS "${BASE_URL}/technical" >/tmp/infinity_vis_technical_${PORT}.html >/dev/null +echo "Smoke-checking ${BASE_URL}/technical.js" +curl -fsS "${BASE_URL}/technical.js" >/tmp/infinity_vis_technical_${PORT}.js >/dev/null + +echo "Creative Surface and Technical Surface were served successfully on ${BASE_URL}" +echo "Saved smoke artifacts to /tmp/infinity_vis_creative_${PORT}.html and /tmp/infinity_vis_technical_${PORT}.html" + +if command -v xdg-open >/dev/null 2>&1; then + echo "Optional manual open:" + echo " xdg-open ${BASE_URL}/" + echo " xdg-open ${BASE_URL}/technical" +fi diff --git a/web/v1/app.js b/web/v1/app.js index 300ffd1..c8c91b0 100644 --- a/web/v1/app.js +++ b/web/v1/app.js @@ -1,9 +1,9 @@ (function () { - const PREVIEW_RENDER_INTERVAL_MS = 90; + const PREVIEW_RENDER_INTERVAL_MS = 16; const STATE_RENDER_INTERVAL_MS = 180; const POSITION_ORDER = { top: 0, middle: 1, bottom: 2 }; const COLOR_PARAM_KEYS = new Set(["color_mode", "palette", "primary_color", "secondary_color"]); - const BRIGHTNESS_PARAM_KEYS = new Set(["brightness"]); + const HIDDEN_PARAMETER_KEYS = new Set(["speed", "brightness"]); const TEMPO_BPM_MIN = 10; const TEMPO_BPM_MAX = 300; const TEMPO_BPM_DEFAULT = 120; @@ -46,16 +46,60 @@ palette: { control: "enum", label: "Palette", - default_value: "Laser Club", + default_value: "Deep Blue", + groups: [ + { + label: "White / Temperature Presets", + options: [ + { value: "Candle", label: "Candle (#FF9329)" }, + { value: "Tungsten 40W", label: "Tungsten 40W (#FFC58F)" }, + { value: "Tungsten 100W", label: "Tungsten 100W (#FFD6AA)" }, + { value: "Halogen", label: "Halogen (#FFF1E0)" }, + { value: "Carbon Arc", label: "Carbon Arc (#FFFAF4)" }, + { value: "High Noon Sun", label: "High Noon Sun (#FFFFFB)" }, + { value: "Overcast Sky", label: "Overcast Sky (#C9E2FF)" }, + { value: "Clear Blue Sky", label: "Clear Blue Sky (#409CFF)" }, + ], + }, + { + label: "Club / Show Presets", + options: [ + { value: "Deep Blue", label: "Deep Blue (#0047FF)" }, + { value: "Electric Cyan", label: "Electric Cyan (#00D8FF)" }, + { value: "Emerald", label: "Emerald (#00C853)" }, + { value: "Acid Lime", label: "Acid Lime (#AEEA00)" }, + { value: "Amber", label: "Amber (#FF9D00)" }, + { value: "Signal Red", label: "Signal Red (#FF2D2D)" }, + { value: "Hot Magenta", label: "Hot Magenta (#FF00A8)" }, + { value: "Violet", label: "Violet (#7A3CFF)" }, + { value: "Warm White", label: "Warm White (#FFD6AA)" }, + { value: "Neutral White", label: "Neutral White (#FFF1E0)" }, + { value: "Cool White", label: "Cool White (#C9E2FF)" }, + { value: "Blacklight Violet", label: "Blacklight Violet (#A700FF)" }, + ], + }, + ], options: [ - { value: "Laser Club", label: "Laser Club" }, - { value: "Magenta Drive", label: "Magenta Drive" }, - { value: "Warehouse Heat", label: "Warehouse Heat" }, - { value: "UV Riot", label: "UV Riot" }, - { value: "Redline", label: "Redline" }, - { value: "Sodium Haze", label: "Sodium Haze" }, - { value: "Afterhours", label: "Afterhours" }, - { value: "Voltage", label: "Voltage" }, + { value: "Candle", label: "Candle (#FF9329)" }, + { value: "Tungsten 40W", label: "Tungsten 40W (#FFC58F)" }, + { value: "Tungsten 100W", label: "Tungsten 100W (#FFD6AA)" }, + { value: "Halogen", label: "Halogen (#FFF1E0)" }, + { value: "Carbon Arc", label: "Carbon Arc (#FFFAF4)" }, + { value: "High Noon Sun", label: "High Noon Sun (#FFFFFB)" }, + { value: "Overcast Sky", label: "Overcast Sky (#C9E2FF)" }, + { value: "Clear Blue Sky", label: "Clear Blue Sky (#409CFF)" }, + { value: "Deep Blue", label: "Deep Blue (#0047FF)" }, + { value: "Electric Cyan", label: "Electric Cyan (#00D8FF)" }, + { value: "Emerald", label: "Emerald (#00C853)" }, + { value: "Acid Lime", label: "Acid Lime (#AEEA00)" }, + { value: "Amber", label: "Amber (#FF9D00)" }, + { value: "Signal Red", label: "Signal Red (#FF2D2D)" }, + { value: "Hot Magenta", label: "Hot Magenta (#FF00A8)" }, + { value: "Violet", label: "Violet (#7A3CFF)" }, + { value: "Warm White", label: "Warm White (#FFD6AA)" }, + { value: "Neutral White", label: "Neutral White (#FFF1E0)" }, + { value: "Cool White", label: "Cool White (#C9E2FF)" }, + { value: "Blacklight Violet", label: "Blacklight Violet (#A700FF)" }, ], }, direction: { @@ -171,11 +215,11 @@ previewLayoutSignature: null, viewOutputSignature: null, stateTimer: null, - previewTimer: null, lastStateRenderAt: 0, lastPreviewRenderAt: 0, stateRenderQueued: false, previewRenderQueued: false, + previewAnimationFrame: 0, eventFilterSignature: null, }, }; @@ -225,7 +269,6 @@ snapshotList: document.getElementById("snapshot-list"), motionParams: document.getElementById("motion-params"), colorParams: document.getElementById("color-params"), - brightnessParams: document.getElementById("brightness-params"), previewGrid: document.getElementById("preview-grid"), summaryCards: document.getElementById("summary-cards"), selectedTileCard: document.getElementById("selected-tile-card"), @@ -565,15 +608,28 @@ return; } apiState.ui.previewRenderQueued = true; - const now = window.performance.now(); - const waitMs = Math.max(0, PREVIEW_RENDER_INTERVAL_MS - (now - apiState.ui.lastPreviewRenderAt)); - window.setTimeout(() => { + const scheduleFrame = () => { + apiState.ui.previewAnimationFrame = window.requestAnimationFrame(() => { + const now = window.performance.now(); + if (now - apiState.ui.lastPreviewRenderAt < PREVIEW_RENDER_INTERVAL_MS) { + scheduleFrame(); + return; + } + apiState.ui.previewAnimationFrame = 0; + apiState.ui.previewRenderQueued = false; + apiState.ui.lastPreviewRenderAt = now; + renderPreview(false); + renderSelectedTile(); + renderSnapshotJson(); + }); + }; + + if (apiState.ui.previewAnimationFrame) { apiState.ui.previewRenderQueued = false; - apiState.ui.lastPreviewRenderAt = window.performance.now(); - renderPreview(false); - renderSelectedTile(); - renderSnapshotJson(); - }, waitMs); + return; + } + + scheduleFrame(); } async function handlePrimitive(primitive, options) { @@ -1120,7 +1176,6 @@ if (!scene || !global) { dom.motionParams.innerHTML = ""; dom.colorParams.innerHTML = ""; - dom.brightnessParams.innerHTML = ""; apiState.ui.parameterCards.clear(); return; } @@ -1128,13 +1183,14 @@ if ( !force && (dom.motionParams.contains(document.activeElement) || - dom.colorParams.contains(document.activeElement) || - dom.brightnessParams.contains(document.activeElement)) + dom.colorParams.contains(document.activeElement)) ) { return; } - const parameters = scene.parameters || []; + const parameters = (scene.parameters || []).filter( + (parameter) => !HIDDEN_PARAMETER_KEYS.has(parameter.key) + ); const signature = parameters .map((parameter) => `${parameter.key}:${parameter.kind}`) .join("|"); @@ -1144,7 +1200,6 @@ apiState.ui.parameterCards.clear(); dom.motionParams.innerHTML = ""; dom.colorParams.innerHTML = ""; - dom.brightnessParams.innerHTML = ""; parameters.forEach((parameter) => { const card = createParameterCard(parameter, global); @@ -1165,9 +1220,6 @@ if (COLOR_PARAM_KEYS.has(parameter.key)) { return dom.colorParams; } - if (BRIGHTNESS_PARAM_KEYS.has(parameter.key)) { - return dom.brightnessParams; - } return dom.motionParams; } @@ -1251,13 +1303,28 @@ card.appendChild(readout); } else if (controlKind === "enum") { primaryInput = document.createElement("select"); - const options = contract && Array.isArray(contract.options) ? contract.options : []; - options.forEach((option) => { - const node = document.createElement("option"); - node.value = option.value; - node.textContent = option.label; - primaryInput.appendChild(node); - }); + const groupedOptions = contract && Array.isArray(contract.groups) ? contract.groups : []; + if (groupedOptions.length) { + groupedOptions.forEach((group) => { + const groupNode = document.createElement("optgroup"); + groupNode.label = group.label; + group.options.forEach((option) => { + const node = document.createElement("option"); + node.value = option.value; + node.textContent = option.label; + groupNode.appendChild(node); + }); + primaryInput.appendChild(groupNode); + }); + } else { + const options = contract && Array.isArray(contract.options) ? contract.options : []; + options.forEach((option) => { + const node = document.createElement("option"); + node.value = option.value; + node.textContent = option.label; + primaryInput.appendChild(node); + }); + } primaryInput.addEventListener("change", async (event) => { const optionValue = event.target.value; const payloadValue = @@ -1550,8 +1617,8 @@ dom.previewUpdated.textContent = `${apiState.previewResponse.generated_at_millis} ms`; dom.previewGrid.className = `preview-grid preview-grid-mode-${apiState.ui.previewMode}`; - const columnCount = uniqueNodeIds(panels).length; - dom.previewGrid.style.gridTemplateColumns = `repeat(${columnCount}, minmax(112px, 1fr))`; + const columnCount = 6; + dom.previewGrid.style.gridTemplateColumns = `repeat(${columnCount}, minmax(0, 1fr))`; const layoutSignature = panels.map((panel) => panel.key).join("|"); if (force || layoutSignature !== apiState.ui.previewLayoutSignature) { @@ -2437,40 +2504,16 @@ const sampleColors = (panel.sample_led_hex || []) .map((hex) => normalizeColorHex(hex)) .filter((hex) => isHexColorString(hex)); - const palette = sampleColors.length - ? sampleColors - : [normalizeColorHex(panel.representative_color_hex)]; - - if (palette.length === 1) { - return TILE_LED_GEOMETRY.map(() => palette[0]); + if (!sampleColors.length) { + return TILE_LED_GEOMETRY.map(() => normalizeColorHex(panel.representative_color_hex)); } return TILE_LED_GEOMETRY.map((_led, index) => { - const factor = TILE_LED_GEOMETRY.length === 1 ? 0 : index / (TILE_LED_GEOMETRY.length - 1); - return interpolatePalette(palette, factor); + const sourceIndex = Math.round((index / Math.max(1, TILE_LED_GEOMETRY.length - 1)) * (sampleColors.length - 1)); + return sampleColors[sourceIndex] || normalizeColorHex(panel.representative_color_hex); }); } - function interpolatePalette(palette, factor) { - const scaled = factor * (palette.length - 1); - const lowerIndex = Math.floor(scaled); - const upperIndex = Math.min(palette.length - 1, Math.ceil(scaled)); - if (lowerIndex === upperIndex) { - return palette[lowerIndex]; - } - return mixHexColors(palette[lowerIndex], palette[upperIndex], scaled - lowerIndex); - } - - function mixHexColors(leftHex, rightHex, factor) { - const left = parseHexColor(leftHex); - const right = parseHexColor(rightHex); - return rgbToHex( - Math.round(lerp(left.r, right.r, factor)), - Math.round(lerp(left.g, right.g, factor)), - Math.round(lerp(left.b, right.b, factor)) - ); - } - function parseHexColor(hex) { const normalized = normalizeColorHex(hex).slice(1); return { @@ -2486,9 +2529,10 @@ function previewLedOpacity(energyPercent) { if (!energyPercent) { - return "0.18"; + return "0.1"; } - return String(Math.min(1, 0.42 + energyPercent / 160)); + const steppedPercent = Math.round(Math.max(0, Math.min(100, energyPercent)) / 10) * 10; + return String(Math.max(0.1, steppedPercent / 100)); } function escapeHtml(value) { diff --git a/web/v1/index.html b/web/v1/index.html index 36b8f48..e39bb7c 100644 --- a/web/v1/index.html +++ b/web/v1/index.html @@ -26,6 +26,12 @@ + +