import Foundation import AppKit import SDWebImageAVIFCoder import SDWebImageWebPCoder private struct ImageJob { let image: String let version: String let maximumWidth: CGFloat let maximumHeight: CGFloat let quality: CGFloat let type: ImageFileType } final class ImageGenerator { private let storage: Storage //private let inputImageFolder: URL private let relativeImageOutputPath: String private var generatedImages: [String : [String]] = [:] private var jobs: [ImageJob] = [] init(storage: Storage, relativeImageOutputPath: String) { self.storage = storage self.relativeImageOutputPath = relativeImageOutputPath do { self.generatedImages = try storage.loadListOfGeneratedImages() } catch { print("Failed to load list of previously generated images: \(error)") self.generatedImages = [:] } } func prepareForGeneration() -> Bool { inOutputImagesFolder { imagesFolder in do { try imagesFolder.ensureFolderExistence() return true } catch { print("Failed to create output images folder: \(error)") return false } } } func runJobs(callback: (String) -> Void) -> Bool { guard !jobs.isEmpty else { return true } print("Generating \(jobs.count) images...") for job in jobs { callback("Generating image \(job.version)") guard generate(job: job) else { return false } } return true } func save() -> Bool { do { try storage.save(listOfGeneratedImages: generatedImages) return true } catch { print("Failed to save list of generated images: \(error)") return false } } private func versionFileName(image: String, type: ImageFileType, width: CGFloat, height: CGFloat) -> String { let fileName = image.fileNameAndExtension.fileName let prefix = "\(fileName)@\(Int(width))x\(Int(height))" return "\(prefix).\(type.fileExtension)" } func generateImageSet(for image: String, maxWidth: CGFloat, maxHeight: CGFloat) { let type = ImageFileType(fileExtension: image.fileExtension!)! let width2x = maxWidth * 2 let height2x = maxHeight * 2 _ = generateVersion(for: image, type: .avif, maximumWidth: maxWidth, maximumHeight: maxHeight) _ = generateVersion(for: image, type: .avif, maximumWidth: width2x, maximumHeight: height2x) _ = generateVersion(for: image, type: .webp, maximumWidth: maxWidth, maximumHeight: maxHeight) _ = generateVersion(for: image, type: .webp, maximumWidth: width2x, maximumHeight: height2x) _ = generateVersion(for: image, type: type, maximumWidth: maxWidth, maximumHeight: maxHeight) _ = generateVersion(for: image, type: type, maximumWidth: width2x, maximumHeight: height2x) } func generateVersion(for image: String, type: ImageFileType, maximumWidth: CGFloat, maximumHeight: CGFloat) -> String { let version = versionFileName(image: image, type: type, width: maximumWidth, height: maximumHeight) let fullPath = "/" + relativeImageOutputPath + "/" + version if exists(version) { hasNowGenerated(version: version, for: image) return fullPath } if hasPreviouslyGenerated(version: version, for: image), exists(version) { // Don't add job again return fullPath } let job = ImageJob( image: image, version: version, maximumWidth: maximumWidth, maximumHeight: maximumHeight, quality: 0.7, type: type) jobs.append(job) return fullPath } private func hasPreviouslyGenerated(version: String, for image: String) -> Bool { guard let versions = generatedImages[image] else { return false } return versions.contains(version) } private func exists(imageVersion version: String) -> Bool { inOutputImagesFolder { $0.appendingPathComponent(version).exists } } private func hasNowGenerated(version: String, for image: String) { guard var versions = generatedImages[image] else { generatedImages[image] = [version] return } versions.append(version) generatedImages[image] = versions } private func removeVersions(for image: String) { generatedImages[image] = nil } // MARK: Image operations private func generate(job: ImageJob) -> Bool { if hasPreviouslyGenerated(version: job.version, for: job.image), exists(job.version), exists(imageVersion: job.version) { return true } let data: Data do { data = try storage.fileData(for: job.image) } catch { print("Failed to load image \(job.image): \(error)") return false } guard let originalImage = NSImage(data: data) else { print("Failed to load image") return false } let sourceRep = originalImage.representations[0] let sourceSize = NSSize(width: sourceRep.pixelsWide, height: sourceRep.pixelsHigh) let maximumSize = NSSize(width: job.maximumWidth, height: job.maximumHeight) let destinationSize = sourceSize.scaledToFit(in: maximumSize) // create NSBitmapRep manually, if using cgImage, the resulting size is wrong let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: Int(destinationSize.width), pixelsHigh: Int(destinationSize.height), bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: NSColorSpaceName.deviceRGB, bytesPerRow: Int(destinationSize.width) * 4, bitsPerPixel: 32)! let ctx = NSGraphicsContext(bitmapImageRep: rep) NSGraphicsContext.saveGraphicsState() NSGraphicsContext.current = ctx originalImage.draw(in: NSMakeRect(0, 0, destinationSize.width, destinationSize.height)) ctx?.flushGraphics() NSGraphicsContext.restoreGraphicsState() guard let data = create(image: rep, type: job.type, quality: job.quality) else { print("Failed to get data for type \(job.type)") return false } let result = inOutputImagesFolder { folder in let url = folder.appendingPathComponent(job.version) if job.type == .avif { let out = url.path() let input = url.deletingPathExtension().appendingPathExtension(job.image.fileExtension!).path() print("avifenc -q 70 \(input) \(out)") return true } do { try data.write(to: url) return true } catch { print("Failed to write image \(job.version): \(error)") return false } } guard result else { return false } hasNowGenerated(version: job.version, for: job.image) return true } private func exists(_ relativePath: String) -> Bool { inOutputImagesFolder { folder in folder.appendingPathComponent(relativePath).exists } } private func inOutputImagesFolder(perform operation: (URL) -> Bool) -> Bool { storage.write(in: .outputPath) { outputFolder in let imagesFolder = outputFolder.appendingPathComponent(relativeImageOutputPath) return operation(imagesFolder) } } // MARK: Avif images private func create(image: NSBitmapImageRep, type: ImageFileType, quality: CGFloat) -> Data? { switch type { case .jpg: return image.representation(using: .jpeg, properties: [.compressionFactor: NSNumber(value: 0.6)]) case .png: return image.representation(using: .png, properties: [.compressionFactor: NSNumber(value: 0.6)]) case .avif: return createAvif(image: image, quality: 0.7) case .webp: return createWebp(image: image, quality: 0.8) case .gif: return image.representation(using: .gif, properties: [.compressionFactor: NSNumber(value: quality)]) case .svg: return nil case .tiff: return nil } } private func createAvif(image: NSBitmapImageRep, quality: CGFloat) -> Data? { return Data() let newImage = NSImage(size: image.size) newImage.addRepresentation(image) return SDImageAVIFCoder.shared.encodedData(with: newImage, format: .AVIF, options: [.encodeCompressionQuality: quality]) } private func createWebp(image: NSBitmapImageRep, quality: CGFloat) -> Data? { let newImage = NSImage(size: image.size) newImage.addRepresentation(image) return SDImageWebPCoder.shared.encodedData(with: newImage, format: .webP, options: [.encodeCompressionQuality: quality]) } }