262 lines
9.2 KiB
Swift
262 lines
9.2 KiB
Swift
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: ImageType
|
|
}
|
|
|
|
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, inputImageFolder: URL, relativeImageOutputPath: String) {
|
|
self.storage = storage
|
|
self.inputImageFolder = inputImageFolder
|
|
self.relativeImageOutputPath = relativeImageOutputPath
|
|
self.generatedImages = storage.loadListOfGeneratedImages()
|
|
}
|
|
|
|
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 {
|
|
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 {
|
|
storage.save(listOfGeneratedImages: generatedImages)
|
|
}
|
|
|
|
private func versionFileName(image: String, type: ImageType, 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, altText: String) -> FeedEntryData.Image {
|
|
let type = ImageType(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)
|
|
|
|
let path = "/" + relativeImageOutputPath + "/" + image
|
|
return .init(rawImagePath: path,
|
|
width: Int(maxWidth),
|
|
height: Int(maxHeight),
|
|
altText: altText)
|
|
}
|
|
|
|
func generateVersion(for image: String, type: ImageType, 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 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) {
|
|
return true
|
|
}
|
|
let inputPath = inputImageFolder.appendingPathComponent(job.image)
|
|
#warning("TODO: Read through security scope")
|
|
guard inputPath.exists else {
|
|
print("Missing image \(inputPath.path())")
|
|
return false
|
|
}
|
|
|
|
let data: Data
|
|
do {
|
|
data = try Data(contentsOf: inputPath)
|
|
} catch {
|
|
print("Failed to load image \(inputPath.path()): \(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 = out.replacingOccurrences(of: ".avif", with: ".jpg")
|
|
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: ImageType, 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)])
|
|
}
|
|
}
|
|
|
|
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])
|
|
}
|
|
}
|