Przeglądaj źródła

fix: stabilize companion animation playback

Alvin Unreal 2 miesięcy temu
rodzic
commit
a7cf10ad6a

+ 40 - 13
companion/src/app.rs

@@ -87,6 +87,21 @@ fn config_key(config: Option<&CompanionConfigState>) -> Option<ConfigKey> {
     })
 }
 
+fn config_for_owner<'a>(
+    sessions: &'a [SessionInfo],
+    owner_session_id: Option<&str>,
+    global_config: Option<&'a CompanionConfigState>,
+) -> Option<&'a CompanionConfigState> {
+    owner_session_id
+        .and_then(|owner| {
+            sessions
+                .iter()
+                .find(|session| session.session_id == owner)
+                .and_then(|session| session.config.as_ref())
+        })
+        .or(global_config)
+}
+
 fn normalized_gif_pack(pack: &str) -> &str {
     match pack {
         "default" => "default",
@@ -297,7 +312,11 @@ impl CompanionApp {
         let mut loop_style = "classic".to_string();
         let mut speed = normalized_speed(f32::NAN);
         let has_modern_config = state.config.is_some();
-        let applied_config = config_key(state.config.as_ref());
+        let applied_config = config_key(config_for_owner(
+            &sessions,
+            owner_session_id.as_deref(),
+            state.config.as_ref(),
+        ));
         apply_config(
             applied_config.as_ref(),
             &mut position,
@@ -338,17 +357,23 @@ impl CompanionApp {
             while self.rx.try_recv().is_ok() {}
             let state = read_state(&self.state_path);
             self.sessions = state.sessions;
+            let owned_config = config_for_owner(
+                &self.sessions,
+                self.owner_session_id.as_deref(),
+                state.config.as_ref(),
+            );
             crate::log::debug(format!(
-                "state update owner={:?} sessions={} config={:?}",
+                "state update owner={:?} sessions={} global_config={:?} owned_config={:?}",
                 self.owner_session_id,
                 self.sessions.len(),
-                state.config
+                state.config,
+                owned_config
             ));
             self.window_positions = state.window_positions;
             self.project_keys
                 .retain(|cwd, _| self.sessions.iter().any(|session| &session.cwd == cwd));
             self.has_modern_config = state.config.is_some();
-            let next_config = config_key(state.config.as_ref());
+            let next_config = config_key(owned_config);
             let config_changed = self.applied_config != next_config;
             if config_changed {
                 apply_config(
@@ -403,7 +428,6 @@ impl eframe::App for CompanionApp {
         }
 
         if !self.registered {
-            self.gifs.register(ctx);
             ctx.data_mut(|d| d.insert_temp(egui::Id::new(SIZE_KEY), self.size));
             self.registered = true;
         } else if config_changed {
@@ -460,6 +484,7 @@ impl eframe::App for CompanionApp {
         let agent_frames: Vec<AnimationFrame> = if session.active_agents.is_empty() {
             self.gifs
                 .frame(
+                    ctx,
                     "intro",
                     &self.gif_pack,
                     self.speed,
@@ -474,6 +499,7 @@ impl eframe::App for CompanionApp {
                 .iter()
                 .filter_map(|agent| {
                     self.gifs.frame(
+                        ctx,
                         agent,
                         &self.gif_pack,
                         self.speed,
@@ -581,7 +607,7 @@ impl eframe::App for CompanionApp {
             });
 
         render_size_picker(ctx);
-        ctx.request_repaint_after(Duration::from_millis(50));
+        ctx.request_repaint_after(Duration::from_millis(16));
     }
 }
 
@@ -811,6 +837,7 @@ mod tests {
             status: status.to_string(),
             pid: Some(1),
             active_agent: None,
+            config: None,
         }
     }
 
@@ -1008,7 +1035,7 @@ mod tests {
             size: "large".into(),
             gif_pack: "default".into(),
             loop_style: "classic".into(),
-            speed: 1.5,
+            speed: 1.0,
         };
         assert_eq!(
             config_key(Some(&cfg)),
@@ -1017,7 +1044,7 @@ mod tests {
                 size: "large".into(),
                 gif_pack: "default".into(),
                 loop_style: "classic".into(),
-                speed_bits: 1.5f32.to_bits(),
+                speed_bits: 1.0f32.to_bits(),
             })
         );
         assert_eq!(config_key(None), None);
@@ -1030,28 +1057,28 @@ mod tests {
             size: "medium".into(),
             gif_pack: "default".into(),
             loop_style: "classic".into(),
-            speed_bits: 1.5f32.to_bits(),
+            speed_bits: 1.0f32.to_bits(),
         });
         let unchanged = Some(ConfigKey {
             position: "bottom-right".into(),
             size: "medium".into(),
             gif_pack: "default".into(),
             loop_style: "classic".into(),
-            speed_bits: 1.5f32.to_bits(),
+            speed_bits: 1.0f32.to_bits(),
         });
         let moved = Some(ConfigKey {
             position: "top-left".into(),
             size: "medium".into(),
             gif_pack: "default".into(),
             loop_style: "classic".into(),
-            speed_bits: 1.5f32.to_bits(),
+            speed_bits: 1.0f32.to_bits(),
         });
         let resized = Some(ConfigKey {
             position: "bottom-right".into(),
             size: "large".into(),
             gif_pack: "default".into(),
             loop_style: "classic".into(),
-            speed_bits: 1.5f32.to_bits(),
+            speed_bits: 1.0f32.to_bits(),
         });
 
         assert_eq!(previous, unchanged);
@@ -1100,6 +1127,6 @@ mod tests {
         assert_eq!(size, 120.0);
         assert_eq!(gif_pack, "default");
         assert_eq!(loop_style, "classic");
-        assert_eq!(speed, 1.5);
+        assert_eq!(speed, 1.0);
     }
 }

+ 53 - 42
companion/src/gifs.rs

@@ -1,7 +1,8 @@
 use egui::{ColorImage, Context, Rect, TextureHandle, TextureId, TextureOptions};
 use std::collections::HashMap;
 
-const DEFAULT_SPEED: f32 = 1.5;
+const DEFAULT_SPEED: f32 = 1.0;
+const BASE_SPEED_MULTIPLIER: f32 = 2.0;
 const FRAME_RATE: f32 = 24.0;
 const FRAME_COUNT: usize = 72;
 const SHEET_COLS: usize = 12;
@@ -40,53 +41,62 @@ impl Gifs {
         }
     }
 
-    pub fn register(&mut self, ctx: &Context) {
-        for (name, bytes) in &self.sheets {
-            if self.textures.contains_key(name) {
-                continue;
-            }
-            let started = std::time::Instant::now();
-            match decode_sprite_sheet(bytes) {
-                Ok(image) => {
-                    let texture = ctx.load_texture(
-                        format!("companion-animation-{name}"),
-                        image,
-                        TextureOptions::LINEAR,
-                    );
-                    crate::log::debug(format!(
-                        "animation register name={} bytes={} elapsed_ms={}",
-                        name,
-                        bytes.len(),
-                        started.elapsed().as_millis()
-                    ));
-                    self.textures.insert(name, texture);
-                }
-                Err(err) => {
-                    crate::log::debug(format!("animation decode failed name={} err={}", name, err));
-                }
-            }
-        }
-    }
-
     pub fn frame(
-        &self,
+        &mut self,
+        ctx: &Context,
         agent: &str,
         gif_pack: &str,
         speed: f32,
         loop_style: &str,
         time_seconds: f64,
     ) -> Option<AnimationFrame> {
-        let name = if gif_pack == "default" && self.textures.contains_key(agent) {
-            agent
-        } else {
-            "orchestrator"
-        };
+        let name = self.resolve_name(agent, gif_pack);
+        self.ensure_texture(ctx, name)?;
         let texture = self.textures.get(name)?;
         Some(AnimationFrame {
             texture_id: texture.id(),
             uv: frame_uv(frame_index(time_seconds, speed, loop_style)),
         })
     }
+
+    fn ensure_texture(&mut self, ctx: &Context, name: &'static str) -> Option<()> {
+        if self.textures.contains_key(name) {
+            return Some(());
+        }
+        let bytes = *self.sheets.get(name)?;
+        let started = std::time::Instant::now();
+        match decode_sprite_sheet(bytes) {
+            Ok(image) => {
+                let texture = ctx.load_texture(
+                    format!("companion-animation-{name}"),
+                    image,
+                    TextureOptions::LINEAR,
+                );
+                crate::log::debug(format!(
+                    "animation lazy-load name={} bytes={} elapsed_ms={}",
+                    name,
+                    bytes.len(),
+                    started.elapsed().as_millis()
+                ));
+                self.textures.insert(name, texture);
+                Some(())
+            }
+            Err(err) => {
+                crate::log::debug(format!("animation decode failed name={} err={}", name, err));
+                None
+            }
+        }
+    }
+
+    fn resolve_name(&self, agent: &str, gif_pack: &str) -> &'static str {
+        if gif_pack != "default" {
+            return "orchestrator";
+        }
+        self.sheets
+            .get_key_value(agent)
+            .map(|(name, _)| *name)
+            .unwrap_or("orchestrator")
+    }
 }
 
 pub fn normalized_speed(speed: f32) -> f32 {
@@ -106,8 +116,9 @@ fn normalized_loop_style(loop_style: &str) -> &str {
 }
 
 fn frame_index(time_seconds: f64, speed: f32, loop_style: &str) -> usize {
-    let tick = (time_seconds.max(0.0) * FRAME_RATE as f64 * normalized_speed(speed) as f64).floor()
-        as usize;
+    let effective_speed = normalized_speed(speed) * BASE_SPEED_MULTIPLIER;
+    let tick =
+        (time_seconds.max(0.0) * FRAME_RATE as f64 * effective_speed as f64).floor() as usize;
     if normalized_loop_style(loop_style) == "smooth" {
         let period = FRAME_COUNT * 2 - 2;
         let phase = tick % period;
@@ -144,7 +155,7 @@ mod tests {
 
     #[test]
     fn speed_is_clamped_and_defaults_fast() {
-        assert_eq!(normalized_speed(f32::NAN), 1.5);
+        assert_eq!(normalized_speed(f32::NAN), 1.0);
         assert_eq!(normalized_speed(0.1), 0.25);
         assert_eq!(normalized_speed(9.0), 4.0);
     }
@@ -152,18 +163,18 @@ mod tests {
     #[test]
     fn classic_loop_wraps_forward() {
         assert_eq!(frame_index(0.0, 1.0, "classic"), 0);
-        assert_eq!(frame_index(3.0, 1.0, "classic"), 0);
-        assert_eq!(frame_index(3.0 + 1.0 / 24.0, 1.0, "classic"), 1);
+        assert_eq!(frame_index(1.5, 1.0, "classic"), 0);
+        assert_eq!(frame_index(1.5 + 1.0 / 48.0, 1.0, "classic"), 1);
     }
 
     #[test]
     fn smooth_loop_ping_pongs_without_duplicate_endpoints() {
         assert_eq!(frame_index(0.0, 1.0, "smooth"), 0);
         assert_eq!(
-            frame_index((FRAME_COUNT - 1) as f64 / 24.0, 1.0, "smooth"),
+            frame_index((FRAME_COUNT - 1) as f64 / 48.0, 1.0, "smooth"),
             71
         );
-        assert_eq!(frame_index(FRAME_COUNT as f64 / 24.0, 1.0, "smooth"), 70);
+        assert_eq!(frame_index(FRAME_COUNT as f64 / 48.0, 1.0, "smooth"), 70);
     }
 
     #[test]

+ 3 - 1
companion/src/state.rs

@@ -28,7 +28,7 @@ fn default_loop_style() -> String {
 }
 
 fn default_speed() -> f32 {
-    1.5
+    1.0
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -60,6 +60,8 @@ pub struct SessionInfo {
     pub status: String,
     #[serde(default)]
     pub pid: Option<u32>,
+    #[serde(default)]
+    pub config: Option<CompanionConfigState>,
 }
 
 pub fn state_file_path() -> PathBuf {

+ 2 - 2
docs/companion.md

@@ -15,7 +15,7 @@ You can enable the companion by adding a `companion` section to your setting con
     "size": "medium",
     "gifPack": "default",
     "loopStyle": "classic",
-    "speed": 1.5,
+    "speed": 1,
     "debug": false
   }
 }
@@ -44,7 +44,7 @@ You can enable the companion by adding a `companion` section to your setting con
     smoother transition.
 
 - **`companion.speed`**: optional animation playback speed multiplier from `0.25` to
-  `4`. The default is `1.5`. Values above `1` play faster; values below `1`
+  `4`. The default is `1`. Values above `1` play faster; values below `1`
   play slower.
 
 - **`companion.debug`**: set to `true` to enable verbose native companion debug

+ 1 - 1
oh-my-opencode-slim.schema.json

@@ -454,7 +454,7 @@
           ]
         },
         "speed": {
-          "description": "Companion animation playback speed multiplier. Defaults to 1.5.",
+          "description": "Companion animation playback speed multiplier. Defaults to 1.",
           "type": "number",
           "minimum": 0.25,
           "maximum": 4

+ 1 - 1
src/cli/providers.ts

@@ -152,7 +152,7 @@ export function generateLiteConfig(
       size: 'medium',
       gifPack: 'default',
       loopStyle: 'classic',
-      speed: 1.5,
+      speed: 1,
       debug: false,
     };
   }

+ 1 - 1
src/companion/manager.test.ts

@@ -268,7 +268,7 @@ describe('CompanionManager', () => {
       size: 'medium',
       gifPack: 'default',
       loopStyle: 'classic',
-      speed: 1.5,
+      speed: 1,
       debug: false,
     });
   });

+ 13 - 1
src/companion/manager.ts

@@ -18,6 +18,7 @@ interface CompanionSession {
   active_agents: string[];
   status: string;
   pid: number;
+  config?: CompanionState['config'];
 }
 
 interface CompanionState {
@@ -245,6 +246,17 @@ export class CompanionManager {
         active_agents: this.activeAgents(),
         status: this.status,
         pid: process.pid,
+        config: this.config
+          ? {
+              enabled: this.config.enabled ?? false,
+              position: this.config.position ?? 'bottom-right',
+              size: this.config.size ?? 'medium',
+              gifPack: this.config.gifPack ?? 'default',
+              loopStyle: this.config.loopStyle ?? 'classic',
+              speed: this.config.speed ?? 1,
+              debug: this.config.debug ?? false,
+            }
+          : undefined,
       };
       writeState((state) => {
         const idx = state.sessions.findIndex((s) => s.session_id === this.id);
@@ -260,7 +272,7 @@ export class CompanionManager {
             size: this.config.size ?? 'medium',
             gifPack: this.config.gifPack ?? 'default',
             loopStyle: this.config.loopStyle ?? 'classic',
-            speed: this.config.speed ?? 1.5,
+            speed: this.config.speed ?? 1,
             debug: this.config.debug ?? false,
           };
         }

+ 1 - 1
src/config/loader.ts

@@ -327,7 +327,7 @@ export function loadPluginConfig(
       size: config.companion.size ?? 'medium',
       gifPack: config.companion.gifPack ?? 'default',
       loopStyle: config.companion.loopStyle ?? 'classic',
-      speed: config.companion.speed ?? 1.5,
+      speed: config.companion.speed ?? 1,
       debug: config.companion.debug ?? false,
     };
   }

+ 1 - 3
src/config/schema.ts

@@ -213,9 +213,7 @@ export const CompanionConfigSchema = z.object({
     .min(0.25)
     .max(4)
     .optional()
-    .describe(
-      'Companion animation playback speed multiplier. Defaults to 1.5.',
-    ),
+    .describe('Companion animation playback speed multiplier. Defaults to 1.'),
   debug: z
     .boolean()
     .optional()