diff --git a/README.md b/README.md index 6dbce74..54e2a27 100644 --- a/README.md +++ b/README.md @@ -185,13 +185,47 @@ Keyboard shortcuts support modifiers `cmd`, `shift`, `ctrl`, `alt`/`opt` and key }, "displays": [ // optional, for monitor brightness { "name": "Left", "serial": "AN_SERIAL_FROM_M1DDC" } - ] + ], + "lcd": { // optional, LCD geometry — see below + "size": 85, + "shift_x": -9, + "shift_y": 8 + } } ``` Valid button keys: `lcd_1` through `lcd_6`, `btn_1` through `btn_3`. Valid knob keys: `big`, `small_1`, `small_2`. Each has `rotate` and `press` sub-actions. +Icons may be `.icns`, `.png` or `.jpg`, at any size or aspect ratio. Larger +images are downscaled, and non-square ones are fitted and centered rather than +stretched, with the unused axis left black to match the LCD background. + +### LCD geometry + +The `lcd` section is optional and only matters if your icons look cropped or +off-center. It controls the size icons are rendered at, and an offset applied +to compensate for where the panel's visible area sits. + +| Key | Default | Meaning | +|-----|---------|---------| +| `size` | `85` | Render size in pixels, before the panel crops to its viewport | +| `shift_x` | `-9` | Horizontal viewport offset | +| `shift_y` | `8` | Vertical viewport offset | + +The defaults suit the TreasLin N3. These decks are rebadged clones and do not +all use the same panel — an **AJAZZ AKP03** wants the LCD's native size with no +offset: + +```jsonc +"lcd": { "size": 72, "shift_x": 0, "shift_y": 0 } +``` + +Symptoms of a mismatch: icons cropped unevenly, most visibly as rounded corners +surviving on some sides of a key and cut flat on others. If yours look wrong, +start from `{ "size": 72, "shift_x": 0, "shift_y": 0 }` and adjust the offsets +until the image is centered. + ## History This project was originally developed under the name "VSD Display Controller" / "VSDDaemon", named after the manufacturer's software ("VSD Craft"). It was renamed to **StreamDeckController** to better describe what it actually does. You may still see `VSD` or `vsd` in some internal references — notably the config directory `~/.config/vsd/` which is kept for backward compatibility. diff --git a/Sources/VSDDaemon/Config.swift b/Sources/VSDDaemon/Config.swift index 3554703..db88df5 100644 --- a/Sources/VSDDaemon/Config.swift +++ b/Sources/VSDDaemon/Config.swift @@ -5,11 +5,31 @@ struct DisplayConfig: Codable { let serial: String // AN Serial from `m1ddc display list detailed` } +/// LCD geometry. These decks are rebadged clones and do not all share the +/// same panel: values that centre the image on one model crop it on another. +/// Defaults match the TreasLin N3; an AJAZZ AKP03 wants 72 with no shift. +struct LCDConfig: Codable { + let size: Int? + let shiftX: Int? + let shiftY: Int? + + enum CodingKeys: String, CodingKey { + case size + case shiftX = "shift_x" + case shiftY = "shift_y" + } + + static let defaultSize = 85 + static let defaultShiftX = -9 + static let defaultShiftY = 8 +} + struct DeckConfig: Codable { let brightness: Int let buttons: [String: ButtonConfig] let knobs: [String: KnobConfig] let displays: [DisplayConfig]? + let lcd: LCDConfig? static func load(from path: String) -> DeckConfig? { let url = URL(fileURLWithPath: path) diff --git a/Sources/VSDDaemon/HIDDevice.swift b/Sources/VSDDaemon/HIDDevice.swift index 44f9a97..fccd26c 100644 --- a/Sources/VSDDaemon/HIDDevice.swift +++ b/Sources/VSDDaemon/HIDDevice.swift @@ -137,7 +137,12 @@ final class HIDDevice { for (configKey, keyIndex) in lcdKeys { guard let buttonConfig = config.buttons[configKey], let iconPath = buttonConfig.icon else { continue } - guard let jpegData = ImageLoader.loadAsJPEG(path: iconPath) else { + guard let jpegData = ImageLoader.loadAsJPEG( + path: iconPath, + size: config.lcd?.size ?? LCDConfig.defaultSize, + shiftX: CGFloat(config.lcd?.shiftX ?? LCDConfig.defaultShiftX), + shiftY: CGFloat(config.lcd?.shiftY ?? LCDConfig.defaultShiftY) + ) else { print("Warning: failed to load icon for \(configKey): \(iconPath)") continue } diff --git a/Sources/VSDDaemon/ImageLoader.swift b/Sources/VSDDaemon/ImageLoader.swift index b1f2ba0..62e0a2c 100644 --- a/Sources/VSDDaemon/ImageLoader.swift +++ b/Sources/VSDDaemon/ImageLoader.swift @@ -4,17 +4,21 @@ import ImageIO enum ImageLoader { - // LCD viewport offset — calibrated empirically to center on physical LCD - private static let shiftX: CGFloat = -9 - private static let shiftY: CGFloat = 8 - - /// Load an image file (.icns, .png, .jpg), auto-trim transparent padding, - /// center in 90x90, rotate 90° CW, encode as JPEG. - /// Max source image dimension (pixels). Icons are rendered at 85x85 — - /// anything larger than 1024px is almost certainly not an icon. + /// Load an image file (.icns, .png, .jpg) of any size or aspect ratio, + /// auto-trim transparent padding, scale to fit, center, rotate 90° CW, + /// encode as JPEG. + /// Decode ceiling (pixels). Larger sources are downscaled while decoding + /// rather than rejected; the icon is rendered at 85x85 regardless, so + /// decoding above this is wasted work. private static let maxSourceDimension = 1024 - static func loadAsJPEG(path: String, size: Int = 85, quality: Double = 0.9) -> Data? { + static func loadAsJPEG( + path: String, + size: Int = LCDConfig.defaultSize, + shiftX: CGFloat = CGFloat(LCDConfig.defaultShiftX), + shiftY: CGFloat = CGFloat(LCDConfig.defaultShiftY), + quality: Double = 0.9 + ) -> Data? { let url = URL(fileURLWithPath: path) as CFURL guard let source = CGImageSourceCreateWithURL(url, nil), CGImageSourceGetCount(source) > 0 else { @@ -22,16 +26,18 @@ enum ImageLoader { return nil } - // Check dimensions from metadata before full decode - if let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], - let pw = props[kCGImagePropertyPixelWidth] as? Int, - let ph = props[kCGImagePropertyPixelHeight] as? Int, - pw > maxSourceDimension || ph > maxSourceDimension { - print("Warning: image too large (\(pw)x\(ph), max \(maxSourceDimension)px) at \(path)") - return nil - } - - guard let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { + // Downscale while decoding rather than rejecting oversized sources. + // ImageIO picks the best-matching representation in a multi-size + // .icns and resamples in one step, so a 1024px source costs no more + // than a small one. + let thumbnailOptions: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maxSourceDimension + ] + let decoded = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnailOptions as CFDictionary) + ?? CGImageSourceCreateImageAtIndex(source, 0, nil) + guard let image = decoded else { print("Warning: could not decode image at \(path)") return nil } @@ -64,10 +70,27 @@ enum ImageLoader { ctx.translateBy(x: CGFloat(size) + shiftX, y: shiftY) ctx.rotate(by: CGFloat.pi / 2) - // Center the trimmed icon with a small margin + // Center the trimmed icon with a small margin, preserving its aspect + // ratio. Drawing into a fixed square stretches anything that isn't + // already square, which most artwork other than .icns isn't; the + // unused axis stays black, matching the LCD background. let margin: CGFloat = 4 let drawSize = CGFloat(size) - margin * 2 - let drawRect = CGRect(x: margin, y: margin, width: drawSize, height: drawSize) + let srcWidth = CGFloat(cropped.width) + let srcHeight = CGFloat(cropped.height) + guard srcWidth > 0, srcHeight > 0 else { + print("Warning: image has zero dimension at \(path)") + return nil + } + let fitScale = min(drawSize / srcWidth, drawSize / srcHeight) + let fittedWidth = srcWidth * fitScale + let fittedHeight = srcHeight * fitScale + let drawRect = CGRect( + x: margin + (drawSize - fittedWidth) / 2, + y: margin + (drawSize - fittedHeight) / 2, + width: fittedWidth, + height: fittedHeight + ) ctx.interpolationQuality = .high diff --git a/config.example.json b/config.example.json index 7e607f1..11439b4 100644 --- a/config.example.json +++ b/config.example.json @@ -1,32 +1,79 @@ { "brightness": 50, "buttons": { - "lcd_1": { "action": "shell:open -a 'Google Chrome'", "icon": "/Applications/Google Chrome.app/Contents/Resources/app.icns" }, - "lcd_2": { "action": "shell:open -a 'Docker Desktop'", "icon": "/Applications/Docker.app/Contents/Resources/AppIcon.icns" }, - "lcd_3": { "action": "shell:open -a 'Penkeep'", "icon": "/Applications/Penkeep.app/Contents/Resources/icon.icns" }, - "lcd_4": { "action": "key:cmd+space" }, - "lcd_5": { "action": "none" }, - "lcd_6": { "action": "none" }, - "btn_1": { "action": "none" }, - "btn_2": { "action": "none" }, - "btn_3": { "action": "none" } + "lcd_1": { + "action": "shell:open -a 'Google Chrome'", + "icon": "/Applications/Google Chrome.app/Contents/Resources/app.icns" + }, + "lcd_2": { + "action": "shell:open -a 'Docker Desktop'", + "icon": "/Applications/Docker.app/Contents/Resources/AppIcon.icns" + }, + "lcd_3": { + "action": "shell:open -a 'Penkeep'", + "icon": "/Applications/Penkeep.app/Contents/Resources/icon.icns" + }, + "lcd_4": { + "action": "key:cmd+space" + }, + "lcd_5": { + "action": "none" + }, + "lcd_6": { + "action": "none" + }, + "btn_1": { + "action": "none" + }, + "btn_2": { + "action": "none" + }, + "btn_3": { + "action": "none" + } }, "knobs": { "big": { - "rotate": { "action": "system_volume", "step": 5 }, - "press": { "action": "system_mute_toggle" } + "rotate": { + "action": "system_volume", + "step": 5 + }, + "press": { + "action": "system_mute_toggle" + } }, "small_1": { - "rotate": { "action": "none", "step": 3 }, - "press": { "action": "none" } + "rotate": { + "action": "none", + "step": 3 + }, + "press": { + "action": "none" + } }, "small_2": { - "rotate": { "action": "display_brightness", "step": 5 }, - "press": { "action": "display_brightness_cycle" } + "rotate": { + "action": "display_brightness", + "step": 5 + }, + "press": { + "action": "display_brightness_cycle" + } } }, "displays": [ - { "name": "Left", "serial": "YOUR_AN_SERIAL_1" }, - { "name": "Right", "serial": "YOUR_AN_SERIAL_2" } - ] + { + "name": "Left", + "serial": "YOUR_AN_SERIAL_1" + }, + { + "name": "Right", + "serial": "YOUR_AN_SERIAL_2" + } + ], + "lcd": { + "size": 85, + "shift_x": -9, + "shift_y": 8 + } }