Browse Source

feat: speed up companion GIF playback

Alvin Unreal 1 month ago
parent
commit
1fc4895561

+ 58 - 18
companion/src/app.rs

@@ -102,11 +102,7 @@ fn normalized_loop_style(style: &str) -> &str {
 }
 
 fn normalized_speed(speed: f32) -> f32 {
-    if speed.is_finite() {
-        speed.clamp(0.25, 4.0)
-    } else {
-        1.0
-    }
+    crate::gifs::normalized_speed(speed)
 }
 
 fn apply_config(
@@ -128,7 +124,7 @@ fn apply_config(
         *size = DEFAULT_SIZE;
         *gif_pack = "default".to_string();
         *loop_style = "classic".to_string();
-        *speed = 1.0;
+        *speed = normalized_speed(f32::NAN);
     }
 }
 
@@ -299,7 +295,7 @@ impl CompanionApp {
         let mut position = "bottom-right".to_string();
         let mut gif_pack = "default".to_string();
         let mut loop_style = "classic".to_string();
-        let mut speed = 1.0;
+        let mut speed = normalized_speed(f32::NAN);
         let has_modern_config = state.config.is_some();
         let applied_config = config_key(state.config.as_ref());
         apply_config(
@@ -407,12 +403,13 @@ impl eframe::App for CompanionApp {
         }
 
         if !self.registered {
-            self.gifs.register(ctx);
+            self.gifs.register(ctx, self.speed);
             ctx.data_mut(|d| d.insert_temp(egui::Id::new(SIZE_KEY), self.size));
             self.registered = true;
         } else if config_changed {
             // Config/state changes are the source of truth. A right-click picker
             // selection remains local until the config tuple changes.
+            self.gifs.register(ctx, self.speed);
             ctx.data_mut(|d| d.insert_temp(egui::Id::new(SIZE_KEY), self.size));
         }
 
@@ -461,12 +458,12 @@ impl eframe::App for CompanionApp {
         let project_key = self.project_key_for(&session.cwd);
         let saved_position = self.window_positions.get(&project_key).copied();
         let agent_uris: Vec<String> = if session.active_agents.is_empty() {
-            vec![self.gifs.uri("intro", &self.gif_pack)]
+            vec![self.gifs.uri("intro", &self.gif_pack, self.speed)]
         } else {
             session
                 .active_agents
                 .iter()
-                .map(|agent| self.gifs.uri(agent, &self.gif_pack))
+                .map(|agent| self.gifs.uri(agent, &self.gif_pack, self.speed))
                 .collect()
         };
         let n = agent_uris.len().max(1);
@@ -559,10 +556,9 @@ impl eframe::App for CompanionApp {
             )
             .show(ctx, |ui| {
                 ui.spacing_mut().item_spacing = egui::Vec2::ZERO;
-                // egui_extras' GIF image loader controls playback from the GIF
-                // bytes, so speed and loopStyle are currently plumbed through
-                // state for future support but not applied at render time.
-                let _speed = self.speed;
+                // Speed is applied by registering GIF bytes with scaled frame
+                // delays. loopStyle is still reserved for future playback
+                // behavior that is independent from the selected GIF pack.
                 let _loop_style = &self.loop_style;
                 render_session(ui, ctx, &session, &agent_uris, self.size, win_w, win_h);
             });
@@ -831,12 +827,12 @@ mod tests {
     }
 
     #[test]
-    fn falls_back_to_first_retained_session() {
+    fn falls_back_to_newest_retained_session() {
         let sessions = vec![
             session("first", "idle", &["intro"]),
             session("second", "idle", &["intro"]),
         ];
-        assert_eq!(choose_session(&sessions), Some(0));
+        assert_eq!(choose_session(&sessions), Some(1));
     }
 
     #[test]
@@ -995,12 +991,18 @@ mod tests {
             enabled: true,
             position: "top-left".into(),
             size: "large".into(),
+            gif_pack: "default".into(),
+            loop_style: "classic".into(),
+            speed: 1.5,
         };
         assert_eq!(
             config_key(Some(&cfg)),
             Some(ConfigKey {
                 position: "top-left".into(),
                 size: "large".into(),
+                gif_pack: "default".into(),
+                loop_style: "classic".into(),
+                speed_bits: 1.5f32.to_bits(),
             })
         );
         assert_eq!(config_key(None), None);
@@ -1011,18 +1013,30 @@ mod tests {
         let previous = Some(ConfigKey {
             position: "bottom-right".into(),
             size: "medium".into(),
+            gif_pack: "default".into(),
+            loop_style: "classic".into(),
+            speed_bits: 1.5f32.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(),
         });
         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(),
         });
         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(),
         });
 
         assert_eq!(previous, unchanged);
@@ -1034,17 +1048,43 @@ mod tests {
     fn apply_config_updates_size_only_for_config_changes() {
         let mut position = "bottom-right".to_string();
         let mut size = 200.0;
+        let mut gif_pack = "default".to_string();
+        let mut loop_style = "classic".to_string();
+        let mut speed = 1.0;
         let cfg = ConfigKey {
             position: "top-left".into(),
             size: "small".into(),
+            gif_pack: "default".into(),
+            loop_style: "smooth".into(),
+            speed_bits: 2.0f32.to_bits(),
         };
 
-        apply_config(Some(&cfg), &mut position, &mut size);
+        apply_config(
+            Some(&cfg),
+            &mut position,
+            &mut size,
+            &mut gif_pack,
+            &mut loop_style,
+            &mut speed,
+        );
         assert_eq!(position, "top-left");
         assert_eq!(size, 80.0);
+        assert_eq!(gif_pack, "default");
+        assert_eq!(loop_style, "smooth");
+        assert_eq!(speed, 2.0);
 
-        apply_config(None, &mut position, &mut size);
+        apply_config(
+            None,
+            &mut position,
+            &mut size,
+            &mut gif_pack,
+            &mut loop_style,
+            &mut speed,
+        );
         assert_eq!(position, "bottom-right");
         assert_eq!(size, 120.0);
+        assert_eq!(gif_pack, "default");
+        assert_eq!(loop_style, "classic");
+        assert_eq!(speed, 1.5);
     }
 }

+ 63 - 4
companion/src/gifs.rs

@@ -1,6 +1,8 @@
 use egui::Context;
 use std::collections::HashMap;
 
+const DEFAULT_SPEED: f32 = 1.5;
+
 pub struct Gifs {
     map: HashMap<&'static str, &'static [u8]>,
 }
@@ -21,19 +23,76 @@ impl Gifs {
         Self { map }
     }
 
-    pub fn register(&self, ctx: &Context) {
+    pub fn register(&self, ctx: &Context, speed: f32) {
         egui_extras::install_image_loaders(ctx);
+        let speed = normalized_speed(speed);
         for (name, bytes) in &self.map {
-            ctx.include_bytes(format!("bytes://{name}.gif"), *bytes);
+            ctx.include_bytes(
+                format!("bytes://{name}@{speed:.2}.gif"),
+                speed_gif(bytes, speed),
+            );
         }
     }
 
-    pub fn uri(&self, agent: &str, gif_pack: &str) -> String {
+    pub fn uri(&self, agent: &str, gif_pack: &str, speed: f32) -> String {
         let name = if gif_pack == "default" && self.map.contains_key(agent) {
             agent
         } else {
             "orchestrator"
         };
-        format!("bytes://{name}.gif")
+        format!("bytes://{name}@{:.2}.gif", normalized_speed(speed))
+    }
+}
+
+pub fn normalized_speed(speed: f32) -> f32 {
+    if speed.is_finite() {
+        speed.clamp(0.25, 4.0)
+    } else {
+        DEFAULT_SPEED
+    }
+}
+
+fn speed_gif(bytes: &[u8], speed: f32) -> Vec<u8> {
+    let speed = normalized_speed(speed);
+    if (speed - 1.0).abs() < f32::EPSILON {
+        return bytes.to_vec();
+    }
+
+    let mut out = bytes.to_vec();
+    let mut i = 0;
+    while i + 7 < out.len() {
+        // GIF Graphic Control Extension:
+        // 21 F9 04 <packed> <delay lo> <delay hi> <transparent index> 00
+        if out[i] == 0x21 && out[i + 1] == 0xF9 && out[i + 2] == 0x04 {
+            let delay = u16::from_le_bytes([out[i + 4], out[i + 5]]);
+            if delay > 0 {
+                let scaled = ((delay as f32) / speed).round().clamp(1.0, u16::MAX as f32) as u16;
+                let [lo, hi] = scaled.to_le_bytes();
+                out[i + 4] = lo;
+                out[i + 5] = hi;
+            }
+            i += 8;
+        } else {
+            i += 1;
+        }
+    }
+    out
+}
+
+#[cfg(test)]
+mod tests {
+    use super::speed_gif;
+
+    #[test]
+    fn speed_gif_scales_graphic_control_extension_delay() {
+        let gif = [
+            0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x21, 0xF9, 0x04, 0x00, 10, 0, 0x00, 0x00,
+        ];
+
+        let sped_up = speed_gif(&gif, 2.0);
+        assert_eq!(u16::from_le_bytes([sped_up[10], sped_up[11]]), 5);
+
+        let slowed_down = speed_gif(&gif, 0.5);
+        assert_eq!(u16::from_le_bytes([slowed_down[10], slowed_down[11]]), 20);
     }
 }

+ 1 - 1
companion/src/state.rs

@@ -28,7 +28,7 @@ fn default_loop_style() -> String {
 }
 
 fn default_speed() -> f32 {
-    1.0
+    1.5
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize, Default)]

+ 3 - 4
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,
+    "speed": 1.5,
     "debug": false
   }
 }
