89060d7177
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
125 lines
4.1 KiB
Swift
125 lines
4.1 KiB
Swift
#!/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)
|
||
}
|