import Foundation import AppKit import SDWebImageAVIFCoder import SDWebImageWebPCoder final class ImageGenerator { private let storage: Storage private let settings: Settings private var relativeImageOutputPath: String { settings.paths.imagesOutputFolderPath } private var generatedImages: [String : [String]] = [:] private var jobs: [ImageGenerationJob] = [] init(storage: Storage, settings: Settings) { self.storage = storage self.settings = settings 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) { let version = versionFileName(image: image, type: type, width: maximumWidth, height: maximumHeight) if exists(version) { hasNowGenerated(version: version, for: image) return } if hasPreviouslyGenerated(version: version, for: image), exists(version) { // Don't add job again return } let job = ImageGenerationJob( image: image, version: version, maximumWidth: maximumWidth, maximumHeight: maximumHeight, quality: 0.7, type: type) jobs.append(job) } 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: ImageGenerationJob) -> 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 representation = create(image: originalImage, width: job.maximumWidth, height: job.maximumHeight) guard let data = create(image: representation, type: job.type, quality: job.quality) else { print("Failed to get data for type \(job.type)") return false } if job.type == .avif { return inOutputImagesFolder { folder in let url = folder.appendingPathComponent(job.version) let out = url.path() let input = url.deletingPathExtension().appendingPathExtension(job.image.fileExtension!).path() print("avifenc -q 70 \(input) \(out)") hasNowGenerated(version: job.version, for: job.image) return true } } guard write(imageData: data, version: job.version) else { return false } hasNowGenerated(version: job.version, for: job.image) return true } private func create(image originalImage: NSImage, width: CGFloat, height: CGFloat) -> NSBitmapImageRep { let sourceRep = originalImage.representations[0] let sourceSize = NSSize(width: sourceRep.pixelsWide, height: sourceRep.pixelsHigh) let maximumSize = NSSize(width: width, height: height) let destinationSize = sourceSize.scaledToFit(in: maximumSize) // create NSBitmapRep manually, if using cgImage, the resulting size is wrong let representation = 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: representation) NSGraphicsContext.saveGraphicsState() NSGraphicsContext.current = ctx originalImage.draw(in: NSMakeRect(0, 0, destinationSize.width, destinationSize.height)) ctx?.flushGraphics() NSGraphicsContext.restoreGraphicsState() return representation } private func write(imageData data: Data, version: String) -> Bool { inOutputImagesFolder { folder in let url = folder.appendingPathComponent(version) do { try data.write(to: url) return true } catch { print("Failed to write image \(version): \(error)") return false } } } 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]) } }