diff --git a/Gifski/ConversionViewController.swift b/Gifski/ConversionViewController.swift index e262933b..d45de824 100644 --- a/Gifski/ConversionViewController.swift +++ b/Gifski/ConversionViewController.swift @@ -80,7 +80,7 @@ final class ConversionViewController: NSViewController { return } - gifski.run(conversion) { result in + gifski.run(conversion, isEstimation: false) { result in do { let gifUrl = try self.generateTemporaryGifUrl(for: conversion.video) try result.get().write(to: gifUrl, options: .atomic) diff --git a/Gifski/EditVideoViewController.swift b/Gifski/EditVideoViewController.swift index 0f571fe8..e0d234f9 100644 --- a/Gifski/EditVideoViewController.swift +++ b/Gifski/EditVideoViewController.swift @@ -49,6 +49,17 @@ final class EditVideoViewController: NSViewController { maxWidth: 300 ) + private var conversionSettings: Gifski.Conversion { + .init( + video: inputUrl, + timeRange: timeRange, + quality: Defaults[.outputQuality], + dimensions: resizableDimensions.changed(dimensionsType: .pixels).currentDimensions.value, + frameRate: frameRateSlider.integerValue, + loopGif: Defaults[.loopGif] + ) + } + convenience init( inputUrl: URL, asset: AVAsset, @@ -65,16 +76,7 @@ final class EditVideoViewController: NSViewController { @IBAction private func convert(_ sender: Any) { - let conversion = Gifski.Conversion( - video: inputUrl, - timeRange: timeRange, - quality: Defaults[.outputQuality], - dimensions: resizableDimensions.changed(dimensionsType: .pixels).currentDimensions.value, - frameRate: frameRateSlider.integerValue, - loopGif: Defaults[.loopGif] - ) - - let convert = ConversionViewController(conversion: conversion) + let convert = ConversionViewController(conversion: conversionSettings) push(viewController: convert) } @@ -356,7 +358,13 @@ final class EditVideoViewController: NSViewController { selectPredefinedSizeBasedOnCurrentDimensions() } - private func estimateFileSize() { + private func setEstimatedFileSize(_ string: NSAttributedString) { + estimatedSizeLabel.attributedStringValue = "Estimated File Size: ".attributedString + string + } + + private var gifski: Gifski? + + private func getNaiveEstimate() -> String { let duration: Double = { guard let timeRange = timeRange else { return videoMetadata.duration @@ -369,7 +377,43 @@ final class EditVideoViewController: NSViewController { let dimensions = resizableDimensions.changed(dimensionsType: .pixels).currentDimensions.value var fileSize = (Double(dimensions.width) * Double(dimensions.height) * frameCount) / 3 fileSize = fileSize * (qualitySlider.doubleValue + 1.5) / 2.5 - estimatedSizeLabel.stringValue = "Estimated File Size: " + formatter.string(fromByteCount: Int64(fileSize)) + + return formatter.string(fromByteCount: Int64(fileSize)) + } + + private func _estimateFileSize() { + // TODO: Deinit doesn't seem to be called. + self.gifski?.cancel() + + let gifski = Gifski() + self.gifski = gifski + + setEstimatedFileSize(getNaiveEstimate().attributedString + " Calculating Accurate Estimate…".attributedString.withColor(.secondaryLabelColor).withFontSize(NSFont.smallSystemFontSize.double)) + + gifski.run(conversionSettings, isEstimation: true) { [weak self] result in + guard let self = self else { + return + } + + switch result { + case .success(let data): + // We add 10% extra because it's better to estimate slightly too much than too little. + let fileSize = (Double(data.count) * gifski.sizeMultiplierForEstimation) * 1.1 + + self.setEstimatedFileSize(self.formatter.string(fromByteCount: Int64(fileSize)).attributedString) + case .failure(let error): + switch error { + case .cancelled: + break + default: + error.presentAsModalSheet(for: self.view.window) + } + } + } + } + + private func estimateFileSize() { + Debouncer.debounce(delay: 0.5, action: _estimateFileSize) } private func updateDimensionsDisplay() { diff --git a/Gifski/Gifski.swift b/Gifski/Gifski.swift index 0e6314af..cfe77db8 100644 --- a/Gifski/Gifski.swift +++ b/Gifski/Gifski.swift @@ -51,6 +51,12 @@ final class Gifski { private var progress: Progress! private var gifski: GifskiWrapper? + var sizeMultiplierForEstimation = 1.0 + + deinit { + cancel() + } + // TODO: Split this method up into smaller methods. It's too large. /** Converts a movie to GIF. @@ -59,6 +65,7 @@ final class Gifski { */ func run( _ conversion: Conversion, + isEstimation: Bool, completionHandler: ((Result) -> Void)? ) { // For debugging. @@ -204,6 +211,20 @@ final class Gifski { ) } + // TODO: The whole estimation thing should be split out into a separate method and the things that are shared should also be split out. + if isEstimation { + let originalCount = frameForTimes.count + + if originalCount > 25 { + frameForTimes = frameForTimes + .chunked(by: 5) + .sample(length: 5) + .flatten() + } + + self.sizeMultiplierForEstimation = Double(originalCount) / Double(frameForTimes.count) + } + Crashlytics.record( key: "\(debugKey): fps", value: fps @@ -276,4 +297,8 @@ final class Gifski { } } } + + func cancel() { + progress?.cancel() + } } diff --git a/Gifski/Utilities.swift b/Gifski/Utilities.swift index d7cda4cc..d0cd52ba 100644 --- a/Gifski/Utilities.swift +++ b/Gifski/Utilities.swift @@ -3370,3 +3370,253 @@ extension SSApp { SKStoreReviewController.requestReview() } } + + +extension Sequence { + /** + Returns an array of elements split into groups of the given size. + + If it can't be split evenly, the final chunk will be the remaining elements. + + If the requested chunk size is larger than the sequence, the chunk will be smaller than requested. + + ``` + [1, 2, 3, 4].chunked(by: 2) + //=> [[1, 2], [3, 4]] + ``` + */ + func chunked(by chunkSize: Int) -> [[Element]] { + reduce(into: []) { result, current in + if let last = result.last, last.count < chunkSize { + result.append(result.removeLast() + [current]) + } else { + result.append([current]) + } + } + } +} + + +extension Collection where Index == Int { + /// Return a subset of the array of the given length by sampling "evenly distributed" elements. + func sample(length: Int) -> [Element] { + precondition(length >= 0, "The length cannot be negative.") + + guard length < count else { + return Array(self) + } + + return (0..: CustomDebugStringConvertible { + private var storage = [Key: Value]() + + private let queue = DispatchQueue( + label: "com.sindresorhus.AtomicDictionary.\(UUID().uuidString)", + qos: .utility, + attributes: .concurrent, + autoreleaseFrequency: .inherit, + target: .global() + ) + + subscript(key: Key) -> Value? { + get { + queue.sync { storage[key] } + } + set { + queue.async(flags: .barrier) { [weak self] in + self?.storage[key] = newValue + } + } + } + + var debugDescription: String { storage.debugDescription } +} + +/** +Debounce a function call. + +Thread-safe. + +``` +final class Foo { + private let debounce = Debouncer(delay: 0.2) + + func reset() { + debounce(_reset) + } + + private func _reset() { + // … + } +} +``` + +or + +``` +final class Foo { + func reset() { + Debouncer.debounce(delay: 0.2, _reset) + } + + private func _reset() { + // … + } +} +``` +*/ +final class Debouncer { + private let delay: TimeInterval + private var workItem: DispatchWorkItem? + + init(delay: TimeInterval) { + self.delay = delay + } + + func callAsFunction(_ action: @escaping () -> Void) { + workItem?.cancel() + let newWorkItem = DispatchWorkItem(block: action) + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: newWorkItem) + workItem = newWorkItem + } +} + +extension Debouncer { + private static var debouncers = AtomicDictionary() + + private static func debounce( + identifier: String, + delay: TimeInterval, + action: @escaping () -> Void + ) { + let debouncer = { () -> Debouncer in + guard let debouncer = debouncers[identifier] else { + let debouncer = self.init(delay: delay) + debouncers[identifier] = debouncer + return debouncer + } + + return debouncer + }() + + debouncer { + debouncers[identifier] = nil + action() + } + } + + /** + Debounce a function call. + + This is less efficient than the instance method, but more convenient. + + Thread-safe. + */ + static func debounce( + file: String = #fileID, + function: StaticString = #function, + line: Int = #line, + delay: TimeInterval, + action: @escaping () -> Void + ) { + let identifier = "\(file)-\(function)-\(line)" + debounce(identifier: identifier, delay: delay, action: action) + } +} + + +extension Sequence where Element: Sequence { + func flatten() -> [Element.Element] { + flatMap { $0 } + } +} + + +extension NSFont { + /// Returns a new version of the font with the existing font descriptor replaced by the given font descriptor. + func withDescriptor(_ descriptor: NSFontDescriptor) -> NSFont { + // It's important that the size is `0` and not `pointSize` as otherwise the descriptor is not able to change the font size. + Self(descriptor: descriptor, size: 0) ?? self + } + + // TODO: When Xcode 12.2 is out, use `[NSFont fontWithSize:]` when available. + /// Returns a font with the size replaced. + /// UIKit polyfill. + func withSize(_ size: CGFloat) -> NSFont { + withDescriptor(fontDescriptor.withSize(size)) + } +} + + +extension String { + var attributedString: NSAttributedString { NSAttributedString(string: self) } +} + + +extension NSAttributedString { + static func + (lhs: NSAttributedString, rhs: NSAttributedString) -> NSAttributedString { + let string = NSMutableAttributedString(attributedString: lhs) + string.append(rhs) + return string + } + + static func + (lhs: NSAttributedString, rhs: String) -> NSAttributedString { + lhs + NSAttributedString(string: rhs) + } + + static func += (lhs: inout NSAttributedString, rhs: NSAttributedString) { + // swiftlint:disable:next shorthand_operator + lhs = lhs + rhs + } + + static func += (lhs: inout NSAttributedString, rhs: String) { + lhs += NSAttributedString(string: rhs) + } + + var nsRange: NSRange { NSRange(0.. Any? { + guard length > 0 else { + return nil + } + + var foundRange = NSRange() + let result = attribute(key, at: 0, longestEffectiveRange: &foundRange, in: nsRange) + + guard foundRange.length == length else { + return nil + } + + return result + } + + /// Returns a `NSMutableAttributedString` version. + func mutable() -> NSMutableAttributedString { + // Force-casting here is safe as it can only be nil if there's no `mutableCopy` implementation, but we know there is for `NSMutableAttributedString`. + // swiftlint:disable:next force_cast + mutableCopy() as! NSMutableAttributedString + } + + func addingAttributes(_ attributes: [Key: Any]) -> NSAttributedString { + let new = mutable() + new.addAttributes(attributes, range: nsRange) + return new + } + + func withColor(_ color: NSColor) -> NSAttributedString { + addingAttributes([.foregroundColor: color]) + } + + func withFontSize(_ fontSize: Double) -> NSAttributedString { + addingAttributes([.font: font.withSize(CGFloat(fontSize))]) + } +}