import Foundation import AppKit import SDWebImageAVIFCoder import SDWebImageWebPCoder final class ImageGenerator { private let storage: Storage private let settings: Settings init(storage: Storage, settings: Settings) { self.storage = storage self.settings = settings } private var outputFolder: String { settings.paths.imagesOutputFolderPath } private var avifCommands: Set = [] /** Write a file to the output folder containing a script to generate all missing AVIF images. - Note: AVIF images could be generated internally, but the process is very slow. */ func writeAvifCommandScript() { guard !avifCommands.isEmpty else { if storage.hasFileInOutputFolder("generate-images.sh") { storage.deleteInOutputFolder("generate-images.sh") } return } let content = avifCommands.sorted().joined(separator: "\n") storage.write(content, to: "generate-images.sh") } private func needsToGenerate(_ version: ImageVersion) -> Bool { if version.wasPreviouslyGenerated { return false } if exists(version) { // Mark as already generated version.wasNowGenerated() return false } return true } // MARK: Files private func exists(_ version: ImageVersion) -> Bool { storage.hasFileInOutputFolder(version.outputPath) } private func write(imageData data: Data, of version: ImageVersion) -> Bool { return storage.write(data, to: version.outputPath) } // MARK: Image operations func generate(version: ImageVersion) -> Bool { guard needsToGenerate(version) else { return true } guard let data = version.image.dataContent() else { print("ImageGenerator: Failed to load data for image \(version.image.id)") return false } guard let originalImage = NSImage(data: data) else { print("ImageGenerator: Failed to load image \(version.image.id)") return false } let representation = create(image: originalImage, width: CGFloat(version.maximumWidth), height: CGFloat(version.maximumHeight)) guard let data = create(image: representation, type: version.type, quality: version.quality) else { print("ImageGenerator: Failed to get data for type \(version.type) of image \(version.image.id)") return false } if version.type == .avif { if version.image.type == .gif { // Skip GIFs, since they can't be converted by avifenc return true } // AVIF conversion is very slow, so we save bash commands // for the conversion instead let baseVersion = ImageVersion( image: version.image, type: version.image.type, maximumWidth: version.maximumWidth, maximumHeight: version.maximumHeight) let originalImagePath = storage.outputPath(to: baseVersion.outputPath)!.path() let generatedImagePath = storage.outputPath(to: version.outputPath)!.path() let quality = Int(version.quality * 100) avifCommands.insert("avifenc -q \(quality) '\(originalImagePath)' '\(generatedImagePath)'") // hasNowGenerated(version) return true } guard write(imageData: data, of: version) else { return false } version.wasNowGenerated() 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]) } }