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
This commit is contained in:
@@ -1,21 +1,32 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# download_team_logos.sh
|
||||
# Downloads NHL team logos (SVG) from the NHL assets CDN.
|
||||
# Downloads NHL team logos from the NHL assets CDN.
|
||||
#
|
||||
# Usage: ./Scripts/download_team_logos.sh [season]
|
||||
# season: e.g. 20252026 (defaults to current season)
|
||||
#
|
||||
# SVGs are saved to Assets.xcassets imagesets for UI use.
|
||||
# PNGs (80x80) are saved to TeamLogos/ for notification attachments.
|
||||
# Downloads SVGs to /tmp, rasterizes to PNGs sized for the macOS notification
|
||||
# thumbnail (72×72 square, aspect-preserved with transparent padding). Output
|
||||
# goes to IceGlass/Resources/TeamLogos/ — used as notification attachments via
|
||||
# Bundle.main.url(forResource:withExtension:subdirectory:"TeamLogos").
|
||||
#
|
||||
# Native notification thumbnail size was measured empirically at ~48 physical
|
||||
# pixels. 72px is a small 1.5x over-sample that stays crisp without any
|
||||
# visible downsample aliasing (tested at 48/72/96/128 — 48–72 are sharp).
|
||||
#
|
||||
# Pipeline: rsvg renders the SVG at a generous intermediate resolution;
|
||||
# square_logo.swift then trims the transparent margins (which NHL brand SVGs
|
||||
# pad the viewBox with) so the logo fills the final square, then scales
|
||||
# aspect-preserved into the 72×72 target.
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
ASSETS_DIR="$PROJECT_DIR/IceGlass/Assets.xcassets"
|
||||
LOGOS_DIR="$PROJECT_DIR/IceGlass/TeamLogos"
|
||||
LOGOS_DIR="$PROJECT_DIR/IceGlass/Resources/TeamLogos"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
|
||||
# Determine season
|
||||
if [ -n "$1" ]; then
|
||||
@@ -30,7 +41,10 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Downloading NHL team logos for season $SEASON"
|
||||
TARGET_SIZE=72
|
||||
INTERMEDIATE_WIDTH=1024 # high-res intermediate so the trim+downscale has detail
|
||||
|
||||
echo "Downloading NHL team logos for season $SEASON (target size ${TARGET_SIZE}×${TARGET_SIZE})"
|
||||
|
||||
TEAMS=(
|
||||
ANA BOS BUF CAR CBJ CGY CHI COL DAL DET
|
||||
@@ -39,57 +53,41 @@ TEAMS=(
|
||||
WPG WSH
|
||||
)
|
||||
|
||||
# Create logos directory for notification attachments
|
||||
mkdir -p "$LOGOS_DIR"
|
||||
|
||||
for TEAM in "${TEAMS[@]}"; do
|
||||
IMAGESET_DIR="$ASSETS_DIR/TeamLogo_${TEAM}.imageset"
|
||||
mkdir -p "$IMAGESET_DIR"
|
||||
|
||||
SVG_URL="https://assets.nhle.com/logos/nhl/svg/${TEAM}_light.svg"
|
||||
SVG_FILE="$IMAGESET_DIR/${TEAM}_light.svg"
|
||||
SVG_FILE="$TMP_DIR/${TEAM}_light.svg"
|
||||
INTERMEDIATE="$TMP_DIR/${TEAM}_native.png"
|
||||
LOGO_PNG="$LOGOS_DIR/${TEAM}.png"
|
||||
|
||||
echo " Downloading $TEAM..."
|
||||
curl -s -o "$SVG_FILE" "${SVG_URL}?season=${SEASON}"
|
||||
|
||||
# Convert SVG to PNG (200px wide, aspect-ratio preserved) for notification attachments
|
||||
# Rasterize SVG at high-res intermediate width (aspect-preserved, transparent)
|
||||
if command -v rsvg-convert &>/dev/null; then
|
||||
rsvg-convert -w 200 -h 200 --keep-aspect-ratio --background-color=transparent "$SVG_FILE" -o "$LOGO_PNG" 2>/dev/null
|
||||
rsvg-convert -w "$INTERMEDIATE_WIDTH" --keep-aspect-ratio --background-color=transparent "$SVG_FILE" -o "$INTERMEDIATE" 2>/dev/null
|
||||
else
|
||||
python3 -c "
|
||||
try:
|
||||
import cairosvg
|
||||
cairosvg.svg2png(url='$SVG_FILE', write_to='$LOGO_PNG', output_width=200)
|
||||
cairosvg.svg2png(url='$SVG_FILE', write_to='$INTERMEDIATE', output_width=$INTERMEDIATE_WIDTH)
|
||||
except ImportError:
|
||||
pass
|
||||
" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Write Contents.json for the imageset (SVG only, no PNG)
|
||||
cat > "$IMAGESET_DIR/Contents.json" << EOJSON
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "${TEAM}_light.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "original"
|
||||
}
|
||||
}
|
||||
EOJSON
|
||||
if [ ! -f "$INTERMEDIATE" ]; then
|
||||
echo " warning: failed to rasterize $TEAM svg"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Composite onto a target-size square transparent canvas (aspect-preserved, centered)
|
||||
swift "$SCRIPT_DIR/square_logo.swift" "$INTERMEDIATE" "$LOGO_PNG" "$TARGET_SIZE"
|
||||
done
|
||||
|
||||
rm -rf "$TMP_DIR"
|
||||
|
||||
echo ""
|
||||
echo "Done! Downloaded ${#TEAMS[@]} team logos to:"
|
||||
echo " Asset catalogs: $ASSETS_DIR/TeamLogo_*.imageset/ (SVG)"
|
||||
echo " Notifications: $LOGOS_DIR/ (PNG 80x80)"
|
||||
echo ""
|
||||
echo "Done! Downloaded ${#TEAMS[@]} team logos to $LOGOS_DIR (${TARGET_SIZE}×${TARGET_SIZE})"
|
||||
echo "Season: $SEASON"
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/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)
|
||||
}
|
||||
Reference in New Issue
Block a user