ChWebsiteApp/CHDataManagement/Generator/ImageGenerator.swift
2024-12-19 16:25:05 +01:00

244 lines
8.6 KiB
Swift

import Foundation
import AppKit
import SDWebImageAVIFCoder
import SDWebImageWebPCoder
final class ImageGenerator {
private let storage: Storage
private let settings: Settings
private var generatedImages: [String : Set<String>] = [:]
private var jobs: [ImageGenerationJob] = []
init(storage: Storage, settings: Settings) {
self.storage = storage
self.settings = settings
self.generatedImages = storage.loadListOfGeneratedImages() ?? [:]
}
private var outputFolder: String {
settings.paths.imagesOutputFolderPath
}
func runJobs(callback: (String) -> Void) -> Bool {
guard !jobs.isEmpty else {
return true
}
print("Generating \(jobs.count) images...")
while let job = jobs.popLast() {
callback("Generating image \(job.version)")
guard generate(job: job) else {
return false
}
}
return true
}
func save() -> Bool {
guard storage.save(listOfGeneratedImages: generatedImages) else {
print("Failed to save list of generated images")
return false
}
return true
}
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)
guard needsToGenerate(version: version, for: image) else {
// Image already present
return
}
guard !jobs.contains(where: { $0.version == version }) else {
// Job already in queue
return
}
let job = ImageGenerationJob(
image: image,
version: version,
maximumWidth: maximumWidth,
maximumHeight: maximumHeight,
quality: 0.7,
type: type)
jobs.append(job)
}
/**
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<String>) {
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 {
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
private 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: 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 {
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: 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])
}
}