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 } 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? { 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]) } // 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 } } }