main
swift 266 lines 9.25 KB
Raw
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 }