Files
iceglass/Scripts/square_logo.swift
T
rzen 89060d7177 Rebake team logos at 72×72 for crisp notification thumbnails
The notification thumbnail's native display size is ~48 physical pixels
(measured empirically with a size-ladder test attached via the dev menu).
Shipping logos at 512×342 forced macOS to downsample ~10×, which is what
was producing the blurry/aliased team logos in banner thumbnails.

- Pre-render logos at 72×72 (1.5× over native; stays sharp, gives a little
  extra detail for retina displays without triggering aliasing)
- Trim transparent margins before fitting: NHL brand SVGs pad their
  viewBox generously, so the actual logo was only ~60% of the bundled
  image. square_logo.swift now scans the alpha channel, crops to the
  tight bounding box, then fits aspect-preserved into the 72×72 canvas.
- Drop the 32 unused TeamLogo_*.imageset asset-catalog entries (dead code
  since the team-filter feature was removed); notifications load PNGs
  from the filesystem bundle subdir
- Move TeamLogos/ → Resources/TeamLogos/ and update project.yml source
  paths; excludes: on the recursive scan prevents duplicate flat copies
  that were bloating the bundle
- Simplify NotificationManager: drop SVG fallback (macOS doesn't accept
  SVG attachments) and content-hash identifier experiments; back to the
  minimal working config
- Dev menu: add "Thumbnail Size Test" which fires a ladder of 10 test
  notifications (16…128px) for future sizing verification
- Fire a test game-start notification on startup in DEBUG builds so the
  dev loop doesn't require clicking through the menu after each launch
- Scripts/square_logo.swift: alpha-bbox trim + aspect-preserved fit
2026-04-19 20:59:33 -04:00

125 lines
4.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env swift
//
// square_logo.swift
// IceGlass
//
// Usage: square_logo.swift <input.png> <output.png> <target-side-pixels>
//
// Loads an RGBA PNG, trims transparent margins (alpha bounding box),
// fits the trimmed content into a target-side × target-side transparent
// sRGB square canvas (aspect-preserved, centered), and writes PNG.
//
// The trim step matters because NHL SVG logos often ship with significant
// transparent padding inside the viewBox without trimming, the visible
// logo only occupies a fraction of the thumbnail.
//
import CoreGraphics
import Foundation
import ImageIO
import UniformTypeIdentifiers
guard CommandLine.arguments.count == 4,
let targetSize = Int(CommandLine.arguments[3]),
targetSize > 0
else {
FileHandle.standardError.write(
Data("Usage: square_logo.swift <input.png> <output.png> <target-side-pixels>\n".utf8)
)
exit(2)
}
let inputURL = URL(fileURLWithPath: CommandLine.arguments[1])
let outputURL = URL(fileURLWithPath: CommandLine.arguments[2])
guard let source = CGImageSourceCreateWithURL(inputURL as CFURL, nil),
let srcImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
FileHandle.standardError.write(Data("failed to load \(inputURL.path)\n".utf8))
exit(1)
}
// MARK: - Find alpha bounding box
/// Read the source image into a premultiplied RGBA8 buffer so we can scan
/// pixels directly, then find the tightest rectangle enclosing all alpha
/// values above a small threshold.
func alphaBoundingBox(_ image: CGImage, alphaThreshold: UInt8 = 8) -> CGRect? {
let w = image.width
let h = image.height
let bytesPerPixel = 4
let bytesPerRow = w * bytesPerPixel
var buffer = [UInt8](repeating: 0, count: h * bytesPerRow)
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
guard let ctx = CGContext(
data: &buffer, width: w, height: h,
bitsPerComponent: 8, bytesPerRow: bytesPerRow,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return nil }
ctx.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h))
var minX = w, minY = h, maxX = -1, maxY = -1
for y in 0..<h {
for x in 0..<w {
let alpha = buffer[y * bytesPerRow + x * bytesPerPixel + 3]
if alpha > alphaThreshold {
if x < minX { minX = x }
if x > maxX { maxX = x }
if y < minY { minY = y }
if y > maxY { maxY = y }
}
}
}
guard maxX >= minX, maxY >= minY else { return nil }
return CGRect(x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1)
}
let bbox = alphaBoundingBox(srcImage) ?? CGRect(x: 0, y: 0, width: srcImage.width, height: srcImage.height)
// MARK: - Crop to bbox, then fit into target square
guard let cropped = srcImage.cropping(to: bbox) else {
FileHandle.standardError.write(Data("crop failed\n".utf8))
exit(1)
}
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
guard let ctx = CGContext(
data: nil,
width: targetSize,
height: targetSize,
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
FileHandle.standardError.write(Data("CGContext create failed\n".utf8))
exit(1)
}
ctx.interpolationQuality = .high
ctx.clear(CGRect(x: 0, y: 0, width: targetSize, height: targetSize))
let cw = CGFloat(cropped.width)
let ch = CGFloat(cropped.height)
let S = CGFloat(targetSize)
let scale = min(S / cw, S / ch)
let dw = cw * scale
let dh = ch * scale
let drawRect = CGRect(x: (S - dw) / 2, y: (S - dh) / 2, width: dw, height: dh)
ctx.draw(cropped, in: drawRect)
guard let final = ctx.makeImage(),
let dest = CGImageDestinationCreateWithURL(
outputURL as CFURL, UTType.png.identifier as CFString, 1, nil
)
else {
FileHandle.standardError.write(Data("image/dest create failed\n".utf8))
exit(1)
}
CGImageDestinationAddImage(dest, final, nil)
guard CGImageDestinationFinalize(dest) else {
FileHandle.standardError.write(Data("finalize failed\n".utf8))
exit(1)
}