| 1 | #!/usr/bin/swift |
| 2 | |
| 3 | import AppKit |
| 4 | import Foundation |
| 5 | |
| 6 | struct TopShelfArtwork { |
| 7 | let filename: String |
| 8 | let section: String |
| 9 | let eyebrow: String |
| 10 | let title: String |
| 11 | let summary: String |
| 12 | let symbolName: String |
| 13 | let backgroundLeft: NSColor |
| 14 | let backgroundCenter: NSColor |
| 15 | let backgroundRight: NSColor |
| 16 | let accentColor: NSColor |
| 17 | } |
| 18 | |
| 19 | private let canvasSize = CGSize(width: 3840, height: 1440) |
| 20 | private let titleSafeLeftInset: CGFloat = 880 |
| 21 | private let outputDirectory = URL( |
| 22 | fileURLWithPath: CommandLine.arguments.contains("--output-dir") |
| 23 | ? CommandLine.arguments[ |
| 24 | CommandLine.arguments.firstIndex(of: "--output-dir")!.advanced(by: 1)] |
| 25 | : defaultOutputDirectoryPath() |
| 26 | ) |
| 27 | |
| 28 | private let assets: [TopShelfArtwork] = [ |
| 29 | .init( |
| 30 | filename: "topshelf-klepon.png", |
| 31 | section: "DISCOVER", |
| 32 | eyebrow: "Featured sweet", |
| 33 | title: "Klepon", |
| 34 | summary: |
| 35 | "A chewy rice cake with molten palm sugar and soft coconut that feels instantly comforting.", |
| 36 | symbolName: "circle.hexagongrid.fill", |
| 37 | backgroundLeft: NSColor(calibratedRed: 0.95, green: 0.91, blue: 0.82, alpha: 1), |
| 38 | backgroundCenter: NSColor(calibratedRed: 0.63, green: 0.82, blue: 0.58, alpha: 1), |
| 39 | backgroundRight: NSColor(calibratedRed: 0.14, green: 0.34, blue: 0.24, alpha: 1), |
| 40 | accentColor: NSColor(calibratedRed: 0.89, green: 0.61, blue: 0.27, alpha: 1) |
| 41 | ), |
| 42 | .init( |
| 43 | filename: "topshelf-rendang.png", |
| 44 | section: "DISCOVER", |
| 45 | eyebrow: "Deep and celebratory", |
| 46 | title: "Rendang", |
| 47 | summary: |
| 48 | "Slow-cooked beef, coconut, and spice reduced into one of Indonesia's richest classics.", |
| 49 | symbolName: "flame.fill", |
| 50 | backgroundLeft: NSColor(calibratedRed: 0.51, green: 0.24, blue: 0.14, alpha: 1), |
| 51 | backgroundCenter: NSColor(calibratedRed: 0.71, green: 0.35, blue: 0.20, alpha: 1), |
| 52 | backgroundRight: NSColor(calibratedRed: 0.19, green: 0.10, blue: 0.08, alpha: 1), |
| 53 | accentColor: NSColor(calibratedRed: 0.95, green: 0.74, blue: 0.44, alpha: 1) |
| 54 | ), |
| 55 | .init( |
| 56 | filename: "topshelf-sambal.png", |
| 57 | section: "DISCOVER", |
| 58 | eyebrow: "Pantry essential", |
| 59 | title: "Sambal", |
| 60 | summary: |
| 61 | "Bright, smoky, sweet, or sharp, sambal changes the whole plate with one spoonful.", |
| 62 | symbolName: "leaf.fill", |
| 63 | backgroundLeft: NSColor(calibratedRed: 0.81, green: 0.26, blue: 0.20, alpha: 1), |
| 64 | backgroundCenter: NSColor(calibratedRed: 0.93, green: 0.46, blue: 0.27, alpha: 1), |
| 65 | backgroundRight: NSColor(calibratedRed: 0.24, green: 0.12, blue: 0.08, alpha: 1), |
| 66 | accentColor: NSColor(calibratedRed: 0.99, green: 0.82, blue: 0.53, alpha: 1) |
| 67 | ), |
| 68 | .init( |
| 69 | filename: "topshelf-street-food.png", |
| 70 | section: "DISCOVER", |
| 71 | eyebrow: "Daily food rhythm", |
| 72 | title: "Street food culture", |
| 73 | summary: |
| 74 | "Portable meals, market snacks, and fast comfort that shape how many people first meet Indonesian food.", |
| 75 | symbolName: "sparkles", |
| 76 | backgroundLeft: NSColor(calibratedRed: 0.22, green: 0.42, blue: 0.31, alpha: 1), |
| 77 | backgroundCenter: NSColor(calibratedRed: 0.37, green: 0.60, blue: 0.44, alpha: 1), |
| 78 | backgroundRight: NSColor(calibratedRed: 0.10, green: 0.17, blue: 0.13, alpha: 1), |
| 79 | accentColor: NSColor(calibratedRed: 0.97, green: 0.84, blue: 0.60, alpha: 1) |
| 80 | ), |
| 81 | ] |
| 82 | |
| 83 | do { |
| 84 | try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true) |
| 85 | |
| 86 | for asset in assets { |
| 87 | let image = draw(asset: asset) |
| 88 | let destination = outputDirectory.appendingPathComponent(asset.filename) |
| 89 | guard |
| 90 | let data = image.tiffRepresentation, |
| 91 | let representation = NSBitmapImageRep(data: data), |
| 92 | let png = representation.representation(using: .png, properties: [:]) |
| 93 | else { |
| 94 | throw NSError( |
| 95 | domain: "TopShelfGenerator", |
| 96 | code: 1, |
| 97 | userInfo: [NSLocalizedDescriptionKey: "Failed to encode \(asset.filename)"] |
| 98 | ) |
| 99 | } |
| 100 | |
| 101 | try png.write(to: destination, options: .atomic) |
| 102 | print("wrote \(destination.path)") |
| 103 | } |
| 104 | } catch { |
| 105 | fputs("Top Shelf generation failed: \(error)\n", stderr) |
| 106 | exit(1) |
| 107 | } |
| 108 | |
| 109 | private func defaultOutputDirectoryPath() -> String { |
| 110 | URL(fileURLWithPath: FileManager.default.currentDirectoryPath) |
| 111 | .appendingPathComponent("KleponTVTopShelfExtension") |
| 112 | .path |
| 113 | } |
| 114 | |
| 115 | private func draw(asset: TopShelfArtwork) -> NSImage { |
| 116 | let image = NSImage(size: canvasSize) |
| 117 | image.lockFocusFlipped(true) |
| 118 | defer { image.unlockFocus() } |
| 119 | |
| 120 | guard let context = NSGraphicsContext.current else { |
| 121 | return image |
| 122 | } |
| 123 | |
| 124 | context.shouldAntialias = true |
| 125 | context.imageInterpolation = .high |
| 126 | |
| 127 | drawBackground(for: asset) |
| 128 | drawForeground(for: asset) |
| 129 | |
| 130 | return image |
| 131 | } |
| 132 | |
| 133 | private func drawBackground(for asset: TopShelfArtwork) { |
| 134 | let fullRect = CGRect(origin: .zero, size: canvasSize) |
| 135 | let gradient = NSGradient(colors: [ |
| 136 | asset.backgroundLeft, asset.backgroundCenter, asset.backgroundRight, |
| 137 | ])! |
| 138 | gradient.draw(in: fullRect, angle: 0) |
| 139 | |
| 140 | let topRightCircle = NSBezierPath( |
| 141 | ovalIn: CGRect(x: canvasSize.width - 1120, y: -80, width: 1160, height: 1160) |
| 142 | ) |
| 143 | NSColor(calibratedWhite: 1, alpha: 0.10).setFill() |
| 144 | topRightCircle.fill() |
| 145 | |
| 146 | let rightGlow = NSBezierPath( |
| 147 | ovalIn: CGRect(x: canvasSize.width - 1480, y: 260, width: 1240, height: 840) |
| 148 | ) |
| 149 | asset.accentColor.withAlphaComponent(0.18).setFill() |
| 150 | rightGlow.fill() |
| 151 | |
| 152 | let rightCard = NSBezierPath( |
| 153 | roundedRect: CGRect(x: 2560, y: 240, width: 980, height: 700), xRadius: 82, yRadius: 82) |
| 154 | NSColor(calibratedWhite: 1, alpha: 0.14).setFill() |
| 155 | rightCard.fill() |
| 156 | |
| 157 | let innerCard = NSBezierPath( |
| 158 | roundedRect: CGRect(x: 2680, y: 350, width: 620, height: 470), xRadius: 58, yRadius: 58) |
| 159 | NSColor(calibratedWhite: 1, alpha: 0.08).setFill() |
| 160 | innerCard.fill() |
| 161 | |
| 162 | let glowBar = NSBezierPath( |
| 163 | roundedRect: CGRect(x: 2280, y: 510, width: 270, height: 260), xRadius: 110, yRadius: 110) |
| 164 | NSColor(calibratedWhite: 1, alpha: 0.16).setFill() |
| 165 | glowBar.fill() |
| 166 | } |
| 167 | |
| 168 | private func drawForeground(for asset: TopShelfArtwork) { |
| 169 | let leftInset = titleSafeLeftInset |
| 170 | |
| 171 | drawText( |
| 172 | asset.section, |
| 173 | in: CGRect(x: leftInset, y: 190, width: 420, height: 60), |
| 174 | font: font(name: "Avenir Next Demi Bold", size: 42) ?? font(name: "Arial Bold", size: 42)!, |
| 175 | color: NSColor(calibratedWhite: 1, alpha: 0.82), |
| 176 | alignment: .left, |
| 177 | uppercase: true |
| 178 | ) |
| 179 | |
| 180 | drawText( |
| 181 | asset.eyebrow, |
| 182 | in: CGRect(x: leftInset, y: 350, width: 980, height: 86), |
| 183 | font: font(name: "Avenir Next Demi Bold", size: 60) ?? font(name: "Arial Bold", size: 60)!, |
| 184 | color: NSColor(calibratedWhite: 1, alpha: 0.94), |
| 185 | alignment: .left |
| 186 | ) |
| 187 | |
| 188 | drawText( |
| 189 | asset.title, |
| 190 | in: CGRect(x: leftInset, y: 480, width: 1560, height: 200), |
| 191 | font: font(name: "Avenir Next Bold", size: 124) ?? font(name: "Arial Bold", size: 124)!, |
| 192 | color: .white, |
| 193 | alignment: .left |
| 194 | ) |
| 195 | |
| 196 | drawText( |
| 197 | asset.summary, |
| 198 | in: CGRect(x: leftInset, y: 720, width: 1700, height: 180), |
| 199 | font: font(name: "Avenir Next Regular", size: 54) ?? font(name: "Arial", size: 54)!, |
| 200 | color: NSColor(calibratedWhite: 1, alpha: 0.88), |
| 201 | alignment: .left |
| 202 | ) |
| 203 | |
| 204 | let orbRect = CGRect(x: 2790, y: 365, width: 410, height: 410) |
| 205 | let orb = NSBezierPath(ovalIn: orbRect) |
| 206 | let orbGradient = NSGradient(colors: [ |
| 207 | asset.accentColor.withAlphaComponent(0.92), |
| 208 | asset.accentColor.withAlphaComponent(0.64), |
| 209 | NSColor(calibratedWhite: 1, alpha: 0.18), |
| 210 | ])! |
| 211 | orbGradient.draw(in: orb, angle: 45) |
| 212 | |
| 213 | let orbHighlight = NSBezierPath(ovalIn: CGRect(x: 2870, y: 440, width: 180, height: 120)) |
| 214 | NSColor(calibratedWhite: 1, alpha: 0.18).setFill() |
| 215 | orbHighlight.fill() |
| 216 | |
| 217 | if let symbol = symbolImage(named: asset.symbolName, pointSize: 150, tint: .white) { |
| 218 | symbol.draw(in: CGRect(x: 2915, y: 485, width: 160, height: 160)) |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | private func drawText( |
| 223 | _ string: String, |
| 224 | in rect: CGRect, |
| 225 | font: NSFont, |
| 226 | color: NSColor, |
| 227 | alignment: NSTextAlignment, |
| 228 | uppercase: Bool = false |
| 229 | ) { |
| 230 | let paragraph = NSMutableParagraphStyle() |
| 231 | paragraph.alignment = alignment |
| 232 | paragraph.lineBreakMode = .byWordWrapping |
| 233 | |
| 234 | let attributed = NSAttributedString( |
| 235 | string: uppercase ? string.uppercased() : string, |
| 236 | attributes: [ |
| 237 | .font: font, |
| 238 | .foregroundColor: color, |
| 239 | .paragraphStyle: paragraph, |
| 240 | ] |
| 241 | ) |
| 242 | |
| 243 | attributed.draw(in: rect) |
| 244 | } |
| 245 | |
| 246 | private func font(name: String, size: CGFloat) -> NSFont? { |
| 247 | NSFont(name: name, size: size) |
| 248 | } |
| 249 | |
| 250 | private func symbolImage(named name: String, pointSize: CGFloat, tint: NSColor) -> NSImage? { |
| 251 | let configuration = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .regular) |
| 252 | guard |
| 253 | let image = NSImage(systemSymbolName: name, accessibilityDescription: nil)? |
| 254 | .withSymbolConfiguration(configuration) |
| 255 | else { |
| 256 | return nil |
| 257 | } |
| 258 | |
| 259 | let tinted = image.copy() as? NSImage |
| 260 | tinted?.lockFocus() |
| 261 | tint.set() |
| 262 | let imageRect = CGRect(origin: .zero, size: image.size) |
| 263 | imageRect.fill(using: .sourceAtop) |
| 264 | tinted?.unlockFocus() |
| 265 | return tinted |
| 266 | } |