development
swift 699 lines 24 KB
Raw
1 #!/usr/bin/swift
2
3 import AppKit
4 import Foundation
5
6 private let bundleIdentifier = "ai.splitfire.klepon"
7 private let catalogName = "AppIconTV.brandassets"
8 private let outputDirectory = URL(
9 fileURLWithPath: CommandLine.arguments.contains("--output-dir")
10 ? CommandLine.arguments[
11 CommandLine.arguments.firstIndex(of: "--output-dir")!.advanced(by: 1)]
12 : defaultOutputDirectoryPath()
13 )
14
15 private let backgroundColor = NSColor(calibratedRed: 0.98, green: 0.96, blue: 0.92, alpha: 1)
16 private let surfaceColor = NSColor(calibratedRed: 0.98, green: 0.95, blue: 0.89, alpha: 1)
17 private let surfaceSecondaryColor = NSColor(calibratedRed: 0.93, green: 0.90, blue: 0.84, alpha: 1)
18 private let accentColor = NSColor(calibratedRed: 0.17, green: 0.48, blue: 0.34, alpha: 1)
19 private let accentLightColor = NSColor(calibratedRed: 0.46, green: 0.78, blue: 0.56, alpha: 1)
20 private let accentDarkColor = NSColor(calibratedRed: 0.12, green: 0.34, blue: 0.23, alpha: 1)
21 private let warmColor = NSColor(calibratedRed: 0.56, green: 0.35, blue: 0.17, alpha: 1)
22 private let warmLightColor = NSColor(calibratedRed: 0.90, green: 0.60, blue: 0.25, alpha: 1)
23 private let textPrimaryColor = NSColor(calibratedRed: 0.17, green: 0.16, blue: 0.13, alpha: 1)
24 private let textSecondaryColor = NSColor(calibratedRed: 0.37, green: 0.35, blue: 0.31, alpha: 1)
25 private let dividerColor = NSColor(calibratedWhite: 0, alpha: 0.08)
26 private let coconutColor = NSColor(calibratedRed: 0.99, green: 0.98, blue: 0.94, alpha: 1)
27 private let coconutShadowColor = NSColor(calibratedRed: 0.88, green: 0.85, blue: 0.76, alpha: 0.72)
28
29 private let coconutFlakes: [(CGFloat, CGFloat, CGFloat, CGFloat, CGFloat)] = [
30 (0.16, 0.34, 0.050, 0.026, -18),
31 (0.19, 0.31, 0.042, 0.024, 12),
32 (0.22, 0.28, 0.052, 0.028, -8),
33 (0.25, 0.25, 0.044, 0.025, 9),
34 (0.28, 0.22, 0.055, 0.028, -12),
35 (0.32, 0.19, 0.048, 0.026, 14),
36 (0.36, 0.16, 0.056, 0.030, -10),
37 (0.41, 0.13, 0.046, 0.026, 6),
38 (0.46, 0.11, 0.054, 0.029, -14),
39 (0.51, 0.10, 0.043, 0.024, 11),
40 (0.56, 0.11, 0.051, 0.028, -9),
41 (0.61, 0.13, 0.046, 0.025, 13),
42 (0.66, 0.17, 0.056, 0.030, -11),
43 (0.70, 0.20, 0.050, 0.027, 8),
44 (0.74, 0.24, 0.047, 0.026, -7),
45 (0.77, 0.28, 0.055, 0.029, 12),
46 (0.80, 0.32, 0.048, 0.026, -9),
47 (0.24, 0.37, 0.032, 0.020, -6),
48 (0.29, 0.34, 0.034, 0.021, 13),
49 (0.35, 0.30, 0.033, 0.020, -10),
50 (0.40, 0.26, 0.035, 0.022, 5),
51 (0.47, 0.23, 0.032, 0.020, -15),
52 (0.54, 0.22, 0.034, 0.021, 7),
53 (0.60, 0.24, 0.033, 0.020, -8),
54 (0.67, 0.28, 0.035, 0.022, 14),
55 (0.72, 0.33, 0.032, 0.020, -11),
56 ]
57
58 private let smallIconCanvas = CGSize(width: 400, height: 240)
59 private let appStoreCanvas = CGSize(width: 1280, height: 768)
60 private let topShelfCanvas = CGSize(width: 1920, height: 720)
61 private let topShelfWideCanvas = CGSize(width: 2320, height: 720)
62
63 do {
64 try regenerateCatalog()
65 } catch {
66 fputs("tvOS brand asset generation failed: \(error)\n", stderr)
67 exit(1)
68 }
69
70 private func regenerateCatalog() throws {
71 if FileManager.default.fileExists(atPath: outputDirectory.path) {
72 try FileManager.default.removeItem(at: outputDirectory)
73 }
74
75 try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
76
77 try writeRootContents()
78 try writePrimaryIconStack(
79 name: "App Icon - tvOS", canvas: smallIconCanvas, scales: [1, 2], includeFrameSize: true)
80 try writePrimaryIconStack(
81 name: "App Icon - tvOS - App Store", canvas: appStoreCanvas, scales: [1],
82 includeFrameSize: false)
83 try writeTopShelfImageSet(
84 name: "Top Shelf Image", canvas: topShelfCanvas, baseFileName: "TopShelfImage")
85 try writeTopShelfImageSet(
86 name: "Top Shelf Image Wide", canvas: topShelfWideCanvas, baseFileName: "TopShelfImageWide")
87 }
88
89 private func defaultOutputDirectoryPath() -> String {
90 let cwd = FileManager.default.currentDirectoryPath
91 return URL(fileURLWithPath: cwd)
92 .appendingPathComponent("Klepon/Assets.xcassets/\(catalogName)")
93 .path
94 }
95
96 private func writeRootContents() throws {
97 try writeJSON(
98 [
99 "assets": [
100 [
101 "idiom": "tv",
102 "filename": "App Icon - tvOS - App Store.imagestack",
103 "role": "primary-app-icon",
104 "size": "1280x768",
105 ],
106 [
107 "idiom": "tv",
108 "filename": "App Icon - tvOS.imagestack",
109 "role": "primary-app-icon",
110 "size": "400x240",
111 ],
112 [
113 "idiom": "tv",
114 "filename": "Top Shelf Image.imageset",
115 "role": "top-shelf-image",
116 "size": "1920x720",
117 ],
118 [
119 "idiom": "tv",
120 "filename": "Top Shelf Image Wide.imageset",
121 "role": "top-shelf-image-wide",
122 "size": "2320x720",
123 ],
124 ],
125 "info": [
126 "author": bundleIdentifier,
127 "version": 1,
128 ],
129 ],
130 to: outputDirectory.appendingPathComponent("Contents.json")
131 )
132 }
133
134 private func writePrimaryIconStack(
135 name: String, canvas: CGSize, scales: [Int], includeFrameSize: Bool
136 ) throws {
137 let stackFolder = outputDirectory.appendingPathComponent("\(name).imagestack")
138 try FileManager.default.createDirectory(at: stackFolder, withIntermediateDirectories: true)
139
140 try writeJSON(
141 [
142 "info": [
143 "author": bundleIdentifier,
144 "version": 1,
145 ],
146 "layers": [
147 ["filename": "Front.imagestacklayer"],
148 ["filename": "Back.imagestacklayer"],
149 ],
150 ],
151 to: stackFolder.appendingPathComponent("Contents.json")
152 )
153
154 try writeLayer(
155 folder: stackFolder.appendingPathComponent("Front.imagestacklayer"),
156 imageSetName: "Content",
157 imageBaseName: "Front",
158 canvas: canvas,
159 scales: scales,
160 includeFrameSize: includeFrameSize,
161 render: { try renderIconFront(canvas: $0) }
162 )
163
164 try writeLayer(
165 folder: stackFolder.appendingPathComponent("Back.imagestacklayer"),
166 imageSetName: "Content",
167 imageBaseName: "Back",
168 canvas: canvas,
169 scales: scales,
170 includeFrameSize: includeFrameSize,
171 render: { try renderIconBack(canvas: $0) }
172 )
173 }
174
175 private func writeLayer(
176 folder: URL,
177 imageSetName: String,
178 imageBaseName: String,
179 canvas: CGSize,
180 scales: [Int],
181 includeFrameSize: Bool,
182 render: (CGSize) throws -> Data
183 ) throws {
184 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
185
186 var object: [String: Any] = [
187 "info": [
188 "author": bundleIdentifier,
189 "version": 1,
190 ]
191 ]
192
193 if includeFrameSize {
194 object["properties"] = [
195 "frame-size": [
196 "width": Int(canvas.width),
197 "height": Int(canvas.height),
198 ]
199 ]
200 }
201
202 try writeJSON(object, to: folder.appendingPathComponent("Contents.json"))
203
204 try writeImageSet(
205 folder: folder.appendingPathComponent("\(imageSetName).imageset"),
206 baseName: imageBaseName,
207 canvas: canvas,
208 scales: scales,
209 render: render
210 )
211 }
212
213 private func writeTopShelfImageSet(name: String, canvas: CGSize, baseFileName: String) throws {
214 try writeImageSet(
215 folder: outputDirectory.appendingPathComponent("\(name).imageset"),
216 baseName: baseFileName,
217 canvas: canvas,
218 scales: [1, 2],
219 render: { try renderTopShelf(canvas: $0, isWide: name.contains("Wide")) }
220 )
221 }
222
223 private func writeImageSet(
224 folder: URL,
225 baseName: String,
226 canvas: CGSize,
227 scales: [Int],
228 render: (CGSize) throws -> Data
229 ) throws {
230 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
231
232 var imageEntries: [[String: String]] = []
233
234 for scale in scales {
235 let scaledCanvas = CGSize(
236 width: canvas.width * CGFloat(scale), height: canvas.height * CGFloat(scale))
237 let fileName = scale == 1 ? "\(baseName).png" : "\(baseName)@\(scale)x.png"
238 let png = try render(scaledCanvas)
239 try png.write(to: folder.appendingPathComponent(fileName), options: .atomic)
240
241 var entry: [String: String] = [
242 "filename": fileName,
243 "idiom": "tv",
244 ]
245
246 if scales.count > 1 {
247 entry["scale"] = "\(scale)x"
248 }
249
250 imageEntries.append(entry)
251 }
252
253 try writeJSON(
254 [
255 "images": imageEntries,
256 "info": [
257 "author": bundleIdentifier,
258 "version": 1,
259 ],
260 ],
261 to: folder.appendingPathComponent("Contents.json")
262 )
263 }
264
265 private func renderIconBack(canvas: CGSize) throws -> Data {
266 try renderPNG(canvas: canvas, opaque: true) { rect in
267 drawWarmBackdrop(in: rect)
268
269 let cardRect = CGRect(
270 x: rect.minX + rect.width * 0.08,
271 y: rect.minY + rect.height * 0.08,
272 width: rect.width * 0.84,
273 height: rect.height * 0.84
274 )
275 drawCard(in: cardRect, canvas: canvas)
276
277 let haloRect = CGRect(
278 x: rect.midX - rect.height * 0.23,
279 y: rect.minY + rect.height * 0.24,
280 width: rect.height * 0.46,
281 height: rect.height * 0.46
282 )
283 let halo = NSBezierPath(ovalIn: haloRect)
284 NSColor(calibratedRed: 0.46, green: 0.78, blue: 0.56, alpha: 0.12).setFill()
285 halo.fill()
286 }
287 }
288
289 private func renderIconFront(canvas: CGSize) throws -> Data {
290 try renderPNG(canvas: canvas) { rect in
291 let orbSize = min(rect.height * 0.70, rect.width * 0.54)
292 let orbRect = CGRect(
293 x: rect.midX - orbSize / 2,
294 y: rect.minY + rect.height * 0.18,
295 width: orbSize,
296 height: orbSize
297 )
298 drawKlepon(in: orbRect, canvas: canvas, shadowAlpha: 0.34)
299 }
300 }
301
302 private func renderTopShelf(canvas: CGSize, isWide: Bool) throws -> Data {
303 try renderPNG(canvas: canvas, opaque: true) { rect in
304 drawWarmBackdrop(in: rect)
305
306 let wave = NSBezierPath()
307 wave.move(to: CGPoint(x: rect.minX, y: rect.minY + rect.height * 0.14))
308 wave.curve(
309 to: CGPoint(x: rect.maxX, y: rect.minY + rect.height * 0.22),
310 controlPoint1: CGPoint(
311 x: rect.minX + rect.width * 0.22, y: rect.minY + rect.height * 0.04),
312 controlPoint2: CGPoint(
313 x: rect.minX + rect.width * 0.68, y: rect.minY + rect.height * 0.30)
314 )
315 wave.line(to: CGPoint(x: rect.maxX, y: rect.minY))
316 wave.line(to: CGPoint(x: rect.minX, y: rect.minY))
317 wave.close()
318 NSColor(calibratedRed: 1, green: 1, blue: 1, alpha: 0.24).setFill()
319 wave.fill()
320
321 let orbGlow = NSBezierPath(
322 ovalIn: CGRect(
323 x: rect.maxX - rect.height * 0.78,
324 y: rect.midY - rect.height * 0.34,
325 width: rect.height * 0.92,
326 height: rect.height * 0.92
327 ))
328 NSColor(calibratedRed: 0.46, green: 0.78, blue: 0.56, alpha: 0.10).setFill()
329 orbGlow.fill()
330
331 let titleWidth = isWide ? rect.width * 0.30 : rect.width * 0.34
332 drawText(
333 "Taste of Indonesia",
334 in: CGRect(
335 x: rect.width * 0.10, y: rect.height * 0.62, width: titleWidth,
336 height: rect.height * 0.10),
337 font: font(name: "Avenir Next Demi Bold", size: rect.height * 0.09)
338 ?? NSFont.systemFont(ofSize: rect.height * 0.09, weight: .semibold),
339 color: textSecondaryColor
340 )
341
342 drawText(
343 "Klepon",
344 in: CGRect(
345 x: rect.width * 0.10, y: rect.height * 0.38, width: rect.width * 0.30,
346 height: rect.height * 0.18),
347 font: font(name: "Avenir Next Bold", size: rect.height * 0.20)
348 ?? NSFont.boldSystemFont(ofSize: rect.height * 0.20),
349 color: textPrimaryColor
350 )
351
352 drawText(
353 "Dishes Ingredients Traditions",
354 in: CGRect(
355 x: rect.width * 0.10, y: rect.height * 0.26, width: rect.width * 0.46,
356 height: rect.height * 0.08),
357 font: font(name: "Avenir Next Regular", size: rect.height * 0.075)
358 ?? NSFont.systemFont(ofSize: rect.height * 0.075),
359 color: textSecondaryColor
360 )
361
362 let cardWidth = isWide ? rect.width * 0.18 : rect.width * 0.23
363 let cardRect = CGRect(
364 x: rect.maxX - cardWidth - rect.width * 0.16,
365 y: rect.midY - rect.height * 0.28,
366 width: cardWidth,
367 height: rect.height * 0.56
368 )
369 drawCard(in: cardRect, canvas: canvas)
370
371 let secondaryCard = NSBezierPath(
372 roundedRect: CGRect(
373 x: cardRect.minX - cardRect.width * 0.18,
374 y: cardRect.minY + cardRect.height * 0.18,
375 width: cardRect.width * 0.34,
376 height: cardRect.height * 0.48
377 ), xRadius: cardRect.width * 0.11, yRadius: cardRect.width * 0.11)
378 NSColor(calibratedWhite: 1, alpha: 0.28).setFill()
379 secondaryCard.fill()
380
381 let tertiaryCard = NSBezierPath(
382 roundedRect: CGRect(
383 x: cardRect.maxX - cardRect.width * 0.02,
384 y: cardRect.minY + cardRect.height * 0.08,
385 width: cardRect.width * 0.26,
386 height: cardRect.height * 0.36
387 ), xRadius: cardRect.width * 0.09, yRadius: cardRect.width * 0.09)
388 NSColor(calibratedWhite: 1, alpha: 0.18).setFill()
389 tertiaryCard.fill()
390
391 let orbSize = min(cardRect.width * 0.68, cardRect.height * 0.68)
392 let orbRect = CGRect(
393 x: cardRect.midX - orbSize / 2,
394 y: cardRect.minY + cardRect.height * 0.24,
395 width: orbSize,
396 height: orbSize
397 )
398 drawKlepon(in: orbRect, canvas: canvas, shadowAlpha: 0.28)
399 }
400 }
401
402 private func drawWarmBackdrop(in rect: CGRect) {
403 let background = NSGradient(colors: [
404 NSColor(calibratedRed: 0.99, green: 0.97, blue: 0.94, alpha: 1),
405 backgroundColor,
406 NSColor(calibratedRed: 0.95, green: 0.91, blue: 0.84, alpha: 1),
407 ])!
408 background.draw(in: rect, angle: 0)
409
410 let topGlow = NSBezierPath(
411 ovalIn: CGRect(
412 x: rect.minX - rect.width * 0.08,
413 y: rect.maxY - rect.height * 0.46,
414 width: rect.width * 0.52,
415 height: rect.height * 0.52
416 ))
417 NSColor(calibratedWhite: 1, alpha: 0.14).setFill()
418 topGlow.fill()
419
420 let rightGlow = NSBezierPath(
421 ovalIn: CGRect(
422 x: rect.maxX - rect.width * 0.26,
423 y: rect.minY + rect.height * 0.18,
424 width: rect.width * 0.24,
425 height: rect.height * 0.44
426 ))
427 NSColor(calibratedRed: 0.90, green: 0.76, blue: 0.42, alpha: 0.10).setFill()
428 rightGlow.fill()
429 }
430
431 private func drawCard(in rect: CGRect, canvas: CGSize) {
432 let radius = min(rect.width, rect.height) * 0.18
433 let card = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius)
434 let cardGradient = NSGradient(colors: [
435 NSColor(calibratedRed: 0.99, green: 0.97, blue: 0.93, alpha: 1),
436 surfaceColor,
437 surfaceSecondaryColor,
438 ])!
439 cardGradient.draw(in: card, angle: 90)
440
441 dividerColor.setStroke()
442 card.lineWidth = max(1, canvas.width * 0.0016)
443 card.stroke()
444
445 let innerGlow = NSBezierPath(
446 roundedRect: rect.insetBy(dx: rect.width * 0.02, dy: rect.height * 0.02),
447 xRadius: radius * 0.92, yRadius: radius * 0.92)
448 NSColor(calibratedWhite: 1, alpha: 0.22).setStroke()
449 innerGlow.lineWidth = max(1.5, canvas.width * 0.0022)
450 innerGlow.stroke()
451
452 NSGraphicsContext.saveGraphicsState()
453 card.addClip()
454 let cornerGlow = NSGradient(colors: [
455 NSColor(calibratedWhite: 1, alpha: 0.24),
456 NSColor(calibratedWhite: 1, alpha: 0),
457 ])!
458 cornerGlow.draw(
459 fromCenter: CGPoint(x: rect.minX + rect.width * 0.24, y: rect.maxY - rect.height * 0.18),
460 radius: rect.width * 0.58,
461 toCenter: CGPoint(x: rect.minX + rect.width * 0.24, y: rect.maxY - rect.height * 0.18),
462 radius: 0,
463 options: []
464 )
465 NSGraphicsContext.restoreGraphicsState()
466 }
467
468 private func drawKlepon(in orbRect: CGRect, canvas: CGSize, shadowAlpha: CGFloat) {
469 let shadowRect = CGRect(
470 x: orbRect.minX + orbRect.width * 0.06,
471 y: orbRect.minY - orbRect.height * 0.12,
472 width: orbRect.width * 0.88,
473 height: orbRect.height * 0.26
474 )
475 let shadow = NSBezierPath(ovalIn: shadowRect)
476 NSColor(calibratedRed: 0.37, green: 0.24, blue: 0.10, alpha: shadowAlpha).setFill()
477 shadow.fill()
478
479 let orb = NSBezierPath(ovalIn: orbRect)
480 let orbGradient = NSGradient(colors: [
481 accentLightColor, accentColor,
482 NSColor(calibratedRed: 0.28, green: 0.58, blue: 0.40, alpha: 1),
483 ])!
484 orbGradient.draw(in: orb, angle: 18)
485
486 let shade = NSBezierPath(
487 ovalIn: CGRect(
488 x: orbRect.minX + orbRect.width * 0.06,
489 y: orbRect.minY + orbRect.height * 0.10,
490 width: orbRect.width * 0.74,
491 height: orbRect.height * 0.70
492 ))
493 let shadeGradient = NSGradient(colors: [
494 NSColor(calibratedRed: 0.06, green: 0.18, blue: 0.12, alpha: 0.26),
495 NSColor(calibratedRed: 0.06, green: 0.18, blue: 0.12, alpha: 0),
496 ])!
497 shadeGradient.draw(in: shade, relativeCenterPosition: NSPoint(x: -0.3, y: -0.1))
498
499 let highlight = NSBezierPath(
500 ovalIn: CGRect(
501 x: orbRect.minX + orbRect.width * 0.16,
502 y: orbRect.minY + orbRect.height * 0.58,
503 width: orbRect.width * 0.30,
504 height: orbRect.height * 0.18
505 ))
506 let highlightGradient = NSGradient(colors: [
507 NSColor(calibratedWhite: 1, alpha: 0.28),
508 NSColor(calibratedWhite: 1, alpha: 0),
509 ])!
510 highlightGradient.draw(in: highlight, angle: 0)
511
512 drawLeaves(around: orbRect, canvas: canvas)
513 drawPalmSugar(in: orbRect, canvas: canvas)
514 drawCoconutFlakes(in: orbRect, canvas: canvas)
515 }
516
517 private func drawLeaves(around orbRect: CGRect, canvas: CGSize) {
518 let darkLeafRect = CGRect(
519 x: orbRect.maxX - orbRect.width * 0.28,
520 y: orbRect.maxY - orbRect.height * 0.18,
521 width: orbRect.width * 0.22,
522 height: orbRect.height * 0.22
523 )
524 let darkLeaf = NSBezierPath(ovalIn: darkLeafRect)
525 accentDarkColor.setFill()
526 darkLeaf.fill()
527
528 let lightLeafRect = CGRect(
529 x: orbRect.maxX - orbRect.width * 0.34,
530 y: orbRect.maxY - orbRect.height * 0.23,
531 width: orbRect.width * 0.18,
532 height: orbRect.height * 0.18
533 )
534 let lightLeaf = NSBezierPath(ovalIn: lightLeafRect)
535 NSColor(calibratedRed: 0.30, green: 0.62, blue: 0.39, alpha: 1).setFill()
536 lightLeaf.fill()
537
538 let stem = NSBezierPath()
539 stem.move(
540 to: CGPoint(x: orbRect.maxX - orbRect.width * 0.16, y: orbRect.maxY - orbRect.height * 0.08)
541 )
542 stem.line(
543 to: CGPoint(x: orbRect.maxX - orbRect.width * 0.16, y: orbRect.maxY - orbRect.height * 0.34)
544 )
545 stem.lineCapStyle = .round
546 stem.lineWidth = max(2.2, canvas.width * 0.006)
547 NSColor(calibratedRed: 0.23, green: 0.56, blue: 0.33, alpha: 1).setStroke()
548 stem.stroke()
549 }
550
551 private func drawPalmSugar(in orbRect: CGRect, canvas: CGSize) {
552 let sugarSize = orbRect.width * 0.24
553 let sugarRect = CGRect(
554 x: orbRect.midX - sugarSize / 2,
555 y: orbRect.midY - sugarSize / 2 + orbRect.height * 0.02,
556 width: sugarSize,
557 height: sugarSize
558 )
559 let sugarGlow = NSBezierPath(
560 ovalIn: sugarRect.insetBy(dx: -sugarSize * 0.16, dy: -sugarSize * 0.16))
561 NSColor(calibratedRed: 0.24, green: 0.14, blue: 0.06, alpha: 0.28).setFill()
562 sugarGlow.fill()
563
564 let sugar = NSBezierPath(ovalIn: sugarRect)
565 let sugarGradient = NSGradient(colors: [
566 warmLightColor, NSColor(calibratedRed: 0.83, green: 0.47, blue: 0.16, alpha: 1), warmColor,
567 ])!
568 sugarGradient.draw(in: sugar, relativeCenterPosition: NSPoint(x: 0.18, y: -0.18))
569
570 NSColor(calibratedWhite: 1, alpha: 0.16).setStroke()
571 sugar.lineWidth = max(1, canvas.width * 0.0022)
572 sugar.stroke()
573 }
574
575 private func drawCoconutFlakes(in orbRect: CGRect, canvas: CGSize) {
576 for flake in coconutFlakes {
577 let flakeRect = CGRect(
578 x: orbRect.minX + orbRect.width * flake.0,
579 y: orbRect.minY + orbRect.height * flake.1,
580 width: orbRect.width * flake.2,
581 height: orbRect.height * flake.3
582 )
583
584 let shadowRect = flakeRect.offsetBy(dx: orbRect.width * 0.008, dy: -orbRect.height * 0.008)
585 let shadow = NSBezierPath(ovalIn: shadowRect)
586 coconutShadowColor.setFill()
587 shadow.fill()
588
589 NSGraphicsContext.saveGraphicsState()
590 if let cgContext = NSGraphicsContext.current?.cgContext {
591 cgContext.translateBy(x: flakeRect.midX, y: flakeRect.midY)
592 cgContext.rotate(by: flake.4 * .pi / 180)
593 cgContext.translateBy(x: -flakeRect.midX, y: -flakeRect.midY)
594 }
595
596 let flakePath = NSBezierPath(ovalIn: flakeRect)
597 coconutColor.setFill()
598 flakePath.fill()
599 NSColor(calibratedWhite: 1, alpha: 0.26).setStroke()
600 flakePath.lineWidth = max(0.4, canvas.width * 0.0009)
601 flakePath.stroke()
602 NSGraphicsContext.restoreGraphicsState()
603 }
604 }
605
606 private func renderPNG(canvas: CGSize, opaque: Bool = false, draw: (CGRect) throws -> Void) throws
607 -> Data
608 {
609 let pixelsWide = Int(canvas.width)
610 let pixelsHigh = Int(canvas.height)
611
612 guard
613 let rep = NSBitmapImageRep(
614 bitmapDataPlanes: nil,
615 pixelsWide: pixelsWide,
616 pixelsHigh: pixelsHigh,
617 bitsPerSample: 8,
618 samplesPerPixel: 4,
619 hasAlpha: true,
620 isPlanar: false,
621 colorSpaceName: .deviceRGB,
622 bitmapFormat: [],
623 bytesPerRow: 0,
624 bitsPerPixel: 0
625 )
626 else {
627 throw NSError(
628 domain: "BrandAssetGenerator",
629 code: 1,
630 userInfo: [
631 NSLocalizedDescriptionKey:
632 "Unable to allocate bitmap for \(pixelsWide)x\(pixelsHigh)"
633 ]
634 )
635 }
636
637 rep.size = canvas
638
639 guard let context = NSGraphicsContext(bitmapImageRep: rep) else {
640 throw NSError(
641 domain: "BrandAssetGenerator",
642 code: 2,
643 userInfo: [NSLocalizedDescriptionKey: "Unable to create bitmap graphics context"]
644 )
645 }
646
647 NSGraphicsContext.saveGraphicsState()
648 NSGraphicsContext.current = context
649 context.shouldAntialias = true
650 context.imageInterpolation = .high
651 context.cgContext.setAllowsAntialiasing(true)
652 context.cgContext.setShouldAntialias(true)
653
654 if opaque {
655 backgroundColor.setFill()
656 CGRect(origin: .zero, size: canvas).fill()
657 }
658
659 try draw(CGRect(origin: .zero, size: canvas))
660 context.flushGraphics()
661 NSGraphicsContext.restoreGraphicsState()
662
663 guard let png = rep.representation(using: .png, properties: [:]) else {
664 throw NSError(
665 domain: "BrandAssetGenerator",
666 code: 3,
667 userInfo: [NSLocalizedDescriptionKey: "Failed to encode PNG"]
668 )
669 }
670
671 return png
672 }
673
674 private func writeJSON(_ object: [String: Any], to url: URL) throws {
675 let data = try JSONSerialization.data(
676 withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
677 try data.write(to: url, options: .atomic)
678 }
679
680 private func drawText(_ string: String, in rect: CGRect, font: NSFont, color: NSColor) {
681 let paragraph = NSMutableParagraphStyle()
682 paragraph.alignment = .left
683 paragraph.lineBreakMode = .byClipping
684
685 let attributed = NSAttributedString(
686 string: string,
687 attributes: [
688 .font: font,
689 .foregroundColor: color,
690 .paragraphStyle: paragraph,
691 ]
692 )
693
694 attributed.draw(in: rect)
695 }
696
697 private func font(name: String, size: CGFloat) -> NSFont? {
698 NSFont(name: name, size: size)
699 }