@@ -43,9 +43,8 @@ You can enable the companion by adding a `companion` section to your setting con
     transitions using the same selected GIF pack.
 
 - **`companion.speed`**: optional GIF playback speed multiplier from `0.25` to
-  `4`. The default is `1`. This setting is stored in companion state for
-  compatibility with speed-capable companion renderers; the current native
-  renderer follows the timing embedded in each GIF file.
+  `4`. The default is `1.5`. Values above `1` play faster; values below `1`
+  play slower.
 
 - **`companion.debug`**: set to `true` to enable verbose native companion debug
   logs while troubleshooting window/session behavior. Logs are written under

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

@@ -454,7 +454,7 @@
           ]
         },
         "speed": {
-          "description": "Companion GIF playback speed multiplier.",
+          "description": "Companion GIF playback speed multiplier. Defaults to 1.5.",
           "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,
+      speed: 1.5,
       debug: false,
     };
   }

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

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

+ 1 - 1
src/companion/manager.ts

@@ -260,7 +260,7 @@ export class CompanionManager {
             size: this.config.size ?? 'medium',
             gifPack: this.config.gifPack ?? 'default',
             loopStyle: this.config.loopStyle ?? 'classic',
-            speed: this.config.speed ?? 1,
+            speed: this.config.speed ?? 1.5,
             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,
+      speed: config.companion.speed ?? 1.5,
       debug: config.companion.debug ?? false,
     };
   }

+ 1 - 1
src/config/schema.ts

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