Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions Sources/VSDDaemon/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion Sources/VSDDaemon/HIDDevice.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
65 changes: 44 additions & 21 deletions Sources/VSDDaemon/ImageLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,40 @@ 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 {
print("Warning: could not load image at \(path)")
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
}
Expand Down Expand Up @@ -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

Expand Down
83 changes: 65 additions & 18 deletions config.example.json
Original file line number Diff line number Diff line change
@@ -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
}
}