import Foundation import AppKit import SDWebImageAVIFCoder import SDWebImageWebPCoder final class ImageGenerator { private let storage: Storage private let settings: Settings private var generatedImages: [String : Set] = [:] init(storage: Storage, settings: Settings) { self.storage = storage self.settings = settings self.generatedImages = storage.loadListOfGeneratedImages() ?? [:] } private var outputFolder: String { settings.paths.imagesOutputFolderPath } func save() -> Bool { guard storage.save(listOfGeneratedImages: generatedImages) else { print("Failed to save list of generated images") return false } return true } /** Remove all versions of an image, so that they will be recreated on the next run. This function does not remove the images from the output folder. */ func removeVersions(of image: String) { generatedImages[image] = nil } func recalculateGeneratedImages(by images: Set) { self.generatedImages = storage.calculateImages(generatedBy: images, in: outputFolder) let versionCount = generatedImages.values.reduce(0) { $0 + $1.count } print("Image generator: \(generatedImages.count)/\(images.count) images (\(versionCount) versions)") } private func needsToGenerate(version: String, for image: String) -> Bool { if exists(version) { return false } guard let versions = generatedImages[image] else { return true } guard versions.contains(version) else { return true } return !exists(version) } private func hasNowGenerated(version: String, for image: String) { guard var versions = generatedImages[image] else { generatedImages[image] = [version] return } versions.insert(version) generatedImages[image] = versions } private func removeVersions(for image: String) { generatedImages[image] = nil } // MARK: Files private func exists(_ image: String) -> Bool { storage.hasFileInOutputFolder(relativePath(for: image)) } private func relativePath(for image: String) -> String { outputFolder + "/" + image } private func write(imageData data: Data, version: String) -> Bool { return storage.write(data, to: relativePath(for: version)) } // MARK: Image operations func generate(job: ImageGenerationJob) -> Bool { guard needsToGenerate(version: job.version, for: job.image) else { return true } guard let data = storage.fileData(for: job.image) else { print("Failed to load image \(job.image)") return false } guard let originalImage = NSImage(data: data) else { print("Failed to load image") return false } let representation = create(image: originalImage, width: CGFloat(job.maximumWidth), height: CGFloat(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 { let input = job.version.fileNameAndExtension.fileName + "." + job.image.fileExtension! print("avifenc -q 70 \(input) \(job.version)") 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 } // MARK: Avif images private func create(image: NSBitmapImageRep, type: FileType, 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 default: 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]) } }