ChWebsiteApp/CHDataManagement/Generator/ImageGenerator.swift
2025-02-16 16:00:28 +01:00

238 lines
8.7 KiB
Swift

import Foundation
import AppKit
import SDWebImageAVIFCoder
import SDWebImageWebPCoder
import AVFoundation
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
}
func needsToGenerate(_ version: ImageVersion) -> Bool {
if version.wasPreviouslyGenerated {
return !exists(version)
}
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 {
if version.type == .avif {
if version.image.type == .gif {
// Skip GIFs, since they can't be converted by avifenc
return true
}
if createAvifUsingBash(version: version) {
version.wasNowGenerated()
return true
}
return false
}
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
}
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? {
let newImage = NSImage(size: image.size)
newImage.addRepresentation(image)
return SDImageAVIFCoder.shared.encodedData(with: newImage, format: .AVIF, options: [.encodeCompressionQuality: quality])
}
private func createAvifUsingBash(version: ImageVersion) -> Bool {
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)
let process = Process()
process.launchPath = "/opt/homebrew/bin/avifenc" // Adjust based on installation
process.arguments = ["-q", "\(quality)", originalImagePath, generatedImagePath]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
process.launch()
process.waitUntilExit()
if process.terminationStatus != 0 {
print("ImageGenerator: Failed to create AVIF image \(version.image.id)")
let outputData = pipe.fileHandleForReading.readDataToEndOfFile()
let outputString = String(data: outputData, encoding: .utf8) ?? ""
print(outputString)
return false
}
return true
}
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])
}
// MARK: Video thumbnails
@discardableResult
func createVideoThumbnail(for videoId: String) async -> Bool {
guard let image = await storage.with(file: videoId, perform: generateThumbnail) else {
print("Failed to generate thumbnail image for video \(videoId)")
return false
}
let scaled = create(image: image, width: image.size.width, height: image.size.height)
guard let data = scaled.representation(using: .jpeg, properties: [.compressionFactor: NSNumber(value: 0.6)]) else {
print("Failed to get thumbnail jpg data of video \(videoId)")
return false
}
if !storage.save(thumbnail: data, for: videoId) {
print("Failed to save thumbnail of video \(videoId)")
}
print("Generated video thumbnail for \(videoId)")
return true
}
private func generateThumbnail(for url: URL) async -> NSImage? {
let time = CMTime(seconds: 1, preferredTimescale: 600)
let asset = AVURLAsset(url: url)
let imageGenerator = AVAssetImageGenerator(asset: asset)
imageGenerator.appliesPreferredTrackTransform = true // Correct for orientation
return await withCheckedContinuation { continuation in
imageGenerator.generateCGImageAsynchronously(for: time) { cgImage, _, error in
if let error {
print("Error generating thumbnail for \(url.path()): \(error.localizedDescription)")
}
if let cgImage {
let image = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))
continuation.resume(returning: image)
} else {
continuation.resume(returning: nil)
}
}
}
}
func getVideoDuration(for videoId: String) async -> TimeInterval? {
guard let duration = await storage.with(file: videoId, perform: getVideoDuration) else {
print("Failed to determine duration for video \(videoId)")
return nil
}
return duration
}
private func getVideoDuration(url: URL) async -> TimeInterval? {
let asset = AVURLAsset(url: url)
do {
let duration = try await asset.load(.duration)
return CMTimeGetSeconds(duration)
} catch {
print("ImageGenerator: Failed to determine video duration: \(error.localizedDescription)")
return nil
}
}
}