Repository: sindresorhus/Gifski Branch: main Commit: 93177b0f2453 Files: 95 Total size: 674.0 KB Directory structure: gitextract_2mi2fano/ ├── .editorconfig ├── .gitattributes ├── .gitignore ├── .swiftlint.yml ├── Config.xcconfig ├── Gifski/ │ ├── App.swift │ ├── AppIcon.icon/ │ │ └── icon.json │ ├── AppState.swift │ ├── Assets.xcassets/ │ │ └── Contents.json │ ├── CompletedScreen.swift │ ├── Components/ │ │ ├── CheckerboardView.swift │ │ ├── IntTextField.swift │ │ └── TrimmingAVPlayer.swift │ ├── Constants.swift │ ├── ConversionScreen.swift │ ├── Credits.rtf │ ├── Crop/ │ │ ├── CropDragGestureModifier.swift │ │ ├── CropHandlePosition.swift │ │ ├── CropOverlayView.swift │ │ ├── CropRect.swift │ │ ├── CropSettings.swift │ │ ├── CropToolBarItems.swift │ │ └── PickerAspectRatio.swift │ ├── EditScreen.swift │ ├── EstimatedFileSize.swift │ ├── ExportModifiedVideo.swift │ ├── GIFGenerator.swift │ ├── Gifski-Bridging-Header.h │ ├── Gifski.entitlements │ ├── Gifski.swift │ ├── GifskiWrapper.swift │ ├── Info.plist │ ├── Intents.swift │ ├── InternetAccessPolicy.json │ ├── MainScreen.swift │ ├── Preview/ │ │ ├── CVPixelBuffer+convertToGIF.swift │ │ ├── CompositePreviewShared.h │ │ ├── FullPreviewGenerationEvent.swift │ │ ├── FullPreviewStream.swift │ │ ├── PreviewRenderer.swift │ │ ├── PreviewRendererContext.swift │ │ ├── PreviewVideoCompositor.swift │ │ ├── PreviewableComposition.swift │ │ ├── SendableTexture.swift │ │ ├── SettingsForFullPreview.swift │ │ └── compositePreview.metal │ ├── ResizableDimensions.swift │ ├── Shared.swift │ ├── StartScreen.swift │ ├── Utilities.swift │ └── VideoValidator.swift ├── Gifski.xcodeproj/ │ ├── project.pbxproj │ ├── project.xcworkspace/ │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata/ │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm/ │ │ └── Package.resolved │ └── xcshareddata/ │ └── xcschemes/ │ ├── Gifski.xcscheme │ └── Share Extension.xcscheme ├── Share Extension/ │ ├── Info.plist │ ├── ShareController.swift │ ├── Share_Extension.entitlements │ └── Utilities.swift ├── Stuff/ │ ├── AppIcon.sketch │ └── BackgroundImage.sketch ├── app-store-description.txt ├── app-store-keywords.txt ├── contributing.md ├── gifski-api/ │ ├── .github/ │ │ └── dependabot.yml │ ├── .gitignore │ ├── Cargo.toml │ ├── LICENSE │ ├── README.md │ ├── gifski.h │ ├── gifski.xcodeproj/ │ │ ├── project.pbxproj │ │ ├── project.xcworkspace/ │ │ │ └── contents.xcworkspacedata │ │ └── xcshareddata/ │ │ └── xcschemes/ │ │ └── gifski.a (static library).xcscheme │ ├── snapcraft.yaml │ └── src/ │ ├── bin/ │ │ ├── ffmpeg_source.rs │ │ ├── gif_source.rs │ │ ├── gifski.rs │ │ ├── png.rs │ │ ├── source.rs │ │ └── y4m_source.rs │ ├── c_api/ │ │ └── c_api_error.rs │ ├── c_api.rs │ ├── collector.rs │ ├── denoise.rs │ ├── encoderust.rs │ ├── error.rs │ ├── gifsicle.rs │ ├── lib.rs │ ├── minipool.rs │ └── progress.rs ├── license ├── maintaining.md └── readme.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] indent_style = tab end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.yml] indent_style = space indent_size = 2 ================================================ FILE: .gitattributes ================================================ * text=auto eol=lf *.pdf binary *.ai binary *.psd binary gifski-api/* linguist-vendored ================================================ FILE: .gitignore ================================================ xcuserdata/ /gifski-api/tests/ ================================================ FILE: .swiftlint.yml ================================================ only_rules: - accessibility_trait_for_button - array_init - blanket_disable_command - block_based_kvo - class_delegate_protocol - closing_brace - closure_end_indentation - closure_parameter_position - closure_spacing - collection_alignment - colon - comma - comma_inheritance - compiler_protocol_init - computed_accessors_order - conditional_returns_on_newline - contains_over_filter_count - contains_over_filter_is_empty - contains_over_first_not_nil - contains_over_range_nil_comparison - control_statement - custom_rules - deployment_target - direct_return - discarded_notification_center_observer - discouraged_assert - discouraged_direct_init - discouraged_none_name - discouraged_object_literal - discouraged_optional_boolean - discouraged_optional_collection - duplicate_conditions - duplicate_enum_cases - duplicate_imports - duplicated_key_in_dictionary_literal - dynamic_inline - empty_collection_literal - empty_count - empty_enum_arguments - empty_parameters - empty_parentheses_with_trailing_closure - empty_string - empty_xctest_method - enum_case_associated_values_count - explicit_init - fallthrough - fatal_error_message - final_test_case - first_where - flatmap_over_map_reduce - for_where - function_name_whitespace - generic_type_name - ibinspectable_in_extension - identical_operands - identifier_name - implicit_getter - implicit_return - implicit_optional_initialization - inclusive_language - invalid_swiftlint_command - is_disjoint - joined_default_parameter - last_where - leading_whitespace - legacy_cggeometry_functions - legacy_constant - legacy_constructor - legacy_hashing - legacy_multiple - legacy_nsgeometry_functions - legacy_random - literal_expression_end_indentation - lower_acl_than_parent - mark - modifier_order - multiline_arguments - multiline_arguments_brackets - multiline_function_chains - multiline_literal_brackets - multiline_parameters - multiline_parameters_brackets - nimble_operator - no_extension_access_modifier - no_fallthrough_only - no_space_in_method_call - non_optional_string_data_conversion - non_overridable_class_declaration - notification_center_detachment - ns_number_init_as_function_reference - nsobject_prefer_isequal - number_separator - operator_usage_whitespace - optional_data_string_conversion - overridden_super_call - prefer_condition_list - prefer_key_path - prefer_self_in_static_references - prefer_self_type_over_type_of_self - prefer_zero_over_explicit_init - private_action - private_outlet - private_subject - private_swiftui_state - private_unit_test - prohibited_super_call - protocol_property_accessors_order - reduce_boolean - reduce_into - redundant_discardable_let - redundant_nil_coalescing - redundant_objc_attribute - redundant_sendable - redundant_set_access_control - redundant_string_enum_value - redundant_type_annotation - redundant_void_return - required_enum_case - return_arrow_whitespace - return_value_from_void_function - self_binding - self_in_property_initialization - shorthand_operator - shorthand_optional_binding - sorted_first_last - statement_position - static_operator - static_over_final_class - strong_iboutlet - superfluous_disable_command - superfluous_else - switch_case_alignment - switch_case_on_newline - syntactic_sugar - test_case_accessibility - toggle_bool - trailing_closure - trailing_comma - trailing_newline - trailing_semicolon - trailing_whitespace - unavailable_condition - unavailable_function - unneeded_break_in_switch - unneeded_override - unneeded_parentheses_in_closure_argument - unowned_variable_capture - untyped_error_in_catch - unused_closure_parameter - unused_control_flow_label - unused_enumerated - unused_optional_binding - unused_setter_value - valid_ibinspectable - vertical_parameter_alignment - vertical_parameter_alignment_on_call - vertical_whitespace_closing_braces - vertical_whitespace_opening_braces - void_function_in_ternary - void_return - xct_specific_matcher - xctfail_message - yoda_condition analyzer_rules: - capture_variable - typesafe_array_init - unneeded_synthesized_initializer - unused_declaration - unused_import redundant_discardable_let: ignore_swiftui_view_bodies: true for_where: allow_for_as_filter: true number_separator: minimum_length: 5 identifier_name: max_length: warning: 100 error: 100 min_length: warning: 2 error: 2 allowed_symbols: - '_' excluded: - 'x' - 'y' - 'z' - 'a' - 'b' - 'x1' - 'x2' - 'y1' - 'y2' - 'z2' redundant_type_annotation: consider_default_literal_types_redundant: true unneeded_override: affect_initializers: true deployment_target: macOS_deployment_target: '14' custom_rules: no_nsrect: regex: '\bNSRect\b' match_kinds: typeidentifier message: 'Use CGRect instead of NSRect' no_nssize: regex: '\bNSSize\b' match_kinds: typeidentifier message: 'Use CGSize instead of NSSize' no_nspoint: regex: '\bNSPoint\b' match_kinds: typeidentifier message: 'Use CGPoint instead of NSPoint' no_cgfloat: regex: '\bCGFloat\b' match_kinds: typeidentifier message: 'Use Double instead of CGFloat' no_cgfloat2: regex: '\bCGFloat\(' message: 'Use Double instead of CGFloat' swiftui_state_private: regex: '@(ObservedObject|EnvironmentObject)\s+var' message: 'SwiftUI @ObservedObject and @EnvironmentObject properties should be private' swiftui_environment_private: regex: '@Environment\(\\\.\w+\)\s+var' message: 'SwiftUI @Environment properties should be private' swiftui_scaledtofit: regex: 'aspectRatio\(contentMode: \.fit\)' message: 'Prefer `scaledToFit()`' swiftui_scaledtofill: regex: 'aspectRatio\(contentMode: \.fill\)' message: 'Prefer `scaledToFill()`' final_class: regex: '^class [a-zA-Z\d]+[^{]+\{' message: 'Classes should be marked as final whenever possible. If you actually need it to be subclassable, just add `// swiftlint:disable:next final_class`.' no_alignment_center: regex: '\b\(alignment: .center\b' message: 'This alignment is the default.' ================================================ FILE: Config.xcconfig ================================================ MARKETING_VERSION = 2.23.1 CURRENT_PROJECT_VERSION = 73 ================================================ FILE: Gifski/App.swift ================================================ import SwiftUI @main struct AppMain: App { private let appState = AppState.shared @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate init() { setUpConfig() } var body: some Scene { Window(SSApp.name, id: "main") { MainScreen() .environment(appState) } .windowResizability(.contentSize) .windowToolbarStyle(.unifiedCompact) // .windowBackgroundDragBehavior(.enabled) // Does not work. (macOS 15.2) .defaultPosition(.center) .restorationBehavior(.disabled) .handlesExternalEvents(matching: []) // Makes sure it does not open a new window when dragging files onto the Dock icon. .commands { CommandGroup(replacing: .newItem) { Button("Open…", systemImage: "arrow.up.forward.square") { appState.isFileImporterPresented = true } .keyboardShortcut("o") .disabled(appState.isConverting) } CommandGroup(replacing: .importExport) { Button("Export as Video…", systemImage: "square.and.arrow.up") { appState.onExportAsVideo?() } .keyboardShortcut("e") .disabled(appState.onExportAsVideo == nil) } CommandGroup(replacing: .textEditing) { Toggle( "Preview", systemImage: "eye", isOn: appState.toggleMode(mode: .preview) ) .keyboardShortcut("p", modifiers: [.command, .shift]) .disabled(!appState.isOnEditScreen) .help("Preview is only available when editing a video") Toggle( "Crop", systemImage: "crop", isOn: appState.toggleMode(mode: .editCrop) ) .keyboardShortcut("c", modifiers: [.command, .shift]) .disabled(!appState.isOnEditScreen) } CommandGroup(replacing: .help) { Link( "Website", systemImage: "safari", destination: "https://sindresorhus.com/Gifski" ) Link( "Source Code", systemImage: "chevron.left.forwardslash.chevron.right", destination: "https://github.com/sindresorhus/Gifski" ) Link( "Gifski Library", systemImage: "shippingbox", destination: "https://github.com/ImageOptim/gifski" ) Divider() RateOnAppStoreButton(appStoreID: "1351639930") ShareAppButton(appStoreID: "1351639930") Divider() SendFeedbackButton() } } } private func setUpConfig() { UserDefaults.standard.register(defaults: [ "NSApplicationCrashOnExceptions": true ]) SSApp.initSentry("https://0ab0665326c54956f3caa10fc2f525d1@o844094.ingest.sentry.io/4505991507738624") SSApp.setUpExternalEventListeners() } } ================================================ FILE: Gifski/AppIcon.icon/icon.json ================================================ { "fill-specializations" : [ { "value" : { "solid" : "gray:0.20000,1.00000" } }, { "appearance" : "dark", "value" : "system-dark" } ], "groups" : [ { "blend-mode" : "normal", "blur-material" : null, "layers" : [ { "blend-mode" : "normal", "image-name" : "Rainbow.png", "name" : "Rainbow", "position" : { "scale" : 1, "translation-in-points" : [ 0, 0 ] } } ], "shadow" : { "kind" : "layer-color", "opacity" : 0.4 }, "translucency" : { "enabled" : true, "value" : 0.4 } }, { "layers" : [ { "glass" : false, "hidden" : false, "image-name" : "Background.png", "name" : "Background", "opacity" : 0.4, "position" : { "scale" : 1.01, "translation-in-points" : [ 0, 0 ] } } ], "shadow" : { "kind" : "none", "opacity" : 0.5 }, "translucency" : { "enabled" : true, "value" : 0.5 } } ], "supported-platforms" : { "circles" : [ "watchOS" ], "squares" : "shared" } } ================================================ FILE: Gifski/AppState.swift ================================================ import SwiftUI import UserNotifications import DockProgress @MainActor @Observable final class AppState { static let shared = AppState() var isOnEditScreen: Bool { guard case .edit = navigationPath.last else { return false } return true } var isConverting: Bool { guard case .conversion = navigationPath.last else { return false } return true } var navigationPath = [Route]() var isFileImporterPresented = false enum Mode { case normal case editCrop case preview } var mode = Mode.normal var shouldShowPreview: Bool { mode == .preview } var isCropActive: Bool { mode == .editCrop } var onExportAsVideo: (() -> Void)? /** Provides a binding for a toggle button to access a certain mode. The getter returns true if in that mode. Setter will toggle the mode on, but return to initial mode if set to off (if we are in the specified mode). */ func toggleMode(mode: Mode) -> Binding { .init( get: { self.mode == mode }, set: { newValue in if newValue { self.mode = mode return } guard self.mode == mode else { return } self.mode = .normal } ) } var error: Error? init() { DockProgress.style = .squircle(color: .white.withAlphaComponent(0.7)) DispatchQueue.main.async { [self] in didLaunch() } } private func didLaunch() { NSApp.servicesProvider = self // We have to include `.badge` otherwise system settings does not show the checkbox to turn off sounds. (macOS 12.4) UNUserNotificationCenter.current().requestAuthorization(options: [.sound, .badge]) { _, _ in } } func start(_ url: URL) { _ = url.startAccessingSecurityScopedResource() // We have to nil it out first and dispatch, otherwise it shows the old video. (macOS 14.3) navigationPath = [] Task { [self] in do { // TODO: Simplify the validator. let (asset, metadata) = try await VideoValidator.validate(url) navigationPath = [.edit(url, asset, metadata)] } catch { self.error = error } } } /** Returns `nil` if it should not continue. */ fileprivate func extractSharedVideoUrlIfAny(from url: URL) -> URL? { guard url.host == "shareExtension" else { return url } guard let path = url.queryDictionary["path"], let appGroupShareVideoUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Shared.appGroupIdentifier)?.appendingPathComponent(path, isDirectory: false) else { NSAlert.showModal( for: SSApp.swiftUIMainWindow, title: "Could not retrieve the shared video." ) return nil } return appGroupShareVideoUrl } } final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { // Set launch completions option if the notification center could not be set up already. LaunchCompletions.applicationDidLaunch() } // TODO: Try to migrate to `.onOpenURL` when targeting macOS 15. func application(_ application: NSApplication, open urls: [URL]) { guard urls.count == 1, let videoUrl = urls.first else { NSAlert.showModal( for: SSApp.swiftUIMainWindow, title: "Gifski can only convert a single file at the time." ) return } guard let videoUrl2 = AppState.shared.extractSharedVideoUrlIfAny(from: videoUrl) else { return } // Start video conversion on launch LaunchCompletions.add { AppState.shared.start(videoUrl2) } } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { if AppState.shared.isConverting { let response = NSAlert.showModal( for: SSApp.swiftUIMainWindow, title: "Do you want to continue converting?", message: "Gifski is currently converting a video. If you quit, the conversion will be cancelled.", buttonTitles: [ "Continue", "Quit" ] ) if response == .alertFirstButtonReturn { return .terminateCancel } } return .terminateNow } func applicationWillTerminate(_ notification: Notification) { UNUserNotificationCenter.current().removeAllDeliveredNotifications() } } extension AppState { /** This is called from NSApp as a service resolver. */ @objc func convertToGIF(_ pasteboard: NSPasteboard, userData: String, error: NSErrorPointer) { guard let url = pasteboard.fileURLs().first else { return } Task { start(url) } } } ================================================ FILE: Gifski/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Gifski/CompletedScreen.swift ================================================ import SwiftUI import UserNotifications import StoreKit struct CompletedScreen: View { @Environment(AppState.self) private var appState @Environment(\.requestReview) private var requestReview @AppStorage("conversionCount") private var conversionCount = 0 @State private var isFileExporterPresented = false @State private var isShowingContent = false @State private var isCopyWarning1Presented = false @State private var isCopyWarning2Presented = false @State private var isDragTipPresented = false let data: Data let url: URL var body: some View { VStack { ImageView(image: NSImage(data: data) ?? NSImage()) .clipShape(.rect(cornerRadius: 8)) .shadow(radius: 8) // TODO: This is probably fixed in macOS 15. Test. // TODO: `.draggable()` does not correctly add a file to the drag pasteboard. (macOS 14.0) // .draggable(ExportableGIF(url: url)) .onDrag { .init(object: url as NSURL) } .popover(isPresented: $isDragTipPresented) { Text("Go ahead and drag the thumbnail to an app like Finder or Safari") .padding() .padding(.vertical, 4) .onTapGesture { isDragTipPresented = false } .accessibilityAddTraits(.isButton) } .opacity(isShowingContent ? 1 : -0.5) .scaleEffect(isShowingContent ? 1 : 4) } .fillFrame() .safeAreaInset(edge: .bottom) { controls } .scenePadding() .fileExporter( isPresented: $isFileExporterPresented, item: ExportableGIF(url: url), defaultFilename: url.filename ) { do { let url = try $0.get() try? url.setAppAsItemCreator() } catch { appState.error = error } } .fileDialogCustomizationID("export") .fileDialogMessage("Choose where to save the GIF") .fileDialogConfirmationLabel("Save") .alert2( "The GIF was copied to the clipboard.", message: "However…", isPresented: $isCopyWarning1Presented ) { Button("Continue") { isCopyWarning2Presented = true } } .alert2( "Please read!", message: "Many apps like Chrome and Slack do not properly handle copied animated GIFs and will paste them as non-animated PNG.\n\nInstead, drag and drop the GIF into such apps.", isPresented: $isCopyWarning2Presented ) .toolbar { ToolbarItem(placement: .principal) { HStack(spacing: 8) { Text("\(url.filename)") // Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.gif") .frame(maxWidth: 200) .truncationMode(.middle) Text("·") Text(url.fileSizeFormatted) } .font(.system(weight: .medium, design: .rounded)) .foregroundStyle(.secondary) } .ss_sharedBackgroundVisibility_hidden() ToolbarItem { Spacer() } ToolbarItem(placement: .primaryAction) { Button("New Conversion", systemImage: "plus") { appState.isFileImporterPresented = true } .if(SSApp.isFirstLaunch) { $0.labelStyle(.titleAndIcon) } } } // .navigationTitle(url.filename) // TODO // .navigationSubtitle(url.fileSizeFormatted) .navigationTitle("") .task { withAnimationWhenNotReduced { isShowingContent = true } } .task { NSApp.requestUserAttention(.informationalRequest) showNotificationIfNeeded() showDragTipIfNeeded() requestReviewIfNeeded() } } private var controls: some View { HStack(spacing: 32) { // TODO: We cannot use controlgroup as the sharelink doesn't work then. (macOS 14.0) // ControlGroup { Button("Save") { isFileExporterPresented = true } .keyboardShortcut("s") CopyButton { copy(url) } .keyboardShortcut("c") ShareLink("Share", item: url) // TODO: Document this shortcut. .keyboardShortcut("s", modifiers: [.command, .shift]) } .labelStyle(.titleOnly) .controlSize(.extraLarge) .buttonStyle(.equalWidth(.constant(0), minimumWidth: 80)) // .background(.regularMaterial) // Enable if using controlgroup again. .frame(width: 300) .padding() .opacity(isShowingContent ? 1 : 0) } private func copy(_ url: URL) { NSPasteboard.general.with { // swiftlint:disable:next legacy_objc_type $0.writeObjects([url as NSURL]) $0.setString(url.filenameWithoutExtension, forType: .urlName) } SSApp.runOnce(identifier: "copyWarning") { isCopyWarning1Presented = true } } private func showNotificationIfNeeded() { guard !NSApp.isActive || SSApp.swiftUIMainWindow?.isVisible == false else { return } let notification = UNMutableNotificationContent() notification.title = "Conversion Completed" notification.subtitle = url.filename notification.sound = .default let request = UNNotificationRequest(identifier: "conversionCompleted", content: notification, trigger: nil) UNUserNotificationCenter.current().add(request) } private func requestReviewIfNeeded() { conversionCount += 1 guard conversionCount == 5 else { return } #if !DEBUG requestReview() #endif } private func showDragTipIfNeeded() { SSApp.runOnce(identifier: "CompletedScreen_dragTip") { Task { try? await Task.sleep(for: .seconds(1)) isDragTipPresented = true try? await Task.sleep(for: .seconds(10)) isDragTipPresented = false } } } } ================================================ FILE: Gifski/Components/CheckerboardView.swift ================================================ import SwiftUI enum CheckerboardViewConstants { static let gridSize = 8 /** I tried just using firstColor directly (instead of splitting between light and dark), but it would not reliably change colors for the preview when switching between light and dark. */ static let firstColorLight = Color(white: 0.98) static let firstColorDark = Color(white: 0.46) static let secondColorLight = Color(white: 0.82) static let secondColorDark = Color(white: 0.26) static let firstColor = Color(light: firstColorLight, dark: firstColorDark) static let secondColor = Color(light: secondColorLight, dark: secondColorDark) } struct CheckerboardView: View { var gridSize = CGSize(width: CheckerboardViewConstants.gridSize, height: CheckerboardViewConstants.gridSize) var clearRect: CGRect? var body: some View { ZStack { Canvas(opaque: true) { context, size in context.fill(Rectangle().path(in: size.cgRect), with: .color(CheckerboardViewConstants.secondColor)) for y in 0...Int(size.height / gridSize.height) { for x in 0...Int(size.width / gridSize.width) where x.isEven == y.isEven { let origin = CGPoint(x: x * Int(gridSize.width), y: y * Int(gridSize.height)) let rect = CGRect(origin: origin, size: gridSize) context.fill(Rectangle().path(in: rect), with: .color(CheckerboardViewConstants.firstColor)) } } } // TODO: Any way to do this directly in the `Canvas`? if let clearRect { Rectangle() .fill(.black) .frame(width: clearRect.width, height: clearRect.height) .blendMode(.destinationOut) } } .compositingGroup() .drawingGroup() } } ================================================ FILE: Gifski/Components/IntTextField.swift ================================================ import SwiftUI // TODO: This does not correctly prevent larger numbers than `minMax`. struct IntTextField: NSViewRepresentable { typealias NSViewType = IntTextFieldCocoa @Binding var value: Int var minMax: ClosedRange? var delta = 1 var alternativeDelta = 10 var alignment: NSTextAlignment? var font: NSFont? var onValueChange: ((Int) -> Void)? var onBlur: ((Int) -> Void)? var onInvalid: ((Int) -> Void)? func makeNSView(context: Context) -> IntTextFieldCocoa { let nsView = IntTextFieldCocoa() nsView.onValueChange = { value = $0 onValueChange?($0) } nsView.onBlur = { value = $0 onBlur?($0) } nsView.onInvalid = { onInvalid?($0) } return nsView } func updateNSView(_ nsView: IntTextFieldCocoa, context: Context) { nsView.stringValue = "\(value)" // We intentionally do not use `nsView.intValue` as it formats the number. nsView.minMax = minMax nsView.delta = delta nsView.alternativeDelta = alternativeDelta if let alignment { nsView.alignment = alignment } if let font { nsView.font = font } } } final class IntTextFieldCocoa: NSTextField, NSTextFieldDelegate, NSControlTextEditingDelegate { override var canBecomeKeyView: Bool { true } /** Delta used for arrow navigation. */ var delta = 1 /** Delta used for option + arrow navigation. */ var alternativeDelta = 10 var onValueChange: ((Int) -> Void)? var onBlur: ((Int) -> Void)? var onInvalid: ((Int) -> Void)? var minMax: ClosedRange? var isEmpty: Bool { stringValue.trimmingCharacters(in: .whitespaces).isEmpty } required init?(coder: NSCoder) { super.init(coder: coder) setup() } override init(frame frameRect: CGRect) { super.init(frame: frameRect) setup() } private func setup() { delegate = self } override func performKeyEquivalent(with event: NSEvent) -> Bool { guard window?.firstResponder == currentEditor() else { return super.performKeyEquivalent(with: event) } let key = event.specialKey let isHoldingOption = event.modifierFlags.contains(.option) let initialDelta = isHoldingOption ? alternativeDelta : delta let delta: Int switch key { case .upArrow?: delta = initialDelta case .downArrow?: delta = initialDelta * -1 default: return super.performKeyEquivalent(with: event) } let currentValue = Int(stringValue) ?? 0 let tentativeNewValue = currentValue + delta func setValue() { stringValue = "\(tentativeNewValue)" handleValueChange() } if let minMax { if minMax.contains(tentativeNewValue) { setValue() } else { indicateValidationFailure(invalidValue: tentativeNewValue) } } else { setValue() } return true } func controlTextDidChange(_ object: Notification) { stringValue = stringValue .replacing(/\D+/, with: "") // Make sure only digits can be entered. .replacing(/^0/, with: "") // Don't allow leading zero. if let minMax { // Ensure the user cannot input more digits than the max. stringValue = String(stringValue.prefix("\(minMax.upperBound)".count)) } let isInvalidButInBounds = !isValid(integerValue) && integerValue > 0 && integerValue <= (minMax?.upperBound ?? Int.max) // For entered text we want to give a little bit more room to breathe if isEmpty || isInvalidButInBounds { return } handleValueChange() } private func handleValueChange() { if !isValid(integerValue) { indicateValidationFailure(invalidValue: integerValue) } onValueChange?(integerValue) } func controlTextDidEndEditing(_ object: Notification) { if !isValid(integerValue) { indicateValidationFailure(invalidValue: integerValue) } onBlur?(integerValue) } func indicateValidationFailure(invalidValue: Int) { shake(direction: .horizontal) onInvalid?(invalidValue) } private func isValid(_ value: Int) -> Bool { guard let minMax else { return true } return minMax.contains(value) } } ================================================ FILE: Gifski/Components/TrimmingAVPlayer.swift ================================================ import AVKit import SwiftUI struct TrimmingAVPlayer: NSViewControllerRepresentable { typealias NSViewControllerType = TrimmingAVPlayerViewController @Environment(\.colorScheme) private var colorScheme let asset: AVAsset let shouldShowPreview: Bool let fullPreviewState: FullPreviewGenerationEvent var controlsStyle = AVPlayerViewControlsStyle.inline var loopPlayback = false var bouncePlayback = false var speed = 1.0 var overlay: NSView? var isPlayPauseButtonEnabled = true var isTrimmerDraggable = false var timeRangeDidChange: ((ClosedRange) -> Void)? func makeNSViewController(context: Context) -> NSViewControllerType { .init( playerItem: .init(asset: asset), controlsStyle: controlsStyle, timeRangeDidChange: timeRangeDidChange ) } func updateNSViewController(_ nsViewController: NSViewControllerType, context: Context) { if asset != nsViewController.currentItem.asset { let item = AVPlayerItem(asset: asset) forceAVPlayerToRedraw(item: item) item.playbackRange = nsViewController.currentItem.playbackRange nsViewController.currentItem = item } // Always update video composition based on preview state. // When preview is ON, use custom compositor. When OFF, clear it so AVPlayer handles rotation. forceAVPlayerToRedraw(item: nsViewController.currentItem) _ = updatePreviewState(nsViewController) nsViewController.loopPlayback = loopPlayback nsViewController.bouncePlayback = bouncePlayback nsViewController.player.defaultRate = Float(speed) if nsViewController.player.rate != 0 { nsViewController.player.rate = nsViewController.player.rate > 0 ? Float(speed) : -Float(speed) } nsViewController.overlay = overlay nsViewController.isTrimmerDraggable = isTrimmerDraggable nsViewController.isPlayPauseButtonEnabled = isPlayPauseButtonEnabled } /** Update the preview state. - Returns: True if state was updated and needs a redraw, false otherwise. */ func updatePreviewState(_ controller: NSViewControllerType) -> Bool { guard let previewVideoCompositor = controller.currentItem.customVideoCompositor as? PreviewVideoCompositor else { return false } let previewCheckerboardParams = CompositePreviewFragmentUniforms( isDarkMode: colorScheme.isDark, videoBounds: controller.playerView.videoBounds ) return previewVideoCompositor.updateState( state: .init( shouldShowPreview: shouldShowPreview, fullPreviewState: fullPreviewState, previewCheckerboardParams: previewCheckerboardParams ) ) } /** Sets or clears the video composition based on preview state. When preview is OFF, we don't use the custom compositor so AVPlayer handles rotation via `preferredTransform` normally. When preview is ON, we use the custom compositor which renders the preview overlay. */ func forceAVPlayerToRedraw(item: AVPlayerItem) { guard let assetVideoComposition = (asset as? PreviewableComposition)?.videoComposition else { return } if shouldShowPreview { item.videoComposition = assetVideoComposition.mutableCopy() as? AVMutableVideoComposition } else { // Clear video composition so AVPlayer handles rotation normally. item.videoComposition = nil } } } // TODO: Move more of the logic here over to the SwiftUI view. /** A view controller containing AVPlayerView and also extending possibilities for trimming (view) customization. */ final class TrimmingAVPlayerViewController: NSViewController { private(set) var timeRange: ClosedRange? private let playerItem: AVPlayerItem fileprivate let player: LoopingPlayer private let controlsStyle: AVPlayerViewControlsStyle private let timeRangeDidChange: ((ClosedRange) -> Void)? private var cancellables = Set() private var currentItemDurationRange: ClosedRange? fileprivate var overlay: NSView? { didSet { guard oldValue != overlay else { return } if let oldValue { oldValue.removeFromSuperview() } guard let overlay else { return } let underTrimOverlayView = overlay underTrimOverlayView.removeConstraints(underTrimOverlayView.constraints) playerView.contentOverlayView?.addSubview(underTrimOverlayView) underTrimOverlayView.translatesAutoresizingMaskIntoConstraints = false let videoBounds = playerView.videoBounds guard let contentOverlayView = playerView.contentOverlayView else { return } NSLayoutConstraint.activate([ underTrimOverlayView.leadingAnchor.constraint(equalTo: contentOverlayView.leadingAnchor, constant: videoBounds.origin.x), underTrimOverlayView.topAnchor.constraint(equalTo: contentOverlayView.topAnchor, constant: videoBounds.origin.y), underTrimOverlayView.widthAnchor.constraint(equalToConstant: videoBounds.size.width), underTrimOverlayView.heightAnchor.constraint(equalToConstant: videoBounds.size.height) ]) } } fileprivate var isTrimmerDraggable = false { didSet { trimmerDragViews?.isDraggable = isTrimmerDraggable } } fileprivate var isPlayPauseButtonEnabled = true { didSet { guard isPlayPauseButtonEnabled != oldValue else { return } playerView.setPlayPauseButton(isEnabled: isPlayPauseButtonEnabled) } } var playerView: TrimmingAVPlayerView { view as! TrimmingAVPlayerView } // We cannot use lazy here because at start this will be `nil` before the player is initialized (there won't be an AVTrimView). private var _trimmerDragViews: TrimmerDragViews? private var trimmerDragViews: TrimmerDragViews? { if let _trimmerDragViews { return _trimmerDragViews } // Needed so that it will hide the trimmer when it is outside the view. This must be done now (as opposed to`viewDidLoad`) because layer is nil in `viewDidLoad`. playerView.layer?.masksToBounds = true guard let avTrimView = (playerView.firstSubview(deep: true) { $0.simpleClassName == "AVTrimView" })?.superview, let avTrimViewParent = avTrimView.superview?.superview else { return nil } _trimmerDragViews = TrimmerDragViews( avTrimView: avTrimView, avTrimViewParent: avTrimViewParent, isDraggable: false ) return _trimmerDragViews } /** The minimum duration the trimmer can be set to. */ var minimumTrimDuration = 0.1 { didSet { playerView.minimumTrimDuration = minimumTrimDuration } } var loopPlayback: Bool { get { player.loopPlayback } set { player.loopPlayback = newValue } } var bouncePlayback: Bool { get { player.bouncePlayback } set { player.bouncePlayback = newValue } } /** Get or set the current player item. When setting an item, it preserves the current playback rate (which means pause state too), playback position, and trim range. */ var currentItem: AVPlayerItem { get { player.currentItem! } set { let rate = player.rate let playbackPercentage = player.currentItem?.playbackProgress ?? 0 let playbackRangePercentage = player.currentItem?.playbackRangePercentage player.replaceCurrentItem(with: newValue) DispatchQueue.main.async { [self] in player.rate = rate player.currentItem?.seek(toPercentage: playbackPercentage) player.currentItem?.playbackRangePercentage = playbackRangePercentage } } } init( playerItem: AVPlayerItem, controlsStyle: AVPlayerViewControlsStyle = .inline, timeRangeDidChange: ((ClosedRange) -> Void)? = nil ) { self.playerItem = playerItem self.player = LoopingPlayer(playerItem: playerItem) self.controlsStyle = controlsStyle self.timeRangeDidChange = timeRangeDidChange super.init(nibName: nil, bundle: nil) } deinit { print("TrimmingAVPlayerViewController - DEINIT") } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func loadView() { let playerView = TrimmingAVPlayerView() playerView.allowsVideoFrameAnalysis = false playerView.controlsStyle = controlsStyle playerView.player = player view = playerView } override func viewDidLoad() { super.viewDidLoad() // Support replacing the item. player.publisher(for: \.currentItem) .compactMap(\.self) .flatMap { currentItem in // TODO: Make a `AVPlayerItem#waitForReady` async property when using Swift 6. currentItem.publisher(for: \.status) .first { $0 == .readyToPlay } .map { _ in currentItem } } .receive(on: DispatchQueue.main) .sink { [weak self] in guard let self else { return } playerView.setupTrimmingObserver() onNewDurationRange(durationRange: $0.durationRange) // This is here as it needs to be refreshed when the current item changes. playerView.observeTrimmedTimeRange { [weak self] timeRange in self?.timeRange = timeRange self?.timeRangeDidChange?(timeRange) } } .store(in: &cancellables) } func onNewDurationRange(durationRange newItemDurationRange: ClosedRange?) { guard let newItemDurationRange else { currentItemDurationRange = nil return } defer { currentItemDurationRange = newItemDurationRange } guard let timeRange, let currentItemDurationRange else { self.timeRange = newItemDurationRange timeRangeDidChange?(newItemDurationRange) return } // Convert `timeRange` from `oldItemDurationRange` to new duration range // necessary for when the video changes speed. let speed: Double = { guard currentItemDurationRange.length != 0 else { return 1.0 } return newItemDurationRange.length / currentItemDurationRange.length }() let newTimeRange = (timeRange - currentItemDurationRange.lowerBound) * speed + newItemDurationRange.lowerBound self.timeRange = newTimeRange timeRangeDidChange?(newTimeRange) } } final class TrimmingAVPlayerView: AVPlayerView { private var timeRangeCancellable: AnyCancellable? private var trimmingCancellable: AnyCancellable? /** The minimum duration the trimmer can be set to. */ var minimumTrimDuration = 0.1 deinit { print("TrimmingAVPlayerView - DEINIT") } // TODO: This should be an AsyncSequence. fileprivate func observeTrimmedTimeRange(_ updateClosure: @escaping (ClosedRange) -> Void) { var skipNextUpdate = false timeRangeCancellable = player?.currentItem?.publisher(for: \.duration, options: .new) .sink { [weak self] _ in guard let self, let item = player?.currentItem, let fullRange = item.durationRange, let playbackRange = item.playbackRange else { return } // Prevent infinite recursion. guard !skipNextUpdate else { skipNextUpdate = false updateClosure(playbackRange.minimumRangeLength(of: minimumTrimDuration, in: fullRange)) return } guard playbackRange.length > minimumTrimDuration else { skipNextUpdate = true item.playbackRange = playbackRange.minimumRangeLength(of: minimumTrimDuration, in: fullRange) return } updateClosure(playbackRange) } } fileprivate func setupTrimmingObserver() { trimmingCancellable = Task { do { try await activateTrimming() addCheckerboardView() hideTrimButtons() window?.makeFirstResponder(self) } catch {} } .toCancellable } fileprivate func setPlayPauseButton(isEnabled: Bool) { guard let avTrimView = firstSubview(deep: true, where: { $0.simpleClassName == "AVTrimView" }), let superview = avTrimView.superview else { return } let playPauseButton = superview .subviews .first { $0 != avTrimView }? .subviews .first { guard let button = ($0 as? NSButton), button.action?.description == "playPauseButtonPressed:" else { return false } return true } as? NSButton guard let playPauseButton else { return } playPauseButton.isEnabled = isEnabled } fileprivate func hideTrimButtons() { // This method is a collection of hacks, so it might be acting funky on different OS versions. guard let avTrimView = firstSubview(deep: true, where: { $0.simpleClassName == "AVTrimView" }), let superview = avTrimView.superview else { return } // First find the constraints for `avTrimView` that pins to the left edge of the button. // Then replace the left edge of a button with the right edge - this will stretch the trim view. if let constraint = superview.constraints.first(where: { ($0.firstItem as? NSView) == avTrimView && $0.firstAttribute == .right }) { superview.removeConstraint(constraint) constraint.changing(secondAttribute: .right).isActive = true } if let constraint = superview.constraints.first(where: { ($0.secondItem as? NSView) == avTrimView && $0.secondAttribute == .right }) { superview.removeConstraint(constraint) constraint.changing(firstAttribute: .right).isActive = true } // Now find buttons that are not images (images are playing controls) and hide them. superview.subviews .first { $0 != avTrimView }? .subviews .filter { ($0 as? NSButton)?.image == nil } .forEach { $0.isHidden = true } } fileprivate func addCheckerboardView() { let overlayView = NSHostingView(rootView: CheckerboardView(clearRect: videoBounds)) contentOverlayView?.addSubview(overlayView) overlayView.constrainEdgesToSuperview() } /** Prevent user from dismissing trimming view. */ override func cancelOperation(_ sender: Any?) {} } @MainActor private class TrimmerDragViews { private var avTrimView: NSView /** The view that holds the entire trimmer. The supermost view. */ private var fullTrimmerView: CustomCursorView private var avTrimViewParent: NSView private var drawHandleView: NSHostingView var isDraggable = false { didSet { if isDraggable { showDrag() } else { hideDrag() } } } /** The initial offset of the trimmer from the bottom before we drag it anywhere. */ static let dragBarHeight = 17.0 static let newHeight = 87.0 static let dragBarTopAnchor = 6.0 /** These offsets are computed before we swap the trimmer. */ private let trimmerConstraints: TrimmerConstraints init(avTrimView: NSView, avTrimViewParent: NSView, isDraggable: Bool) { self.avTrimView = avTrimView self.avTrimViewParent = avTrimViewParent self.fullTrimmerView = CustomCursorView() self.drawHandleView = NSHostingView(rootView: DragHandleView()) self.trimmerConstraints = TrimmerConstraints(avTrimViewParent: avTrimViewParent) swapTrimmerSuperviews() self.isDraggable = isDraggable let panGesture = NSPanGestureRecognizer(target: self, action: #selector(handleDrag(_:))) panGesture.delaysPrimaryMouseButtonEvents = false fullTrimmerView.addGestureRecognizer(panGesture) } /** Remove the `avTrimViewParent` from its old location in the view hierarchy and swap with our `fullTrimmerView`. */ private func swapTrimmerSuperviews() { // The view that previously held the full trimmer view. guard let oldSuperview = avTrimViewParent.superview else { return } avTrimViewParent.removeFromSuperview() fullTrimmerView.translatesAutoresizingMaskIntoConstraints = false fullTrimmerView.addSubview(avTrimViewParent) oldSuperview.addSubview(fullTrimmerView) avTrimViewParent.constrainEdgesToSuperview() trimmerConstraints.apply(toNewView: fullTrimmerView, avTrimViewParentSuperView: oldSuperview) } private func showDrag() { fullTrimmerView.addSubview(drawHandleView) drawHandleView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ drawHandleView.leadingAnchor.constraint(equalTo: fullTrimmerView.leadingAnchor, constant: 0), drawHandleView.trailingAnchor.constraint(equalTo: fullTrimmerView.trailingAnchor, constant: 0), drawHandleView.topAnchor.constraint(equalTo: fullTrimmerView.topAnchor, constant: Self.dragBarTopAnchor), drawHandleView.heightAnchor.constraint(equalToConstant: Self.dragBarHeight) ]) fullTrimmerHeightConstraint?.constant = Self.newHeight trimmerWindowTopConstraint?.constant = Self.newHeight - trimmerConstraints.height trimmerBottomConstraint?.animate(to: trimmerConstraints.height, duration: .seconds(0.3)) { guard self.isDraggable else { return } self.avTrimView.isHidden = true } } private func hideDrag() { Task { // Defer the NSHostingView removal to avoid reentrant layout, which happens if this code runs on the main actor drawHandleView.removeFromSuperview() avTrimView.isHidden = false trimmerBottomConstraint?.animate(to: trimmerConstraints.bottomOffset, duration: .seconds(0.3)) fullTrimmerHeightConstraint?.constant = trimmerConstraints.height trimmerWindowTopConstraint?.constant = 0 } } /** Bound the view so that it can only go just a bit below the bottom and to the top. Then also bound the drag gesture so that drags outside the view bounds won't affect the drag. */ @objc private func handleDrag(_ gesture: NSPanGestureRecognizer) { guard isDraggable, let view = gesture.view, let superview = view.superview, let trimmerBottomConstraint else { return } let endLocation = gesture.location(in: superview).y let translation = gesture.translation(in: superview).y let startLocation = endLocation - translation defer { gesture.setTranslation(.zero, in: superview) } let bounds = superview.bounds.minY...superview.bounds.maxY guard bounds.contains(startLocation) else { return } let boundedTranslation = endLocation.clamped(to: bounds) - startLocation let newBottom = (trimmerBottomConstraint.constant - boundedTranslation).clamped(to: -superview.bounds.height + view.frame.height...trimmerConstraints.height) trimmerBottomConstraint.constant = newBottom avTrimView.isHidden = newBottom > trimmerConstraints.height - 2 } private lazy var fullTrimmerHeightConstraint: NSLayoutConstraint? = fullTrimmerView.constraints.first { $0.firstAttribute == .height && $0.firstItem as? NSView == fullTrimmerView } private lazy var trimmerBottomConstraint: NSLayoutConstraint? = fullTrimmerView.getConstraintFromSuperview(attribute: .bottom) private lazy var trimmerWindowTopConstraint: NSLayoutConstraint? = avTrimView.getConstraintFromSuperview(attribute: .top) /** Grab the constraints on the trimmer while it is still constrained to its superview, so that when we move it to a new superview it will have no visual change. */ @MainActor private struct TrimmerConstraints { let bottomOffset: Double let leadingOffset: Double let trailingOffset: Double let height: Double init(avTrimViewParent: NSView) { self.bottomOffset = -(avTrimViewParent.getConstraintConstantFromSuperView(attribute: .bottom) ?? 6.0) self.leadingOffset = avTrimViewParent.getConstraintConstantFromSuperView(attribute: .leading) ?? 6.0 self.trailingOffset = -(avTrimViewParent.getConstraintConstantFromSuperView(attribute: .trailing) ?? 6.0) self.height = avTrimViewParent.getConstraintConstantFromSuperView(attribute: .height) ?? 64.0 } /** Apply the saved constraints to a new container view, placing it in the same position as avTrimViewParent used to be. */ func apply( toNewView newView: NSView, avTrimViewParentSuperView oldSuperview: NSView ) { NSLayoutConstraint.activate([ newView.leadingAnchor.constraint(equalTo: oldSuperview.leadingAnchor, constant: leadingOffset), newView.bottomAnchor.constraint(equalTo: oldSuperview.bottomAnchor, constant: bottomOffset), newView.trailingAnchor.constraint(equalTo: oldSuperview.trailingAnchor, constant: trailingOffset), newView.heightAnchor.constraint(equalToConstant: height) ]) } } private class CustomCursorView: NSView { var cursor = NSCursor.arrow override func resetCursorRects() { super.resetCursorRects() addCursorRect(bounds, cursor: cursor) } } private struct DragHandleView: View { var body: some View { ZStack { Color.clear .contentShape(.rect) RoundedRectangle(cornerRadius: 2) .fill(Color.white) .frame(width: 128, height: 4) .padding() } .pointerStyle(.rowResize) } } } ================================================ FILE: Gifski/Constants.swift ================================================ import SwiftUI import CoreTransferable import AVFoundation enum Constants { static let allowedFrameRate = 3.0...50.0 static let loopCountRange = 0...100 } extension Defaults.Keys { static let outputQuality = Key("outputQuality", default: 1) static let outputSpeed = Key("outputSpeed", default: 1) static let outputFPS = Key("outputFPS", default: 10) static let loopGIF = Key("loopGif", default: true) static let bounceGIF = Key("bounceGif", default: false) static let suppressKeyframeWarning = Key("suppressKeyframeWarning", default: false) } enum Route: Hashable { case edit(URL, AVAsset, AVAsset.VideoMetadata) case conversion(GIFGenerator.Conversion) case completed(Data, URL) } struct ExportableGIF: Transferable { let url: URL static var transferRepresentation: some TransferRepresentation { FileRepresentation(exportedContentType: .gif) { .init($0.url) } // TODO: Does not work when using `.fileExporter`. (macOS 14.3) .suggestedFileName { $0.url.filename } } } ================================================ FILE: Gifski/ConversionScreen.swift ================================================ import SwiftUI import AVFoundation import DockProgress struct ConversionScreen: View { @Environment(\.dismiss) private var dismiss @Environment(AppState.self) private var appState @State private var progress = 0.0 @State private var timeRemaining: String? @State private var startTime: Date? let conversion: GIFGenerator.Conversion var body: some View { VStack { ProgressView(value: progress) .progressViewStyle( .ssCircular( fill: LinearGradient( gradient: .init( colors: [ .purple, .pink, .orange ] ), startPoint: .top, endPoint: .bottom ), lineWidth: 30, text: "Converting" ) ) .frame(width: 300, height: 300) .overlay { Group { if let timeRemaining { Text(timeRemaining) .font(.subheadline) .monospacedDigit() .offset(y: 24) } } .animation(.default, value: timeRemaining == nil) } .offset(y: -16) // Makes it centered (needed because of toolbar). } .fillFrame() .onKeyboardShortcut(.escape, modifiers: []) { dismiss() } .navigationTitle("") .task(priority: .utility) { do { try await convert() } catch { if !(error is CancellationError) { print("Conversion error:", error) appState.error = error } // So it doesn't get triggered when we press Escape to cancel. if !Task.isCancelled { dismiss() } } } .activity(options: .userInitiated, reason: "Converting") } func convert() async throws { startTime = .now defer { timeRemaining = nil DockProgress.resetProgress() } let data = try await GIFGenerator.run(conversion) { progress in self.progress = progress updateEstimatedTimeRemaining(for: progress) // This should not be needed. It silences a thread sanitizer warning. Task { @MainActor in DockProgress.progress = progress } } try Task.checkCancellation() let filename = conversion.sourceURL.filenameWithoutExtension let url = try data.writeToUniqueTemporaryFile(filename: filename, contentType: .gif) try? url.setAppAsItemCreator() try await Task.sleep(for: .seconds(1)) // Let the progress circle finish. // TODO: Support task cancellation. // TODO: Make sure it deinits too. // appState.navigationPath.removeLast() // appState.navigationPath.append(.completed(data)) // This works around some race issue where it would sometimes end up with edit screen after conversion. var path = appState.navigationPath path.removeLast() path.append(.completed(data, url)) appState.navigationPath = path } private func updateEstimatedTimeRemaining(for progress: Double) { guard progress > 0, let startTime else { timeRemaining = nil return } /** The delay before revealing the estimated time remaining, allowing the estimation to stabilize. */ let bufferDuration = Duration.seconds(3) /** Don't show the estimate at all if the total time estimate (after it stabilizes) is less than this amount. */ let skipThreshold = Duration.seconds(10) /** Begin fade out when remaining time reaches this amount. */ let fadeOutThreshold = Duration.seconds(1) let elapsed = Duration.seconds(Date.now.timeIntervalSince(startTime)) let remaining = (elapsed / progress) * (1 - progress) let total = elapsed + remaining guard elapsed > bufferDuration, remaining > fadeOutThreshold, total > skipThreshold else { timeRemaining = nil return } let formatter = DateComponentsFormatter() formatter.unitsStyle = .full formatter.includesApproximationPhrase = true formatter.includesTimeRemainingPhrase = true formatter.allowedUnits = remaining < .seconds(60) ? .second : [.hour, .minute] timeRemaining = formatter.string(from: remaining.toTimeInterval) } } ================================================ FILE: Gifski/Credits.rtf ================================================ {\rtf1\ansi\ansicpg1252\cocoartf2638 \cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fswiss\fcharset0 Helvetica;\f1\fswiss\fcharset0 Helvetica-Bold;} {\colortbl;\red255\green255\blue255;\red0\green0\blue0;} {\*\expandedcolortbl;;\cssrgb\c0\c0\c0\c84706\cname labelColor;} \paperw11900\paperh16840\margl1440\margr1440\vieww8040\viewh6780\viewkind0 \pard\tx566\tx1133\tx1700\tx2267\tx2834\tx3401\tx3968\tx4535\tx5102\tx5669\tx6236\tx6803\pardirnatural\qc\partightenfactor0 \f0\fs24 \cf2 \ \pard\tx566\tx1133\tx1700\tx2267\tx2834\tx3401\tx3968\tx4535\tx5102\tx5669\tx6236\tx6803\sl288\slmult1\pardirnatural\qc\partightenfactor0 \f1\b \cf2 Created by \f0\b0 \ {\field{\*\fldinst{HYPERLINK "https://github.com/sindresorhus"}}{\fldrslt Sindre Sorhus}}\ {\field{\*\fldinst{HYPERLINK "https://github.com/kornelski"}}{\fldrslt Kornel Lesi\uc0\u324 ski}}\ {\field{\*\fldinst{HYPERLINK "https://github.com/sindresorhus/Gifski/graphs/contributors"}}{\fldrslt awesome contributors}}} ================================================ FILE: Gifski/Crop/CropDragGestureModifier.swift ================================================ import SwiftUI extension View { func cropDragGesture( isDragging: Binding, cropRect: Binding, frame: CGRect, dimensions: CGSize, position: CropHandlePosition, dragMode: CropRect.DragMode ) -> some View { modifier( CropDragGestureModifier( isDragging: isDragging, cropRect: cropRect, frame: frame, dimensions: dimensions, position: position, dragMode: dragMode ) ) } } struct CropDragGestureModifier: ViewModifier { @GestureState private var initialCropRect: CropRect? @Binding var isDragging: Bool @Binding var cropRect: CropRect let frame: CGRect let dimensions: CGSize let position: CropHandlePosition let dragMode: CropRect.DragMode func body(content: Content) -> some View { let dragGesture = DragGesture() .updating($initialCropRect) { _, state, _ in state = state ?? cropRect } .onChanged { drag in guard let initial = initialCropRect else { return } isDragging = true cropRect = initial.applyDragToCropRect( drag: drag, frame: frame, dimensions: dimensions, position: position, dragMode: dragMode ) } .onEnded { _ in isDragging = false } content.highPriorityGesture(dragGesture) } } ================================================ FILE: Gifski/Crop/CropHandlePosition.swift ================================================ import SwiftUI enum CropHandlePosition: CaseIterable { case top case topRight case right case bottomRight case bottom case bottomLeft case left case topLeft case center var location: UnitPoint { sides.location } var isVerticalOnlyHandle: Bool { sides.isVerticalOnlyHandle } var isLeft: Bool { sides.isLeft } var isRight: Bool { sides.isRight } var isTop: Bool { sides.isTop } var isBottom: Bool { sides.isBottom } var isCorner: Bool { switch self { case .topLeft, .topRight, .bottomLeft, .bottomRight: true case .bottom, .top, .left, .right, .center: false } } var sides: RectSides { switch self { case .top: .init(horizontal: .center, vertical: .primary) case .topRight: .init(horizontal: .secondary, vertical: .primary) case .right: .init(horizontal: .secondary, vertical: .center) case .bottomRight: .init(horizontal: .secondary, vertical: .secondary) case .bottom: .init(horizontal: .center, vertical: .secondary) case .bottomLeft: .init(horizontal: .primary, vertical: .secondary) case .left: .init(horizontal: .primary, vertical: .center) case .topLeft: .init(horizontal: .primary, vertical: .primary) case .center: .init(horizontal: .center, vertical: .center) } } private var pointerPosition: FrameResizePosition { Self.positionToPointer[self] ?? .top } private static let positionToPointer: [Self: FrameResizePosition] = [ .top: .top, .topRight: .topTrailing, .right: .trailing, .bottomRight: .bottomTrailing, .bottom: .bottom, .bottomLeft: .bottomLeading, .left: .leading, .topLeft: .topLeading, .center: .top ] var pointerStyle: PointerStyle { if self == .center { return .grabIdle } return .frameResize(position: pointerPosition) } } struct RectSides: Equatable, Hashable { let horizontal: Side let vertical: Side var isVerticalOnlyHandle: Bool { horizontal == .center && vertical != .center } var isLeft: Bool { horizontal == .primary } var isRight: Bool { horizontal == .secondary } var isTop: Bool { vertical == .primary } var isBottom: Bool { vertical == .secondary } var location: UnitPoint { .init(x: horizontal.location, y: vertical.location) } } /** A position on a rectangle. Primary means left or top, secondary means right or bottom. Center is in the center. */ enum Side: Hashable { case primary case center case secondary /** Location in the crop, from 0-1. */ var location: Double { switch self { case .primary: 0 case .center: 0.5 case .secondary: 1 } } } ================================================ FILE: Gifski/Crop/CropOverlayView.swift ================================================ import SwiftUI import AVFoundation import AVKit struct CropOverlayView: View { @State private var dragMode = CropRect.DragMode.normal @State private var isDragging = false // swiftlint:disable:next discouraged_optional_boolean @State private var windowIsMovable: Bool? @State private var window: NSWindow? @Binding var cropRect: CropRect let dimensions: CGSize var editable: Bool var body: some View { GeometryReader { geometry in let frame = geometry.frame(in: .local) let cropFrame = cropRect.unnormalize(forDimensions: frame.size) ZStack { Canvas { context, size in // Darken the entire video by drawing a transparent black color, then "cut-out" the section of what we are cropping. If we are editing we then draw a white outline over our path let entireCanvasPath = Path { path in path.addRect(.init(origin: .zero, size: size)) } context.fill(entireCanvasPath, with: .color(.black.opacity(0.5))) let holePath = Path { path in path.addRect(cropFrame) } context.blendMode = .clear context.fill(holePath, with: .color(.black)) if editable { context.blendMode = .normal context.stroke(holePath, with: .color(.white), lineWidth: 1) } } if editable { Color .clear .contentShape( Path { path in path.addRect(cropFrame.insetBy(dx: 5, dy: 5)) } ) .pointerStyle(isDragging ? .grabActive : .grabIdle) .cropDragGesture( isDragging: $isDragging, cropRect: $cropRect, frame: frame, dimensions: dimensions, position: .center, dragMode: dragMode ) } if isDragging { DraggingSections(cropFrame: cropFrame) .stroke(Color.white) .allowsHitTesting(false) } if editable { ForEach(CropHandlePosition.allCases, id: \.self) { position in if position != .center { HandleView( position: position, cropRect: $cropRect, frame: frame, dimensions: dimensions, cropFrame: cropFrame, dragMode: dragMode, isDragging: $isDragging ) } } } } } /** The setter is necessary because there are lifecycle changes not captured by the $binding. For example consider this: ```swift struct SomeView: View { @State var window: NSWindow? var body: some View { Color.clear() .bindHostingWindow($window) .onDisappear { /* By the time this is called `window` is already nil */ assert(window == nil) } .accessHostingWindow { window in /** When view disappears this is never called. */ } .onChange(of: window) { old, new in /** When the view disappears this is never called. */ } } } ``` This is because on view disappear the following events happen in order: 1. `viewDidMoveToWindow` with `window` == nil 2. Then `onDisappear` is called ∞. ` accessHostingWindow` and `onChange` are never called because SwiftUI does not build the view again when disappearing I need a custom setter to capture all changes before the the view disappears, and I can't use `accessHostingWindow` or `onChange(of:)` or `onDisappear` */ .bindHostingWindow( .init( get: { window }, set: { newWindow in guard newWindow != window else { return } // Defer window property modifications to avoid circular updates during view lifecycle DispatchQueue.main.async { if let windowIsMovable { window?.isMovableByWindowBackground = windowIsMovable } windowIsMovable = newWindow?.isMovableByWindowBackground newWindow?.isMovableByWindowBackground = false } window = newWindow } ) ) .onModifierKeysChanged(mask: [.option, .shift]) { _, new in dragMode = { if new.contains(.option) { if new.contains(.shift) { return .aspectRatioLockScale } return .symmetric } if new.contains(.shift) { return .scale } return .normal }() } } /** The four lines that divide your crop into sections that appear when dragging. */ private struct DraggingSections: Shape { var cropFrame: CGRect func path(in rect: CGRect) -> Path { var path = Path() for factor in [1.0 / 3.0, 2.0 / 3.0] { let x = cropFrame.minX + cropFrame.width * factor path.move(to: CGPoint(x: x, y: cropFrame.minY)) path.addLine(to: CGPoint(x: x, y: cropFrame.maxY)) let y = cropFrame.minY + cropFrame.height * factor path.move(to: CGPoint(x: cropFrame.minX, y: y)) path.addLine(to: CGPoint(x: cropFrame.maxX, y: y)) } return path } } private struct HandleView: View { private static let cornerLineWidth = 3.0 private static let cornerWidthHeight = 28.0 let position: CropHandlePosition @Binding var cropRect: CropRect let frame: CGRect let dimensions: CGSize var cropFrame: CGRect var dragMode: CropRect.DragMode @Binding var isDragging: Bool var body: some View { Group { if [.top, .left, .right, .bottom].contains(position) { SideHandleView( cropFrame: cropFrame, position: position ) } else { CornerLine(corner: position) } } .pointerStyle(position.pointerStyle) .position(canvasPosition) .cropDragGesture( isDragging: $isDragging, cropRect: $cropRect, frame: frame, dimensions: dimensions, position: position, dragMode: dragMode ) } /** Where to place this handle in the canvas. Top is at the top, bottom is at the bottom, etc. */ private var canvasPosition: CGPoint { let inset = (Self.cornerWidthHeight + Self.cornerLineWidth) / 2.0 - 3.0 let adjustedFrame = position.isCorner ? cropFrame.insetBy(dx: inset, dy: inset) : cropFrame return CGPoint( x: adjustedFrame.minX + adjustedFrame.width * position.location.x, y: adjustedFrame.minY + adjustedFrame.height * position.location.y ) } /** The handles for top, bottom, left, and right. They are invisible and used only to change the pointer and handle drags. */ struct SideHandleView: View { var cropFrame: CGRect var position: CropHandlePosition var body: some View { ZStack { Color.clear .frame( width: sideViewSize.width, height: sideViewSize.height ) .contentShape( Path { path in // A rectangle around the drag used to catch hits so we can drag. let hitBoxSize = 20.0 if position.isVerticalOnlyHandle { path.addRect(.init( origin: .init(x: 0, y: -hitBoxSize / 2.0), width: sideViewSize.width, height: hitBoxSize )) return } path.addRect(.init( origin: .init(x: -hitBoxSize / 2.0, y: 0), width: hitBoxSize, height: sideViewSize.height )) } ) } } private var sideViewSize: CGSize { switch position.isVerticalOnlyHandle { case true: CGSize(width: max(0.0, cropFrame.width - HandleView.cornerWidthHeight * 2.0), height: 2.0) case false: CGSize(width: 2.0, height: max(0.0, cropFrame.height - HandleView.cornerWidthHeight * 2.0)) } } } private struct CornerLine: View { @Environment(\.displayScale) private var displayScale private let hitboxExtensionSize = 5.0 let corner: CropHandlePosition var body: some View { CornerLineShape(displayScale: displayScale, corner: corner) .stroke(Color.white, lineWidth: HandleView.cornerLineWidth) .contentShape( Rectangle() .size(.init(widthHeight: HandleView.cornerWidthHeight + hitboxExtensionSize)) .offset(offset) ) .frame(width: HandleView.cornerWidthHeight, height: HandleView.cornerWidthHeight) } var offset: CGSize { let sx = corner.location.x * 2 - 1 let sy = corner.location.y * 2 - 1 return .init(width: sx * hitboxExtensionSize, height: sy * hitboxExtensionSize) } /** The bent line at the corners. */ private struct CornerLineShape: Shape { let displayScale: Double let corner: CropHandlePosition func path(in rect: CGRect) -> Path { var path = Path() guard !rect.width.isNaN, !rect.height.isNaN else { return path } let tab = displayScale == 1.0 ? 0.0 : -2.0 let insetRect = rect.insetBy(dx: 3, dy: 3) let inset = -3.0 let base: [CGPoint] = [ .init(x: -tab, y: insetRect.height), .init(x: 0 - inset, y: insetRect.height), .init(x: 0 - inset, y: 0 - inset), .init(x: insetRect.width, y: 0 - inset), .init(x: insetRect.width, y: -tab) ] let transforms: [CropHandlePosition: CGAffineTransform] = [ .topLeft: .identity.translatedBy(x: rect.minX, y: rect.minY), .topRight: CGAffineTransform(scaleX: -1, y: 1) .translatedBy(x: -rect.minX - rect.width, y: rect.minY), .bottomRight: CGAffineTransform(scaleX: -1, y: -1) .translatedBy(x: -rect.minX - rect.width, y: -rect.minY - rect.height), .bottomLeft: CGAffineTransform(scaleX: 1, y: -1) .translatedBy(x: rect.minX, y: -rect.minY - rect.height) ] guard let transform = transforms[corner] else { return path } path.move(to: base[0].applying(transform)) for point in base.dropFirst() { path.addLine(to: point.applying(transform)) } return path } } } } } ================================================ FILE: Gifski/Crop/CropRect.swift ================================================ import SwiftUI /** Represents a crop rect. Both size and origin are unit points, so it does not matter what the aspect of the source is. */ struct CropRect: Equatable { var origin: UnitPoint var size: UnitSize } extension CropRect { static let initialCropRect = Self(x: 0, y: 0, width: 1, height: 1) init( x: Double, y: Double, width: Double, height: Double ) { self.origin = .init(x: x, y: y) self.size = .init(width: width, height: height) } var width: Double { size.width } var height: Double { size.height } var x: Double { origin.x } var y: Double { origin.y } var midX: Double { origin.x + (size.width / 2) } var midY: Double { origin.y + (size.height / 2) } enum Axis { case horizontal case vertical // swiftlint:disable:next no_cgfloat var origin: WritableKeyPath { switch self { case .horizontal: \.x case .vertical: \.y } } var size: WritableKeyPath { switch self { case .horizontal: \.width case .vertical: \.height } } } func mid(axis: Axis) -> Double { switch axis { case .horizontal: midX case .vertical: midY } } var isReset: Bool { origin.x == 0 && origin.y == 0 && size.width == 1 && size.height == 1 } /** Produce an unnormalized `CGRect` in pixels. */ func unnormalize(forDimensions dimensions: CGSize) -> CGRect { .init( x: dimensions.width * x, y: dimensions.height * y, width: dimensions.width * width, height: dimensions.height * height ) } func unnormalize(forDimensions dimensions: (Int, Int)) -> CGRect { unnormalize(forDimensions: .init(width: Double(dimensions.0), height: Double(dimensions.1))) } /** Creates a new `CropRect` with a given aspect ratio. If the crop rect is full screen, it potentially expands the crop (lengthening the longest side of the rect), otherwise it will keep the crop inside the current crop rect (trying to keep the longest side of the rect the same, unless it would exceed the video dimensions). */ func withAspectRatio( aspectWidth: Double, aspectHeight: Double, forDimensions dimensions: CGSize ) -> Self { if width == 1.0 || height == 1.0 { return Self.centeredFrom( aspectWidth: aspectWidth, aspectHeight: aspectHeight, forDimensions: dimensions ) } return withAspectRatioInsideCurrentRect( aspectWidth: aspectWidth, aspectHeight: aspectHeight, withinVideoDimensions: dimensions ) } /** The range of valid numbers for the aspect ratio. */ static let defaultAspectRatioBounds = 1...99 /** Adjusts the crop rect to fit a specified aspect ratio inside the current rect and scaling down if necessary to ensure it remains within the given video dimensions. - Parameters: - aspectWidth: The width of the desired aspect ratio. - aspectHeight: The height of the desired aspect ratio. - dimensions: The dimensions of the video in pixels. - Returns: A new `CropRect` adjusted to the specified aspect ratio and constrained within the video dimensions. */ private func withAspectRatioInsideCurrentRect( aspectWidth: Double, aspectHeight: Double, withinVideoDimensions dimensions: CGSize ) -> Self { let cropRectInPixels = unnormalize(forDimensions: dimensions) let aspectSize = CGSize(width: aspectWidth, height: aspectHeight) let newLongestSide = withAspectRatioInsideCurrentRectLongestSide( cropRectInPixels: cropRectInPixels, aspectSize: aspectSize, withinVideoDimensions: dimensions ) let newAspect = Self.clampAspect( aspectRatio: aspectSize.aspectRatio, newLongestSide: newLongestSide ) return cropRectInPixels .centeredRectWith(size: newAspect * newLongestSide) .toCropRect(forVideoDimensions: dimensions) } private func withAspectRatioInsideCurrentRectLongestSide( cropRectInPixels: CGRect, aspectSize: CGSize, withinVideoDimensions dimensions: CGSize ) -> Double { let normalizedAspect = aspectSize.aspectRatio.normalizedAspectRatioSides let scaleBounds = Self.scaleBounds( videoDimensions: dimensions, center: cropRectInPixels.center, normalizedAspect: normalizedAspect ) let desiredScale = aspectSize .aspectFittedSize(targetWidthHeight: cropRectInPixels.size.longestSide)[keyPath: Self.desiredSide(aspectRatio: normalizedAspect.aspectRatio)] .toDouble return desiredScale.clamped(to: scaleBounds) } /** Adjusts the aspect ratio such that it is achievable (not too small). */ private static func clampAspect(aspectRatio: Double, newLongestSide: Double) -> CGSize { if aspectRatio >= 1.0 { return min(aspectRatio, newLongestSide / minRectWidthHeight).normalizedAspectRatioSides } return max(aspectRatio, minRectWidthHeight / newLongestSide).normalizedAspectRatioSides } // swiftlint:disable:next no_cgfloat private static func desiredSide(aspectRatio: Double) -> KeyPath { aspectRatio >= 1.0 ? \.width : \.height } private static func scaleBounds( videoDimensions dimensions: CGSize, center: CGPoint, normalizedAspect: CGSize ) -> ClosedRange { let maxScale = min( maxScaleForSide( in: 0...dimensions.width, center: center.x, normalizedAspectOfSide: normalizedAspect.width ), maxScaleForSide( in: 0...dimensions.height, center: center.y, normalizedAspectOfSide: normalizedAspect.height ) ) return 0...maxScale } private static func maxScaleForSide( in range: ClosedRange, center: Double, normalizedAspectOfSide: Double = 1.0 ) -> Double { min(center - range.lowerBound, range.upperBound - center) * 2.0 / normalizedAspectOfSide } /** Returns a new `CGRect` trying to expand or shrink the size. If the size is within the video bounds it will expand, otherwise it will change the center position and expand) */ func changeSize(size: UnitSize, minSize: UnitSize) -> Self { var out = Self(x: 0.0, y: 0.0, width: 0.0, height: 0.0) changeSizeFor(axis: .horizontal, size: size, minSize: minSize, out: &out) changeSizeFor(axis: .vertical, size: size, minSize: minSize, out: &out) return out } private func changeSizeFor( axis: Axis, size: UnitSize, minSize: UnitSize, out: inout Self ) { let newSideLength = size[keyPath: axis.size].clamped( from: minSize[keyPath: axis.size], to: 1.0 ) var newOrigin = mid(axis: axis) - newSideLength / 2.0 if newOrigin < 0 { newOrigin = 0 } else if newOrigin + newSideLength > 1.0 { newOrigin = 1.0 - newSideLength } out.origin[keyPath: axis.origin] = newOrigin out.size[keyPath: axis.size] = newSideLength } } extension CropRect { enum DragMode { case normal case symmetric case scale case aspectRatioLockScale } /** The minimum crop rect width/height in pixels. As crop rectangles use unit points for size, you need a frame to convert a crop rect to pixels and use `CropRect.minSize`. */ static let minRectWidthHeight = 100.0 static func minSize(videoSize: CGSize) -> UnitSize { .init(width: minRectWidthHeight / videoSize.width, height: minRectWidthHeight / videoSize.height) } /** Returns a crop rect centered in a video from an aspect and dimensions. */ static func centeredFrom( aspectWidth: Double, aspectHeight: Double, forDimensions dimensions: CGSize ) -> CropRect { let aspectSize = CGSize(width: aspectWidth, height: aspectHeight) let fittedSize = aspectSize.aspectFittedSize( targetWidth: dimensions.width, targetHeight: dimensions.height ) let newAspect = clampAspect( aspectRatio: aspectSize.aspectRatio, newLongestSide: fittedSize.longestSide ) let newSize = newAspect * fittedSize.longestSide let cropWidth = newSize.width / dimensions.width let cropHeight = newSize.height / dimensions.height return .init( origin: .init( x: 0.5 - cropWidth / 2.0, y: 0.5 - cropHeight / 2.0 ), size: .init( width: cropWidth, height: cropHeight ) ) } func applyDragToCropRect( drag: DragGesture.Value, frame: CGRect, dimensions: CGSize, position: CropHandlePosition, dragMode: DragMode ) -> CropRect { let delta = getRelativeDragDelta(drag: drag, position: position, frame: frame) if position == .center { return applyCenterDrag(delta: delta) } let minSize = CropRect.minSize(videoSize: dimensions) return switch dragMode { case .normal: applyNormal( position: position, minSize: minSize, delta: delta ) case .symmetric: applySymmetric( position: position, minSize: minSize, delta: delta ) case .scale: applyScale( position: position, minSize: minSize, delta: delta ) case .aspectRatioLockScale: applyAspectRatioLock( minSize: minSize, dragLocation: drag.locationInside(frame: frame) ) } } func getRelativeDragDelta( drag: DragGesture.Value, position: CropHandlePosition, frame: CGRect ) -> UnitPoint { let dragStartAnchor: UnitPoint = switch position { case .bottom, .right, .center, .left, .top: .init(x: drag.startLocation.x / frame.width, y: drag.startLocation.y / frame.height) case .topLeft, .topRight, .bottomLeft, .bottomRight: .init( x: x + width * position.location.x, y: y + height * position.location.y ) } let dragLocation = drag.locationInside(frame: frame) return .init(x: dragLocation.x - dragStartAnchor.x, y: dragLocation.y - dragStartAnchor.y) } /** Drag the crop rect without scaling. Also prevents the crop rect from leaving the rect. */ func applyCenterDrag( delta: UnitPoint ) -> CropRect { .init( x: x + delta.x.clamped(from: -x, to: 1.0 - x - width), y: y + delta.y.clamped(from: -y, to: 1.0 - y - height), width: width, height: height ) } /** Apply normal dragging. If you grab the top-left corner, the bottom location and right-hand side location remains the same while the top and left sides move. Also prevents the crop rect from leaving the rect, and it has a minimum size. */ func applyNormal( position: CropHandlePosition, minSize: UnitSize, delta: UnitPoint ) -> CropRect { let (dx, dWidth) = Self.helpNormal( isPrimary: position.isLeft, isSecondary: position.isRight, origin: x, size: width, minSize: minSize.width, raw: delta.x ) let (dy, dHeight) = Self.helpNormal( isPrimary: position.isTop, isSecondary: position.isBottom, origin: y, size: height, minSize: minSize.height, raw: delta.y ) return .init( x: x + dx, y: y + dy, width: width + dWidth, height: height + dHeight ) } private static func helpNormal( isPrimary: Bool, isSecondary: Bool, origin: Double, size: Double, minSize: Double, raw: Double ) -> (Double, Double) { switch (isPrimary, isSecondary) { case (true, _): let dx = raw.clamped(from: -origin, to: size - minSize) return (dx, -dx) case (_, true): return (0.0, raw.clamped(from: minSize - size, to: (1.0 - origin) - size)) default: return (0.0, 0.0) } } /** Apply a scaling such that it is symmetric depending on drag direction. For example, if you drag a corner along the axis to the center, the entire rect will scale uniformly from the center. If you drag to the left, the entire crop rect will scale horizontally from the center, and so on. Also prevents the crop rect from leaving the rect, and it has minimum size. */ func applySymmetric( position: CropHandlePosition, minSize: UnitSize, delta: UnitPoint ) -> CropRect { let dx = Double(delta.x).clamped( to: Self.symmetricDeltaRange( primary: position.isLeft, secondary: position.isRight, origin: x, size: width, minSize: minSize.width ) ) let dy = Double(delta.y).clamped( to: Self.symmetricDeltaRange( primary: position.isTop, secondary: position.isBottom, origin: y, size: height, minSize: minSize.height ) ) let xSign = position.isLeft ? 1.0 : position.isRight ? -1.0 : 0.0 let ySign = position.isTop ? 1.0 : position.isBottom ? -1.0 : 0.0 return .init( x: x + xSign * dx, y: y + ySign * dy, width: width - 2 * xSign * dx, height: height - 2 * ySign * dy ) } /** For `applySymmetric`. - `primary` is left/top. - `secondary` is right/bottom. */ static func symmetricDeltaRange( primary: Bool, secondary: Bool, origin: Double, size: Double, minSize: Double ) -> ClosedRange { if primary { let lower = max(-origin, origin + size - 1) let upper = (size - minSize) / 2 return lower...upper } guard secondary else { return 0...0 } let lower = (minSize - size) / 2 let upper = min(origin, 1 - (origin + size)) return lower...upper } /** Scale the crop rect by finding an anchor point on the opposite side of the handle (so if you grab the top left, the anchor point would be on the bottom-right), then apply scale. Also prevents the crop rect from leaving the rect, and it has a minimum size. */ func applyScale( position: CropHandlePosition, minSize: UnitSize, delta: UnitPoint ) -> CropRect { let scaleX = (position.location.x * 2) - 1 let scaleY = (position.location.y * 2) - 1 let handleCount = max((abs(scaleX) > 0 ? 1 : 0) + (abs(scaleY) > 0 ? 1 : 0), 1) let (tempScale, anchorX) = Self.scaleAnchorPoint( origin: x, size: width, location: position.location.x, scale: 1 + (scaleX * delta.x / width + scaleY * delta.y / height) / Double(handleCount) ) var (scale, anchorY) = Self.scaleAnchorPoint( origin: y, size: height, location: position.location.y, scale: tempScale ) scale = max(scale, minSize.width / width, minSize.height / height) return .init( x: anchorX - (anchorX - x) * scale, y: anchorY - (anchorY - y) * scale, width: width * scale, height: height * scale ) } /** For `applyScale`. */ static func scaleAnchorPoint( origin: Double, size: Double, location: Double, scale inScale: Double ) -> (scale: Double, anchor: Double) { let anchor = origin + size * (1 - location) var scale = inScale if anchor > 0 { scale = min(anchor / (anchor - origin), scale) } if anchor < 1 { scale = min((1 - anchor) / (origin + size - anchor), scale) } return (scale: scale, anchor: anchor) } /** Scale the crop rect while maintaining aspect ratio. Also prevents the crop rect from leaving the rect, and it has minimum size. */ func applyAspectRatioLock( minSize: UnitSize, dragLocation: UnitPoint ) -> CropRect { let dx = abs(dragLocation.x - midX) let dy = abs(dragLocation.y - midY) let rawScale = max( dx / (width / 2), dy / (height / 2) ) let scaleRange = max( minSize.width / width, minSize.height / height )...[ 2 * midX / width, 2 * (1 - midX) / width, 2 * midY / height, 2 * (1 - midY) / height ].min()! let scale = rawScale.clamped(to: scaleRange) let newWidth = width * scale let newHeight = height * scale return CropRect( x: midX - newWidth / 2, y: midY - newHeight / 2, width: newWidth, height: newHeight ) } } /** A normalized 2D size in a view’s coordinate space. `UnitSize` is for sizes as `UnitPoint` is for points. */ struct UnitSize: Hashable { var width: Double var height: Double } extension DragGesture.Value { /** If you drag outside of the view's frame, this will clamp it back to an edge. */ func locationInside(frame: CGRect) -> UnitPoint { .init( x: location.x.clamped(from: frame.minX, to: frame.maxX) / frame.width, y: location.y.clamped(from: frame.minY, to: frame.maxY) / frame.height ) } } ================================================ FILE: Gifski/Crop/CropSettings.swift ================================================ import Foundation import AVKit protocol CropSettings { var dimensions: (width: Int, height: Int)? { get } var trackPreferredTransform: CGAffineTransform? { get } var crop: CropRect? { get } } extension GIFGenerator.Conversion: CropSettings {} extension CropSettings { /** We don't use `croppedOutputDimensions` here because the `CGImage` source may have a different size. We use the size directly from the image. If the rect parameter defines an area that is not in the image, it returns nil: https://developer.apple.com/documentation/coregraphics/cgimage/1454683-cropping */ func croppedImage(image: CGImage) -> CGImage? { guard crop != nil else { return image } let transformedCrop = unnormalizedCropRect(sizeInPreferredTransformationSpace: .init(width: image.width, height: image.height)) return image.cropping(to: transformedCrop) } /** Returns the unnormalized crop rect for an image that is already in the preferred transform space (i.e., already rotated). Since `AVAssetImageGenerator.appliesPreferredTrackTransform = true` and the preview manually applies the transform, images are always pre-rotated. The crop rect (which is defined in rotated space via the UI) can be applied directly. */ func unnormalizedCropRect(sizeInPreferredTransformationSpace preferredSize: CGSize) -> CGRect { guard let cropRect = crop else { return .init(origin: .zero, size: preferredSize) } return cropRect.unnormalize(forDimensions: preferredSize) } var croppedOutputDimensions: (width: Int, height: Int)? { guard crop != nil else { return dimensions } guard let dimensions else { return nil } let outputDimensions = unnormalizedCropRect(sizeInPreferredTransformationSpace: .init(width: dimensions.width, height: dimensions.height)) return (outputDimensions.width.toIntAndClampingIfNeeded, outputDimensions.height.toIntAndClampingIfNeeded) } } ================================================ FILE: Gifski/Crop/CropToolBarItems.swift ================================================ import SwiftUI import AVFoundation struct CropToolbarItems: View { @State private var showCropTooltip = false @Binding var isCropActive: Bool let metadata: AVAsset.VideoMetadata @Binding var outputCropRect: CropRect @FocusState private var isCropToggleFocused: Bool var body: some View { HStack { if isCropActive { AspectRatioPicker( metadata: metadata, outputCropRect: $outputCropRect ) } Toggle("Crop", systemImage: "crop", isOn: $isCropActive) .focused($isCropToggleFocused) .onChange(of: isCropActive) { isCropToggleFocused = true guard isCropActive else { return } SSApp.runOnce(identifier: "showCropTooltip") { showCropTooltip = true } } .popover(isPresented: $showCropTooltip) { TipsView(title: "Crop Tips", tips: Self.tips) } } } private static let tips = [ "• Hold Shift to scale both sides.", "• Hold Option to resize from the center.", "• Hold both to keep aspect ratio and resize from center." ] } private enum CustomFieldType { case pixel case aspect } private struct AspectRatioPicker: View { @State private var showEnterCustomAspectRatio = false @State private var customAspectRatio: PickerAspectRatio? @State private var customPixelSize = CGSize.zero @State private var modifiedCustomField: CustomFieldType? let metadata: AVAsset.VideoMetadata @Binding var outputCropRect: CropRect var body: some View { Menu(selectionText) { presetSection customSection otherSections } .onChange(of: customAspectRatio) { guard let customAspectRatio else { return } outputCropRect = outputCropRect.withAspectRatio( for: customAspectRatio, forDimensions: metadata.dimensions ) // Change the `customAspectRatio` to reflect the bounded crop rect (as in it is not too small on one side), but debounce it to let the user enter intermediate invalid values. Debouncer.debounce(delay: .seconds(2)) { let cropSizeRightNow = outputCropRect.unnormalize(forDimensions: metadata.dimensions).size let newRatio = PickerAspectRatio.closestAspectRatio( for: cropSizeRightNow, within: CropRect.defaultAspectRatioBounds ) guard newRatio.aspectRatio != self.customAspectRatio?.aspectRatio else { // Prevent simplification (like `25:5` -> `5:1`), only assign if the aspect ratio is new. return } self.customAspectRatio = newRatio } } .staticPopover(isPresented: $showEnterCustomAspectRatio) { CustomAspectRatioView( cropRect: $outputCropRect, customAspectRatio: $customAspectRatio, customPixelSize: $customPixelSize, modifiedCustomField: $modifiedCustomField, dimensions: metadata.dimensions ) } } private var selectionText: String { PickerAspectRatio.selectionText(for: aspect, customAspectRatio: customAspectRatio, videoDimensions: metadata.dimensions, cropRect: outputCropRect) } private var presetSection: some View { Section("Presets") { ForEach(PickerAspectRatio.presets, id: \.self) { aspectRatio in AspectToggle( aspectRatio: aspectRatio, outputCropRect: $outputCropRect, customAspectRatio: $customAspectRatio, currentAspect: aspect, dimensions: metadata.dimensions ) } } } @ViewBuilder private var customSection: some View { if let customAspectRatio, !customAspectRatio.matchesPreset() { Section("Custom") { AspectToggle( aspectRatio: customAspectRatio, outputCropRect: $outputCropRect, customAspectRatio: $customAspectRatio, currentAspect: aspect, dimensions: metadata.dimensions ) } } } @ViewBuilder private var otherSections: some View { Section { Button("Custom") { handleCustomAspectButton() } } Section { Button("Reset") { resetAspectRatio() } } } private var aspect: Double { let cropRectInPixels = outputCropRect.unnormalize(forDimensions: metadata.dimensions) return cropRectInPixels.width / cropRectInPixels.height } private func handleCustomAspectButton() { let cropSizeRightNow = outputCropRect.unnormalize(forDimensions: metadata.dimensions).size customAspectRatio = PickerAspectRatio.closestAspectRatio( for: cropSizeRightNow, within: CropRect.defaultAspectRatioBounds ) customPixelSize = cropSizeRightNow modifiedCustomField = nil showEnterCustomAspectRatio = true } private func resetAspectRatio() { customAspectRatio = nil outputCropRect = .initialCropRect } } private struct AspectToggle: View { var aspectRatio: PickerAspectRatio @Binding var outputCropRect: CropRect @Binding var customAspectRatio: PickerAspectRatio? var currentAspect: Double var dimensions: CGSize var body: some View { Toggle( aspectRatio.description, isOn: .init( get: { aspectRatio.aspectRatio.isAlmostEqual(to: currentAspect) }, set: { _ in outputCropRect = outputCropRect.withAspectRatio(for: aspectRatio, forDimensions: dimensions) } ) ) } } private struct CustomAspectRatioView: View { @Binding var cropRect: CropRect @Binding var customAspectRatio: PickerAspectRatio? @Binding var customPixelSize: CGSize @Binding var modifiedCustomField: CustomFieldType? var dimensions: CGSize var body: some View { VStack(spacing: 10) { HStack(spacing: 4) { CustomAspectField( customAspectRatio: $customAspectRatio, modifiedCustomField: $modifiedCustomField, side: \.width ) Text(":") .foregroundStyle(.secondary) CustomAspectField( customAspectRatio: $customAspectRatio, modifiedCustomField: $modifiedCustomField, side: \.height ) } .frame(width: 90) .opacity(modifiedCustomField == .pixel ? 0.7 : 1) HStack(spacing: 4) { CustomPixelField( customPixelSize: $customPixelSize, cropRect: $cropRect, modifiedCustomField: $modifiedCustomField, dimensions: dimensions, side: \.width ) Text("x") .foregroundStyle(.secondary) CustomPixelField( customPixelSize: $customPixelSize, cropRect: $cropRect, modifiedCustomField: $modifiedCustomField, dimensions: dimensions, side: \.height ) } .opacity(modifiedCustomField == .aspect ? 0.7 : 1) } .padding() .frame(width: 135) } } private struct CustomPixelField: View { @Binding var customPixelSize: CGSize @Binding var cropRect: CropRect @Binding var modifiedCustomField: CustomFieldType? var dimensions: CGSize // swiftlint:disable:next no_cgfloat let side: WritableKeyPath @State private var showWarning = false @State private var warningCount = 0 var body: some View { IntTextField( value: .init( get: { value }, set: { guard minMax.contains($0) else { return } var newSize = cropRect.size newSize[keyPath: unitSizeSide] = Double($0) / dimensions[keyPath: side] cropRect = cropRect.changeSize(size: newSize, minSize: CropRect.minSize(videoSize: dimensions)) if value != $0 { modifiedCustomField = .pixel } customPixelSize[keyPath: side] = Double($0) showWarning = false } ), minMax: minMax, alignment: isWidth ? .right : .left, font: .fieldFont, //swiftlint:disable:next trailing_closure onInvalid: { invalidValue in customPixelSize[keyPath: side] = Double(invalidValue.clamped(to: minMax)) warningCount += 1 showWarning = true } ) .onChange(of: warningCount) {} // Noop. Having the `warningCount` in the view hierarchy causes SwiftUI to refresh the `IntTextField` whenever an invalid value is entered even if we have already set the pixel size to `Self.minValue`. Can't use `.id()` modifier because it will close the popover. .frame(width: OS.isMacOS26OrLater ? 46 : 42.0) .popover2(isPresented: $showWarning) { VStack { Text("Value must be in the range \(minMax.lowerBound) to \(minMax.upperBound)") } .padding() } } var value: Int { Int(customPixelSize[keyPath: side].rounded()) } var isWidth: Bool { side == \.width } var minMax: ClosedRange { Int(CropRect.minRectWidthHeight)...Int(dimensions[keyPath: side]) } var unitSizeSide: WritableKeyPath { isWidth ? \.width : \.height } } private struct CustomAspectField: View { @Binding var customAspectRatio: PickerAspectRatio? @Binding var modifiedCustomField: CustomFieldType? let side: WritableKeyPath var body: some View { IntTextField( value: .init( get: { customAspectRatio?[keyPath: side] ?? 1 }, set: { guard var customAspectRatioCopy = customAspectRatio, $0 > 0 else { return } if customAspectRatioCopy[keyPath: side] != $0 { modifiedCustomField = .aspect } customAspectRatioCopy[keyPath: side] = $0.clamped(to: minMax) customAspectRatio = customAspectRatioCopy } ), minMax: minMax, alignment: side == \.width ? .right : .left, font: .fieldFont ) .frame(width: OS.isMacOS26OrLater ? 30 : 26) } var minMax: ClosedRange { CropRect.defaultAspectRatioBounds } var isWidth: Bool { side == \.width } var unitSizeSide: WritableKeyPath { isWidth ? \.width : \.height } } private struct TipsView: View { let title: String let tips: [String] var body: some View { VStack(alignment: .leading, spacing: 10) { Text(title) .font(.headline) ForEach(tips, id: \.self) { tip in Text(tip) } } .padding() .fixedSize() } } extension NSFont { fileprivate static var fieldFont: NSFont { monospacedDigitSystemFont(ofSize: 12, weight: .regular) } } ================================================ FILE: Gifski/Crop/PickerAspectRatio.swift ================================================ import Foundation struct PickerAspectRatio: Hashable { var width: Int var height: Int init(_ width: Int, _ height: Int) { self.width = width self.height = height } } extension PickerAspectRatio: CustomStringConvertible { var description: String { "\(width):\(height)" } } extension PickerAspectRatio { static let presets: [Self] = [ .init(16, 9), .init(4, 3), .init(1, 1), .init(9, 16), .init(3, 4) ] /** The description is the aspect ratio and the size in pixels for the given crop rect if were to switch to using this aspect ratio. */ func description( forVideoDimensions dimensions: CGSize, cropRect: CropRect ) -> String { "\(description) - \(cropRect.withAspectRatio(for: self, forDimensions: dimensions).unnormalize(forDimensions: dimensions).size.videoSizeDescription)" } var aspectRatio: Double { Double(width) / Double(height) } } extension PickerAspectRatio { func matchesPreset() -> Bool { Self.presets.contains { $0.isCloseTo(self.aspectRatio) } } func isCloseTo(_ aspect: Double, tolerance: Double = 0.01) -> Bool { abs(aspectRatio - aspect) < tolerance } static func selectionText( for aspect: Double, customAspectRatio: PickerAspectRatio?, videoDimensions: CGSize, cropRect: CropRect ) -> String { let allRatios = presets + (customAspectRatio.map { [$0] } ?? []) if let matchingRatio = allRatios.first(where: { $0.aspectRatio.isAlmostEqual(to: aspect) }) { return matchingRatio.description( forVideoDimensions: videoDimensions, cropRect: cropRect ) } let customSizeDescription = cropRect.unnormalize(forDimensions: videoDimensions).size.videoSizeDescription return "Custom - \(customSizeDescription)" } /** Calculates the closest current aspect ratio of the crop rec with width and height within the given range. First, it tries to calculate the greatest common divisor (GCD) of the width and height to simplify the ratio. If the the width and height of the ratio are both less than within the range, it uses that as the aspect ratio. Otherwise, it approximates the aspect ratio by finding the closest fraction with a denominator less than the upper bound of the range that matches the current aspect ratio as closely as possible. */ static func closestAspectRatio( for size: CGSize, within range: ClosedRange ) -> Self { let (intWidth, intHeight) = size.integerAspectRatio() if range.contains(intWidth), range.contains(intHeight) { return .init(intWidth, intHeight) } return approximateAspectRatio(for: size, within: range) } private static func approximateAspectRatio( for size: CGSize, within range: ClosedRange ) -> Self { // Calculate the aspect ratio as a floating-point value let aspect = size.width / size.height // Generate all possible numerator-denominator pairs within the range let bestPairMap = range.flatMap { denominator in let numerator = Int(round(aspect * Double(denominator))) return range.contains(numerator) ? [(numerator, denominator)] : [] } // Find the pair that most closely matches the aspect ratio let bestPair = bestPairMap.min { abs(Double($0.0) / Double($0.1) - aspect) < abs(Double($1.0) / Double($1.1) - aspect) } ?? (1, 1) return .init(bestPair.0, bestPair.1) } } extension CropRect { func withAspectRatio( for newRatio: PickerAspectRatio, forDimensions dimensions: CGSize ) -> CropRect { withAspectRatio( aspectWidth: Double(newRatio.width), aspectHeight: Double(newRatio.height), forDimensions: dimensions ) } } ================================================ FILE: Gifski/EditScreen.swift ================================================ import SwiftUI import AVFoundation struct EditScreen: View { @Environment(AppState.self) private var appState @State private var outputCropRect = CropRect.initialCropRect @State private var fullPreviewStream = FullPreviewStream() var url: URL var asset: AVAsset var metadata: AVAsset.VideoMetadata init(url: URL, asset: AVAsset, metadata: AVAsset.VideoMetadata) { self.url = url self.asset = asset self.metadata = metadata } var body: some View { _EditScreen( url: url, asset: asset, metadata: metadata, outputCropRect: $outputCropRect, overlay: NSHostingView(rootView: CropOverlayView( cropRect: $outputCropRect, dimensions: metadata.dimensions, editable: appState.isCropActive )), fullPreviewStream: fullPreviewStream ) } } private struct _EditScreen: View { @Environment(AppState.self) private var appState @Default(.outputQuality) private var outputQuality @Default(.bounceGIF) private var bounceGIF @Default(.outputFPS) private var frameRate @Default(.loopGIF) private var loopGIF @Default(.suppressKeyframeWarning) private var suppressKeyframeWarning @State private var url: URL @State private var asset: AVAsset @State private var modifiedAsset: AVAsset @State private var modifiedAssetTimeRange: CMTimeRange? @State private var metadata: AVAsset.VideoMetadata @State private var estimatedFileSizeModel = EstimatedFileSizeModel() @State private var timeRange: ClosedRange? @State private var loopCount = 0 @State private var isKeyframeRateChecked = false @State private var isReversePlaybackWarningPresented = false @State private var resizableDimensions = Dimensions.percent(1, originalSize: .init(widthHeight: 100)) @State private var shouldShow = false @State private var fullPreviewState = FullPreviewGenerationEvent.initialState @State private var fullPreviewDebouncer = Debouncer(delay: .milliseconds(200)) @Binding private var outputCropRect: CropRect @State private var exportModifiedVideoState = ExportModifiedVideoState.idle @State private var isExportModifiedVideoAudioWarningPresented = false private var overlay: NSView private let fullPreviewStream: FullPreviewStream @State private var lastSpeed: Double? init( url: URL, asset: AVAsset, metadata: AVAsset.VideoMetadata, outputCropRect: Binding, overlay: NSView, fullPreviewStream: FullPreviewStream ) { self._url = .init(wrappedValue: url) self._asset = .init(wrappedValue: asset) self._modifiedAsset = .init(wrappedValue: asset) self._metadata = .init(wrappedValue: metadata) self._outputCropRect = outputCropRect self.overlay = overlay self.fullPreviewStream = fullPreviewStream } var body: some View { VStack { trimmingAVPlayer controls bottomBar ExportModifiedVideoView( state: $exportModifiedVideoState, sourceURL: url, isAudioWarningPresented: $isExportModifiedVideoAudioWarningPresented ) } .background(.ultraThickMaterial) .navigationTitle(url.lastPathComponent) .navigationDocument(url) .toolbar { ToolbarItemGroup { if fullPreviewState.isGenerating { ProgressView(value: fullPreviewState.progress) .progressViewStyle(.circular) .controlSize(.mini) .scaleEffect(0.8) .overlay { if let fullPreviewStateErrorMessage = fullPreviewState.errorMessage { Color.clear .popover(isPresented: .constant(true)) { Text(fullPreviewStateErrorMessage) .padding() .frame(maxWidth: 300) } } } } Toggle( "Preview", systemImage: appState.shouldShowPreview && fullPreviewState.canShowPreview ? "eye" : "eye.slash", isOn: appState.toggleMode(mode: .preview) ) } // We have to use this as the glass background is buggy. .ss_sharedBackgroundVisibility_hidden() if #available(macOS 26, *) { ToolbarSpacer(.fixed) } ToolbarItemGroup { CropToolbarItems( isCropActive: appState.toggleMode(mode: .editCrop), metadata: metadata, outputCropRect: $outputCropRect ) .focusSection() } .ss_sharedBackgroundVisibility_hidden() } .onReceive(Defaults.publisher(.outputSpeed, options: [])) { _ in Debouncer.debounce(delay: .seconds(0.4)) { Task { await setSpeed() } } } // We cannot use `Defaults.publisher(.outputSpeed, options: [])` without the `options` as it causes some weird glitches. .task { await setSpeed() } .onChange(of: outputQuality, initial: true) { estimatedFileSizeModel.duration = metadata.duration estimatedFileSizeModel.updateEstimate() updatePreviewOnSettingsChange() } // TODO: Make these a single call when tuples are equatable. .onChange(of: resizableDimensions) { estimatedFileSizeModel.updateEstimate() updatePreviewOnSettingsChange() } .onChange(of: timeRange) { estimatedFileSizeModel.updateEstimate() updatePreviewOnSettingsChange() } .onChange(of: bounceGIF) { estimatedFileSizeModel.updateEstimate() } .onChange(of: frameRate) { estimatedFileSizeModel.updateEstimate() updatePreviewOnSettingsChange() } .onChange(of: bounceGIF) { guard bounceGIF else { return } showKeyframeRateWarningIfNeeded() } .alert2( "Reverse Playback Preview Limitation", message: "Reverse playback may stutter when the video has a low keyframe rate. The GIF will not have the same stutter.", isPresented: $isReversePlaybackWarningPresented ) .dialogSuppressionToggle(isSuppressed: $suppressKeyframeWarning) .opacity(shouldShow ? 1 : 0) .onAppear { setUp() appState.onExportAsVideo = onExportAsVideo } .onDisappear { appState.onExportAsVideo = nil switch exportModifiedVideoState { case .idle: break case .exporting(let task, _): task.cancel() case .finished(let url): try? FileManager.default.removeItem(at: url) } } .task { try? await Task.sleep(for: .seconds(0.3)) withAnimation { shouldShow = true } } .task { for await event in fullPreviewStream.eventStream { fullPreviewState = event } } } private func onExportAsVideo() { switch exportModifiedVideoState { case .idle: break case .exporting, .finished: // If another alert (like bounce warning) occurs when you activate this callback, the `fileExporter` modifier won't show and the state will be stuck on `.finished`. By reassigning the state this will force a swiftUI draw and bring up the file exporter. exportModifiedVideoState = exportModifiedVideoState return } if metadata.hasAudio { SSApp.runOnce(identifier: "audioTrackExportWarning") { isExportModifiedVideoAudioWarningPresented = true } } exportModifiedVideoState = .exporting( Task { do { let outputURL = try await exportModifiedVideo(conversion: conversionSettings) try await MainActor.run { try Task.checkCancellation() exportModifiedVideoState = .finished(outputURL) } } catch { if Task.isCancelled || error.isCancelled { return } await MainActor.run { exportModifiedVideoState = .idle appState.error = error } } }, videoIsOverTwentySeconds: conversionSettings.gifDuration(assetTimeRange: modifiedAssetTimeRange, withBounce: false) > .seconds(20) ) } private func updatePreviewOnSettingsChange() { guard appState.mode != .editCrop else { return } fullPreviewDebouncer { Task { let conversion = conversionSettings await fullPreviewStream.requestNewFullPreview( asset: conversion.asset, settingsEvent: .init( conversion: conversion, speed: Defaults[.outputSpeed], framesPerSecondsWithoutSpeedAdjustment: Defaults[.outputFPS], duration: metadata.duration.toTimeInterval ) ) } } } private func setSpeed() async { do { if Defaults[.outputSpeed] == lastSpeed { return } lastSpeed = Defaults[.outputSpeed] // We could have set the `rate` of the player instead of modifying the asset, but it's just easier to modify the asset as then it matches what we want to generate. Otherwise, we would have to translate trimming ranges to the correct speed, etc. let changedSpeedAsset = try await asset.firstVideoTrack?.extractToNewAssetAndChangeSpeed(to: Defaults[.outputSpeed]) ?? modifiedAsset modifiedAsset = try await PreviewableComposition(extractPreviewableCompositionFrom: changedSpeedAsset) modifiedAssetTimeRange = try await changedSpeedAsset.firstVideoTrack?.load(.timeRange) estimatedFileSizeModel.updateEstimate() updatePreviewOnSettingsChange() } catch { appState.error = error } } private func setUp() { estimatedFileSizeModel.getConversionSettings = { conversionSettings } updatePreviewOnSettingsChange() } /** Paused because the preview is generating the new preview. */ var previewPaused: Bool { appState.shouldShowPreview && fullPreviewState.isGenerating } private var trimmingAVPlayer: some View { // TODO: Move the trimmer outside the video view. TrimmingAVPlayer( asset: modifiedAsset, shouldShowPreview: appState.shouldShowPreview, fullPreviewState: fullPreviewState, loopPlayback: loopGIF, bouncePlayback: bounceGIF, speed: previewPaused ? 0.0 : 1.0, overlay: appState.shouldShowPreview ? nil : overlay, isPlayPauseButtonEnabled: !previewPaused, isTrimmerDraggable: appState.isCropActive ) { timeRange in DispatchQueue.main.async { self.timeRange = timeRange estimatedFileSizeModel.updateEstimate() updatePreviewOnSettingsChange() } } .onChange(of: appState.mode) { if appState.mode == .editCrop { Task { await fullPreviewStream.cancelFullPreviewGeneration() } } // Because we don't update the preview during editCrop, the preview may be stale. updatePreviewOnSettingsChange() } } private var controls: some View { HStack(spacing: 0) { Form { DimensionsSetting( videoDimensions: metadata.dimensions, resizableDimensions: $resizableDimensions ) SpeedSetting() .padding(.bottom, 6) // Makes the forms have equal height. } .padding(.horizontal, -8) // Form comes with some default padding, which we don't want. .fillFrame() .containerRelativeFrame(.horizontal, count: 2, span: 1, spacing: 0) .padding(.trailing, -8) Form { FrameRateSetting(videoFrameRate: metadata.frameRate) QualitySetting() LoopSetting(loopCount: $loopCount) } .padding(.horizontal, -8) .fillFrame() .containerRelativeFrame(.horizontal, count: 2, span: 1, spacing: 0) } .padding(-12) .formStyle(.grouped) .scrollContentBackground(.hidden) .scrollDisabled(true) .fixedSize() } private var bottomBar: some View { HStack { Spacer() Button("Convert") { appState.navigationPath.append(.conversion(conversionSettings)) } .keyboardShortcut(.defaultAction) .padding(.top, -1) // Makes the bar have equal spacing on top and bottom. } .overlay { EstimatedFileSizeView(model: estimatedFileSizeModel) } .padding() .padding(.top, -16) } private var conversionSettings: GIFGenerator.Conversion { .init( asset: modifiedAsset, sourceURL: url, timeRange: timeRange, quality: outputQuality, dimensions: resizableDimensions.pixels.toInt, frameRate: frameRate, loop: { guard loopGIF else { return loopCount == 0 ? .never : .count(loopCount) } return .forever }(), bounce: bounceGIF, crop: outputCropRect, trackPreferredTransform: metadata.trackPreferredTransform ) } private func showKeyframeRateWarningIfNeeded(maximumKeyframeInterval: Double = 30) { guard !isKeyframeRateChecked, !Defaults[.suppressKeyframeWarning] else { return } isKeyframeRateChecked = true Task.detached(priority: .utility) { do { guard let keyframeInfo = try await modifiedAsset.firstVideoTrack?.getKeyframeInfo(), keyframeInfo.keyframeInterval > maximumKeyframeInterval else { return } print("Low keyframe interval \(keyframeInfo.keyframeInterval)") await MainActor.run { isReversePlaybackWarningPresented = true } } catch { await MainActor.run { appState.error = error } } } } } enum PredefinedSizeItem: Hashable { case custom case spacer case dimensions(Dimensions) var resizableDimensions: Dimensions? { switch self { case .dimensions(let dimensions): dimensions default: nil } } } private struct DimensionsSetting: View { @State private var predefinedSizes = [PredefinedSizeItem]() @State private var selectedPredefinedSize: PredefinedSizeItem? @State private var dimensionsType = DimensionsType.pixels @State private var width = 0 @State private var height = 0 @State private var percent = 0 @State private var isArrowKeyTipPresented = false let videoDimensions: CGSize @Binding var resizableDimensions: Dimensions // TODO: Rename. var body: some View { VStack(spacing: 16) { Picker("Dimensions", selection: $selectedPredefinedSize) { ForEach(predefinedSizes, id: \.self) { size in switch size { case .custom: if selectedPredefinedSize == .custom { let string = switch dimensionsType { case .pixels: // TODO: Make this a property on `resizableDimensions`. String(format: "%.0f%%", resizableDimensions.percent * 100) case .percent: resizableDimensions.pixels.formatted } Text("Custom — \(string)") .tag(size as PredefinedSizeItem?) } case .spacer: Divider() .tag(UUID()) case .dimensions(let dimensions): Text("\(dimensions.description)") .tag(size as PredefinedSizeItem?) } } } .onChange(of: selectedPredefinedSize) { updateDimensionsBasedOnSelection(selectedPredefinedSize) } HStack { Spacer() HStack { switch dimensionsType { case .pixels: let textFieldWidth = OS.isMacOS26OrLater ? 44 : 42.0 HStack(spacing: 4) { LabeledContent("Width") { IntTextField( value: $width, minMax: resizableDimensions.widthMinMax.toInt, onBlur: { _ in // swiftlint:disable:this trailing_closure DispatchQueue.main.async { applyWidth() } } ) .frame(width: textFieldWidth) .onChange(of: width) { applyWidth() } } // TODO: Use TipKit when targeting macOS 15. .popover(isPresented: $isArrowKeyTipPresented) { Text("Press the arrow up/down keys to change the value by 1.\nHold the Option key meanwhile to change it by 10.") .padding() .padding(.vertical, 4) .onTapGesture { isArrowKeyTipPresented = false } .accessibilityAddTraits(.isButton) } Text("×") LabeledContent("Height") { IntTextField( value: $height, minMax: resizableDimensions.heightMinMax.toInt, onBlur: { _ in // swiftlint:disable:this trailing_closure DispatchQueue.main.async { applyHeight() } } ) .frame(width: textFieldWidth) .onChange(of: height) { applyHeight() } } } case .percent: LabeledContent("Percent") { IntTextField( value: $percent, minMax: resizableDimensions.percentMinMax.toInt, onBlur: { _ in // swiftlint:disable:this trailing_closure DispatchQueue.main.async { // Ensures it uses updated values. applyPercent() } } ) .frame(width: OS.isMacOS26OrLater ? 36 : 32) .onChange(of: percent) { applyPercent() } } } } .padding(.trailing, -8) Picker("Dimension type", selection: $dimensionsType) { ForEach(DimensionsType.allCases, id: \.self) { Text($0.rawValue) } } .onChange(of: dimensionsType) { DispatchQueue.main.async { // Fixes an issue where if you do 100%, then 99%, and then try to switch to "pixel" type, it doesn't switch. updateTextFieldsForCurrentDimensions() } } } .fixedSize() .fillFrame(.horizontal, alignment: .trailing) .labelsHidden() } .onAppear { setUpDimensions() updateTextFieldsForCurrentDimensions() showArrowKeyTipIfNeeded() } } private func setUpDimensions() { let dimensions = Dimensions.pixels(videoDimensions, originalSize: videoDimensions) resizableDimensions = dimensions var pixelCommonSizes: [Double] = [ 960, 800, 640, 500, 480, 320, 256, 200, 160, 128, 80, 64 ] if !pixelCommonSizes.contains(dimensions.pixels.width) { pixelCommonSizes.append(dimensions.pixels.width) pixelCommonSizes.sort(by: >) } let pixelDimensions = pixelCommonSizes.map { width in let ratio = width / dimensions.pixels.width let height = dimensions.pixels.height * ratio return CGSize(width: width, height: height).rounded() } .filter { $0.width <= videoDimensions.width && $0.height <= videoDimensions.height } let predefinedPixelDimensions = pixelDimensions // TODO // .filter { resizableDimensions.validate(newSize: $0) } .map { Dimensions.pixels($0, originalSize: videoDimensions) } let percentCommonSizes: [Double] = [ 100, 50, 33, 25, 20 ] let predefinedPercentDimensions = percentCommonSizes.map { Dimensions.percent($0 / 100, originalSize: videoDimensions) } predefinedSizes = [.custom] predefinedSizes.append(.spacer) predefinedSizes.append(contentsOf: predefinedPixelDimensions.map { .dimensions($0) }) predefinedSizes.append(.spacer) predefinedSizes.append(contentsOf: predefinedPercentDimensions.map { .dimensions($0) }) selectPredefinedSizeBasedOnCurrentDimensions() } private func updateDimensionsBasedOnSelection(_ selectedSize: PredefinedSizeItem?) { guard let selectedSize else { return } switch selectedSize { case .custom, .spacer: break case .dimensions(let dimensions): dimensionsType = dimensions.isPercent ? .percent : .pixels resizableDimensions = dimensions } updateTextFieldsForCurrentDimensions() } private func applyWidth() { resizableDimensions = resizableDimensions.aspectResized(usingWidth: width.toDouble) height = resizableDimensions.pixels.height.toDouble.clamped(to: resizableDimensions.heightMinMax).toIntAndClampingIfNeeded } private func applyHeight() { resizableDimensions = resizableDimensions.aspectResized(usingHeight: height.toDouble) width = resizableDimensions.pixels.width.toDouble.clamped(to: resizableDimensions.widthMinMax).toIntAndClampingIfNeeded selectPredefinedSizeBasedOnCurrentDimensions(forceCustom: true) } private func applyPercent() { resizableDimensions = .percent(percent.toDouble / 100, originalSize: videoDimensions) width = resizableDimensions.pixels.width.toDouble.clamped(to: resizableDimensions.widthMinMax).toIntAndClampingIfNeeded height = resizableDimensions.pixels.height.toDouble.clamped(to: resizableDimensions.heightMinMax).toIntAndClampingIfNeeded selectPredefinedSizeBasedOnCurrentDimensions(forceCustom: true) } private func updateTextFieldsForCurrentDimensions() { width = resizableDimensions.pixels.width.toDouble.clamped(to: resizableDimensions.widthMinMax).toIntAndClampingIfNeeded height = resizableDimensions.pixels.height.toDouble.clamped(to: resizableDimensions.heightMinMax).toIntAndClampingIfNeeded percent = (resizableDimensions.percent * 100).rounded().toIntAndClampingIfNeeded selectPredefinedSizeBasedOnCurrentDimensions() } private func selectPredefinedSizeBasedOnCurrentDimensions(forceCustom: Bool = false) { if forceCustom { selectedPredefinedSize = .custom return } guard let index = (predefinedSizes.first { size in guard case .dimensions(let dimensions) = size else { return false } return dimensions == resizableDimensions }) else { selectedPredefinedSize = .custom return } selectedPredefinedSize = index } private func showArrowKeyTipIfNeeded() { SSApp.runOnce(identifier: "DimensionsSetting_arrowKeyTip") { Task { try? await Task.sleep(for: .seconds(1)) isArrowKeyTipPresented = true try? await Task.sleep(for: .seconds(10)) isArrowKeyTipPresented = false } } } } private struct SpeedSetting: View { @Default(.outputSpeed) private var outputSpeed var body: some View { LabeledContent("Speed") { Slider(value: $outputSpeed, in: 0.5...5, step: 0.25) Text("\(outputSpeed.formatted(.number.precision(.fractionLength(2))))×") .monospacedDigit() .frame(width: 40, alignment: .leading) } } } private struct FrameRateSetting: View { @Default(.outputFPS) private var frameRate @Default(.outputSpeed) private var speed @State private var isHighFrameRateWarningPresented = false var videoFrameRate: Double var body: some View { LabeledContent("FPS") { Slider( value: $frameRate.intToDouble, in: range ) Text("\(frameRate.formatted())") .monospacedDigit() .frame(width: 38, alignment: .leading) } .alert2( "Animated GIF Limitation", message: "Exporting GIFs with a frame rate higher than 50 is not supported as browsers will throttle and play them at 10 FPS.", isPresented: $isHighFrameRateWarningPresented ) .onChange(of: frameRate) { if frameRate > 50 { SSApp.runOnce(identifier: "fpsWarning") { isHighFrameRateWarningPresented = true } } } .onAppear { frameRate = frameRate.clamped(to: intRange) } } private var maxFrameRate: Double { // We round it so that `29.970` becomes `30` for practical reasons. (videoFrameRate * speed).rounded().clamped(to: Constants.allowedFrameRate) } private var range: ClosedRange { .fromGraceful( Constants.allowedFrameRate.lowerBound, maxFrameRate ) } // TODO: Make extension for this conversion. private var intRange: ClosedRange { .fromGraceful( Int(Constants.allowedFrameRate.lowerBound.rounded()), Int(maxFrameRate.rounded()) ) } } private struct QualitySetting: View { @Default(.outputQuality) private var quality var body: some View { LabeledContent("Quality") { Slider(value: $quality, in: 0.01...1) // We replace the non-breaking space with a word-joiner to save space. Text("\(quality.formatted(.percent.noFraction).replacing("\u{00A0}", with: "\u{2060}"))") .monospacedDigit() .frame(width: 38, alignment: .leading) } } } private struct LoopSetting: View { @Default(.loopGIF) private var loop @Default(.bounceGIF) private var bounce @State private var isGifLoopCountWarningPresented = false @Binding var loopCount: Int var body: some View { LabeledContent("Loops") { Stepper( "Loop count", value: $loopCount.intToDouble, in: 0...100, step: 1, format: .number ) .labelsHidden() .disabled(loop) Toggle("Forever", isOn: $loop) Toggle("Bounce", isOn: $bounce) } .alert2( "Animated GIF Preview Limitation", message: "Due to a bug in the macOS GIF handling, the after-conversion preview may not loop as expected. The GIF will loop correctly in web browsers and other image viewing apps.", isPresented: $isGifLoopCountWarningPresented ) .onChange(of: loop) { if loop { loopCount = 0 } else { showConversionCompletedAnimationWarningIfNeeded() } } } private func showConversionCompletedAnimationWarningIfNeeded() { // NOTE: This function eventually will become an OS version check when Apple fixes their GIF animation implementation. // So far `NSImageView` and Quick Look are affected and may be fixed in later OS versions. Depending on how Apple fixes the issue, the message may need future modifications. Safari works as expected, so it's not all of Apple's software. // FB8947153: https://github.com/feedback-assistant/reports/issues/187 SSApp.runOnce(identifier: "gifLoopCountWarning") { isGifLoopCountWarningPresented = true } } } ================================================ FILE: Gifski/EstimatedFileSize.swift ================================================ import SwiftUI // TODO: Rewrite the whole estimation thing. @MainActor @Observable final class EstimatedFileSizeModel { var estimatedFileSize: String? var estimatedFileSizeNaive: String? var error: Error? // TODO: This is outside the scope of "file estimate", but it was easier to add this here than doing a separate SwiftUI view. This should be refactored out into a separate view when all of Gifski is SwiftUI. var duration = Duration.zero var getConversionSettings: (() -> GIFGenerator.Conversion)? private var gifski: GIFGenerator? private func getEstimatedFileSizeNaive() async -> String { await Int(getNaiveEstimate()).formatted(.byteCount(style: .file)) } private func _estimateFileSize() { self.gifski = nil let gifski = GIFGenerator() self.gifski = gifski error = nil estimatedFileSize = nil Task { // TODO: Improve. duration = (try? await getConversionSettings?().gifDuration) ?? .zero } Task { estimatedFileSizeNaive = await getEstimatedFileSizeNaive() guard let settings = getConversionSettings?() else { return } do { let data = try await gifski.run(settings, isEstimation: true) { _ in } // We add 10% extra because it's better to estimate slightly too much than too little. let fileSize = await (Double(data.count) * gifski.sizeMultiplierForEstimation) * 1.1 estimatedFileSize = Int(fileSize).formatted(.byteCount(style: .file)) } catch { guard !(error is CancellationError) else { return } if case .notEnoughFrames = error as? GIFGenerator.Error { estimatedFileSize = await getEstimatedFileSizeNaive() } else { self.error = error } } } } func updateEstimate() { Debouncer.debounce(delay: .seconds(0.5), action: _estimateFileSize) } private func getNaiveEstimate() async -> Double { guard let conversionSettings = getConversionSettings?(), let duration = try? await conversionSettings.gifDuration else { return 0 } let frameCount = duration.toTimeInterval * Defaults[.outputFPS].toDouble // TODO: Needs to be live. let dimensions = conversionSettings.dimensions ?? (0, 0) // TODO: Get asset dimensions. var fileSize = (dimensions.width.toDouble * dimensions.height.toDouble * frameCount) / 3 fileSize = fileSize * (Defaults[.outputQuality] + 1.5) / 2.5 return fileSize } } struct EstimatedFileSizeView: View { @State private var model: EstimatedFileSizeModel init(model: EstimatedFileSizeModel) { _model = .init(wrappedValue: model) } var body: some View { HStack { if let error = model.error { Text("Failed to get estimate: \(error.localizedDescription)") .help(error.localizedDescription) } else { HStack(spacing: 0) { Text("Estimated size: ") Text(model.estimatedFileSize ?? model.estimatedFileSizeNaive ?? "…") .monospacedDigit() .foregroundStyle(model.estimatedFileSize == nil ? .secondary : .primary) } .foregroundStyle(.secondary) if model.estimatedFileSize == nil { ProgressView() .controlSize(.mini) .padding(.leading, -4) .help("Calculating file size estimate") } } } .fillFrame(.horizontal, alignment: .leading) .overlay { if model.error == nil { HStack { let formattedDuration = model.duration.formatted(.time(pattern: .minuteSecond(padMinuteToLength: 2, fractionalSecondsLength: 2))) Text(formattedDuration) .monospacedDigit() .padding(.horizontal, 6) .padding(.vertical, 3) .background(Color.primary.opacity(0.04)) .clipShape(.rect(cornerRadius: 4)) } } } .task { if model.estimatedFileSize == nil { model.updateEstimate() } } } } ================================================ FILE: Gifski/ExportModifiedVideo.swift ================================================ import Foundation import AVKit import SwiftUI struct ExportModifiedVideoView: View { @Environment(AppState.self) private var appState @Binding var state: ExportModifiedVideoState let sourceURL: URL @Binding var isAudioWarningPresented: Bool var body: some View { ZStack{} .sheet(isPresented: isProgressSheetPresented) { ProgressView() } .fileExporter( isPresented: isFileExporterPresented, item: exportableMP4, defaultFilename: defaultExportModifiedFileName ) { do { let url = try $0.get() try? url.setAppAsItemCreator() } catch { appState.error = error } } .fileDialogCustomizationID("export") .fileDialogMessage("Choose where to save the video") .fileDialogConfirmationLabel("Save") .alert2( "Export Video Limitation", message: "Exporting a video with audio is not supported. The audio track will be ignored.", isPresented: $isAudioWarningPresented ) } private var exportableMP4: ExportableMP4? { guard case let .finished(url) = state else { return nil } return ExportableMP4(url: url) } private var defaultExportModifiedFileName: String { "\(sourceURL.filenameWithoutExtension) modified.mp4" } private var isProgressSheetPresented: Binding { .init( get: { guard !isAudioWarningPresented, case let .exporting(_, videoIsOverTwentySeconds) = state else { return false } return videoIsOverTwentySeconds }, set: { guard !$0, case let .exporting(task, _) = state else { return } task.cancel() state = .idle } ) } private var isFileExporterPresented: Binding { .init( get: { state.isFinished && !isAudioWarningPresented }, set: { guard !$0, case let .finished(url) = state else { return } try? FileManager.default.removeItem(at: url) state = .idle } ) } enum Error: Swift.Error { case unableToExportAsset case unableToCreateExportSession case unableToAddCompositionTrack var errorDescription: String? { switch self { case .unableToExportAsset: "Unable to export the asset because it is not compatible with the current device." case .unableToCreateExportSession: "Unable to create an export session for the video." case .unableToAddCompositionTrack: "Failed to add a composition track to the video." } } } } enum ExportModifiedVideoState: Equatable { case idle case exporting(Task, videoIsOverTwentySeconds: Bool) case finished(URL) var shouldShowProgress: Bool { switch self { case .idle, .finished: false case .exporting(_, videoIsOverTwentySeconds: let videoIsOverTwentySeconds): videoIsOverTwentySeconds } } var shouldShowFileExporter: Bool { switch self { case .idle, .exporting: false case .finished: true } } var isExporting: Bool { switch self { case .exporting: true default: false } } var isFinished: Bool { switch self { case .finished: true default: false } } } /** Convert a source video to an `.mp4` using the same scale, speed, and crop as the exported `.gif`. - Returns: Temporary URL of the exported video. */ func exportModifiedVideo(conversion: GIFGenerator.Conversion) async throws -> URL { let (composition, compositionVideoTrack) = try await createComposition( conversion: conversion ) let videoComposition = try await createVideoComposition( compositionVideoTrack: compositionVideoTrack, conversion: conversion ) let outputURL = FileManager.default.temporaryDirectory.appendingPathComponent( "\(UUID().uuidString).mp4") let presets = AVAssetExportSession.allExportPresets() guard presets.contains(AVAssetExportPresetHighestQuality) else { throw ExportModifiedVideoView.Error.unableToCreateExportSession } guard await AVAssetExportSession.compatibility(ofExportPreset: AVAssetExportPresetHighestQuality, with: composition, outputFileType: .mp4) else { throw ExportModifiedVideoView.Error.unableToCreateExportSession } guard let exportSession = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetHighestQuality) else { throw ExportModifiedVideoView.Error.unableToCreateExportSession } exportSession.shouldOptimizeForNetworkUse = true exportSession.videoComposition = videoComposition try await exportSession.export(to: outputURL, as: .mp4) return outputURL } /** Creates the mutable composition along with the video track inserted. */ private func createComposition( conversion: GIFGenerator.Conversion, ) async throws -> (AVMutableComposition, AVMutableCompositionTrack) { let composition = AVMutableComposition() guard let compositionTrack = composition.addMutableTrack( withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid ) else { throw ExportModifiedVideoView.Error.unableToAddCompositionTrack } let videoTrack = try await conversion.firstVideoTrack try compositionTrack.insertTimeRange( try await conversion.exportModifiedVideoTimeRange, of: videoTrack, at: .zero ) if let preferredTransform = conversion.trackPreferredTransform { compositionTrack.preferredTransform = preferredTransform } return (composition, compositionTrack) } /** Create an `AVMutableVideoComposition` that will scale, translate, and crop the `compositionVideoTrack`. */ private func createVideoComposition( compositionVideoTrack: AVMutableCompositionTrack, conversion: GIFGenerator.Conversion ) async throws -> AVMutableVideoComposition { let videoComposition = AVMutableVideoComposition() videoComposition.renderSize = try await conversion.exportModifiedRenderRect.size videoComposition.frameDuration = try await compositionVideoTrack.load(.minFrameDuration) let instruction = AVMutableVideoCompositionInstruction() // The instruction time range must be greater than or equal to the video and there is no penalty for making it longer, so add 1.0 second to the duration just to be safe instruction.timeRange = CMTimeRange(start: .zero, duration: .init(seconds: try await conversion.videoWithoutBounceDuration.toTimeInterval + 1.0, preferredTimescale: .video)) let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: compositionVideoTrack) // Layer instructions operate in natural space (unrotated). The crop rect from UI is in // preferred space, so `cropRectAppliedToNaturalSize` transforms it back to natural space. let cropRectAppliedToNaturalSize = try await conversion.cropRectAppliedToNaturalSize let preferredTransform = conversion.trackPreferredTransform ?? .identity let scaleTransform = CGAffineTransform(scaledBy: try await conversion.scale) let scaledCropRect = cropRectAppliedToNaturalSize.applying(scaleTransform) let cropRectAfterPreferred = scaledCropRect.applying(preferredTransform) // Place the crop rect in the top left corner. let translateTransform = CGAffineTransform(translationX: -cropRectAfterPreferred.minX, y: -cropRectAfterPreferred.minY) layerInstruction.setCropRectangle(cropRectAppliedToNaturalSize, at: .zero) layerInstruction.setTransform(scaleTransform.concatenating(preferredTransform).concatenating(translateTransform), at: .zero) instruction.layerInstructions = [layerInstruction] videoComposition.instructions = [instruction] return videoComposition } private struct ExportableMP4: Transferable { let url: URL static var transferRepresentation: some TransferRepresentation { FileRepresentation(exportedContentType: .mpeg4Movie) { .init($0.url) } .suggestedFileName { $0.url.filename } } } ================================================ FILE: Gifski/GIFGenerator.swift ================================================ import Foundation import AVFoundation actor GIFGenerator { private var gifski: Gifski? private(set) var sizeMultiplierForEstimation = 1.0 static func run( _ conversion: Conversion, isEstimation: Bool = false, onProgress: @escaping (Double) -> Void ) async throws -> Data { let converter = Self() return try await converter.run( conversion, isEstimation: isEstimation, onProgress: onProgress ) } /** Converts a single frame to GIF data. */ static func convertOneFrame( frame: CGImage, dimensions: (width: Int, height: Int)?, quality: Double, fast: Bool = false ) async throws -> Data { let gifski = try Gifski( dimensions: dimensions, quality: quality, loop: .never, fast: fast ) try gifski.addFrame(frame, presentationTimestamp: 0.0) return try gifski.finish() } deinit { print("GIFGenerator DEINIT") } // TODO: Make private. /** Converts a movie to GIF. */ func run( _ conversion: Conversion, isEstimation: Bool = false, onProgress: @escaping (Double) -> Void ) async throws -> Data { gifski = try Gifski( dimensions: conversion.croppedOutputDimensions, quality: conversion.quality.clamped(to: 0.1...1), loop: conversion.loop ) defer { // Ensure Gifski finishes no matter what. gifski = nil } let result = try await generateData( for: conversion, isEstimation: isEstimation, onProgress: onProgress ) try Task.checkCancellation() return result } /** Generates GIF data for the provided conversion. - Parameters: - conversion: The source information of the conversion. - isEstimation: Whether the frame is part of a size estimation job. - jobKey: The string used to identify the current conversion job. - completionHandler: Closure called when the data conversion completes or an error is encountered. */ private func generateData( for conversion: Conversion, isEstimation: Bool, onProgress: @escaping (Double) -> Void ) async throws -> Data { var (generator, times, frameRate) = try await imageGenerator(for: conversion) // 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 = times.count if originalCount > 25 { times = times .chunked(by: 5) .sample(length: 5) .flatten() } sizeMultiplierForEstimation = Double(originalCount) / Double(times.count) } let totalFrameCount = totalFrameCount(for: conversion, sourceFrameCount: times.count) var completedFrameCount = 0 gifski?.onProgress = { let progress = Double(completedFrameCount.increment()) / Double(totalFrameCount) onProgress(progress.clamped(to: 0...1)) // TODO: For some reason, when we use `bounce`, `totalFrameCount` can be 1 less than `completedFrameCount` on completion. } // TODO: Use `Duration`. let startTime = times.first?.seconds ?? 0 // TODO: Does it handle cancellation? var index = 0 var previousTime = -100.0 // Just to make sure it doesn't match any timestamp. print("Total frame count:", totalFrameCount) for await imageResult in generator.images(for: times) { try Task.checkCancellation() let requestedTime = imageResult.requestedTime // `generator.images` returns old frames randomly. For example, after index 7, it would emit index 3 another time. We filter out times that are lower than last. (macOS 14.3) guard requestedTime.seconds > previousTime else { continue } previousTime = requestedTime.seconds guard let image = conversion.croppedImage(image: try imageResult.image) else { throw GIFGenerator.Error.cropNotInBounds } let actualTime = try imageResult.actualTime do { let frameNumber = index if index > 0 { assert(actualTime.seconds > 0) } // TODO: Use a custom executer for this when using Swift 6. try gifski?.addFrame( image, frameNumber: frameNumber, presentationTimestamp: max(0, actualTime.seconds - startTime) ) if conversion.bounce { /* Inserts the frame again at the reverse index of the natural order. For example, if this frame is at index 2 of 5 in its natural order: ``` ↓ 0, 1, 2, 3, 4 ``` Then the frame should be inserted at 6 of 9 in the reverse order: ``` ↓ 0, 1, 2, 3, 4, 3, 2, 1, 0 ``` */ let reverseFrameNumber = totalFrameCount - frameNumber - 1 // Determine the reverse timestamp by finding the expected timestamp (frame number / frame rate) and adjusting for the image generator's slippage (actualTime - requestedTime) let expectedReverseTimestamp = TimeInterval(reverseFrameNumber) / TimeInterval(frameRate) let timestampSlippage = actualTime - requestedTime let actualReverseTimestamp = max(0, expectedReverseTimestamp + timestampSlippage.seconds) // Prevent duplicate frame with the same frame number causing an unwanted frame at the end of the GIF. if frameNumber != reverseFrameNumber { try gifski?.addFrame( image, frameNumber: reverseFrameNumber, presentationTimestamp: actualReverseTimestamp ) } } index += 1 } catch { throw Error.addFrameFailed(error) } await Task.yield() // Give `addFrame` room to start. } guard let gifski else { throw CancellationError() } return try gifski.finish() } /** Creates an image generator for the provided conversion. - Parameters: - conversion: The conversion source of the image generator. - Returns: An `AVAssetImageGenerator` along with the times of the frames requested by the conversion. */ private func imageGenerator(for conversion: Conversion) async throws -> (generator: AVAssetImageGenerator, times: [CMTime], frameRate: Int) { let asset = conversion.asset // // record( // jobKey: jobKey, // key: "Is readable?", // value: asset.isReadable // ) // record( // jobKey: jobKey, // key: "First video track", // value: asset.firstVideoTrack // ) // record( // jobKey: jobKey, // key: "First video track time range", // value: asset.firstVideoTrack?.timeRange // ) // record( // jobKey: jobKey, // key: "Duration", // value: asset.duration.seconds // ) // record( // jobKey: jobKey, // key: "AVAsset debug info", // value: asset.debugInfo // ) // TODO: Parallelize using `async let`. guard try await asset.load(.isReadable), let assetFrameRate = try await asset.frameRate, let firstVideoTrack = try await asset.firstVideoTrack, // We use the duration of the first video track since the total duration of the asset can actually be longer than the video track. If we use the total duration and the video is shorter, we'll get errors in `generateCGImagesAsynchronously` (#119). // We already extract the video into a new asset in `VideoValidator` if the first video track is shorter than the asset duration, so the handling here is not strictly necessary but kept just to be safe. let videoTrackRange = try await firstVideoTrack.load(.timeRange).range else { // This can happen if the user selects a file, and then the file becomes // unavailable or deleted before the "Convert" button is clicked. throw Error.unreadableFile } // // record( // jobKey: jobKey, // key: "AVAsset debug info2", // value: asset.debugInfo // ) let generator = AVAssetImageGenerator(asset: asset) // Images are returned already rotated to match how the user sees the video. // This means crop coordinates (defined in rotated space) can be applied directly. generator.appliesPreferredTrackTransform = true generator.requestedTimeToleranceBefore = .zero generator.requestedTimeToleranceAfter = .zero // We are intentionally not setting a `generator.maximumSize` as it's buggy: https://github.com/sindresorhus/Gifski/pull/278 // Even though we enforce a minimum of 3 FPS in the GUI, a source video could have lower FPS, and we should allow that. var frameRate = (conversion.frameRate.map(Double.init) ?? assetFrameRate).clamped(to: 0.1...Constants.allowedFrameRate.upperBound) frameRate = min(frameRate, assetFrameRate) print("Video FPS:", frameRate) // TODO: Instead of calculating what part of the video to get, we could just trim the actual `AVAssetTrack`. let videoRange = conversion.timeRange?.clamped(to: videoTrackRange) ?? videoTrackRange let startTime = videoRange.lowerBound let duration = videoRange.length let frameCount = Int(duration * frameRate) let timescale = try await firstVideoTrack.load(.naturalTimeScale) // TODO: Move this to the other `load` call. print("Video frame count:", frameCount) guard frameCount >= 2 else { throw Error.notEnoughFrames(frameCount) } let frameStep = 1 / frameRate var frameForTimes: [CMTime] = (0.. Int { /* Bouncing doubles the frame count except for the frame at the apex (middle) of the bounce. For example, a sequence of 5 frames becomes a sequence of 9 frames when bounced: ``` 0, 1, 2, 3, 4 ↓ 0, 1, 2, 3, 4, 3, 2, 1, 0 ``` */ conversion.bounce ? (sourceFrameCount * 2 - 1) : sourceFrameCount } } extension GIFGenerator { /** - Parameter frameRate: Clamped to `5...30`. Uses the frame rate of `input` if not specified. - Parameter loopGif: Whether output should loop infinitely or not. - Parameter bounce: Whether output should bounce or not. */ struct Conversion: ReflectiveHashable { // TODO let asset: AVAsset let sourceURL: URL var timeRange: ClosedRange? var quality: Double = 1 var dimensions: (width: Int, height: Int)? var frameRate: Int? var loop: Gifski.Loop var bounce: Bool var crop: CropRect? var trackPreferredTransform: CGAffineTransform? } } extension GIFGenerator.Conversion { var gifDuration: Duration { get async throws { // TODO: Make this lazy so it's only used for fallback. let fallbackRange = try await asset.firstVideoTrack?.load(.timeRange) return gifDuration(assetTimeRange: fallbackRange) } } func gifDuration(assetTimeRange fallbackRange: CMTimeRange?, withBounce: Bool = true) -> Duration { guard let duration = (timeRange ?? fallbackRange?.range)?.length else { return .zero } // TODO: Do this when Swift supports async in `??`. // guard let duration = (timeRange ?? asset.firstVideoTrack?.timeRange.range)?.length else { // return .zero // } return .seconds(withBounce && bounce ? (duration * 2) : duration) } var videoWithoutBounceDuration: Duration { get async throws { .seconds(try await gifDuration.toTimeInterval / (bounce ? 2 : 1)) } } /** - Returns: The current scale of the `dimensions` compared to the dimensions of the video track. */ var scale: CGSize { get async throws { guard let trackDimensions = try await trackDimensions else { return .one } guard trackDimensions > 0 else { throw Error.invalidDimensions } guard let dimensions = dimensionsAsCGSize else { return .one } let scale = dimensions / trackDimensions guard scale > 0 else { throw Error.invalidScale } return scale } } /** - Returns: Dimensions of the first video track after applying preferredTransform */ var trackDimensions: CGSize? { get async throws { try await asset.firstVideoTrack?.dimensions } } var dimensionsAsCGSize: CGSize? { dimensions.map { .init(width: Double($0.0), height: Double($0.1)) } } /** The size of the output render without taking crop into account. */ var renderSize: CGSize { get async throws { if let dimensionsAsCGSize { return dimensionsAsCGSize } guard let trackSize = try await trackDimensions else { throw Error.invalidDimensions } return trackSize } } /** - Returns: Crop rect in pixels, if there is no crop rect then it returns the full render size. */ var cropRectInPixels: CGRect { get async throws { (crop ?? .initialCropRect).unnormalize(forDimensions: try await renderSize) } } /** The crop rect applied to the natural (unrotated) size of the video track. The crop rect from the UI is defined in the preferred/rotated space (how the user sees the video). To apply it to `naturalSize`, we need to transform it from rotated space to natural space. */ var cropRectAppliedToNaturalSize: CGRect { get async throws { guard let videoTrack = try await asset.firstVideoTrack else { return .zero } let (naturalSize, preferredTransform) = try await videoTrack.load(.naturalSize, .preferredTransform) // Get the rotated dimensions (how the user sees the video) let rotatedSize = CGRect(origin: .zero, size: naturalSize).applying(preferredTransform).size let rotatedDimensions = CGSize(width: abs(rotatedSize.width), height: abs(rotatedSize.height)) // The crop rect is defined in rotated space, so unnormalize it using rotated dimensions let cropRectInRotatedSpace = (crop ?? .initialCropRect).unnormalize(forDimensions: rotatedDimensions) // Transform the crop rect from rotated space back to natural space return cropRectInRotatedSpace.applying(preferredTransform.inverted()) } } var exportModifiedRenderRect: CGRect { get async throws { unnormalizedCropRect(sizeInPreferredTransformationSpace: try await renderSize) } } /** - Returns: The time range used to export the modified video (i.e. not the `.gif` export). */ var exportModifiedVideoTimeRange: CMTimeRange { get async throws { if let timeRange { return timeRange.cmTimeRange } return (0...(try await videoWithoutBounceDuration.toTimeInterval)).cmTimeRange } } var firstVideoTrack: AVAssetTrack { get async throws { guard let videoTrack = try await asset.firstVideoTrack else { throw Error.noVideoTrack } return videoTrack } } enum Error: Swift.Error { case invalidDimensions case invalidScale case noVideoTrack } } extension GIFGenerator { enum Error: LocalizedError { case invalidSettings case unreadableFile case notEnoughFrames(Int) case generateFrameFailed(Swift.Error) case addFrameFailed(Swift.Error) case writeFailed(Swift.Error) case cropNotInBounds case cancelled var errorDescription: String? { switch self { case .invalidSettings: "Invalid settings." case .unreadableFile: "The selected file is no longer readable." case .notEnoughFrames(let frameCount): "An animated GIF requires a minimum of 2 frames. Your video contains \(frameCount) frame\(frameCount == 1 ? "" : "s")." case .generateFrameFailed(let error): "Failed to generate frame: \(error.localizedDescription)" case .addFrameFailed(let error): "Failed to add frame, with underlying error: \(error.localizedDescription)" case .writeFailed(let error): "Failed to write, with underlying error: \(error.localizedDescription)" case .cropNotInBounds: "The crop is not in bounds of the video." case .cancelled: "The conversion was cancelled." } } } } extension GIFGenerator { static func runProgressable(_ conversion: GIFGenerator.Conversion) -> ProgressableTask { ProgressableTask { progressContinuation in try await GIFGenerator.run(conversion) { progressContinuation.yield($0) } } } } ================================================ FILE: Gifski/Gifski-Bridging-Header.h ================================================ #import "gifski.h" #include "CompositePreviewShared.h" ================================================ FILE: Gifski/Gifski.entitlements ================================================ com.apple.security.application-groups group.com.sindresorhus.Gifski ================================================ FILE: Gifski/Gifski.swift ================================================ import SwiftUI // TODO: Actor final class Gifski { enum Loop { case forever case never case count(Int) } private var wrapper: GifskiWrapper? private var frameNumber = 0 private var data = Data() var onProgress: (() -> Void)? // TODO: Make this when the rest of the app uses more async // var progress: AsyncStream {} init( dimensions: (width: Int, height: Int)? = nil, quality: Double, loop: Loop, fast: Bool = false ) throws { let loopCount = { switch loop { case .forever: return 0 case .never: return -1 case .count(let the_count): assert(the_count > 0) return the_count } }() assert(quality >= 0.1) assert(quality <= 1) let settings = GifskiSettings( width: UInt32(clamping: dimensions?.width ?? 0), height: UInt32(clamping: dimensions?.height ?? 0), quality: UInt8(clamping: Int((quality * 100).rounded()).clamped(to: 1...100)), fast: fast, repeat: Int16(clamping: loopCount) ) guard let wrapper = GifskiWrapper(settings) else { throw GifskiWrapper.Error.invalidInput } self.wrapper = wrapper wrapper.setErrorMessageCallback { SSApp.reportError($0) } wrapper.setProgressCallback { [weak self] in guard let self else { return 0 } onProgress?() return self.wrapper == nil ? 0 : 1 } wrapper.setWriteCallback { [weak self] bufferLength, bufferPointer in guard let self else { return 0 } data.append(bufferPointer, count: bufferLength) return 0 } } deinit { _ = try? wrapper?.finish() } func addFrame( _ image: CGImage, frameNumber: Int, presentationTimestamp: Double ) throws { guard let wrapper else { assertionFailure("Called “addFrame” after it finished.") throw GifskiWrapper.Error.invalidState } let pixels = try image.pixels(as: .rgba, premultiplyAlpha: false) try wrapper.addFrame( pixelFormat: .rgba, frameNumber: frameNumber, width: pixels.width, height: pixels.height, bytesPerRow: pixels.bytesPerRow, pixels: pixels.bytes, presentationTimestamp: presentationTimestamp ) } func addFrame( _ image: CGImage, presentationTimestamp: Double ) throws { try addFrame( image, frameNumber: frameNumber, presentationTimestamp: presentationTimestamp ) frameNumber += 1 } func finish() throws -> Data { guard let wrapper else { assertionFailure("Called “finish” more than once.") throw GifskiWrapper.Error.invalidState } try wrapper.finish() self.wrapper = nil return data } } ================================================ FILE: Gifski/GifskiWrapper.swift ================================================ import Foundation // TODO: Make it an actor. final class GifskiWrapper { enum PixelFormat { case rgba case argb case rgb } typealias ErrorMessageCallback = (String) -> Void typealias ProgressCallback = () -> Int typealias WriteCallback = (Int, UnsafePointer) -> Int private let pointer: OpaquePointer private var unmanagedSelf: Unmanaged! private var hasFinished = false private var errorMessageCallback: ErrorMessageCallback! private var progressCallback: ProgressCallback! private var writeCallback: WriteCallback! init?(_ settings: GifskiSettings) { var settings = settings guard let pointer = gifski_new(&settings) else { return nil } self.pointer = pointer // We need to keep a strong reference to self so we can ensure it's not deallocated before libgifski finishes writing. self.unmanagedSelf = Unmanaged.passRetained(self) } private func wrap(_ fn: () -> GifskiError) throws { let result = fn() guard result == GIFSKI_OK else { throw Error(rawValue: result.rawValue) ?? .other } } func setErrorMessageCallback(_ callback: @escaping ErrorMessageCallback) { guard !hasFinished else { return } errorMessageCallback = callback gifski_set_error_message_callback( pointer, { message, context in // swiftlint:disable:this opening_brace guard let message, let context else { return } let this = Unmanaged.fromOpaque(context).takeUnretainedValue() this.errorMessageCallback(String(cString: message)) }, unmanagedSelf.toOpaque() ) } func setProgressCallback(_ callback: @escaping ProgressCallback) { guard !hasFinished else { return } progressCallback = callback gifski_set_progress_callback( pointer, { context in // swiftlint:disable:this opening_brace guard let context else { return 0 } let this = Unmanaged.fromOpaque(context).takeUnretainedValue() return Int32(this.progressCallback()) }, unmanagedSelf.toOpaque() ) } func setWriteCallback(_ callback: @escaping WriteCallback) { guard !hasFinished else { return } writeCallback = callback gifski_set_write_callback( pointer, { bufferLength, bufferPointer, context in // swiftlint:disable:this opening_brace guard bufferLength > 0, let bufferPointer, let context else { return 0 } let this = Unmanaged.fromOpaque(context).takeUnretainedValue() return Int32(this.writeCallback(bufferLength, bufferPointer)) }, unmanagedSelf.toOpaque() ) } // swiftlint:disable:next function_parameter_count func addFrame( pixelFormat: PixelFormat, frameNumber: Int, width: Int, height: Int, bytesPerRow: Int, pixels: [UInt8], presentationTimestamp: Double ) throws { guard !hasFinished else { throw Error.invalidState } try wrap { var pixels = pixels switch pixelFormat { case .rgba: return gifski_add_frame_rgba_stride( pointer, UInt32(frameNumber), UInt32(width), UInt32(height), UInt32(bytesPerRow), &pixels, presentationTimestamp ) case .argb: return gifski_add_frame_argb( pointer, UInt32(frameNumber), UInt32(width), UInt32(bytesPerRow), UInt32(height), &pixels, presentationTimestamp ) case .rgb: return gifski_add_frame_rgb( pointer, UInt32(frameNumber), UInt32(width), UInt32(bytesPerRow), UInt32(height), &pixels, presentationTimestamp ) } } } func finish() throws { guard !hasFinished else { throw Error.invalidState } hasFinished = true defer { unmanagedSelf.release() } try wrap { gifski_finish(pointer) } } } extension GifskiWrapper { enum Error: UInt32, LocalizedError { case nullArg = 1 case invalidState case quant case gif case threadLost case notFound case permissionDenied case alreadyExists case invalidInput case timedOut case writeZero case interrupted case unexpectedEof case aborted case other var errorDescription: String? { switch self { case .nullArg: "One of input arguments was NULL" case .invalidState: "A one-time function was called twice, or functions were called in wrong order" case .quant: "Internal error related to palette quantization" case .gif: "Internal error related to GIF composing" case .threadLost: "Internal error related (panic)" case .notFound: "I/O error: File or directory not found" case .permissionDenied: "I/O error: Permission denied" case .alreadyExists: "I/O error: File already exists" case .invalidInput: "Invalid arguments passed to function" case .timedOut, .writeZero, .interrupted, .unexpectedEof: "Misc I/O error" case .aborted: "Progress callback returned 0, writing aborted" case .other: "Should not happen, file a bug: https://github.com/ImageOptim/gifski" } } } } ================================================ FILE: Gifski/Info.plist ================================================ CFBundleDocumentTypes CFBundleTypeName Video CFBundleTypeRole Viewer LSHandlerRank Alternate LSItemContentTypes public.mpeg-4 com.apple.m4v-video com.apple.quicktime-movie NSExportableTypes com.compuserve.gif public.mpeg-4 CFBundleURLTypes CFBundleTypeRole Viewer CFBundleURLName com.sindresorhus.Gifski CFBundleURLSchemes gifski ITSAppUsesNonExemptEncryption MDItemKeywords gif,convert,video NSServices NSMenuItem default Convert to GIF with Gifski NSMessage convertToGIF NSPortName ${EXECUTABLE_NAME} NSRequiredContext NSSendFileTypes public.mpeg-4 com.apple.m4v-video com.apple.quicktime-movie ================================================ FILE: Gifski/Intents.swift ================================================ import AppIntents import AVFoundation struct Crop_AppEntity: Hashable, Codable, AppEntity { var mode: CropMode_AppEnum var x: Int? var bottomLeftY: Int? var width: Int var height: Int init() { self.mode = .aspectRatio self.width = 1 self.height = 1 } static let defaultQuery = CropEntityQuery() static let typeDisplayRepresentation: TypeDisplayRepresentation = "Crop" var displayRepresentation: DisplayRepresentation { .init(title: "\(description)") } } extension Crop_AppEntity: Identifiable { var id: String { let encoder = JSONEncoder() encoder.outputFormatting = .sortedKeys return (try? encoder.encode(self).base64EncodedString()) ?? Self.errorID } } extension Crop_AppEntity { private static let errorID = "0" var description: String { switch mode { case .exact: "\(width)x\(height) at (\(x ?? 0),\(bottomLeftY ?? 0))" case .aspectRatio: "\(width):\(height)" } } func cropRect(forDimensions dimensions: (Int, Int)) throws -> CropRect? { let (dimensionsWidth, dimensionsHeight) = dimensions switch mode { case .exact: guard let bottomLeftY, let x else { return nil } let cropWidth = width > 1 ? width : 1 let cropHeight = height > 1 ? height : 1 let topLeftY = dimensionsHeight - bottomLeftY - cropHeight let entityRect = CGRect(x: x, y: topLeftY, width: cropWidth, height: cropHeight) let videoRect = CGRect(origin: .zero, size: .init(width: dimensionsWidth, height: dimensionsHeight)) let intersectionRect = videoRect.intersection(entityRect) guard intersectionRect.width >= 1, intersectionRect.height >= 1 else { throw CropOutOfBoundsError(enteredRect: CGRect(x: x, y: bottomLeftY, width: cropWidth, height: cropHeight), videoRect: videoRect) } return CropRect( x: Double(intersectionRect.x) / Double(dimensionsWidth), y: Double(intersectionRect.y) / Double(dimensionsHeight), width: Double(intersectionRect.width) / Double(dimensionsWidth), height: Double(intersectionRect.height) / Double(dimensionsHeight) ) case .aspectRatio: let aspectWidth = width > 1 ? width : 1 let aspectHeight = height > 1 ? height : 1 return CropRect.centeredFrom( aspectWidth: Double(aspectWidth), aspectHeight: Double(aspectHeight), forDimensions: .init(width: dimensionsWidth, height: dimensionsHeight) ) } } static func from(id: String) throws -> Self { guard id != errorID else { return Self() } guard let data = Data(base64Encoded: id) else { throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Invalid ID format")) } return try JSONDecoder().decode(Self.self, from: data) } } struct CropEntityQuery: EntityQuery { func entities(for identifiers: [Crop_AppEntity.ID]) async throws -> [Crop_AppEntity] { try identifiers.map { try Crop_AppEntity.from(id: $0) } } func suggestedEntities() async throws -> [Crop_AppEntity] { PickerAspectRatio.presets.map { var crop = Crop_AppEntity() crop.mode = .aspectRatio crop.width = $0.width crop.height = $0.height return crop } } } enum CropMode_AppEnum: String, AppEnum, CaseIterable, Codable, Hashable { case aspectRatio case exact static let typeDisplayRepresentation: TypeDisplayRepresentation = "Crop Mode" static let caseDisplayRepresentations: [Self: DisplayRepresentation] = [ .aspectRatio: "a fixed aspect ratio", .exact: "exact dimensions" ] } struct CropOutOfBoundsError: Error, CustomLocalizedStringResourceConvertible { let localizedStringResource: LocalizedStringResource init(enteredRect: CGRect, videoRect: CGRect) { self.localizedStringResource = .init(stringLiteral: "Crop rectangle is out of bounds! It's \(Int(enteredRect.width.rounded()))x\(Int(enteredRect.height.rounded())) at (\(Int(enteredRect.x.rounded())),\(Int(enteredRect.y.rounded())), but it needs to fit inside \(Int(videoRect.width.rounded()))x\(Int(videoRect.height.rounded())) for this video.") } } struct CreateCropIntent: AppIntent { static let title: LocalizedStringResource = "Create Crop for Gifski" static let description = IntentDescription( """ Creates a crop to pass into the “Convert Video to Animated GIF” action. """, searchKeywords: [ "video", "conversion", "converter", "crop", "mp4", "mov" ], resultValueName: "Crop" ) static var parameterSummary: some ParameterSummary { Switch(\.$mode) { Case(.exact) { Summary("Create a crop with \(\.$mode): \(\.$width)x\(\.$height) at (\(\.$x), \(\.$y))") } DefaultCase { Summary("Create a crop with \(\.$mode): \(\.$aspectWidth):\(\.$aspectHeight)") } } } @Parameter( description: "Crop by aspect ratio or exact dimensions.", default: .aspectRatio ) var mode: CropMode_AppEnum @Parameter( title: "X Position", description: "The position of the left side of the crop in pixels. 0 is the left edge of the image.", default: 0, inclusiveRange: (0, 100_000) ) var x: Int @Parameter( title: "Y Position", description: "The position of the bottom side of the crop in pixels. 0 is the bottom edge of the image.", default: 0, inclusiveRange: (0, 100_000) ) var y: Int @Parameter( description: "The width of the crop in pixels.", default: 1, inclusiveRange: (1, 100_000) ) var width: Int @Parameter( description: "The height of the crop in pixels.", default: 1, inclusiveRange: (1, 100_000) ) var height: Int @Parameter( description: "The ratio of the width to the height of the crop. For example, 16:9 is the standard for most videos, so 16 is the aspect width and 9 is the aspect height.", default: 16, inclusiveRange: (1, 99) ) var aspectWidth: Int @Parameter( description: "The ratio of the width to the height of the crop. For example, 16:9 is the standard for most videos, so 16 is the aspect width and 9 is the aspect height.", default: 9, inclusiveRange: (1, 99) ) var aspectHeight: Int func perform() async throws -> some IntentResult & ReturnsValue { .result(value: entity) } private var entity: Crop_AppEntity { var entity = Crop_AppEntity() switch mode { case .exact: entity.mode = .exact entity.x = x entity.bottomLeftY = y entity.width = width entity.height = height case .aspectRatio: entity.mode = .aspectRatio entity.width = aspectWidth entity.height = aspectHeight } return entity } } struct ConvertIntent: AppIntent, ProgressReportingIntent { static let title: LocalizedStringResource = "Convert Video to Animated GIF" static let description = IntentDescription( """ Converts a video to a high-quality animated GIF. """, searchKeywords: [ "video", "conversion", "converter", "mp4", "mov" ], resultValueName: "Animated GIF" ) @Parameter( description: "Accepts MP4 and MOV video files.", supportedContentTypes: [ .mpeg4Movie, .quickTimeMovie ] ) var video: IntentFile @Parameter( default: 1, controlStyle: .slider, inclusiveRange: (0, 1) ) var quality: Double @Parameter( description: "By default, it's the same as the video file. Must be in the range 3...50. It will never be higher than the source video. It cannot be above 50 because browsers throttle such frame rates, playing them at 10 FPS.", inclusiveRange: (3, 50) ) var frameRate: Int? @Parameter( description: "Makes the GIF loop forever.", default: true ) var loop: Bool @Parameter( description: "Makes the GIF play forward and then backwards.", default: false ) var bounce: Bool @Parameter( description: "Choose how to specify the dimensions.", default: DimensionsType.percent ) var dimensionsType: DimensionsType @Parameter( description: "The resize percentage of the original dimensions (1-100%).", default: 100, inclusiveRange: (1, 100) ) var dimensionsPercent: Double? @Parameter( title: "Max Width", description: "You can specify both width and height or either.", inclusiveRange: (10, 10_000) ) var dimensionsWidth: Int? @Parameter( title: "Max Height", description: "You can specify both width and height or either.", inclusiveRange: (10, 10_000) ) var dimensionsHeight: Int? @Parameter( description: "Optionally crop the video.", ) var crop: Crop_AppEntity? @Parameter( description: "Whether it should generate only a single frame preview of the GIF.", default: false ) var isPreview: Bool // TODO: Dimensions setting. Percentage or width/height. static var parameterSummary: some ParameterSummary { Switch(\.$dimensionsType) { Case(.pixels) { Summary("Convert \(\.$video) to animated GIF") { \.$quality \.$frameRate \.$loop \.$bounce \.$dimensionsType \.$dimensionsWidth \.$dimensionsHeight \.$crop \.$isPreview } } DefaultCase { Summary("Convert \(\.$video) to animated GIF") { \.$quality \.$frameRate \.$loop \.$bounce \.$dimensionsType \.$dimensionsPercent \.$crop \.$isPreview } } } } @MainActor func perform() async throws -> some IntentResult & ReturnsValue { let videoURL = try video.writeToUniqueTemporaryFile() defer { try? FileManager.default.removeItem(at: videoURL) } let data = try await generateGIF(videoURL: videoURL) let file = data.toIntentFile( contentType: .gif, filename: videoURL.filenameWithoutExtension ) return .result(value: file) } private func generateGIF(videoURL: URL) async throws -> Data { let (videoAsset, metadata) = try await VideoValidator.validate(videoURL) guard !isPreview else { guard let frame = try await videoAsset.image(at: .init(seconds: metadata.duration.toTimeInterval / 3.0, preferredTimescale: .video)) else { throw "Could not generate a preview image from the source video.".toError } return try await GIFGenerator.convertOneFrame( frame: frame, dimensions: dimensions(metadataDimensions: metadata.dimensions), quality: quality ) } // TODO: Progress does not seem to show in the Shortcuts app. progress.totalUnitCount = 100 return try await GIFGenerator.run( try conversionSettings( videoAsset: videoAsset, videoURL: videoURL, metaDatDimensions: metadata.dimensions ) ) { fractionCompleted in progress.completedUnitCount = .init(fractionCompleted * 100) } } private func conversionSettings( videoAsset: AVAsset, videoURL: URL, metaDatDimensions: CGSize ) async throws -> GIFGenerator.Conversion { let dimensions = dimensions(metadataDimensions: metaDatDimensions) return GIFGenerator.Conversion( asset: videoAsset, sourceURL: videoURL, timeRange: nil, quality: quality, dimensions: dimensions, frameRate: frameRate, loop: loop ? .forever : .never, bounce: bounce, crop: try crop?.cropRect(forDimensions: dimensions ?? metaDatDimensions.toInt), trackPreferredTransform: try? await videoAsset.firstVideoTrack?.load(.preferredTransform) ) } private func dimensions(metadataDimensions dimensions: CGSize) -> (Int, Int)? { switch dimensionsType { case .pixels: guard dimensionsWidth != nil || dimensionsHeight != nil else { return nil } let size = dimensions.aspectFittedSize( targetWidth: dimensionsWidth, targetHeight: dimensionsHeight ) return ( Int(size.width.rounded()), Int(size.height.rounded()) ) case .percent: guard let dimensionsPercent else { return nil } let factor = dimensionsPercent / 100 return ( Int((dimensions.width * factor).rounded()), Int((dimensions.height * factor).rounded()) ) } } } ================================================ FILE: Gifski/InternetAccessPolicy.json ================================================ { "ApplicationDescription": "Gifski converts videos to high-quality GIFs.", "DeveloperName": "Sindre Sorhus", "Website": "https://sindresorhus.com/gifski", "Connections" : [ { "Host" : "*.sentry.io", "NetworkProtocol" : "TCP", "Port" : "443", "Purpose" : "Gifski sends crash reports to this server.", "DenyConsequences" : "If you deny this connection, crash reports will not be sent and bugs might stay unfixed." } ] } ================================================ FILE: Gifski/MainScreen.swift ================================================ import SwiftUI struct MainScreen: View { @Environment(AppState.self) private var appState @State private var isDropTargeted = false @State private var isWelcomeScreenPresented = false var body: some View { @Bindable var appState = appState NavigationStack(path: $appState.navigationPath) { StartScreen() .navigationDestination(for: Route.self) { switch $0 { case .edit(let url, let asset, let metadata): // TODO: Make a `Job` struct for this? EditScreen(url: url, asset: asset, metadata: metadata) case .conversion(let conversion): ConversionScreen(conversion: conversion) case .completed(let data, let url): CompletedScreen(data: data, url: url) } } } .frame(width: 760, height: 640) .fileImporter( isPresented: $appState.isFileImporterPresented, allowedContentTypes: Device.supportedVideoTypes ) { do { appState.start(try $0.get()) } catch { appState.error = error } } .fileDialogCustomizationID("import") .fileDialogMessage("Choose a MP4 or MOV video to convert to an animated GIF") .fileDialogDefaultDirectory(.downloadsDirectory) // .backgroundWithMaterial(.underWindowBackground, blendingMode: .behindWindow) .alert(error: $appState.error) .border(isDropTargeted ? Color.accentColor : .clear, width: 5, cornerRadius: 10) // TODO: use `.dropDestination` here when targeting macOS 15. It's stil buggy in macOS 14 (from experience with Aiko) .onDrop( of: appState.isConverting ? [] : [.fileURL], delegate: AnyDropDelegate( isTargeted: $isDropTargeted.animation(.easeInOut(duration: 0.2)), onValidate: { $0.hasFileURLsConforming(to: Device.supportedVideoTypes) }, onPerform: { guard let itemProvider = $0.itemProviders(for: [.fileURL]).first else { return false } Task { guard let url = await itemProvider.getURL() else { return } appState.start(url) } return true } ) ) .alert2( "Welcome to Gifski!", message: """ Keep in mind that the GIF image format is very space inefficient. Only convert short video clips unless you want huge files. If you have any feedback, bug reports, or feature requests, use the feedback button in the “Help” menu. We quickly respond to all submissions. """, isPresented: $isWelcomeScreenPresented ) { Button("Get Started") {} } .task { if SSApp.isFirstLaunch { isWelcomeScreenPresented = true } } .task { #if DEBUG // appState.isFileImporterPresented = true #endif } .toolbar { Color.clear .frame(width: 0, height: 0) } // `.materialActiveAppearance` does not currently work here. Remove `.windowIsVibrant` when it does. // .containerBackground(.thinMaterial.materialActiveAppearance(.active), for: .window) .toolbarBackgroundVisibility(.hidden, for: .windowToolbar) .windowResizeBehavior(.disabled) .windowTabbingMode(.disallowed) .windowCollectionBehavior(.fullScreenNone) .windowIsMovableByWindowBackground() .windowIsVibrant() } } #Preview { MainScreen() } ================================================ FILE: Gifski/Preview/CVPixelBuffer+convertToGIF.swift ================================================ import Foundation import AVKit extension CVPixelBuffer { enum ConvertToGIFError: Error { case failedToCreateCGContext } func convertToGIF( settings: SettingsForFullPreview ) async throws -> Data { // Not the fastest way to convert `CVPixelBuffer` to image, but the runtime of `GIFGenerator.convertOneFrame` is so much larger that optimizing this would be a waste. var ciImage = CIImage(cvPixelBuffer: self) // Raw pixel buffers are in natural space (unrotated). Apply the transform to rotate // the image to preferred space so crop coordinates (defined in preferred space) work correctly. if let trackPreferredTransform = settings.conversion.trackPreferredTransform { // Convert AVFoundation (top-left origin) transform to Core Image (bottom-left origin). let imageHeight = ciImage.extent.height let flip = CGAffineTransform(translationX: 0, y: imageHeight).scaledBy(x: 1, y: -1) let ciTransform = flip.concatenating(trackPreferredTransform).concatenating(flip) ciImage = ciImage.transformed(by: ciTransform) } let ciContext = CIContext() guard let cgImage = ciContext.createCGImage(ciImage, from: ciImage.extent) else { throw ConvertToGIFError.failedToCreateCGContext } guard let croppedImage = settings.conversion.croppedImage(image: cgImage) else { throw GIFGenerator.Error.cropNotInBounds } return try await GIFGenerator.convertOneFrame( frame: croppedImage, dimensions: settings.conversion.croppedOutputDimensions, quality: max(0.1, settings.conversion.settings.quality), fast: true ) } } ================================================ FILE: Gifski/Preview/CompositePreviewShared.h ================================================ #pragma once #ifdef __METAL_VERSION__ // Metal types #include using namespace metal; typedef float2 shared_float2; typedef float3 shared_float3; typedef float4 shared_float4; typedef uint shared_uint; #define SHARED_CONSTANT constant #else // Swift/C types #include typedef simd_float2 shared_float2; typedef simd_float3 shared_float3; typedef simd_float4 shared_float4; typedef uint32_t shared_uint; #define SHARED_CONSTANT #endif SHARED_CONSTANT const shared_uint VERTICES_PER_QUAD = 6; typedef struct { /** Must be >= 0. */ shared_float2 videoOrigin; /** Must be >= 0 */ shared_float2 videoSize; shared_float4 firstColor; shared_float4 secondColor; /** Must be >= 1; */ int gridSize; } CompositePreviewFragmentUniforms; typedef struct { shared_float2 scale; } CompositePreviewVertexUniforms; ================================================ FILE: Gifski/Preview/FullPreviewGenerationEvent.swift ================================================ import Foundation import AVFoundation import Metal /** Events that will be emitted by `PreviewStream`, which represent the state or a generation request. */ struct FullPreviewGenerationEvent: Equatable, Sendable { let requestID: Int private let state: State } extension FullPreviewGenerationEvent { var canShowPreview: Bool { switch state { case .empty, .cancelled: false case .generating, .ready: true } } var errorMessage: String? { switch state { case .empty(error: let error): error case .generating, .ready, .cancelled: nil } } var progress: Double { switch state { case .empty, .cancelled: 0.0 case .generating(let generating): generating.progress case .ready: 1.0 } } var isGenerating: Bool { switch state { case .generating: true case .empty, .ready, .cancelled: false } } /** - Returns: The texture that represents the current preview frame, or `nil` if there is no preview for this frame. */ func getPreviewFrame( originalFrame: CVPixelBuffer, compositionTime: CMTime ) async throws -> SendableTexture? { switch state { case .empty, .cancelled: nil case .generating(let generating): try await originalFrame.convertToGIF(settings: generating.settings).convertToTexture() case .ready(let fullPreview): try fullPreview.getGIF(at: compositionTime) } } /** Check if we can skip generating a full preview based on the last state. - Returns: `true` if a new generation is required, `false` otherwise. */ func isNecessaryToCreateNewFullPreview( newSettings: SettingsForFullPreview, newRequestID: Int ) -> Bool { settings?.areSettingsDifferentEnoughForANewFullPreview( newSettings: newSettings, areCurrentlyGenerating: isGenerating, oldRequestID: requestID, newRequestID: newRequestID ) ?? true } private var settings: SettingsForFullPreview? { switch state { case .empty, .cancelled: nil case .generating(let generating): generating.settings case .ready(let fullPreview): fullPreview.settings } } } extension FullPreviewGenerationEvent { static let initialState = Self(requestID: -1, state: .initialState) static func empty(error: String? = nil, requestID: Int) -> Self { .init(requestID: requestID, state: .empty(error: error)) } static func cancelled(requestID: Int) -> Self { .init(requestID: requestID, state: .cancelled) } static func generating( settings: SettingsForFullPreview, progress: Double, requestID: Int ) -> Self { .init( requestID: requestID, state: .generating(.init(settings: settings, progress: progress)) ) } static func ready( settings: SettingsForFullPreview, gifData: [SendableTexture?], requestID: Int ) -> Self { .init( requestID: requestID, state: .ready(.init(settings: settings, gifData: gifData)) ) } } extension FullPreviewGenerationEvent { private enum State: Equatable { case empty(error: String?) case cancelled case generating(Generating) case ready(FullPreview) static let initialState = Self.empty(error: nil) func sameCase(as other: Self) -> Bool { switch (self, other) { case (.empty, .empty), (.cancelled, .cancelled), (.generating, .generating), (.ready, .ready): true default: false } } } private struct Generating: Equatable { let settings: SettingsForFullPreview let progress: Double } } extension FullPreviewGenerationEvent { fileprivate struct FullPreview { let settings: SettingsForFullPreview let gifData: [SendableTexture?] } } extension FullPreviewGenerationEvent.FullPreview: Equatable { /** They are equal if the settings that led to the creation of a full preview are equal. */ static func == (lhs: Self, rhs: Self) -> Bool { lhs.settings == rhs.settings } } extension FullPreviewGenerationEvent.FullPreview { enum Error: Swift.Error { case failedToGetGIFFrame } func getGIF(at compositionTime: CMTime) throws(Error) -> SendableTexture { guard let image = gifData[getCurrentGIFIndex(at: compositionTime)] else { throw .failedToGetGIFFrame } return image } private func getCurrentGIFIndex(at compositionTime: CMTime) -> Int { let timeRangeInOriginalSpeed = settings.conversion.timeRange ?? (0...settings.assetDuration) let gifTimeInOriginalSpeed = originalCompositionTime(from: compositionTime) - timeRangeInOriginalSpeed.lowerBound let adjustedFramesPerSecond = Double(settings.framesPerSecondsWithoutSpeedAdjustment) / settings.speed return Int(floor(gifTimeInOriginalSpeed * adjustedFramesPerSecond)) .clamped(from: 0, to: gifData.count - 1) } /** Time that has been scaled. The composition will speed up or slow down the time. For example, if the player is at 2x speed. This struct takes the reported time of 0.5 and multiplies it by 2, to get 1 second of the original time in the composition. */ private func originalCompositionTime(from reportedCompositionTime: CMTime) -> Double { reportedCompositionTime.seconds * settings.speed } } extension FullPreviewGenerationEvent: PreviewComparable { /** `PreviewComparable` compares if the image on the screen is visually different between the two states. */ static func ~= (lhs: Self, rhs: Self) -> Bool { // If we have two settings, compare if the settings are the same. if let lhsSettings = lhs.settings { if let rhsSettings = rhs.settings { return lhs.state.sameCase(as: rhs.state) && lhsSettings == rhsSettings } return false } // lhs is `no preview`, so if rhs has `settings` we know we are different. if rhs.settings != nil { return false } // lhs has `no preview` and rhs has `no preview` so they are the same. return true } } ================================================ FILE: Gifski/Preview/FullPreviewStream.swift ================================================ import Foundation import AVFoundation import Compression actor FullPreviewStream { private let stateStreamContinuation: AsyncStream.Continuation private var state = FullPreviewGenerationEvent.initialState /** The current cancellable task that may be creating a new full preview. There will only be one `generationTask` at a time. The old one will be canceled before starting a new one. */ private var generationTask: Task? /** Incremented on every new request. */ private var automaticRequestID = 0 private func newID() -> Int { automaticRequestID += 1 return automaticRequestID } let eventStream: AsyncStream init() { // The output stream. This is a stream of `FullPreviewGenerationEvents`. (self.eventStream, self.stateStreamContinuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(100)) stateStreamContinuation.onTermination = { [weak self] _ in guard let self else { return } Task { [weak self] in await self?.generationTask?.cancel() } } } deinit { generationTask?.cancel() stateStreamContinuation.finish() } /** Request a new full preview. Returns when the generation has *started* not when it finishes. Monitor the `eventStream` for the status of the generation. */ func requestNewFullPreview( asset: sending AVAsset, settingsEvent newSettings: SettingsForFullPreview ) async { let requestID = newID() requestID.p("starting new settings") guard state.isNecessaryToCreateNewFullPreview(newSettings: newSettings, newRequestID: requestID) else { // Not necessary to create a new full preview since there is no state change. return } requestID.p("Generating") if let generationTask, !generationTask.isCancelled { requestID.p("canceling") generationTask.cancel() _ = await generationTask.result requestID.p("canceled old") } generationTask = .detached(priority: .medium) { do { await self.updatePreview( newPreviewState: .generating( settings: newSettings, progress: 0, requestID: requestID ) ) let fullPreviewTask = Self.convertToFullPreview(asset: asset, newSettings: newSettings) await withTaskCancellationHandler { for await progress in fullPreviewTask.progress { await self.updatePreview( newPreviewState: .generating( settings: newSettings, progress: progress, requestID: requestID ) ) } } onCancel: { fullPreviewTask.cancel() } try Task.checkCancellation() let textures = try await fullPreviewTask.value try Task.checkCancellation() requestID.p("success") await self.updatePreview(newPreviewState: .ready(settings: newSettings, gifData: textures, requestID: requestID)) } catch { if Task.isCancelled || error.isCancelled { requestID.p("I was cancelled") return } await self.updatePreview(newPreviewState: .empty(error: error.localizedDescription, requestID: requestID)) } } } static func convertToFullPreview( asset: AVAsset, newSettings: SettingsForFullPreview ) -> ProgressableTask { GIFGenerator.runProgressable(newSettings.conversion.toConversion(asset: asset)) .then(progressWeight: 0.67) { try await PreviewRenderer.shared.convertAnimatedGIFToTextures(gifData: $0) } } /** Request cancellation of the current generation. Monitor `eventStream` for `.cancelled` events. */ func cancelFullPreviewGeneration() { generationTask?.cancel() guard state.isGenerating else { return } updatePreview(newPreviewState: .cancelled(requestID: newID())) } private func updatePreview(newPreviewState: FullPreviewGenerationEvent) { guard newPreviewState.requestID >= state.requestID else { return } state = newPreviewState stateStreamContinuation.yield(newPreviewState) } } extension Int { /** For debugging `createPreviewStream`. */ func p(_ message: String) { #if DEBUG // print("\n\n\(self): \(message)\n\n") #endif } } ================================================ FILE: Gifski/Preview/PreviewRenderer.swift ================================================ import Foundation import Metal import MetalKit actor PreviewRenderer { private static var sharedRenderer: PreviewRenderer? static var shared: PreviewRenderer { get throws { if let sharedRenderer { return sharedRenderer } let renderer = try PreviewRenderer() sharedRenderer = renderer return renderer } } static let colorAttachmentPixelFormat = MTLPixelFormat.bgra8Unorm static let depthAttachmentPixelFormat = MTLPixelFormat.depth32Float private let context: PreviewRendererContext let metalDevice: MTLDevice let textureLoader: MTKTextureLoader var depthTextureCache = [DepthTextureSize: MTLTexture]() private init() throws { guard let metalDevice = MTLCreateSystemDefaultDevice() else { throw Error.noDevice } self.metalDevice = metalDevice guard metalDevice.supportsFamily(.common1) else { throw Error.unsupportedDevice } self.textureLoader = MTKTextureLoader(device: metalDevice) self.context = try PreviewRendererContext(metalDevice) } func renderOriginal( from videoFrame: SendableCVPixelBuffer, to outputFrame: SendableCVPixelBuffer, ) throws { videoFrame.pixelBuffer.propagateAttachments(to: outputFrame.pixelBuffer) try videoFrame.pixelBuffer.copy(to: outputFrame.pixelBuffer) } func renderPreview( previewFrame: SendableTexture, outputFrame: SendableCVPixelBuffer, fragmentUniforms: CompositePreviewFragmentUniforms ) async throws { outputFrame.pixelBuffer.setSRGBColorSpace() // Get a command buffer which will let us submit commands to the GPU. try await context.commandQueue.withCommandBuffer(isolated: self) { commandBuffer in // Convert our pixel buffer to a texture. let outputTexture = try context.textureCache.createTexture( from: outputFrame.pixelBuffer, pixelFormat: Self.colorAttachmentPixelFormat ) // Remove isolation. let previewTexture = previewFrame.getTexture(isolated: self) // Setup the scale of our preview frame. let scale = SIMD2( x: outputTexture.texture.width > 0 ? Float(previewTexture.width.toDouble / outputTexture.texture.width.toDouble) : 1.0, y: outputTexture.texture.height > 0 ? Float(previewTexture.height.toDouble / outputTexture.texture.height.toDouble) : 1.0 ) // The render command encoder will create a render command (render on the GPU) (the command will run when the command buffer commits (which happens automatically at the end of this closure)). try commandBuffer.withRenderCommandEncoder( renderPassDescriptor: PreviewRendererContext.makeRenderPassDescriptor( outputTexture: outputTexture, depthTexture: try getDepthTexture( width: outputTexture.texture.width, height: outputTexture.texture.height ) ) ) { renderEncoder in context.applyContext(to: renderEncoder) // Turn off back culling (this means we don't care what order triangles are wound, we can list the vertices in any order). renderEncoder.setCullMode(.none) // Send the texture to the fragment shader (which chooses the color of each pixel). renderEncoder.setFragmentTexture(previewTexture, index: 0) do { // Send data to the vertex shader. In this case, what scale the preview image is. var vertexUniforms = CompositePreviewVertexUniforms(scale: scale) renderEncoder.setVertexBytes( &vertexUniforms, length: MemoryLayout.stride, index: 0 ) } do { // Send our data to the fragment shader. Mostly about the checkerboard pattern. var fragmentUniforms = fragmentUniforms renderEncoder.setFragmentBytes( &fragmentUniforms, length: MemoryLayout.stride, index: 0 ) } // Tell the encoder to draw. We want to draw 2 quads (one for the preview, one for the checkerboard pattern). The next code to look at will be the vertex shader in `previewVertexShader`. renderEncoder.drawPrimitives( type: .triangle, vertexStart: 0, vertexCount: Int(VERTICES_PER_QUAD) * 2 ) } } } } extension PreviewRenderer { enum Error: Swift.Error { case noDevice case unsupportedDevice case noCommandQueue case failedToMakeSampler case failedToMakeTextureCache case libraryFailure case failedToMakeDepthStencilState case failedToMakeSendableTexture } } extension PreviewRenderer { /** After it is sent to `SendableCVPixelBuffer`, the `CVPixelBuffer` is only accessible to `PreviewRenderer`. */ final class SendableCVPixelBuffer: @unchecked Sendable { fileprivate let pixelBuffer: CVPixelBuffer init(pixelBuffer: CVPixelBuffer) { self.pixelBuffer = pixelBuffer } } } ================================================ FILE: Gifski/Preview/PreviewRendererContext.swift ================================================ import Foundation import MetalKit /** The static state context we setup at runtime and use later. */ struct PreviewRendererContext { private let pipelineState: MTLRenderPipelineState private let depthStencilState: MTLDepthStencilState private let samplerState: MTLSamplerState let commandQueue: MTLCommandQueue let textureCache: CVMetalTextureCache init(_ metalDevice: MTLDevice) throws { guard let commandQueue = metalDevice.makeCommandQueue() else { throw PreviewRenderer.Error.noCommandQueue } self.pipelineState = try Self.setupPipelineState(metalDevice) self.samplerState = try Self.setupSamplerState(metalDevice) self.depthStencilState = try Self.setupDepthStencilState(metalDevice) self.commandQueue = commandQueue self.textureCache = try Self.setupTextureCache(metalDevice) } /** Set the render command encoder to use the context we have created. */ func applyContext(to renderCommandEncoder: MTLRenderCommandEncoder) { // Set up the depth buffer. renderCommandEncoder.setDepthStencilState(depthStencilState) // Set up the actual render. renderCommandEncoder.setRenderPipelineState(pipelineState) // Set up the sampler (allow us to read from the texture). renderCommandEncoder.setFragmentSamplerState(samplerState, index: 0) } /** The render pipeline sets up our shaders in `compositePreview.metal` and sets up to write to a color attachment with a depth buffer. */ private static func setupPipelineState(_ metalDevice: MTLDevice) throws -> MTLRenderPipelineState { guard let library = metalDevice.makeDefaultLibrary(), let vertexFunction = library.makeFunction(name: "previewVertexShader"), let fragmentFunction = library.makeFunction(name: "previewFragment") else { throw PreviewRenderer.Error.libraryFailure } let pipelineDescriptor = MTLRenderPipelineDescriptor() pipelineDescriptor.vertexFunction = vertexFunction pipelineDescriptor.fragmentFunction = fragmentFunction // This is the output of the render pass. pipelineDescriptor.colorAttachments[0].pixelFormat = PreviewRenderer.colorAttachmentPixelFormat // This is a texture which stores the "depth" of each pixel. It is used to decide whether a pixel will occlude another pixel. pipelineDescriptor.depthAttachmentPixelFormat = PreviewRenderer.depthAttachmentPixelFormat return try metalDevice.makeRenderPipelineState(descriptor: pipelineDescriptor) } /** Create a render pass descriptor to match our pipeline. Here we pass in the actual data (i.e. the textures). */ static func makeRenderPassDescriptor( outputTexture: CVMetalTextureReference, depthTexture: MTLTexture ) -> MTLRenderPassDescriptor { let renderPassDescriptor = MTLRenderPassDescriptor() renderPassDescriptor.colorAttachments[0].texture = outputTexture.texture // before the render pass clear the output to the clear color renderPassDescriptor.colorAttachments[0].loadAction = .clear // which in this case is black renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColorMake(0, 0, 0, 1) // after the render pass write to the output texture renderPassDescriptor.colorAttachments[0].storeAction = .store renderPassDescriptor.depthAttachment.texture = depthTexture // before render pass clear the depth texture to the clear depth renderPassDescriptor.depthAttachment.loadAction = .clear // which is 1.0, since our `depthCompareFunction` is `.less` anything less than `1.0` will be drawn renderPassDescriptor.depthAttachment.clearDepth = 1.0 // after render pass we don't care what happens to the depth texture (it has served its purpose) renderPassDescriptor.depthAttachment.storeAction = .dontCare return renderPassDescriptor } /** The sampler is how we retrieve texture data inside the shader. We set it up such that we will linearly interpret all pixel data. */ private static func setupSamplerState(_ metalDevice: MTLDevice) throws(PreviewRenderer.Error) -> MTLSamplerState { let samplerDescriptor = MTLSamplerDescriptor() // Linearly interpolate colors between texels. samplerDescriptor.minFilter = .linear samplerDescriptor.magFilter = .linear // If we sample outside of our texture (0-1) use the same color as the edge. samplerDescriptor.sAddressMode = .clampToEdge samplerDescriptor.tAddressMode = .clampToEdge guard let samplerState = metalDevice.makeSamplerState(descriptor: samplerDescriptor) else { throw .failedToMakeSampler } return samplerState } /** Set up a depth buffer so that the preview will appear above the checkerboard pattern on all devices. */ private static func setupDepthStencilState( _ metalDevice: MTLDevice ) throws(PreviewRenderer.Error) -> MTLDepthStencilState { let depthStencilDescriptor = MTLDepthStencilDescriptor() // For each pixel, if the depth is less than the current depth buffer, then draw, other wise don't draw. depthStencilDescriptor.depthCompareFunction = .less // Each time you do draw (it is less than current depth buffer), store the current depth in the depth buffer. depthStencilDescriptor.isDepthWriteEnabled = true guard let depthStencilState = metalDevice.makeDepthStencilState(descriptor: depthStencilDescriptor) else { throw .failedToMakeDepthStencilState } return depthStencilState } /** Set up a texture cache to write out output pixel buffer to. */ private static func setupTextureCache( _ metalDevice: MTLDevice ) throws(PreviewRenderer.Error) -> CVMetalTextureCache { var textureCache: CVMetalTextureCache? CVMetalTextureCacheCreate(nil, nil, metalDevice, nil, &textureCache) guard let textureCache else { throw .failedToMakeTextureCache } return textureCache } } ================================================ FILE: Gifski/Preview/PreviewVideoCompositor.swift ================================================ import Foundation import AVFoundation import CoreImage /** A video compositor to composite the preview over the original video. This is called by the `AVPlayer` on redraws. What it draws depends on the state: if we are generating or don't have a full preview, we will generate a GIF of the current frame on the fly. If we have a full preview then it will just composite the full preview with the frame in most cases. */ final class PreviewVideoCompositor: NSObject, AVVideoCompositing { enum Error: Swift.Error { case failedToGetVideoFrame } @MainActor private var state = State() /** - Returns: True if the state needed an update and you should redraw, false if there is no change. */ @MainActor func updateState( state: State ) -> Bool { if self.state ~= state { return false } self.state = state return true } func startRequest(_ unwrappedRequest: AVAsynchronousVideoCompositionRequest) { // Safe to wrap it like this because we never ever use the wrapped value in this thread anymore. struct WrappedRequest: @unchecked Sendable { let value: AVAsynchronousVideoCompositionRequest } let wrapped = WrappedRequest(value: unwrappedRequest) Task.detached(priority: .userInitiated) { let asyncVideoCompositionRequest = wrapped.value let compositionTime = asyncVideoCompositionRequest.compositionTime guard let outputFrame = asyncVideoCompositionRequest.renderContext.newPixelBuffer(), let sourceTrackID = asyncVideoCompositionRequest.sourceTrackIDs.first, let originalFrame = asyncVideoCompositionRequest.sourceFrame(byTrackID: sourceTrackID.int32Value) else { asyncVideoCompositionRequest.finish(with: Error.failedToGetVideoFrame) return } do { try await self.state.render( originalFrame: originalFrame, outputFrame: outputFrame, compositionTime: compositionTime ) asyncVideoCompositionRequest.finish(withComposedVideoFrame: outputFrame) } catch { assertionFailure() try? await PreviewRenderer.shared.renderOriginal( from: originalFrame.previewSendable, to: outputFrame.previewSendable ) asyncVideoCompositionRequest.finish(withComposedVideoFrame: outputFrame) } } } // swiftlint:disable:next discouraged_optional_collection let sourcePixelBufferAttributes: [String: any Sendable]? = [ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA ] let requiredPixelBufferAttributesForRenderContext: [String: any Sendable] = [ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA ] func renderContextChanged(_ newRenderContext: AVVideoCompositionRenderContext) { // no-op } } extension PreviewVideoCompositor { struct State: Equatable { private let shouldShowPreview: Bool private let fullPreviewState: FullPreviewGenerationEvent private let previewCheckerboardParams: CompositePreviewFragmentUniforms init() { self.shouldShowPreview = false self.fullPreviewState = .initialState self.previewCheckerboardParams = .init() } init( shouldShowPreview: Bool, fullPreviewState: FullPreviewGenerationEvent, previewCheckerboardParams: CompositePreviewFragmentUniforms ) { self.shouldShowPreview = shouldShowPreview self.fullPreviewState = fullPreviewState self.previewCheckerboardParams = previewCheckerboardParams } func render( originalFrame: CVPixelBuffer, outputFrame: CVPixelBuffer, compositionTime: CMTime ) async throws { guard shouldShowPreview, let previewFrame = try await fullPreviewState.getPreviewFrame( originalFrame: originalFrame, compositionTime: compositionTime ) else { try await PreviewRenderer.shared.renderOriginal( from: originalFrame.previewSendable, to: outputFrame.previewSendable ) return } try await PreviewRenderer.shared.renderPreview( previewFrame: previewFrame, outputFrame: outputFrame.previewSendable, fragmentUniforms: previewCheckerboardParams ) } } } extension PreviewVideoCompositor.State: PreviewComparable { static func ~= (lhs: Self, rhs: Self) -> Bool { guard lhs.shouldShowPreview == rhs.shouldShowPreview, lhs.previewCheckerboardParams == rhs.previewCheckerboardParams else { return false } return lhs.fullPreviewState ~= rhs.fullPreviewState } } ================================================ FILE: Gifski/Preview/PreviewableComposition.swift ================================================ import Foundation import AVFoundation /** Adds `PreviewVideoCompositor` to a `AVComposition`, setting up the instructions and tracks. */ final class PreviewableComposition: AVMutableComposition { enum Error: Swift.Error { case assetHasNoTracks case couldNotCreateTracks } let videoComposition = AVMutableVideoComposition() init(extractPreviewableCompositionFrom asset: AVAsset) async throws { super.init() let (assetTracks, duration) = try await asset.load(.tracks, .duration) guard let assetTrack = assetTracks.first else { throw Error.assetHasNoTracks } let (trackSize, frameDuration, preferredTransform) = try await assetTrack.load(.naturalSize, .minFrameDuration, .preferredTransform) guard let compositionOriginalTrack = addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else { throw Error.couldNotCreateTracks } compositionOriginalTrack.preferredTransform = preferredTransform try compositionOriginalTrack.insertTimeRange( CMTimeRange(start: .videoZero, duration: duration), of: assetTrack, at: .videoZero ) let instruction = AVMutableVideoCompositionInstruction() instruction.timeRange = CMTimeRange(start: .videoZero, duration: duration) instruction.layerInstructions = [AVMutableVideoCompositionLayerInstruction(assetTrack: compositionOriginalTrack)] // Render size in preferred space (rotated) so preview displays correctly. let rotatedRect = CGRect(origin: .zero, size: trackSize).applying(preferredTransform) videoComposition.frameDuration = frameDuration videoComposition.renderSize = CGSize(width: abs(rotatedRect.width), height: abs(rotatedRect.height)) videoComposition.instructions = [instruction] videoComposition.customVideoCompositorClass = PreviewVideoCompositor.self } } ================================================ FILE: Gifski/Preview/SendableTexture.swift ================================================ import Foundation import Metal import MetalKit /** Textures that can only be accessed on the `PreviewRenderer` actor. */ struct SendableTexture: @unchecked Sendable { private let texture: MTLTexture /* Kept fileprivate, because in this file we can ensure that the `SendableTexture` is isolated to the `PreviewRenderer`. */ fileprivate init(texture: MTLTexture) { self.texture = texture } func getTexture(isolated: isolated PreviewRenderer) -> MTLTexture { texture } } extension PreviewRenderer { func convertToTexture(data: Data) async throws -> SendableTexture { try await newSendableTexture(source: .data(data), options: textureOptions) } func convertToTexture(cgImage: CGImage) async throws -> SendableTexture { try await newSendableTexture(source: .image(cgImage), options: textureOptions) } private var textureOptions: [MTKTextureLoader.Option: Any] { [ .SRGB: false, .origin: MTKTextureLoader.Origin.flippedVertically ] } func newSendableTexture( source: SendableTextureSource, options: [MTKTextureLoader.Option: Any]? = nil // swiftlint:disable:this discouraged_optional_collection ) async throws -> SendableTexture { let texture = switch source { case .data(let data): try await textureLoader.newTexture( data: data, options: options ) case .image(let image): try await textureLoader.newTexture( cgImage: image, options: options ) } return .init(texture: texture) } /** [Metal Feature Set Tables ](https://developer.apple.com/metal/Metal-Feature-Set-Tables.pdf) */ var supportsASTCCompressedTextures: Bool { metalDevice.supportsFamily(.apple2) } /** Compress to pixel format `astc_8x8_ldr`. See `convertToASTCTexture` for more info. */ func convertToASTCTexture(cgImage: CGImage) throws -> SendableTexture { let astcData = try cgImage.convertToData( withNewType: "org.khronos.astc", // TODO: Use https://developer.apple.com/documentation/imageio/kcgimagepropertyastcblocksize8x8?changes=lat_7_3 when targeting macOS 26. addOptions: ["kCGImagePropertyASTCBlockSize": 0x88] ) return try metalDevice.convertToASTCTexture(isolated: self, astcData: astcData) } func convertAnimatedGIFToTextures(gifData: Data) -> ProgressableTask { ProgressableTask { progressContinuation in let imageSource = try CGImageSource.from(gifData) let supportsCompressedTextures = self.supportsASTCCompressedTextures var out = [SendableTexture?]() out.reserveCapacity(imageSource.count) for index in 0.. MTLTexture { let size = DepthTextureSize(width: width, height: height) if let existingTexture = depthTextureCache[size] { return existingTexture } // Clean cache if it gets too large. if depthTextureCache.count >= 10 { depthTextureCache.removeAll() } let descriptor = MTLTextureDescriptor.texture2DDescriptor( pixelFormat: Self.depthAttachmentPixelFormat, width: width, height: height, mipmapped: false ) descriptor.usage = .renderTarget descriptor.storageMode = .private guard let depthTexture = metalDevice.makeTexture(descriptor: descriptor) else { throw "Failed to create depth texture.".toError } depthTextureCache[size] = depthTexture return depthTexture } } extension MTLDevice { /** Use the compressed texture pixel Format [ASTC](https://www.khronos.org/opengl/wiki/ASTC_Texture_Compression) According to the [documentation](/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/AppleTextureEncoder.h), `astc_8x8` is the smallest we can encode to with the built-in encoder, which is 2 bits per pixel. */ func convertToASTCTexture( isolated: isolated PreviewRenderer, astcData: Data ) throws -> SendableTexture { let astcImage = try ASTCImage(data: astcData) let descriptor = try astcImage.descriptor() descriptor.storageMode = .managed descriptor.usage = [.shaderRead] guard let texture = makeTexture(descriptor: descriptor) else { throw ConvertToASTCTextureError.failedToCreateTextures } try astcImage.write(to: texture) return SendableTexture(texture: texture) } } enum ConvertToASTCTextureError: Error { case failedToCreateTextures } enum SendableTextureSource { case data(Data) case image(CGImage) } extension Data { /** If data holds `imageData`, this will convert to a texture suitable for `PreviewRenderer`. */ func convertToTexture() async throws -> SendableTexture { try await PreviewRenderer.shared.convertToTexture(data: self) } } ================================================ FILE: Gifski/Preview/SettingsForFullPreview.swift ================================================ import Foundation import CoreMedia import AVFoundation /** When creating a full preview, you don't need the some setting such as loop or bounce, plus it has additional info like asset duration and speed. */ struct SettingsForFullPreview: Equatable, Sendable { let conversion: SendableConversion let speed: Double let assetDuration: TimeInterval let framesPerSecondsWithoutSpeedAdjustment: Int init( conversion: GIFGenerator.Conversion, speed: Double, framesPerSecondsWithoutSpeedAdjustment: Int, duration assetDuration: TimeInterval ) { self.speed = speed self.framesPerSecondsWithoutSpeedAdjustment = framesPerSecondsWithoutSpeedAdjustment self.assetDuration = assetDuration self.conversion = SendableConversion(conversion: conversion) } func areSettingsDifferentEnoughForANewFullPreview( newSettings: Self, areCurrentlyGenerating: Bool, oldRequestID: Int, newRequestID: Int ) -> Bool { guard speed == newSettings.speed else { return true } if self == newSettings { newRequestID.p("Skipping - Same as \(oldRequestID)") return false } if !areCurrentlyGenerating, areTheSameBesidesTimeRange(newSettings), timeRangeContainsTimeRange(of: newSettings) { newRequestID.p("Skipping - Same as ready \(oldRequestID)") return false } newRequestID.p("Different than \(oldRequestID)") return true } /** Check if the settings for full preview are the same, ignoring settings that do not affect full preview. */ private func areTheSameBesidesTimeRange(_ settings: Self) -> Bool { conversion.settings == settings.conversion.settings } /** Check if the time range of the new settings is a subset of the old settings. */ private func timeRangeContainsTimeRange(of newSettings: Self) -> Bool { guard let oldTimeRange = conversion.timeRange else { /** `nil` means the entire duration, so all sets are subset of the range. */ return true } guard let newTimeRange = newSettings.conversion.timeRange else { /** Old is not full, but new is full, thus it is not a subset. */ return false } return oldTimeRange.contains(newTimeRange) } struct SendableConversion: ReflectiveHashable, Sendable, CropSettings { let timeRange: ClosedRange? let settings: ConversionSettings var dimensions: (width: Int, height: Int)? { settings.dimensions } var trackPreferredTransform: CGAffineTransform? { settings.trackPreferredTransform } var crop: CropRect? { settings.crop } struct ConversionSettings: ReflectiveHashable, Sendable { let sourceURL: URL let quality: Double let dimensions: (width: Int, height: Int)? let frameRate: Int? let crop: CropRect? let trackPreferredTransform: CGAffineTransform? var loop: Gifski.Loop { .never } var bounce: Bool { false } } init(conversion: GIFGenerator.Conversion) { self.timeRange = conversion.timeRange self.settings = .init( sourceURL: conversion.sourceURL, quality: conversion.quality, dimensions: conversion.dimensions, frameRate: conversion.frameRate, crop: conversion.crop, trackPreferredTransform: conversion.trackPreferredTransform ) } func toConversion(asset: AVAsset) -> GIFGenerator.Conversion { .init( asset: asset, sourceURL: settings.sourceURL, timeRange: timeRange, quality: settings.quality, dimensions: settings.dimensions, frameRate: settings.frameRate, loop: settings.loop, bounce: settings.bounce, crop: settings.crop, trackPreferredTransform: settings.trackPreferredTransform ) } } } ================================================ FILE: Gifski/Preview/compositePreview.metal ================================================ #include #include #include "CompositePreviewShared.h" using namespace metal; struct Vertex { float2 position; float2 textureCoordinates; Vertex(float2 position, float2 textureCoordinates): position(position), textureCoordinates(textureCoordinates) {} }; struct VertexOut { // The position of the vertex in homogeneous clip space. In our case, the clip space goes from -1...1 in both the x and y directions. (-1,-1) is the bottom-left of the screen, while (1,1) is the top right. The position goes from 0...1 in the z direction, and it is used by the depth buffer to decide what pixels will occlude other pixels. In our case, pixels "closer" to 0 will be "on-top" of pixels "farther" away (1.0 being the maximum depth). `w` will be kept 1.0 and can be ignored for now. float4 position [[position]]; // Pass the texture coordinates on to the fragment shader. Texture coordinates range from 0...1 in `s` and `t` (i.e., horizontal and vertical). float2 textureCoordinates; // Pass whether or not the triangle is checkerboard to the fragment shader. uint isCheckerboard; VertexOut(Vertex vert, float2 scale, float z, bool isCheckerboard): position(float4(vert.position * scale, z, 1.0)), textureCoordinates(vert.textureCoordinates), isCheckerboard(isCheckerboard) {} }; constant int vertexIndices[VERTICES_PER_QUAD] = {0, 1, 2, 2, 1, 3}; constant Vertex vertices[4] = { Vertex(float2(-1.0, -1.0), float2(0.0, 0.0)), Vertex(float2( 1.0, -1.0), float2(1.0, 0.0)), Vertex(float2(-1.0, 1.0), float2(0.0, 1.0)), Vertex(float2( 1.0, 1.0), float2(1.0, 1.0)) }; /* The vertex shader computes the position of each vertex. This function gets called once per vertex (which in our case is `VERTICES_PER_QUAD * 2` vertices). This shader simply looks up the vertex position, and texture coordinates from some precomputed vertex data we include in the shader. After the vertex shader stage completes, the GPU will [rasterize](https://jtsorlinis.github.io/rendering-tutorial/) each triangle, computing the position of pixels on the screen. Then it will move on to the fragment shader `previewFragment`. */ vertex VertexOut previewVertexShader( uint vertexID [[vertex_id]], constant CompositePreviewVertexUniforms &uniforms [[buffer(0)]] ) { bool isCheckerboard = vertexID >= VERTICES_PER_QUAD; return VertexOut( vertices[vertexIndices[vertexID % VERTICES_PER_QUAD]], isCheckerboard ? float2(1.0, 1.0) : uniforms.scale, isCheckerboard ? 0.5 : 0.1, isCheckerboard ); } /* The preview fragment shader runs for each rasterized pixel. The data from each vertex (`VertexOut`) for each triangle is interpolated (at one of the vertices the data is exactly the same as the input vertex; in the exact middle of the triangle is a blend of each vertex). Returns a color for the pixel. */ fragment float4 previewFragment( VertexOut in [[stage_in]], texture2d inputTexture [[texture(0)]], sampler inputSampler [[sampler(0)]], constant CompositePreviewFragmentUniforms &uniforms [[buffer(0)]] ) { if (!in.isCheckerboard) { // Grab the color given by the texture at the coordinates given by `textureCoordinates`. return inputTexture.sample(inputSampler, in.textureCoordinates); } float2 topLeftOriginTexCoords = float2(in.textureCoordinates.x, 1.0 - in.textureCoordinates.y); float2 texCoordsInPixels = topLeftOriginTexCoords * uniforms.videoSize + uniforms.videoOrigin; int gridSize = uniforms.gridSize; int checkerX = (int(texCoordsInPixels.x) % (gridSize * 2)) >= gridSize ? 1 : 0; int checkerY = (int(texCoordsInPixels.y) % (gridSize * 2)) >= gridSize ? 1 : 0; return (checkerX + checkerY) % 2 == 0 ? uniforms.firstColor : uniforms.secondColor; } ================================================ FILE: Gifski/ResizableDimensions.swift ================================================ import CoreGraphics import AppIntents enum DimensionsType: String, Equatable, CaseIterable { case pixels case percent } extension DimensionsType: AppEnum { static let typeDisplayRepresentation: TypeDisplayRepresentation = "Dimension Type" static let caseDisplayRepresentations: [Self: DisplayRepresentation] = [ .pixels: "Pixels", .percent: "Percent" ] } enum Dimensions: Hashable { case pixels(_ value: CGSize, originalSize: CGSize) case percent(_ value: Double, originalSize: CGSize) } extension Dimensions { var pixels: CGSize { switch self { case .pixels(let value, _): return value.rounded() case .percent(let percent, let originalSize): guard originalSize != .zero else { return .zero } return (originalSize * percent).rounded() } } var percent: Double { switch self { case .pixels(let value, let originalSize): guard originalSize.width > 0 else { return 0 } return value.width / originalSize.width case .percent(let value, _): return value } } var isPercent: Bool { switch self { case .pixels: false case .percent: true } } var originalSize: CGSize { switch self { case .pixels(_, let originalSize): originalSize case .percent(_, let originalSize): originalSize } } var widthMinMax: ClosedRange { let minimumSize = originalSize.aspectFill(to: 5) return minimumSize.width.clamped(to: ...originalSize.width).rounded()...originalSize.width } var heightMinMax: ClosedRange { let minimumSize = originalSize.aspectFill(to: 5) return minimumSize.height.clamped(to: ...originalSize.height).rounded()...originalSize.height } var percentMinMax: ClosedRange { 1...100 } func rounded(_ rule: FloatingPointRoundingRule = .toNearestOrAwayFromZero) -> Self { switch self { case .pixels(let value, let originalSize): let roundedValue = CGSize(width: value.width.rounded(rule), height: value.height.rounded(rule)) return .pixels(roundedValue, originalSize: originalSize) case .percent(let value, let originalSize): let roundedValue = value.rounded(rule) return .percent(roundedValue, originalSize: originalSize) } } func resized(to newSize: CGSize) -> Self { switch self { case .pixels(_, let originalSize): return .pixels(newSize, originalSize: originalSize) case .percent(_, let originalSize): let newWidthPercent = (newSize.width / originalSize.width) * 100 let newHeightPercent = (newSize.height / originalSize.height) * 100 let averagePercent = (newWidthPercent + newHeightPercent) / 2 return .percent(averagePercent, originalSize: originalSize) } } } extension Dimensions: CustomStringConvertible { var description: String { switch self { case .pixels(let value, _): let percent = percent * 100 let percentString = percent == 100 ? "Original" : String(format: "~%.0f%%", percent) return "\(value.formatted) (\(percentString))" case .percent(let value, _): let pixels = pixels let percentValue = value * 100 let pixelString = percentValue == 100 ? "Original" : "\(pixels.formatted)" return String(format: "%.0f%% (\(pixelString))", percentValue) } } } extension Dimensions { func aspectResized(usingWidth width: Double) -> Self { switch self { case .pixels(let originalValue, let originalSize): print("ORIGINAL", originalSize, originalValue) guard originalSize.width != .zero else { return self } let newHeight = originalSize.height * (width / originalSize.width) return .pixels(CGSize(width: width, height: newHeight).rounded(), originalSize: originalSize) case .percent(_, let originalSize): print("ORIGINAL2", originalSize) let newPercent = width / originalSize.width return .percent(newPercent, originalSize: originalSize) } } func aspectResized(usingHeight height: Double) -> Self { switch self { case .pixels(_, let originalSize): guard originalSize.height != .zero else { return self } let newWidth = originalSize.width * (height / originalSize.height) return .pixels(CGSize(width: newWidth, height: height).rounded(), originalSize: originalSize) case .percent(_, let originalSize): let newPercent = height / originalSize.height return .percent(newPercent, originalSize: originalSize) } } } ================================================ FILE: Gifski/Shared.swift ================================================ import Foundation enum Shared { static let appGroupIdentifier = "group.com.sindresorhus.Gifski" } ================================================ FILE: Gifski/StartScreen.swift ================================================ import SwiftUI struct StartScreen: View { @Environment(AppState.self) private var appState var body: some View { VStack(spacing: 8) { Text("Drop Video") Text("or") .font(.system(size: 10)) .italic() Button("Open") { appState.isFileImporterPresented = true } } .font(.title3) .controlSize(.extraLarge) .foregroundStyle(.secondary) .padding() .fillFrame() .navigationTitle("") // TODO: When targeting macOS 15, set `.containerShape()` at the top-level and then use `ContainerRelativeShape()` for the border. // TODO: Or do a `.windowBorder()` utility. } } ================================================ FILE: Gifski/Utilities.swift ================================================ import SwiftUI import AVKit import Combine import AVFoundation import Accelerate.vImage import AppIntents import Defaults import Sentry import ExtendedAttributes typealias Defaults = _Defaults typealias Default = _Default typealias AnyCancellable = Combine.AnyCancellable // TODO: Check if any of these can be removed when targeting macOS 15. extension NSItemProvider: @retroactive @unchecked Sendable {} @discardableResult func with(_ item: T, update: (inout T) throws(E) -> Void) throws(E) -> T { var this = item try update(&this) return this } func delay(@_implicitSelfCapture _ duration: Duration, closure: @escaping () -> Void) { DispatchQueue.main.asyncAfter(duration, execute: closure) } extension DispatchQueue { func asyncAfter(_ duration: Duration, execute: @escaping () -> Void) { asyncAfter(deadline: .now() + duration.toTimeInterval, execute: execute) } func asyncAfter(_ duration: Duration, execute: DispatchWorkItem) { asyncAfter(deadline: .now() + duration.toTimeInterval, execute: execute) } } func asyncNilCoalescing( _ optional: T?, default defaultValue: @escaping @autoclosure () async throws -> T ) async rethrows -> T { guard let optional else { return try await defaultValue() } return optional } func asyncNilCoalescing( _ optional: T?, default defaultValue: @escaping @autoclosure () async throws -> T? ) async rethrows -> T? { guard let optional else { return try await defaultValue() } return optional } // swiftlint:disable:next no_cgfloat extension CGFloat { /** Get a Double from a CGFloat. This makes it easier to work with optionals. */ var toDouble: Double { Double(self) } } extension Double { /** Discouraged but sometimes needed when implicit coercion doesn't work. */ var toCGFloat: CGFloat { CGFloat(self) } // swiftlint:disable:this no_cgfloat no_cgfloat2 /** If this represents an aspect ratio, return the normalized aspect ratio for each side as a `CGSize`. */ var normalizedAspectRatioSides: CGSize { self > 1.0 ? .init(width: 1.0, height: 1.0 / self) : .init(width: self, height: 1.0) } } extension BinaryInteger { var toDouble: Double { Double(Int(self)) } } extension BinaryFloatingPoint { var toInt: Int? { self >= Self(Int.min) && self <= Self(Int.max) ? Int(self) : nil } var toIntAndClampingIfNeeded: Int { Int(clamped(to: Self(Int.min)...Self(Int.max))) } } extension Link> { init( _ title: String, systemImage: String, destination: URL ) { self.init(destination: destination) { Label(title, systemImage: systemImage) } } } extension NSView { func shake(duration: Duration = .seconds(0.3), direction: NSUserInterfaceLayoutOrientation) { let translation = direction == .horizontal ? "x" : "y" let animation = CAKeyframeAnimation(keyPath: "transform.translation.\(translation)") animation.timingFunction = .linear animation.duration = duration.toTimeInterval animation.values = [-5, 5, -2.5, 2.5, 0] layer?.add(animation, forKey: nil) } } struct SendFeedbackButton: View { var body: some View { Link( "Support & Feedback", systemImage: "exclamationmark.bubble", destination: SSApp.appFeedbackUrl() ) } } struct ShareAppButton: View { let appStoreID: String var body: some View { ShareLink("Share App", item: "https://apps.apple.com/app/id\(appStoreID)") } } struct RateOnAppStoreButton: View { let appStoreID: String var body: some View { Link( "Rate App", systemImage: "star", destination: URL(string: "itms-apps://apps.apple.com/app/id\(appStoreID)?action=write-review")! ) } } // NOTE: This is moot with macOS 12, but `.values` property provided is super buggy and crashes a lot. extension Publisher where Failure == Never { var toAsyncSequence: some AsyncSequence { AsyncStream(Output.self) { continuation in let cancellable = sink { completion in switch completion { case .finished: continuation.finish() } } receiveValue: { output in continuation.yield(output) } continuation.onTermination = { [cancellable] _ in cancellable.cancel() } } } } extension Task { /** Make a task cancellable. - Important: You need to assign it to a cancellable property for it to be cancelled. It's not weak by default like Combine. */ var toCancellable: AnyCancellable { .init(cancel) } } extension Sequence { func asyncMap( _ transform: (Element) async throws(E) -> T ) async throws(E) -> [T] { var values = [T]() for element in self { try await values.append(transform(element)) } return values } } extension NSView { @discardableResult func insertVibrancyView( material: NSVisualEffectView.Material, blendingMode: NSVisualEffectView.BlendingMode = .behindWindow, appearanceName: NSAppearance.Name? = nil ) -> NSVisualEffectView { let view = NSVisualEffectView(frame: bounds) view.autoresizingMask = [.width, .height] view.material = material view.blendingMode = blendingMode if let appearanceName { view.appearance = NSAppearance(named: appearanceName) } addSubview(view, positioned: .below, relativeTo: nil) return view } } extension NSWindow { private enum AssociatedKeys { static let cancellable = ObjectAssociation() } func makeVibrant() { // So there seems to be a visual effect view already created by NSWindow. // If we can attach ourselves to it and make it a vibrant one - awesome. // If not, let's just add our view as a first one so it is vibrant anyways. guard let visualEffectView = contentView?.superview?.subviews.lazy.compactMap({ $0 as? NSVisualEffectView }).first else { contentView?.superview?.insertVibrancyView(material: .underWindowBackground) return } visualEffectView.blendingMode = .behindWindow visualEffectView.material = .underWindowBackground AssociatedKeys.cancellable[self] = visualEffectView.publisher(for: \.effectiveAppearance) .sink { _ in visualEffectView.blendingMode = .behindWindow visualEffectView.material = .underWindowBackground } } } extension Binding { var doubleToInt: Binding { map( get: { Int($0) }, set: { Double($0) } ) } } extension Binding { var intToDouble: Binding { map( get: { Double($0) }, set: { Int($0) } ) } } extension NSView { private final class AddedToSuperviewObserverView: NSView { var onAdded: (() -> Void)? override var acceptsFirstResponder: Bool { false } convenience init() { self.init(frame: .zero) } override func viewDidMoveToWindow() { guard window != nil else { return } onAdded?() removeFromSuperview() } } func onAddedToSuperview(_ closure: @escaping () -> Void) { let view = AddedToSuperviewObserverView() view.onAdded = closure addSubview(view) } } extension NSAlert { /** Show an alert as a window-modal sheet, or as an app-modal (window-indepedendent) alert if the window is `nil` or not given. */ @discardableResult static func showModal( for window: NSWindow? = nil, title: String, message: String? = nil, detailText: String? = nil, style: Style = .warning, buttonTitles: [String] = [], defaultButtonIndex: Int? = nil, minimumWidth: Double? = nil ) -> NSApplication.ModalResponse { NSAlert( title: title, message: message, detailText: detailText, style: style, buttonTitles: buttonTitles, defaultButtonIndex: defaultButtonIndex, minimumWidth: minimumWidth ).runModal(for: window) } /** The index in the `buttonTitles` array for the button to use as default. Set `-1` to not have any default. Useful for really destructive actions. */ var defaultButtonIndex: Int { get { buttons.firstIndex { $0.keyEquivalent == "\r" } ?? -1 } set { // Clear the default button indicator from other buttons. for button in buttons where button.keyEquivalent == "\r" { button.keyEquivalent = "" } if newValue != -1 { buttons[newValue].keyEquivalent = "\r" } } } convenience init( title: String, message: String? = nil, detailText: String? = nil, style: Style = .warning, buttonTitles: [String] = [], defaultButtonIndex: Int? = nil, minimumWidth: Double? = nil ) { self.init() self.messageText = title self.alertStyle = style if let message { self.informativeText = message } if let detailText { let scrollView = NSTextView.scrollableTextView() // We're setting the frame manually here as it's impossible to use auto-layout, // since it has nothing to constrain to. This will eventually be rewritten in SwiftUI anyway. scrollView.frame = CGRect(width: minimumWidth ?? 300, height: 120) if minimumWidth == nil { scrollView.onAddedToSuperview { if let messageTextField = (scrollView.superview?.superview?.subviews.first { $0 is NSTextField }) { scrollView.frame.width = messageTextField.frame.width } else { assertionFailure("Couldn't detect the message textfield view of the NSAlert panel") } } } let textView = scrollView.documentView as! NSTextView textView.drawsBackground = false textView.isEditable = false textView.font = .systemFont(ofSize: NSFont.systemFontSize(for: .small)) textView.textColor = .secondaryLabelColor textView.string = detailText self.accessoryView = scrollView } else if let minimumWidth { self.accessoryView = NSView(frame: CGRect(width: minimumWidth, height: 0)) } addButtons(withTitles: buttonTitles) if let defaultButtonIndex { self.defaultButtonIndex = defaultButtonIndex } } /** Runs the alert as a window-modal sheet, or as an app-modal (window-indepedendent) alert if the window is `nil` or not given. */ @discardableResult func runModal(for window: NSWindow? = nil) -> NSApplication.ModalResponse { guard let window else { return runModal() } beginSheetModal(for: window) { returnCode in NSApp.stopModal(withCode: returnCode) } return NSApp.runModal(for: window) } /** Adds buttons with the given titles to the alert. */ func addButtons(withTitles buttonTitles: [String]) { for buttonTitle in buttonTitles { addButton(withTitle: buttonTitle) } } } extension CMTimeScale { /** Apple-recommended scale for video. ``` CMTime(seconds: (1 / fps), preferredTimescale: .video) ``` */ static let video: Self = 600 } extension CMTime { /** Zero in the video timescale. */ static var videoZero: Self { .init(seconds: 0, preferredTimescale: .video) } } extension Comparable { func clamped(from lowerBound: Self, to upperBound: Self) -> Self { min(max(self, lowerBound), upperBound) } func clamped(to range: ClosedRange) -> Self { clamped(from: range.lowerBound, to: range.upperBound) } func clamped(to range: PartialRangeThrough) -> Self { min(self, range.upperBound) } func clamped(to range: PartialRangeFrom) -> Self { max(self, range.lowerBound) } } extension Strideable where Stride: SignedInteger { func clamped(to range: CountableRange) -> Self { clamped(from: range.lowerBound, to: range.upperBound.advanced(by: -1)) } func clamped(to range: CountableClosedRange) -> Self { clamped(from: range.lowerBound, to: range.upperBound) } func clamped(to range: PartialRangeUpTo) -> Self { min(self, range.upperBound.advanced(by: -1)) } } extension String.StringInterpolation { /** Interpolate the value by unwrapping it, and if `nil`, use the given default string. ``` // This doesn't work as you can only use nil coalescing in interpolation with the same type as the optional "foo \(optionalDouble ?? "none") // Now you can do this "foo \(optionalDouble, default: "none") ``` */ public mutating func appendInterpolation(_ value: Any?, default defaultValue: String) { if let value { appendInterpolation(value) } else { appendLiteral(defaultValue) } } /** Interpolate the value by unwrapping it, and if `nil`, use `"nil"`. ``` // This doesn't work as you can only use nil coalescing in interpolation with the same type as the optional "foo \(optionalDouble ?? "nil") // Now you can do this "foo \(describing: optionalDouble) ``` */ public mutating func appendInterpolation(describing value: Any?) { if let value { appendInterpolation(value) } else { appendLiteral("nil") } } } extension CGSize { /** Example: `140×100` */ var formatted: String { "\(Double(width).formatted(.number.grouping(.never)))\u{2009}×\u{2009}\(Double(height).formatted(.number.grouping(.never)))" } } extension NSImage { /** `UIImage` polyfill. */ convenience init(cgImage: CGImage) { self.init(cgImage: cgImage, size: .zero) } } extension CGImage { var toNSImage: NSImage { NSImage(cgImage: self) } } extension AVAsset { func image(at time: CMTime) async throws -> CGImage? { let imageGenerator = AVAssetImageGenerator(asset: self) imageGenerator.appliesPreferredTrackTransform = true imageGenerator.requestedTimeToleranceAfter = .zero imageGenerator.requestedTimeToleranceBefore = .zero return try await imageGenerator.image(at: time).image } } extension AVAssetTrack { enum VideoTrimmingError: Error { case unknownAssetReaderFailure case videoTrackIsEmpty case assetIsMissingVideoTrack case compositionCouldNotBeCreated case codecNotSupported } /** Removes blank frames from the beginning of the track. This can be useful to trim blank frames from files produced by tools like the iOS simulator screen recorder. */ func trimmingBlankFrames() async throws -> AVAssetTrack { // See https://github.com/sindresorhus/Gifski/issues/254 for context. // In short: Some codecs seem to always report a sample buffer size of 0 when reading, breaking this function. (macOS 11.6) let buggyCodecs = ["v210", "BGRA"] if let codecIdentifier = try await codecIdentifier, buggyCodecs.contains(codecIdentifier) { throw VideoTrimmingError.codecNotSupported } // Create new composition let composition = AVMutableComposition() guard let wrappedTrack = composition.addMutableTrack(withMediaType: mediaType, preferredTrackID: .zero) else { throw VideoTrimmingError.compositionCouldNotBeCreated } let (preferredTransform, timeRange) = try await load(.preferredTransform, .timeRange) wrappedTrack.preferredTransform = preferredTransform try wrappedTrack.insertTimeRange(timeRange, of: self, at: .zero) let reader = try AVAssetReader(asset: composition) // Create reader for wrapped track. let readerOutput = AVAssetReaderTrackOutput(track: wrappedTrack, outputSettings: nil) readerOutput.alwaysCopiesSampleData = false reader.add(readerOutput) reader.startReading() defer { reader.cancelReading() } // TODO: When targeting macOS 13, use this instead: https://developer.apple.com/documentation/avfoundation/avsamplebuffergenerator/3950878-makebatch?changes=latest_minor // Iterate through samples until we reach one with a non-zero size. while let sampleBuffer = readerOutput.copyNextSampleBuffer() { guard [.completed, .reading].contains(reader.status) else { throw reader.error ?? VideoTrimmingError.unknownAssetReaderFailure } // On first non-empty frame. guard sampleBuffer.totalSampleSize == 0 else { let currentTimestamp = sampleBuffer.outputPresentationTimeStamp wrappedTrack.removeTimeRange(.init(start: .zero, end: currentTimestamp)) return wrappedTrack } } throw VideoTrimmingError.videoTrackIsEmpty } } extension AVAssetTrack.VideoTrimmingError: LocalizedError { public var errorDescription: String? { switch self { case .unknownAssetReaderFailure: "Asset could not be read." case .videoTrackIsEmpty: "Video track is empty." case .assetIsMissingVideoTrack: "Asset is missing video track." case .compositionCouldNotBeCreated: "Composition could not be created." case .codecNotSupported: "Video codec is not supported." } } } extension AVAsset { typealias VideoTrimmingError = AVAssetTrack.VideoTrimmingError /** Removes blank frames from the beginning of the first video track of the asset. The returned asset only includes the first video track. This can be useful to trim blank frames from files produced by tools like the iOS simulator screen recorder. */ func trimmingBlankFramesFromFirstVideoTrack() async throws -> AVAsset { guard let firstVideoTrack = try await firstVideoTrack else { throw VideoTrimmingError.assetIsMissingVideoTrack } let trimmedTrack = try await firstVideoTrack.trimmingBlankFrames() guard let trimmedAsset = trimmedTrack.asset else { assertionFailure("Track is somehow missing asset") return AVMutableComposition() } return trimmedAsset } } extension AVAssetTrack { /** Returns the dimensions of the track if it's a video. */ var dimensions: CGSize? { get async throws { let (naturalSize, preferredTransform) = try await load(.naturalSize, .preferredTransform) guard naturalSize != .zero else { return nil } let size = naturalSize.applying(preferredTransform) let preferredSize = CGSize(width: abs(size.width), height: abs(size.height)) // Workaround for https://github.com/sindresorhus/gifski-app/issues/76 guard preferredSize != .zero else { // SInce this is just a fallback, we don't want to throw the error here. return try? await asset?.image(at: CMTime(seconds: 0, preferredTimescale: .video))?.size } return preferredSize } } /** Returns the frame rate of the track if it's a video. */ var frameRate: Double? { get async throws { Double(try await load(.nominalFrameRate)) } } /** Returns the aspect ratio of the track if it's a video. */ var aspectRatio: Double? { get async throws { try await dimensions?.aspectRatio } } // TODO: Deprecate this. The system now provides strongly-typed identifiers. /** Example: `avc1` (video) `aac` (audio) */ var codecIdentifier: String? { get async throws { try await load(.formatDescriptions).first?.mediaSubType.rawValue.fourCharCodeToString().nilIfEmpty } } // TODO: Rename to `format`? var codec: AVFormat? { get async throws { guard let codecString = try await codecIdentifier else { return nil } return AVFormat(fourCC: codecString) } } /** Use this for presenting the codec to the user. This is either the codec name, if known, or the codec identifier. You can just default to `"Unknown"` if this is `nil`. */ var codecTitle: String? { get async throws { // TODO: Doesn't work because of missing `reasync`. // try await codec?.description ?? codecIdentifier guard let codec = try await codec else { return try await codecIdentifier } return codec.description } } /** Returns a debug string with the media format. Example: `vide/avc1` */ var mediaFormat: String { get async throws { try await load(.formatDescriptions).map { // Get string representation of media type (vide, soun, sbtl, etc.) let type = $0.mediaType.description // Get string representation media subtype (avc1, aac, tx3g, etc.) let subType = $0.mediaSubType.description return "\(type)/\(subType)" } .joined(separator: ",") } } /** Estimated file size of the track, in bytes */ var estimatedFileSize: Int { get async throws { let (estimatedDataRate, timeRange) = try await load(.estimatedDataRate, .timeRange) let dataRateInBytes = Double(estimatedDataRate / 8) let bytes = timeRange.duration.seconds * dataRateInBytes return Int(bytes) } } } extension AVAssetTrack { /** Whether the track's duration is the same as the total asset duration. */ var isFullDuration: Bool { get async throws { guard let asset else { return false } async let timeRange = load(.timeRange) async let assetDuration = asset.load(.duration) return try await (timeRange.duration == assetDuration) } } /** Extract the track into a new AVAsset. Optionally, mutate the track. This can be useful if you only want the video or audio of an asset. For example, sometimes the video track duration is shorter than the total asset duration. Extracting the track into a new asset ensures the asset duration is only as long as the video track duration. */ func extractToNewAsset( _ modify: ((AVMutableCompositionTrack) -> Void)? = nil ) async throws -> AVAsset? { let composition = AVMutableComposition() let (timeRange, preferredTransform) = try await load(.timeRange, .preferredTransform) guard let track = composition.addMutableTrack(withMediaType: mediaType, preferredTrackID: kCMPersistentTrackID_Invalid), (try? track.insertTimeRange(CMTimeRange(start: .zero, duration: timeRange.duration), of: self, at: .zero)) != nil else { return nil } track.preferredTransform = preferredTransform modify?(track) return composition } } extension AVAssetTrack { struct VideoKeyframeInfo { let frameCount: Int let keyframeCount: Int var keyframeInterval: Double { Double(frameCount) / Double(keyframeCount) } var keyframeRate: Double { Double(keyframeCount) / Double(frameCount) } } func getKeyframeInfo() -> VideoKeyframeInfo? { guard let asset, let reader = try? AVAssetReader(asset: asset) else { return nil } let trackReaderOutput = AVAssetReaderTrackOutput(track: self, outputSettings: nil) reader.add(trackReaderOutput) guard reader.startReading() else { return nil } var frameCount = 0 var keyframeCount = 0 while true { guard let sampleBuffer = trackReaderOutput.copyNextSampleBuffer() else { reader.cancelReading() break } if sampleBuffer.numSamples > 0 { frameCount += 1 if sampleBuffer.sampleAttachments.first?[.notSync] == nil { keyframeCount += 1 } } } return VideoKeyframeInfo(frameCount: frameCount, keyframeCount: keyframeCount) } } /* > FOURCC is short for "four character code" - an identifier for a video codec, compression format, color or pixel format used in media files. */ extension FourCharCode { /** Create a String representation of a FourCC. */ func fourCharCodeToString() -> String { let a_ = self >> 24 let b_ = self >> 16 let c_ = self >> 8 let d_ = self let bytes: [CChar] = [ CChar(a_ & 0xFF), CChar(b_ & 0xFF), CChar(c_ & 0xFF), CChar(d_ & 0xFF), 0 ] // Swift type-checking is too slow for this... // let bytes: [CChar] = [ // CChar((self >> 24) & 0xff), // CChar((self >> 16) & 0xff), // CChar((self >> 8) & 0xff), // CChar(self & 0xff), // 0 // ] return String(cString: bytes).trimmingCharacters(in: .whitespaces) } } enum AVFormat: String { case hevc case h264 case av1 case vp9 case appleProResRAWHQ case appleProResRAW case appleProRes4444XQ case appleProRes4444 case appleProRes422HQ case appleProRes422 case appleProRes422LT case appleProRes422Proxy case appleAnimation // https://hap.video/using-hap.html // https://github.com/Vidvox/hap/blob/master/documentation/HapVideoDRAFT.md#names-and-identifiers case hap1 case hap5 case hapY case hapM case hapA case hap7 case cineFormHD // https://en.wikipedia.org/wiki/QuickTime_Graphics case quickTimeGraphics // https://en.wikipedia.org/wiki/Avid_DNxHD case avidDNxHD init?(fourCC: String) { switch fourCC.trimmingCharacters(in: .whitespaces) { case "hvc1": self = .hevc case "avc1": self = .h264 case "av01": self = .av1 case "vp09": self = .vp9 case "aprh": // From https://avpres.net/Glossar/ProResRAW.html self = .appleProResRAWHQ case "aprn": self = .appleProResRAW case "ap4x": self = .appleProRes4444XQ case "ap4h": self = .appleProRes4444 case "apch": self = .appleProRes422HQ case "apcn": self = .appleProRes422 case "apcs": self = .appleProRes422LT case "apco": self = .appleProRes422Proxy case "rle": self = .appleAnimation case "Hap1": self = .hap1 case "Hap5": self = .hap5 case "HapY": self = .hapY case "HapM": self = .hapM case "HapA": self = .hapA case "Hap7": self = .hap7 case "CFHD": self = .cineFormHD case "smc": self = .quickTimeGraphics case "AVdh": self = .avidDNxHD default: return nil } } init?(fourCC: FourCharCode) { self.init(fourCC: fourCC.fourCharCodeToString()) } var fourCC: String { switch self { case .hevc: "hvc1" case .h264: "avc1" case .av1: "av01" case .vp9: "vp09" case .appleProResRAWHQ: "aprh" case .appleProResRAW: "aprn" case .appleProRes4444XQ: "ap4x" case .appleProRes4444: "ap4h" case .appleProRes422HQ: "apcn" case .appleProRes422: "apch" case .appleProRes422LT: "apcs" case .appleProRes422Proxy: "apco" case .appleAnimation: "rle " case .hap1: "Hap1" case .hap5: "Hap5" case .hapY: "HapY" case .hapM: "HapM" case .hapA: "HapA" case .hap7: "Hap7" case .cineFormHD: "CFHD" case .quickTimeGraphics: "smc" case .avidDNxHD: "AVdh" } } var isAppleProRes: Bool { [ .appleProResRAWHQ, .appleProResRAW, .appleProRes4444XQ, .appleProRes4444, .appleProRes422HQ, .appleProRes422, .appleProRes422LT, .appleProRes422Proxy ].contains(self) } /** - Important: This check only covers known (by us) compatible formats. It might be missing some. Don't use it for strict matching. Also keep in mind that even though a codec is supported, it might still not be decodable as the codec profile level might not be supported. */ var isSupported: Bool { self == .hevc || self == .h264 || isAppleProRes } } extension AVFormat: CustomStringConvertible { var description: String { switch self { case .hevc: "HEVC" case .h264: "H264" case .av1: "AV1" case .vp9: "VP9" case .appleProResRAWHQ: "Apple ProRes RAW HQ" case .appleProResRAW: "Apple ProRes RAW" case .appleProRes4444XQ: "Apple ProRes 4444 XQ" case .appleProRes4444: "Apple ProRes 4444" case .appleProRes422HQ: "Apple ProRes 422 HQ" case .appleProRes422: "Apple ProRes 422" case .appleProRes422LT: "Apple ProRes 422 LT" case .appleProRes422Proxy: "Apple ProRes 422 Proxy" case .appleAnimation: "Apple Animation" case .hap1: "Vidvox Hap" case .hap5: "Vidvox Hap Alpha" case .hapY: "Vidvox Hap Q" case .hapM: "Vidvox Hap Q Alpha" case .hapA: "Vidvox Hap Alpha-Only" case .hap7: // No official name for this. "Vidvox Hap" case .cineFormHD: "CineForm HD" case .quickTimeGraphics: "QuickTime Graphics" case .avidDNxHD: "Avid DNxHD" } } } extension AVFormat: CustomDebugStringConvertible { var debugDescription: String { "\(description) (\(fourCC.trimmingCharacters(in: .whitespaces)))" } } extension AVMediaType: @retroactive CustomDebugStringConvertible { public var debugDescription: String { switch self { case .audio: return "Audio" case .closedCaption: return "Closed-caption content" case .depthData: return "Depth data" case .metadata: return "Metadata" #if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst) case .metadataObject: return "Metadata objects" #endif case .muxed: return "Muxed media" case .subtitle: return "Subtitles" case .text: return "Text" case .timecode: return "Time code" case .video: return "Video" default: return "Unknown" } } } extension AVAsset { /** Whether the first video track is decodable. */ var isVideoDecodable: Bool { get async throws { guard try await load(.isReadable), let firstVideoTrack = try await firstVideoTrack else { return false } return try await firstVideoTrack.load(.isDecodable) } } /** Returns a boolean of whether there are any video tracks. */ var hasVideo: Bool { get async throws { try await !loadTracks(withMediaType: .video).isEmpty } } /** Returns a boolean of whether there are any audio tracks. */ var hasAudio: Bool { get async throws { try await !loadTracks(withMediaType: .audio).isEmpty } } /** Returns the first video track if any. */ var firstVideoTrack: AVAssetTrack? { get async throws { try await loadTracks(withMediaType: .video).first } } /** Returns the first audio track if any. */ var firstAudioTrack: AVAssetTrack? { get async throws { try await loadTracks(withMediaType: .audio).first } } /** Returns the dimensions of the first video track if any. */ var dimensions: CGSize? { get async throws { try await firstVideoTrack?.dimensions } } /** Returns the frame rate of the first video track if any. */ var frameRate: Double? { get async throws { try await firstVideoTrack?.frameRate } } /** Returns the aspect ratio of the first video track if any. */ var aspectRatio: Double? { get async throws { try await firstVideoTrack?.aspectRatio } } /** Returns the video codec of the first video track if any. */ var videoCodec: AVFormat? { get async throws { try await firstVideoTrack?.codec } } /** Returns the audio codec of the first audio track if any. Example: `aac` */ var audioCodec: String? { get async throws { try await firstAudioTrack?.codecIdentifier } } /** The file size of the asset, in bytes. - Note: If self is an `AVAsset` and not an `AVURLAsset`, the file size will just be an estimate. */ var fileSize: Int { get async throws { guard let urlAsset = self as? AVURLAsset else { // TODO: Use `concurrentMap` when targeting macOS 15. return try await load(.tracks) .asyncMap { try await $0.estimatedFileSize } .sum() } return urlAsset.url.fileSize } } var fileSizeFormatted: String { get async throws { try await fileSize.formatted(.byteCount(style: .file)) } } var trackPreferredTransform: CGAffineTransform? { get async throws { try await firstVideoTrack?.load(.preferredTransform) } } } extension AVAsset { /** Returns debug info for the asset to use in logging and error messages. */ var debugInfo: String { get async throws { var output = [String]() let durationFormatter = DateComponentsFormatter() durationFormatter.unitsStyle = .abbreviated let fileExtension = (self as? AVURLAsset)?.url.fileExtension async let codec = asyncNilCoalescing(videoCodec?.debugDescription, default: await self.firstVideoTrack?.codecIdentifier) ?? "" async let audioCodec = audioCodec async let duration = Duration.seconds(load(.duration).seconds).formatted() async let dimensions = dimensions?.formatted async let frameRate = frameRate?.rounded(toDecimalPlaces: 2).formatted() async let fileSizeFormatted = fileSizeFormatted async let (isReadable, isPlayable, isExportable, hasProtectedContent) = load(.isReadable, .isPlayable, .isExportable, .hasProtectedContent) output.append( """ ## AVAsset debug info ## Extension: \(describing: fileExtension) Video codec: \(try await codec) Audio codec: \(describing: try await audioCodec) Duration: \(describing: try await duration) Dimension: \(describing: try await dimensions) Frame rate: \(describing: try await frameRate) File size: \(try await fileSizeFormatted) Is readable: \(try await isReadable) Is playable: \(try await isPlayable) Is exportable: \(try await isExportable) Has protected content: \(try await hasProtectedContent) """ ) for track in try await load(.tracks) { async let codec = track.mediaType == .video ? asyncNilCoalescing(track.codec?.debugDescription, default: try await track.codecIdentifier) : track.codecIdentifier async let duration = Duration.seconds(track.load(.timeRange).duration.seconds).formatted() async let dimensions = track.dimensions?.formatted async let frameRate = track.frameRate?.rounded(toDecimalPlaces: 2).formatted() async let (naturalSize, isPlayable, isDecodable) = track.load(.naturalSize, .isPlayable, .isDecodable) output.append( """ Track #\(track.trackID) ---- Type: \(track.mediaType.debugDescription) Codec: \(describing: try await codec) Duration: \(describing: try await duration) Dimensions: \(describing: try await dimensions) Natural size: \(describing: try await naturalSize) Frame rate: \(describing: try await frameRate) Is playable: \(try await isPlayable) Is decodable: \(try await isDecodable) ---- """ ) } return output.joined(separator: "\n\n") } } } extension AVAsset { struct VideoMetadata: Hashable { let dimensions: CGSize let duration: Duration let frameRate: Double let fileSize: Int var hasAudio: Bool let trackPreferredTransform: CGAffineTransform? } var videoMetadata: VideoMetadata? { get async throws { async let dimensionsResult = dimensions async let frameRateResult = frameRate async let fileSizeResult = fileSize async let durationResult = load(.duration) async let trackPreferredTransformResult = trackPreferredTransform async let hasAudioResult = firstAudioTrack != nil guard let dimensions = try await dimensionsResult, let frameRate = try await frameRateResult else { return nil } let fileSize = try await fileSizeResult let duration = try await durationResult let hasAudio = try await hasAudioResult let trackPreferredTransform = try await trackPreferredTransformResult return .init( dimensions: dimensions, duration: .seconds(duration.seconds), frameRate: frameRate, fileSize: fileSize, hasAudio: hasAudio, trackPreferredTransform: trackPreferredTransform ) } } } extension URL { var videoMetadata: AVAsset.VideoMetadata? { get async throws { try await AVURLAsset(url: self).videoMetadata } } var isVideoDecodable: Bool { get async throws { try await AVURLAsset(url: self).isVideoDecodable } } } extension NSView { func constrainEdgesToSuperview(with insets: NSEdgeInsets = .zero) { guard let superview else { assertionFailure("There is no superview for this view") return } superview.translatesAutoresizingMaskIntoConstraints = false translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ leadingAnchor.constraint(equalTo: superview.leadingAnchor, constant: insets.left), trailingAnchor.constraint(equalTo: superview.trailingAnchor, constant: -insets.right), topAnchor.constraint(equalTo: superview.topAnchor, constant: insets.top), bottomAnchor.constraint(equalTo: superview.bottomAnchor, constant: -insets.bottom) ]) } func getConstraintConstantFromSuperView(attribute: NSLayoutConstraint.Attribute) -> Double? { guard let constant = getConstraintFromSuperview(attribute: attribute)?.constant else { return nil } return Double(constant) } func getConstraintFromSuperview(attribute: NSLayoutConstraint.Attribute) -> NSLayoutConstraint? { guard let superview else { return nil } return superview.constraints.first { ($0.secondItem as? NSView == self && $0.secondAttribute == attribute) || ($0.firstItem as? NSView == self && $0.firstAttribute == attribute) } } } extension NSPasteboard.PasteboardType { /** The name of the URL if you put a URL on the pasteboard. */ static let urlName = Self("public.url-name") } extension NSPasteboard.PasteboardType { /** Convention for getting the bundle identifier of the source app. > This marker’s presence indicates that the source of the content is the application with the bundle identifier matching its UTF–8 string content. For example: `pasteboard.setString("com.sindresorhus.Foo" forType: "org.nspasteboard.source")`. This is useful when the source is not the foreground application. This is meant to be shown to the user by a supporting app for informational purposes only. Note that an empty string is a valid value as explained below. > - http://nspasteboard.org */ static let sourceAppBundleIdentifier = Self("org.nspasteboard.source") } extension NSPasteboard { /** Add a marker to the pasteboard indicating which app put the current data on the pasteboard. This helps clipboard managers identity the source app. - Important: All pasteboard operation should call this, unless you use `NSPasteboard#with`. Read more: http://nspasteboard.org */ func setSourceApp() { setString(SSApp.idString, forType: .sourceAppBundleIdentifier) } } extension NSPasteboard { /** Starts a new pasteboard writing session. Do all pasteboard write operations in the given closure. It takes care of calling `NSPasteboard#prepareForNewContents()` for you and also adds a marker for the source app (`NSPasteboard#setSourceApp()`). ``` NSPasteboard.general.with { $0.setString("Unicorn", forType: .string) } ``` */ func with(_ callback: (NSPasteboard) -> Void) { prepareForNewContents() callback(self) setSourceApp() } } extension NSPasteboard { /** Get the file URLs from dragged and dropped files. */ func fileURLs(contentTypes: [UTType] = []) -> [URL] { var options: [ReadingOptionKey: Any] = [ .urlReadingFileURLsOnly: true ] if !contentTypes.isEmpty { options[.urlReadingContentsConformToTypes] = contentTypes.map(\.identifier) } guard // swiftlint:disable:next legacy_objc_type let urls = readObjects(forClasses: [NSURL.self], options: options) as? [URL] else { return [] } return urls } } enum AssociationPolicy { case assign case retainNonatomic case copyNonatomic case retain case copy var rawValue: objc_AssociationPolicy { switch self { case .assign: .OBJC_ASSOCIATION_ASSIGN case .retainNonatomic: .OBJC_ASSOCIATION_RETAIN_NONATOMIC case .copyNonatomic: .OBJC_ASSOCIATION_COPY_NONATOMIC case .retain: .OBJC_ASSOCIATION_RETAIN case .copy: .OBJC_ASSOCIATION_COPY } } } final class ObjectAssociation { private let defaultValue: Value private let policy: AssociationPolicy init(defaultValue: Value, policy: AssociationPolicy = .retainNonatomic) { self.defaultValue = defaultValue self.policy = policy } subscript(index: AnyObject) -> Value { get { objc_getAssociatedObject(index, Unmanaged.passUnretained(self).toOpaque()) as? Value ?? defaultValue } set { objc_setAssociatedObject(index, Unmanaged.passUnretained(self).toOpaque(), newValue, policy.rawValue) } } } extension ObjectAssociation { convenience init(policy: AssociationPolicy = .retainNonatomic) where Value == T? { self.init(defaultValue: nil, policy: policy) } } extension AnyCancellable { private static var foreverStore = Set() func storeForever() { store(in: &Self.foreverStore) } } extension CAMediaTimingFunction { static let `default` = CAMediaTimingFunction(name: .default) static let linear = CAMediaTimingFunction(name: .linear) static let easeIn = CAMediaTimingFunction(name: .easeIn) static let easeOut = CAMediaTimingFunction(name: .easeOut) static let easeInOut = CAMediaTimingFunction(name: .easeInEaseOut) } extension String { /** `NSString` has some useful properties that `String` does not. */ var toNS: NSString { self as NSString } // swiftlint:disable:this legacy_objc_type } enum SSApp { static let idString = Bundle.main.bundleIdentifier! static let name = Bundle.main.object(forInfoDictionaryKey: kCFBundleNameKey as String) as! String static let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String static let build = Bundle.main.object(forInfoDictionaryKey: kCFBundleVersionKey as String) as! String static let versionWithBuild = "\(version) (\(build))" } extension SSApp { static let isFirstLaunch: Bool = { let key = "SS_hasLaunched" if UserDefaults.standard.bool(forKey: key) { return false } UserDefaults.standard.set(true, forKey: key) return true }() } extension SSApp { static func setUpExternalEventListeners() { DistributedNotificationCenter.default.publisher(for: .init("\(SSApp.idString):openSendFeedback")) .sink { _ in DispatchQueue.main.async { SSApp.appFeedbackUrl().open() } } .storeForever() DistributedNotificationCenter.default.publisher(for: .init("\(SSApp.idString):copyDebugInfo")) .sink { _ in DispatchQueue.main.async { NSPasteboard.general.prepareForNewContents() NSPasteboard.general.setString(SSApp.debugInfo, forType: .string) } } .storeForever() } } extension SSApp { static var debugInfo: String { """ \(name) \(versionWithBuild) - \(idString) macOS \(Device.osVersion) \(Device.hardwareModel) \(Device.architecture) """ } /** - Note: Call this lazily only when actually needed as otherwise it won't get the live info. */ static func appFeedbackUrl() -> URL { let info: [String: String] = [ "product": name, "metadata": debugInfo ] return URL("https://sindresorhus.com/feedback").settingQueryItems(from: info) } } extension SSApp { @MainActor static var swiftUIMainWindow: NSWindow? { // It seems like the main window is always the first one. NSApp.windows.first { $0.simpleClassName == "AppKitWindow" } } } extension SSApp { static func runOnce(identifier: String, _ execute: () -> Void) { let key = "SS_App_runOnce__\(identifier)" if !UserDefaults.standard.bool(forKey: key) { UserDefaults.standard.set(true, forKey: key) execute() } } } extension SSApp { /** Initialize Sentry. */ static func initSentry(_ dsn: String) { #if !DEBUG && canImport(Sentry) SentrySDK.start { $0.dsn = dsn $0.enableSwizzling = false $0.enableAppHangTracking = false // https://github.com/getsentry/sentry-cocoa/issues/2643 } #endif } } extension SSApp { /** Report an error to the chosen crash reporting solution. */ @inlinable static func reportError( _ error: Error, userInfo: [String: Any] = [:], file: String = #fileID, line: Int = #line ) { guard !(error is CancellationError) else { #if DEBUG print("[\(file):\(line)] CancellationError:", error) #endif return } let userInfo = userInfo .appending([ "file": file, "line": line ]) let error = NSError.from( error: error, userInfo: userInfo ) #if DEBUG print("[\(file):\(line)] Reporting error:", error) #endif #if canImport(Sentry) SentrySDK.capture(error: error) #endif } /** Report an error message to the chosen crash reporting solution. */ @inlinable static func reportError( _ message: String, userInfo: [String: Any] = [:], file: String = #fileID, line: Int = #line ) { reportError( message.toError, userInfo: userInfo, file: file, line: line ) } } struct GeneralError: LocalizedError, CustomNSError { // LocalizedError let errorDescription: String? let recoverySuggestion: String? let helpAnchor: String? // CustomNSError let errorUserInfo: [String: Any] // We don't define `errorDomain` as it will generate something like `AppName.GeneralError` by default. init( _ description: String, recoverySuggestion: String? = nil, userInfo: [String: Any] = [:], url: URL? = nil, underlyingErrors: [Error] = [], helpAnchor: String? = nil ) { self.errorDescription = description self.recoverySuggestion = recoverySuggestion self.helpAnchor = helpAnchor self.errorUserInfo = { var userInfo = userInfo if !underlyingErrors.isEmpty { userInfo[NSMultipleUnderlyingErrorsKey] = underlyingErrors } if let url { userInfo[NSURLErrorKey] = url } return userInfo }() } } extension String { /** Convert a string into an error. */ var toError: some LocalizedError { GeneralError(self) } } extension URL: @retroactive ExpressibleByStringLiteral { /** Example: ``` let url: URL = "https://sindresorhus.com" ``` */ public init(stringLiteral value: StaticString) { self.init(string: "\(value)")! } } extension URL { /** Example: ``` URL("https://sindresorhus.com") ``` */ init(_ staticString: StaticString) { self.init(string: "\(staticString)")! } } extension URL { /** Convenience for opening URLs. */ func open() { NSWorkspace.shared.open(self) } } extension String { /* ``` "https://sindresorhus.com".openURL() ``` */ func openURL() { URL(string: self)?.open() } } enum Device { static let osVersion: String = { let os = ProcessInfo.processInfo.operatingSystemVersion return "\(os.majorVersion).\(os.minorVersion).\(os.patchVersion)" }() static let hardwareModel: String = { var size = 0 sysctlbyname("hw.model", nil, &size, nil, 0) var model = [CChar](repeating: 0, count: size) sysctlbyname("hw.model", &model, &size, nil, 0) return String(cString: model) }() /** The CPU architecture. ``` Device.architecture //=> "arm64" ``` */ static let architecture: String = { var sysinfo = utsname() let result = uname(&sysinfo) guard result == EXIT_SUCCESS else { return "unknown" } let data = Data(bytes: &sysinfo.machine, count: Int(_SYS_NAMELEN)) guard let identifier = String(bytes: data, encoding: .ascii) else { return "unknown" } return identifier.trimmingCharacters(in: .controlCharacters) }() static let isRunningNativelyOnMacWithAppleSilicon: Bool = { #if os(macOS) && arch(arm64) true #else false #endif }() static let supportedVideoTypes: [UTType] = [ .mpeg4Movie, .quickTimeMovie ] } typealias QueryDictionary = [String: String] extension CharacterSet { /** Characters allowed to be unescaped in an URL. https://tools.ietf.org/html/rfc3986#section-2.3 */ static let urlUnreservedRFC3986 = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~") } /** This should really not be necessary, but it's at least needed for my `formspree.io` form... Otherwise is results in "Internal Server Error" after submitting the form. Relevant: https://www.djackson.org/why-we-do-not-use-urlcomponents/ */ private func escapeQueryComponent(_ query: String) -> String { query.addingPercentEncoding(withAllowedCharacters: .urlUnreservedRFC3986)! } extension Dictionary where Key == String { /** This correctly escapes items. See `escapeQueryComponent`. */ var toQueryItems: [URLQueryItem] { map { URLQueryItem( name: escapeQueryComponent($0), value: escapeQueryComponent("\($1)") ) } } var toQueryString: String { var components = URLComponents() components.queryItems = toQueryItems return components.query! } } extension Dictionary { func compactValues() -> [Key: T] where Value == T? { compactMapValues { $0 } } } extension URLComponents { /** This correctly escapes items. See `escapeQueryComponent`. */ init?(string: String, query: QueryDictionary) { self.init(string: string) self.queryDictionary = query } /** This correctly escapes items. See `escapeQueryComponent`. */ var queryDictionary: QueryDictionary { get { queryItems?.toDictionary { ($0.name, $0.value) }.compactValues() ?? [:] } set { // Using `percentEncodedQueryItems` instead of `queryItems` since the query items are already custom-escaped. See `escapeQueryComponent`. percentEncodedQueryItems = newValue.toQueryItems } } } extension URL { var directoryURL: Self { deletingLastPathComponent() } var directory: String { directoryURL.path } var filename: String { get { lastPathComponent } set { deleteLastPathComponent() appendPathComponent(newValue) } } var fileExtension: String { get { pathExtension } set { deletePathExtension() appendPathExtension(newValue) } } var filenameWithoutExtension: String { get { deletingPathExtension().lastPathComponent } set { let fileExtension = pathExtension deleteLastPathComponent() appendPathComponent(newValue) appendPathExtension(fileExtension) } } func changingFileExtension(to fileExtension: String) -> Self { var url = self url.fileExtension = fileExtension return url } /** Returns `self` with the given query dictionary merged in. The keys in the given dictionary overwrites any existing keys. */ func settingQueryItems(from queryDictionary: QueryDictionary) -> Self { guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { return self } components.queryDictionary = components.queryDictionary.appending(queryDictionary) return components.url ?? self } private func resourceValue(forKey key: URLResourceKey) -> T? { guard let values = try? resourceValues(forKeys: [key]) else { return nil } return values.allValues[key] as? T } private func boolResourceValue(forKey key: URLResourceKey, defaultValue: Bool = false) -> Bool { guard let values = try? resourceValues(forKeys: [key]) else { return defaultValue } return values.allValues[key] as? Bool ?? defaultValue } var contentType: UTType? { resourceValue(forKey: .contentTypeKey) } /** File size in bytes. */ var fileSize: Int { resourceValue(forKey: .fileSizeKey) ?? 0 } var fileSizeFormatted: String { fileSize.formatted(.byteCount(style: .file)) } var exists: Bool { FileManager.default.fileExists(atPath: path) } var isReadable: Bool { boolResourceValue(forKey: .isReadableKey) } var isWritable: Bool { boolResourceValue(forKey: .isWritableKey) } var isVolumeReadonly: Bool { boolResourceValue(forKey: .volumeIsReadOnlyKey) } } extension URL { /** Returns the user's real home directory when called in a sandboxed app. */ static let realHomeDirectory = Self( fileURLWithFileSystemRepresentation: getpwuid(getuid())!.pointee.pw_dir!, isDirectory: true, relativeTo: nil ) } extension URL { func relationship(to url: Self) -> FileManager.URLRelationship { var relationship = FileManager.URLRelationship.other _ = try? FileManager.default.getRelationship(&relationship, ofDirectoryAt: self, toItemAt: url) return relationship } } extension URL { /** Check whether the URL is inside the home directory. */ var isInsideHomeDirectory: Bool { Self.realHomeDirectory.relationship(to: self) == .contains } /** Check whether the URL path is on the main volume; The volume with the root file system. - Note: The URL does not need to exist. */ var isOnMainVolume: Bool { // We intentionally do a string check instead of `try? resourceValues(forKeys: [.volumeIsRootFileSystemKey]).volumeIsRootFileSystem` as it's faster and it works on URLs that doesn't exist. !path.hasPrefix("/Volumes/") } } extension URL { /** Whether the directory URL is suitable for use as a default directory for a save panel. */ var canBeDefaultSavePanelDirectory: Bool { // We allow if it's inside the home directory on the main volume or on a different writable volume. isInsideHomeDirectory || (!isOnMainVolume && !isVolumeReadonly) } } extension CGSize { static func * (lhs: Self, rhs: Double) -> Self { .init(width: lhs.width * rhs, height: lhs.height * rhs) } static func / (lhs: Self, rhs: Self) -> Self { .init(width: lhs.width / rhs.width, height: lhs.height / rhs.height) } static func > (lhs: Self, rhs: Double) -> Bool { lhs.width > rhs && lhs.height > rhs } static let one = Self(widthHeight: 1) init(widthHeight: Double) { self.init(width: widthHeight, height: widthHeight) } var cgRect: CGRect { .init(origin: .zero, size: self) } var longestSide: Double { max(width, height) } var aspectRatio: Double { width / height } func aspectFit(to boundingSize: CGSize) -> Self { let ratio = min(boundingSize.width / width, boundingSize.height / height) return self * ratio } func aspectFit(to widthHeight: Double) -> Self { aspectFit(to: Self(width: widthHeight, height: widthHeight)) } func aspectFill(to boundingSize: CGSize) -> Self { let ratio = max(boundingSize.width / width, boundingSize.height / height) return self * ratio } func aspectFill(to widthHeight: Double) -> Self { aspectFill(to: Self(width: widthHeight, height: widthHeight)) } /** Returns the simplest integer aspect ratio (width, height) for the current size. ``` let (widthRatio, heightRatio) = size.integerAspectRatio() ``` */ func integerAspectRatio() -> (Int, Int) { let roundedWidth = Int(width.rounded()) let roundedHeight = Int(height.rounded()) let divisor = greatestCommonDivisor(roundedWidth, roundedHeight) let widthRatio = roundedWidth / divisor let heightRatio = roundedHeight / divisor return (widthRatio, heightRatio) } } extension CGRect { init(origin: CGPoint = .zero, width: Double, height: Double) { self.init(origin: origin, size: CGSize(width: width, height: height)) } init(widthHeight: Double) { self.init() self.origin = .zero self.size = CGSize(widthHeight: widthHeight) } var x: Double { get { origin.x } set { origin.x = newValue } } var y: Double { get { origin.y } set { origin.y = newValue } } var width: Double { get { size.width } set { size.width = newValue } } var height: Double { get { size.height } set { size.height = newValue } } // MARK: - Edges var left: Double { get { x } set { x = newValue } } var right: Double { get { x + width } set { x = newValue - width } } var top: Double { get { y + height } set { y = newValue - height } } var bottom: Double { get { y } set { y = newValue } } // MARK: - var center: CGPoint { get { CGPoint(x: midX, y: midY) } set { origin = CGPoint( x: newValue.x - (size.width / 2), y: newValue.y - (size.height / 2) ) } } var centerX: Double { get { midX } set { center = CGPoint(x: newValue, y: midY) } } var centerY: Double { get { midY } set { center = CGPoint(x: midX, y: newValue) } } /** Returns a `CGRect` where `self` is centered in `rect`. */ func centered( in rect: Self, xOffset: Double = 0, yOffset: Double = 0 ) -> Self { .init( x: ((rect.width - size.width) / 2) + xOffset, y: ((rect.height - size.height) / 2) + yOffset, width: size.width, height: size.height ) } /** Returns a CGRect where `self` is centered in `rect`. - Parameters: - xOffsetPercent: The offset in percentage of `rect.width`. */ func centered( in rect: Self, xOffsetPercent: Double, yOffsetPercent: Double ) -> Self { centered( in: rect, xOffset: rect.width * xOffsetPercent, yOffset: rect.height * yOffsetPercent ) } /** Returns a `CGRect` with the same center position, but a new size. */ func centeredRectWith(size: CGSize) -> Self { CGRect( x: midX - size.width / 2.0, y: midY - size.height / 2.0, width: size.width, height: size.height ) } /** Returns a Crop Rect of the current Rect given a certain size */ func toCropRect(forVideoDimensions dimensions: CGSize) -> CropRect { .init( x: x / dimensions.width, y: y / dimensions.height, width: width / dimensions.width, height: height / dimensions.height ) } } extension Error { public var isCancelled: Bool { do { throw self } catch is CancellationError, URLError.cancelled, CocoaError.userCancelled { return true } catch { return false } } } extension NSResponder { /** Presents the error in the given window if it's not nil, otherwise falls back to an app-modal dialog. */ public func presentError(_ error: Error, modalFor window: NSWindow?) { guard let window else { presentError(error) return } presentError(error, modalFor: window, delegate: nil, didPresent: nil, contextInfo: nil) } } extension Error { var isNsError: Bool { Self.self is NSError.Type } } extension NSError { static func from(error: Error, userInfo: [String: Any] = [:]) -> NSError { let nsError = error as NSError // Since Error and NSError are often bridged between each other, we check if it was originally an NSError and then return that. guard !error.isNsError else { guard !userInfo.isEmpty else { return nsError } return nsError.appending(userInfo: userInfo) } var userInfo = userInfo userInfo[NSLocalizedDescriptionKey] = error.localizedDescription // This is needed as `localizedDescription` often lacks important information, for example, when an NSError is wrapped in a Swift.Error. userInfo["Swift.Error"] = "\(nsError.domain).\(error)" // Awful, but no better way to get the enum case name. // This gets `Error.generateFrameFailed` from `Error.generateFrameFailed(Error Domain=AVFoundationErrorDomain Code=-11832 […]`. let errorName = "\(error)".split(separator: "(").first ?? "" return .init( domain: "\(SSApp.idString) - \(nsError.domain)\(errorName.isEmpty ? "" : ".")\(errorName)", code: nsError.code, userInfo: userInfo ) } /** Returns a new error with the user info appended. */ func appending(userInfo newUserInfo: [String: Any]) -> Self { .init( domain: domain, code: code, userInfo: userInfo.appending(newUserInfo) ) } } extension NSError { /** Use this for generic app errors. - Note: Prefer using a specific enum-type error whenever possible. - Parameter description: The description of the error. This is shown as the first line in error dialogs. - Parameter recoverySuggestion: Explain how the user how they can recover from the error. For example, "Try choosing a different directory". This is usually shown as the second line in error dialogs. - Parameter userInfo: Metadata to add to the error. Can be a custom key or any of the `NSLocalizedDescriptionKey` keys except `NSLocalizedDescriptionKey` and `NSLocalizedRecoverySuggestionErrorKey`. - Parameter domainPostfix: String to append to the `domain` to make it easier to identify the error. The domain is the app's bundle identifier. */ static func appError( _ description: String, recoverySuggestion: String? = nil, userInfo: [String: Any] = [:], domainPostfix: String? = nil ) -> Self { var userInfo = userInfo userInfo[NSLocalizedDescriptionKey] = description if let recoverySuggestion { userInfo[NSLocalizedRecoverySuggestionErrorKey] = recoverySuggestion } return .init( domain: domainPostfix.map { "\(SSApp.idString) - \($0)" } ?? SSApp.idString, code: 1, // This is what Swift errors end up as. userInfo: userInfo ) } } extension Dictionary { /** Adds the elements of the given dictionary to a copy of self and returns that. Identical keys in the given dictionary overwrites keys in the copy of self. */ func appending(_ dictionary: [Key: Value]) -> [Key: Value] { var newDictionary = self for (key, value) in dictionary { newDictionary[key] = value } return newDictionary } } extension Sequence where Element: AdditiveArithmetic { func sum() -> Element { reduce(into: .zero, +=) } } extension Sequence { /** Returns the sum of elements in a sequence by mapping the elements with a numerator. ``` [1, 2, 3].sum { $0 == 1 ? 10 : $0 } //=> 15 ``` */ func sum(_ numerator: (Element) throws(E) -> T) throws(E) -> T { var result = T.zero for element in self { result += try numerator(element) } return result } } extension Sequence { /** Convert a sequence to a dictionary by mapping over the values and using the returned key as the key and the current sequence element as value. ``` [1, 2, 3].toDictionary { $0 } //=> [1: 1, 2: 2, 3: 3] ``` */ func toDictionary(with pickKey: (Element) -> Key) -> [Key: Element] { var dictionary = [Key: Element]() for element in self { dictionary[pickKey(element)] = element } return dictionary } /** Convert a sequence to a dictionary by mapping over the elements and returning a key/value tuple representing the new dictionary element. ``` [(1, "a"), (2, "b")].toDictionary { ($1, $0) } //=> ["a": 1, "b": 2] ``` */ func toDictionary(with pickKeyValue: (Element) -> (Key, Value)) -> [Key: Value] { var dictionary = [Key: Value]() for element in self { let newElement = pickKeyValue(element) dictionary[newElement.0] = newElement.1 } return dictionary } /** Same as the above but supports returning optional values. ``` [(1, "a"), (nil, "b")].toDictionary { ($1, $0) } //=> ["a": 1, "b": nil] ``` */ func toDictionary(with pickKeyValue: (Element) -> (Key, Value?)) -> [Key: Value?] { var dictionary = [Key: Value?]() for element in self { let newElement = pickKeyValue(element) dictionary[newElement.0] = newElement.1 } return dictionary } } extension BinaryFloatingPoint { func rounded( toDecimalPlaces decimalPlaces: Int, rule: FloatingPointRoundingRule = .toNearestOrAwayFromZero ) -> Self { guard decimalPlaces >= 0 else { return self } var divisor: Self = 1 for _ in 0.. Self { Self(width: width.rounded(rule), height: height.rounded(rule)) } } extension Collection { /** Returns the element at the specified index if it is within bounds, otherwise `nil`. */ subscript(safe index: Index) -> Element? { indices.contains(index) ? self[index] : nil } } protocol Copyable { init(instance: Self) } extension Copyable { func copy() -> Self { Self(instance: self) } } // swiftlint:disable all extension FloatingPoint { @inlinable public func isAlmostEqual( to other: Self, tolerance: Self = ulpOfOne.squareRoot() ) -> Bool { assert(tolerance >= .ulpOfOne && tolerance < 1, "tolerance should be in [.ulpOfOne, 1).") guard isFinite, other.isFinite else { return rescaledAlmostEqual(to: other, tolerance: tolerance) } let scale = max(abs(self), abs(other), .leastNormalMagnitude) return abs(self - other) < scale * tolerance } @inlinable public func isAlmostZero( absoluteTolerance tolerance: Self = ulpOfOne.squareRoot() ) -> Bool { assert(tolerance > 0) return abs(self) < tolerance } @usableFromInline func rescaledAlmostEqual(to other: Self, tolerance: Self) -> Bool { if isNaN || other.isNaN { return false } if isInfinite { if other.isInfinite { return self == other } let scaledSelf = Self( sign: sign, exponent: Self.greatestFiniteMagnitude.exponent, significand: 1 ) let scaledOther = Self( sign: .plus, exponent: -1, significand: other ) return scaledSelf.isAlmostEqual(to: scaledOther, tolerance: tolerance) } return other.rescaledAlmostEqual(to: self, tolerance: tolerance) } } // swiftlint:enable all extension NSEdgeInsets { static let zero = NSEdgeInsetsZero init( top: Double = 0, left: Double = 0, bottom: Double = 0, right: Double = 0 ) { self.init() self.top = top self.left = left self.bottom = bottom self.right = right } init(all: Double) { self.init( top: all, left: all, bottom: all, right: all ) } var vertical: Double { top + bottom } var horizontal: Double { left + right } } extension URL { func setAppAsItemCreator() throws { try systemMetadata.set(kMDItemCreator as String, value: "\(SSApp.name) \(SSApp.version)") } } extension URL { var components: URLComponents? { URLComponents(url: self, resolvingAgainstBaseURL: true) } var queryDictionary: [String: String] { components?.queryDictionary ?? [:] } } extension NSView { /** Get a subview matching a condition. */ func firstSubview(deep: Bool = false, where matches: (NSView) -> Bool) -> NSView? { for subview in subviews { if matches(subview) { return subview } if deep, let match = subview.firstSubview(deep: deep, where: matches) { return match } } return nil } } extension NSLayoutConstraint { /** Returns copy of the constraint with changed properties provided as arguments. */ func changing( firstItem: Any? = nil, firstAttribute: Attribute? = nil, relation: Relation? = nil, secondItem: NSView? = nil, secondAttribute: Attribute? = nil, multiplier: Double? = nil, constant: Double? = nil ) -> Self { .init( item: firstItem ?? self.firstItem as Any, attribute: firstAttribute ?? self.firstAttribute, relatedBy: relation ?? self.relation, toItem: secondItem ?? self.secondItem, attribute: secondAttribute ?? self.secondAttribute, // The compiler fails to auto-convert to CGFloat here. multiplier: multiplier.flatMap(CGFloat.init) ?? self.multiplier, constant: constant.flatMap(CGFloat.init) ?? self.constant ) } func animate( to constant: Double, duration: Duration, timingFunction: CAMediaTimingFunction = .init(name: .easeInEaseOut), completionHandler: (() -> Void)? = nil ) { NSAnimationContext.runAnimationGroup { context in context.duration = duration.toTimeInterval context.timingFunction = timingFunction animator().constant = constant } completionHandler: { completionHandler?() } } } extension NSObject { // Note: It's intentionally a getter to get the dynamic self. /** Returns the class name without module name. */ static var simpleClassName: String { String(describing: self) } /** Returns the class name of the instance without module name. */ var simpleClassName: String { Self.simpleClassName } } extension CMTime { /** Get the `CMTime` as a duration from zero to the seconds value of `self`. Can be `nil` when the `.duration` is not available, for example, when an asset has not yet been fully loaded or if it's a live stream. */ var durationRange: ClosedRange? { guard isNumeric else { return nil } return 0...seconds } } extension CMTimeRange { /** Get `self` as a range in seconds. Can be `nil` when the range is not available, for example, when an asset has not yet been fully loaded or if it's a live stream. */ var range: ClosedRange? { guard start.isNumeric, end.isNumeric else { return nil } return start.seconds...end.seconds } } extension ClosedRange { var cmTimeRange: CMTimeRange { .init(start: .init(seconds: lowerBound, preferredTimescale: .video), end: .init(seconds: upperBound, preferredTimescale: .video)) } } extension AVPlayerItem { /** The duration range of the item. Can be `nil` when the `.duration` is not available, for example, when the asset has not yet been fully loaded or if it's a live stream. */ var durationRange: ClosedRange? { duration.durationRange } /** The playable range of the item. Can be `nil` when the `.duration` is not available, for example, when the asset has not yet been fully loaded or if it's a live stream. Or if the user is dragging the trim handle of a video. */ var playbackRange: ClosedRange? { get { // These are not available while the user is dragging the video trim handle of `AVPlayerView`. guard reversePlaybackEndTime.isNumeric, forwardPlaybackEndTime.isNumeric else { return nil } let startTime = reversePlaybackEndTime.seconds let endTime = forwardPlaybackEndTime.seconds return .fromGraceful(startTime, endTime) } set { guard let newValue else { return } forwardPlaybackEndTime = CMTime(seconds: newValue.upperBound, preferredTimescale: .video) reversePlaybackEndTime = CMTime(seconds: newValue.lowerBound, preferredTimescale: .video) } } } extension FileManager { /** Copy a file and optionally overwrite the destination if it exists. */ func copyItem( at sourceURL: URL, to destinationURL: URL, overwrite: Bool = false ) throws { if overwrite { try? removeItem(at: destinationURL) } try copyItem(at: sourceURL, to: destinationURL) } } extension ClosedRange where Bound: AdditiveArithmetic { /** Get the length between the lower and upper bound. */ var length: Bound { upperBound - lowerBound } } extension ClosedRange { /** Returns true if `self` is a superset of the given range. ``` (1.0...1.5).isSuperset(of: 1.2...1.3) //=> true ``` */ func isSuperset(of other: Self) -> Bool { other.isEmpty || ( lowerBound <= other.lowerBound && other.upperBound <= upperBound ) } /** Returns true if `self` is a subset of the given range. ``` (1.2...1.3).isSubset(of: 1.0...1.5) //=> true ``` */ func isSubset(of other: Self) -> Bool { other.isSuperset(of: self) } } extension ClosedRange { // TODO: Add support for negative ranges. /** Make a new range where the length (difference between the lower and upper bound) is at least the given amount. The use-case for this method is being able to ensure a sub-range inside a range is of a certain size. It will first try to expand on both the lower and upper bound, and if not possible, it will expand the lower bound, and if that is not possible, it will expand the upper bound. If the resulting range is larger than the given `fullRange`, it will be clamped to `fullRange`. - Precondition: The range and the given range must be positive. - Precondition: The range must be a subset of the given range. ``` (1...1.2).minimumRangeLength(of: 1, in: 0...4) //=> 0.5...1.7 (0...0.5).minimumRangeLength(of: 1, in: 0...4) //=> 0...1 (3.5...4).minimumRangeLength(of: 1, in: 0...4) //=> 3...4 (0...0.1).minimumRangeLength(of: 1, in: 0...4) //=> 0...1 ``` */ func minimumRangeLength(of length: Bound, in fullRange: Self) -> Self { guard length > self.length else { return self } assert(isSubset(of: fullRange), "`self` must be a subset of the given range") assert(lowerBound >= 0 && upperBound >= 0, "`self` must the positive") assert(fullRange.lowerBound >= 0 && fullRange.upperBound >= 0, "The given range must be positive") let lower = lowerBound - (length / 2) let upper = upperBound + (length / 2) if fullRange.contains(lower), fullRange.contains(upper) { return lower...upper } if !fullRange.contains(lower), fullRange.contains(upper) { return fullRange.lowerBound...length } if fullRange.contains(lower), !fullRange.contains(upper) { return (fullRange.upperBound - length)...fullRange.upperBound } return self } } extension BinaryInteger { var isEven: Bool { isMultiple(of: 2) } var isOdd: Bool { !isEven } } final class LaunchCompletions { private static var shouldAddObserver = true private static var shouldRunInstantly = false private static var finishedLaunchingCompletions = [() -> Void]() static func add(_ completion: @escaping () -> Void) { finishedLaunchingCompletions.append(completion) if shouldAddObserver { NotificationCenter.default.addObserver( self, selector: #selector(runFinishedLaunchingCompletions), name: NSApplication.didFinishLaunchingNotification, object: nil ) shouldAddObserver = false } if shouldRunInstantly { runFinishedLaunchingCompletions() } } static func applicationDidLaunch() { shouldAddObserver = false shouldRunInstantly = true } @objc private static func runFinishedLaunchingCompletions() { for completion in finishedLaunchingCompletions { completion() } finishedLaunchingCompletions = [] } } extension NSResponder { // This method is internally implemented on `NSResponder` as `Error` is generic which comes with many limitations. fileprivate func presentErrorAsSheet( _ error: Error, for window: NSWindow, didPresent: (() -> Void)? ) { final class DelegateHandler { var didPresent: (() -> Void)? @objc func didPresentHandler() { didPresent?() } } let delegate = DelegateHandler() delegate.didPresent = didPresent presentError( error, modalFor: window, delegate: delegate, didPresent: #selector(delegate.didPresentHandler), contextInfo: nil ) } } extension Error { /** Present the error as an async sheet on the given window. - Note: This exists because the built-in `NSResponder#presentError(forModal:)` method requires too many arguments, selector as callback, and it says it's modal but it's not blocking, which is surprising. */ func presentAsSheet(for window: NSWindow, didPresent: (() -> Void)?) { NSApp.presentErrorAsSheet(self, for: window, didPresent: didPresent) } /** Present the error as a blocking modal sheet on the given window. If the window is nil, the error will be presented in an app-level modal dialog. */ func presentAsModalSheet(for window: NSWindow?) { guard let window else { presentAsModal() return } presentAsSheet(for: window) { NSApp.stopModal() } NSApp.runModal(for: window) } /** Present the error as a blocking app-level modal dialog. */ func presentAsModal() { NSApp.presentError(self) } } extension AVPlayer { /** Seek to the start of the playable range of the video. The start might not be at `0` if, for example, the video has been trimmed in `AVPlayerView` trim mode. */ func seekToStart() { let seconds = currentItem?.playbackRange?.lowerBound ?? 0 seek( to: CMTime(seconds: seconds, preferredTimescale: .video), toleranceBefore: .zero, toleranceAfter: .zero ) } /** Seek to the end of the playable range of the video. The start might not be at `duration` if, for example, the video has been trimmed in `AVPlayerView` trim mode. */ func seekToEnd() { guard let seconds = currentItem?.playbackRange?.upperBound ?? currentItem?.duration.seconds else { return } seek( to: CMTime(seconds: seconds, preferredTimescale: .video), toleranceBefore: .zero, toleranceAfter: .zero ) } } final class LoopingPlayer: AVPlayer { private var cancellable: AnyCancellable? /** Loop the playback. */ var loopPlayback = false { didSet { updateObserver() } } /** Bounce the playback. */ var bouncePlayback = false { didSet { updateObserver() if !bouncePlayback, rate == -1 { rate = 1 } } } override func replaceCurrentItem(with item: AVPlayerItem?) { super.replaceCurrentItem(with: item) cancellable = nil updateObserver() } private func updateObserver() { guard bouncePlayback || loopPlayback else { cancellable = nil actionAtItemEnd = .pause return } actionAtItemEnd = .none guard cancellable == nil else { // Already observing. No need to update. return } cancellable = NotificationCenter.default .publisher(for: .AVPlayerItemDidPlayToEndTime, object: currentItem) .sink { [weak self] _ in guard let self else { return } pause() if bouncePlayback, currentItem?.canPlayReverse == true, currentTime().seconds > currentItem?.playbackRange?.lowerBound ?? 0 { seekToEnd() playImmediately(atRate: -defaultRate) } else if loopPlayback { seekToStart() playImmediately(atRate: defaultRate) } } } } extension Numeric { mutating func increment(by value: Self = 1) -> Self { self += value return self } mutating func decrement(by value: Self = 1) -> Self { self -= value return self } } 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: Duration private var workItem: DispatchWorkItem? init(delay: Duration) { self.delay = delay } func callAsFunction(_ action: @escaping () -> Void) { workItem?.cancel() let newWorkItem = DispatchWorkItem(block: action) DispatchQueue.main.asyncAfter(delay, execute: newWorkItem) workItem = newWorkItem } } extension Debouncer { private static var debouncers = AtomicDictionary() private static func debounce( identifier: String, delay: Duration, 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: Duration, 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(\.self) } } extension String { var trimmedTrailing: Self { replacingOccurrences(of: #"\s+$"#, with: "", options: .regularExpression) } /** ``` "Unicorn".truncating(to: 4) //=> "Uni…" ``` */ func truncating(to number: Int, truncationIndicator: Self = "…") -> Self { if number <= 0 { return "" } if count > number { return String(prefix(number - truncationIndicator.count)).trimmedTrailing + truncationIndicator } return self } } extension CGImage { static let empty = NSImage(size: CGSize(widthHeight: 1), flipped: false) { _ in true } .cgImage(forProposedRect: nil, context: nil, hints: nil)! } extension CGImage { var size: CGSize { CGSize(width: width, height: height) } } extension CGImage { /** Convert an image to a `vImage` buffer of the given pixel format. - Parameter premultiplyAlpha: Whether the alpha channel should be premultiplied. */ func toVImageBuffer( pixelFormat: PixelFormat, premultiplyAlpha: Bool ) throws -> vImage.PixelBuffer { guard var imageFormat = vImage_CGImageFormat( bitsPerComponent: vImage.Interleaved8x4.bitCountPerComponent, bitsPerPixel: vImage.Interleaved8x4.bitCountPerPixel, colorSpace: CGColorSpaceCreateDeviceRGB(), bitmapInfo: pixelFormat.toBitmapInfo(premultiplyAlpha: premultiplyAlpha), renderingIntent: .perceptual ) else { throw NSError.appError("Could not initialize vImage_CGImageFormat") } return try vImage.PixelBuffer( cgImage: self, cgImageFormat: &imageFormat, pixelFormat: vImage.Interleaved8x4.self ) } } extension CGImage { enum PixelFormat { /** Big-endian, alpha first. */ case argb /** Big-endian, alpha last. */ case rgba /** Little-endian, alpha first. */ case bgra /** Little-endian, alpha last. */ case abgr var title: String { switch self { case .argb: "ARGB" case .rgba: "RGBA" case .bgra: "BGRA" case .abgr: "ABGR" } } } } extension CGImage.PixelFormat: CustomDebugStringConvertible { var debugDescription: String { "CGImage.PixelFormat(\(title)" } } extension CGImage.PixelFormat { func toBitmapInfo(premultiplyAlpha: Bool) -> CGBitmapInfo { let alphaFirst = premultiplyAlpha ? CGImageAlphaInfo.premultipliedFirst : .first let alphaLast = premultiplyAlpha ? CGImageAlphaInfo.premultipliedLast : .last let byteOrder: CGBitmapInfo let alphaInfo: CGImageAlphaInfo switch self { case .argb: byteOrder = .byteOrder32Big alphaInfo = alphaFirst case .rgba: byteOrder = .byteOrder32Big alphaInfo = alphaLast case .bgra: byteOrder = .byteOrder32Little alphaInfo = alphaFirst // This might look wrong, but the order is inverse because of little endian. case .abgr: byteOrder = .byteOrder32Little alphaInfo = alphaLast } return CGBitmapInfo(rawValue: byteOrder.rawValue | alphaInfo.rawValue) } } extension CGImage { struct Pixels { let bytes: [UInt8] let width: Int let height: Int let bytesPerRow: Int } /** Get the pixels of an image. - Parameter premultiplyAlpha: Whether the alpha channel should be premultiplied. If you pass the pixels to a C API or external library, you most likely want `premultiplyAlpha: false`. */ func pixels( as pixelFormat: PixelFormat, premultiplyAlpha: Bool ) throws -> Pixels { let buffer = try toVImageBuffer(pixelFormat: pixelFormat, premultiplyAlpha: premultiplyAlpha) return Pixels( bytes: buffer.array, width: buffer.width, height: buffer.height, bytesPerRow: buffer.byteCountPerRow ) } } extension vImage.PixelBuffer where Format: StaticPixelFormat { var byteCountPerRow: Int { width * byteCountPerPixel } } extension CGBitmapInfo { /** The alpha info of the current `CGBitmapInfo`. */ var alphaInfo: CGImageAlphaInfo { get { CGImageAlphaInfo(rawValue: rawValue & Self.alphaInfoMask.rawValue) ?? .none } set { remove(.alphaInfoMask) insert(.init(rawValue: newValue.rawValue)) } } /** The pixel format of the image. Returns `nil` if the pixel format is not supported, for example, non-alpha. */ var pixelFormat: CGImage.PixelFormat? { // While the host byte order is little-endian, by default, `CGImage` is stored in big-endian format on Intel Macs and little-endian on Apple silicon Macs. let alphaInfo = alphaInfo let isLittleEndian = contains(.byteOrder32Little) guard alphaInfo != .none else { // TODO: Support non-alpha formats. // return isLittleEndian ? .bgr : .rgb return nil } let isAlphaFirst = alphaInfo == .premultipliedFirst || alphaInfo == .first || alphaInfo == .noneSkipFirst if isLittleEndian { return isAlphaFirst ? .bgra : .abgr } return isAlphaFirst ? .argb : .rgba } /** Whether the alpha channel is premultipled. */ var isPremultipliedAlpha: Bool { let alphaInfo = alphaInfo return alphaInfo == .premultipliedFirst || alphaInfo == .premultipliedLast } } extension CGColorSpace { /** Presentable title of the color space. */ var title: String { guard let name else { return "Unknown" } return (name as String).replacingOccurrences(of: #"^kCGColorSpace"#, with: "", options: .regularExpression, range: nil) } } extension CGImage { /** Debug info for the image. ``` print(image.debugInfo) ``` */ var debugInfo: String { """ ## CGImage debug info ## Dimension: \(size.formatted) Pixel format: \(bitmapInfo.pixelFormat?.title, default: "Unknown") Premultiplied alpha: \(bitmapInfo.isPremultipliedAlpha) Color space: \(colorSpace?.title, default: "nil") """ } } extension Font { /** The default system font size. */ static let systemFontSize = NSFont.systemFontSize.toDouble /** The system font in default size. */ static func system( weight: Font.Weight = .regular, design: Font.Design = .default ) -> Self { system(size: systemFontSize, weight: weight, design: design) } } extension Font { /** The default small system font size. */ static let smallSystemFontSize = NSFont.smallSystemFontSize.toDouble /** The system font in small size. */ static func smallSystem( weight: Font.Weight = .regular, design: Font.Design = .default ) -> Self { system(size: smallSystemFontSize, weight: weight, design: design) } } extension CMTime { static func * (lhs: Self, rhs: Double) -> Self { CMTimeMultiplyByFloat64(lhs, multiplier: rhs) } static func *= (lhs: inout Self, rhs: Double) { lhs = lhs * rhs } static func / (lhs: Self, rhs: Double) -> Self { lhs * (1.0 / rhs) } static func /= (lhs: inout Self, rhs: Double) { lhs = lhs / rhs } } extension AVMutableCompositionTrack { /** Change the speed of the track using the given multiplier. 1 is the current speed. 2 means doubled speed. Etc. */ func changeSpeed(by speedMultiplier: Double) { scaleTimeRange(timeRange, toDuration: timeRange.duration / speedMultiplier) } } extension AVAssetTrack { /** Extract the track to a new asset and also change the speed of the track using the given multiplier. 1 is the current speed. 2 means doubled speed. Etc. */ func extractToNewAssetAndChangeSpeed(to speedMultiplier: Double) async throws -> AVAsset? { try await extractToNewAsset { $0.changeSpeed(by: speedMultiplier) } } } extension AVPlayerItem { /** The played duration percentage (`0...1`). */ var playbackProgress: Double { let totalDuration = duration.seconds let duration = currentTime().seconds guard totalDuration != 0, duration != 0 else { return 0 } return duration / totalDuration } /** Seek to the given percentage (`0...1`) of the total duration. */ func seek(toPercentage percentage: Double) { seek( to: duration * percentage, toleranceBefore: .zero, toleranceAfter: .zero, completionHandler: nil ) } } extension AVPlayerItem { /** The playable range of the item as percentage of the total duration. For example, if the video has a duration of 10 seconds and you trim it to the last half, this would return `0.5...1`. Can be `nil` when the `.duration` is not available, for example, when the asset has not yet been fully loaded or if it's a live stream. */ var playbackRangePercentage: ClosedRange? { get { guard let playbackRange, let duration = durationRange?.upperBound else { return nil } let lowerPercentage = playbackRange.lowerBound / duration let upperPercentage = playbackRange.upperBound / duration return lowerPercentage...upperPercentage } set { guard let duration = durationRange?.upperBound, let playbackPercentageRange = newValue else { return } let lowerBound = duration * playbackPercentageRange.lowerBound let upperBound = duration * playbackPercentageRange.upperBound playbackRange = lowerBound...upperBound } } } enum OperatingSystem { case macOS case iOS case tvOS case watchOS case visionOS #if os(macOS) static let current = macOS #elseif os(iOS) static let current = iOS #elseif os(tvOS) static let current = tvOS #elseif os(watchOS) static let current = watchOS #elseif os(visionOS) static let current = visionOS #else #error("Unsupported platform") #endif } extension OperatingSystem { static let isMacOS = current == .macOS static let isIOS = current == .iOS static let isVisionOS = current == .visionOS static let isMacOrVision = isMacOS || isVisionOS static let isIOSOrVision = isIOS || isVisionOS static let isMacOS26OrLater: Bool = { #if os(macOS) if #available(macOS 26, *) { return true } return false #else false #endif }() static let isMacOS27OrLater: Bool = { #if os(macOS) if #available(macOS 27, *) { return true } return false #else false #endif }() } typealias OS = OperatingSystem extension ClosedRange { /** Create a `ClosedRange` where it does not matter which bound is upper and lower. Using a range literal would hard crash if the lower bound is higher than the upper bound. */ static func fromGraceful(_ bound1: Bound, _ bound2: Bound) -> Self { bound1 <= bound2 ? bound1...bound2 : bound2...bound1 } } extension Duration { var nanoseconds: Int64 { let (seconds, attoseconds) = components let secondsNanos = seconds * 1_000_000_000 let attosecondsNanons = attoseconds / 1_000_000_000 let (totalNanos, isOverflow) = secondsNanos.addingReportingOverflow(attosecondsNanons) return isOverflow ? .max : totalNanos } var toTimeInterval: TimeInterval { Double(nanoseconds) / 1_000_000_000 } } struct ImportedVideoFile: Transferable { let url: URL static var transferRepresentation: some TransferRepresentation { FileRepresentation.importedURL( .mpeg4Movie, .quickTimeMovie ) { Self(url: $0) } } } extension FileRepresentation { /** An importing-only file representation that copies the URL to a temporary directory and returns that. ``` struct VideoFile: Transferable { let url: URL static var transferRepresentation: some TransferRepresentation { FileRepresentation.importedURL(contentType: .mpeg4Movie) { Self(url: $0) } } } ``` */ static func importedURL( _ contentType: UTType, createItem: @escaping (URL) async throws -> Item ) -> Self { .init(importedContentType: contentType) { try await createItem(try $0.file.copyToUniqueTemporaryDirectory()) } } // TODO: Use variadic generics here when targeting macOS 15. @TransferRepresentationBuilder static func importedURL( _ contentType1: UTType, _ contentType2: UTType, createItem: @escaping (URL) async throws -> Item ) -> some TransferRepresentation { importedURL(contentType1, createItem: createItem) importedURL(contentType2, createItem: createItem) } @TransferRepresentationBuilder static func importedURL( _ contentType1: UTType, _ contentType2: UTType, _ contentType3: UTType, createItem: @escaping (URL) async throws -> Item ) -> some TransferRepresentation { importedURL(contentType1, createItem: createItem) importedURL(contentType2, createItem: createItem) importedURL(contentType3, createItem: createItem) } @TransferRepresentationBuilder static func importedURL( _ contentType1: UTType, _ contentType2: UTType, _ contentType3: UTType, _ contentType4: UTType, createItem: @escaping (URL) async throws -> Item ) -> some TransferRepresentation { importedURL(contentType1, createItem: createItem) importedURL(contentType2, createItem: createItem) importedURL(contentType3, createItem: createItem) importedURL(contentType4, createItem: createItem) } } extension View { /** Fills the frame. */ func fillFrame( _ axis: Axis.Set = [.horizontal, .vertical], alignment: Alignment = .center ) -> some View { frame( maxWidth: axis.contains(.horizontal) ? .infinity : nil, maxHeight: axis.contains(.vertical) ? .infinity : nil, alignment: alignment ) } } // TODO: Try to use `ContainerRelativeShape` when it's supported outside of widgets. (as of macOS 11.2.3, it's only supported in widgets) // Note: I have extensively tested and researched the current code. Don't change it lightly. extension View { /** Corner radius with a custom corner style. */ func cornerRadius(_ radius: Double, style: RoundedCornerStyle = .continuous) -> some View { clipShape(.rect(cornerRadius: radius, style: style)) } /** Draws a border inside the view. */ @_disfavoredOverload func border( _ content: some ShapeStyle, width lineWidth: Double = 1, cornerRadius: Double, cornerStyle: RoundedCornerStyle = .circular ) -> some View { self.cornerRadius(cornerRadius, style: cornerStyle) .overlay { RoundedRectangle(cornerRadius: cornerRadius, style: cornerStyle) .strokeBorder(content, lineWidth: lineWidth) } } // I considered supporting an `inside`/`center` position option, but there's really no benefit to drawing the border at center as we need to pad the view anyway because of the clipping. /** Draws a border inside the view. */ func border( _ color: Color, width lineWidth: Double = 1, cornerRadius: Double, cornerStyle: RoundedCornerStyle = .circular ) -> some View { self.cornerRadius(cornerRadius, style: cornerStyle) .overlay { RoundedRectangle(cornerRadius: cornerRadius, style: cornerStyle) .strokeBorder(color, lineWidth: lineWidth) } } } // TODO: Remove these when targeting macOS 15. extension NSItemProvider { func loadObject(ofClass: T.Type) async throws -> T? where T: NSItemProviderReading { try await withCheckedThrowingContinuation { continuation in _ = loadObject(ofClass: ofClass) { data, error in if let error { continuation.resume(throwing: error) return } guard let object = data as? T else { continuation.resume(returning: nil) return } continuation.resume(returning: object) } } } func loadObject(ofClass: T.Type) async throws -> T? where T: _ObjectiveCBridgeable, T._ObjectiveCType: NSItemProviderReading { try await withCheckedThrowingContinuation { continuation in _ = loadObject(ofClass: ofClass) { data, error in if let error { continuation.resume(throwing: error) return } guard let data else { continuation.resume(returning: nil) return } continuation.resume(returning: data) } } } } extension NSItemProvider { /** Get a URL from the item provider, if any. */ func getURL() async -> URL? { try? await loadObject(ofClass: URL.self) } } extension Sequence { func asyncFlatMap( _ transform: (Element) async throws(E) -> T ) async throws(E) -> [T.Element] { var values = [T.Element]() for element in self { try await values.append(contentsOf: transform(element)) } return values } } extension Sequence where Element: Sendable { func concurrentCompactMap( withPriority priority: TaskPriority? = nil, concurrencyLimit: Int? = nil, _ transform: @Sendable (Element) async -> T? ) async -> [T] { await chunked(by: concurrencyLimit ?? .max).asyncFlatMap { chunk in await withoutActuallyEscaping(transform) { escapingTransform in await withTaskGroup(of: (offset: Int, value: T?).self) { group -> [T] in for (offset, element) in chunk.enumerated() { group.addTask(priority: priority) { await (offset, escapingTransform(element)) } } var result = [(offset: Int, value: T)]() result.reserveCapacity(chunk.count) while let next = await group.next() { if let value = next.value { result.append((offset: next.offset, value: value)) } } return result .sorted { $0.offset < $1.offset } .map(\.value) } } } } } struct NativeVisualEffectsView: NSViewRepresentable { typealias NSViewType = NSVisualEffectView var material: NSVisualEffectView.Material var blendingMode = NSVisualEffectView.BlendingMode.withinWindow var state = NSVisualEffectView.State.followsWindowActiveState var isEmphasized = false var cornerRadius = 0.0 func makeNSView(context: Context) -> NSViewType { let nsView = NSVisualEffectView() nsView.wantsLayer = true nsView.translatesAutoresizingMaskIntoConstraints = false nsView.setContentHuggingPriority(.defaultHigh, for: .vertical) nsView.setContentHuggingPriority(.defaultHigh, for: .horizontal) nsView.setAccessibilityHidden(true) nsView.layer?.masksToBounds = true return nsView } func updateNSView(_ nsView: NSViewType, context: Context) { nsView.material = material nsView.blendingMode = blendingMode nsView.state = state nsView.isEmphasized = isEmphasized nsView.layer?.cornerRadius = cornerRadius } } extension View { /** Add a material as a background. Only use this over the native materials when either: - You need to blend with what's behind the window. - You need the material to be visible even when the window is inactive. */ func backgroundWithMaterial( _ material: NSVisualEffectView.Material, blendingMode: NSVisualEffectView.BlendingMode = .withinWindow, state: NSVisualEffectView.State = .followsWindowActiveState, isEmphasized: Bool = false, cornerRadius: Double = 0, ignoresSafeAreaEdges edges: Edge.Set = .all ) -> some View { background { NativeVisualEffectsView( material: material, blendingMode: blendingMode, state: state, isEmphasized: isEmphasized, cornerRadius: cornerRadius ) .ignoresSafeArea(edges: edges) } } } extension View { /** https://twitter.com/oskargroth/status/1323013160333381641 */ func visualEffectsViewVibrancy(_ level: Double) -> some View { blendMode(.overlay) .overlay { opacity(1 - level) } } } extension Binding { /** Converts the binding of an optional value to a binding to a boolean for whether the value is non-nil. You could use this in a `isPresent` parameter for a sheet, alert, etc, to have it show when the value is non-nil. */ func isPresent() -> Binding where Value == Wrapped? { .init( get: { wrappedValue != nil }, set: { isPresented in if !isPresented { wrappedValue = nil } } ) } } extension Binding { func map( get: @escaping (Value) -> Result, set: @escaping (Result) -> Value ) -> Binding { .init( get: { get(wrappedValue) }, set: { newValue in wrappedValue = set(newValue) } ) } } extension View { func alert(error: Binding) -> some View { alert2( title: { ($0 as NSError).localizedDescription }, message: { ($0 as NSError).localizedRecoverySuggestion }, presenting: error ) { let nsError = $0 as NSError if let options = nsError.localizedRecoveryOptions, let recoveryAttempter = nsError.recoveryAttempter { // Alert only supports 3 buttons, so we limit it to 2 attempters, otherwise it would take over the cancel button. ForEach(Array(options.prefix(2).enumerated()), id: \.0) { index, option in Button(option) { // We use the old NSError mechanism for recovery attempt as recoverable NSError's are not bridged to RecoverableError. _ = (recoveryAttempter as AnyObject).attemptRecovery(fromError: nsError, optionIndex: index) } } Button("Cancel", role: .cancel) {} } } } } extension View { /** This allows multiple sheets on a single view, which `.sheet()` doesn't. */ func sheet2( isPresented: Binding, onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping () -> some View ) -> some View { background( EmptyView().sheet( isPresented: isPresented, onDismiss: onDismiss, content: content ) ) } /** This allows multiple sheets on a single view, which `.sheet()` doesn't. */ func sheet2( item: Binding, onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping (Item) -> some View ) -> some View { background( EmptyView().sheet( item: item, onDismiss: onDismiss, content: content ) ) } } extension View { /** This allows multiple popovers on a single view, which `.popover()` doesn't. */ func popover2( isPresented: Binding, attachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds), arrowEdge: Edge = .top, @ViewBuilder content: @escaping () -> some View ) -> some View { background( EmptyView() .popover( isPresented: isPresented, attachmentAnchor: attachmentAnchor, arrowEdge: arrowEdge, content: content ) ) } } // Multiple `.alert` are stil broken in iOS 15.0 extension View { /** This allows multiple alerts on a single view, which `.alert()` doesn't. */ func alert2( _ title: Text, isPresented: Binding, @ViewBuilder actions: () -> some View, @ViewBuilder message: () -> some View ) -> some View { background( EmptyView() .alert( title, isPresented: isPresented, actions: actions, message: message ) ) } /** This allows multiple alerts on a single view, which `.alert()` doesn't. */ func alert2( _ title: String, isPresented: Binding, @ViewBuilder actions: () -> some View, @ViewBuilder message: () -> some View ) -> some View { alert2( Text(title), isPresented: isPresented, actions: actions, message: message ) } /** This allows multiple alerts on a single view, which `.alert()` doesn't. */ func alert2( _ title: Text, message: String? = nil, isPresented: Binding, @ViewBuilder actions: () -> some View ) -> some View { alert2( title, isPresented: isPresented, actions: actions, message: { // swiftlint:disable:this trailing_closure if let message { Text(message) } } ) } // This is a convenience method and does not exist natively. /** This allows multiple alerts on a single view, which `.alert()` doesn't. */ func alert2( _ title: String, message: String? = nil, isPresented: Binding, @ViewBuilder actions: () -> some View ) -> some View { alert2( title, isPresented: isPresented, actions: actions, message: { // swiftlint:disable:this trailing_closure if let message { Text(message) } } ) } /** This allows multiple alerts on a single view, which `.alert()` doesn't. */ func alert2( _ title: Text, message: String? = nil, isPresented: Binding ) -> some View { alert2( title, message: message, isPresented: isPresented, actions: {} // swiftlint:disable:this trailing_closure ) } // This is a convenience method and does not exist natively. /** This allows multiple alerts on a single view, which `.alert()` doesn't. */ func alert2( _ title: String, message: String? = nil, isPresented: Binding ) -> some View { alert2( title, message: message, isPresented: isPresented, actions: {} // swiftlint:disable:this trailing_closure ) } } extension View { // This exist as the new `item`-type alert APIs in iOS 15 are shit. // This is a convenience method and does not exist natively. /** This allows multiple alerts on a single view, which `.alert()` doesn't. */ func alert2( title: (T) -> Text, presenting data: Binding, @ViewBuilder actions: (T) -> some View, @ViewBuilder message: (T) -> some View ) -> some View { background( EmptyView() .alert( data.wrappedValue.map(title) ?? Text(""), isPresented: data.isPresent(), presenting: data.wrappedValue, actions: actions, message: message ) ) } // This is a convenience method and does not exist natively. /** This allows multiple alerts on a single view, which `.alert()` doesn't. */ func alert2( title: (T) -> Text, message: ((T) -> String?)? = nil, presenting data: Binding, @ViewBuilder actions: (T) -> some View ) -> some View { alert2( title: { title($0) }, presenting: data, actions: actions, message: { // swiftlint:disable:this trailing_closure if let message = message?($0) { Text(message) } } ) } // This is a convenience method and does not exist natively. /** This allows multiple alerts on a single view, which `.alert()` doesn't. */ func alert2( title: (T) -> String, message: ((T) -> String?)? = nil, presenting data: Binding, @ViewBuilder actions: (T) -> some View ) -> some View { alert2( title: { Text(title($0)) }, message: message, presenting: data, actions: actions ) } // This is a convenience method and does not exist natively. /** This allows multiple alerts on a single view, which `.alert()` doesn't. */ func alert2( title: (T) -> Text, message: ((T) -> String?)? = nil, presenting data: Binding ) -> some View { alert2( title: title, message: message, presenting: data, actions: { _ in } // swiftlint:disable:this trailing_closure ) } // This is a convenience method and does not exist natively. /** This allows multiple alerts on a single view, which `.alert()` doesn't. */ func alert2( title: (T) -> String, message: ((T) -> String?)? = nil, presenting data: Binding ) -> some View { alert2( title: { Text(title($0)) }, message: message, presenting: data ) } } // Multiple `.confirmationDialog` are broken in iOS 15.0 extension View { /** This allows multiple confirmation dialogs on a single view, which `.confirmationDialog()` doesn't. */ func confirmationDialog2( _ title: Text, isPresented: Binding, titleVisibility: Visibility = .automatic, @ViewBuilder actions: () -> some View, @ViewBuilder message: () -> some View ) -> some View { background( EmptyView() .confirmationDialog( title, isPresented: isPresented, titleVisibility: titleVisibility, actions: actions, message: message ) ) } /** This allows multiple confirmation dialogs on a single view, which `.confirmationDialog()` doesn't. */ func confirmationDialog2( _ title: Text, message: String? = nil, isPresented: Binding, titleVisibility: Visibility = .automatic, @ViewBuilder actions: () -> some View ) -> some View { confirmationDialog2( title, isPresented: isPresented, titleVisibility: titleVisibility, actions: actions, message: { // swiftlint:disable:this trailing_closure if let message { Text(message) } } ) } /** This allows multiple confirmation dialogs on a single view, which `.confirmationDialog()` doesn't. */ func confirmationDialog2( _ title: String, message: String? = nil, isPresented: Binding, titleVisibility: Visibility = .automatic, @ViewBuilder actions: () -> some View ) -> some View { confirmationDialog2( Text(title), message: message, isPresented: isPresented, titleVisibility: titleVisibility, actions: actions ) } } // This exist as the new `item`-type alert APIs in iOS 15 are shit. extension View { // This is a convenience method and does not exist natively. /** This allows multiple confirmation dialogs on a single view, which `.confirmationDialog()` doesn't. */ func confirmationDialog2( title: (T) -> Text, titleVisibility: Visibility = .automatic, presenting data: Binding, @ViewBuilder actions: (T) -> some View, @ViewBuilder message: (T) -> some View ) -> some View { background( EmptyView() .confirmationDialog( data.wrappedValue.map(title) ?? Text(""), isPresented: data.isPresent(), titleVisibility: titleVisibility, presenting: data.wrappedValue, actions: actions, message: message ) ) } // This is a convenience method and does not exist natively. /** This allows multiple confirmation dialogs on a single view, which `.confirmationDialog()` doesn't. */ func confirmationDialog2( title: (T) -> Text, message: ((T) -> String?)? = nil, titleVisibility: Visibility = .automatic, presenting data: Binding, @ViewBuilder actions: (T) -> some View ) -> some View { confirmationDialog2( title: { title($0) }, titleVisibility: titleVisibility, presenting: data, actions: actions, message: { // swiftlint:disable:this trailing_closure if let message = message?($0) { Text(message) } } ) } // This is a convenience method and does not exist natively. /** This allows multiple confirmation dialogs on a single view, which `.confirmationDialog()` doesn't. */ func confirmationDialog2( title: (T) -> String, message: ((T) -> String?)? = nil, titleVisibility: Visibility = .automatic, presenting data: Binding, @ViewBuilder actions: (T) -> some View ) -> some View { confirmationDialog2( title: { Text(title($0)) }, message: message, titleVisibility: titleVisibility, presenting: data, actions: actions ) } } struct ImageView: NSViewRepresentable { typealias NSViewType = NSImageView let image: NSImage func makeNSView(context: Context) -> NSViewType { let nsView = NSImageView() nsView.wantsLayer = true nsView.translatesAutoresizingMaskIntoConstraints = false nsView.setContentHuggingPriority(.defaultHigh, for: .vertical) nsView.setContentHuggingPriority(.defaultHigh, for: .horizontal) return nsView } func updateNSView(_ nsView: NSViewType, context: Context) { nsView.image = image } func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSImageView, context: Context) -> CGSize? { guard let size = proposal.toCGSize else { return nil } return image.size.aspectFitInside(size) } } extension ProposedViewSize { var toCGSize: CGSize? { guard let width, let height else { return nil } return .init(width: width, height: height) } } extension CGSize { /** Returns a new size that fits within a target size while maintaining the aspect ratio and ensuring it does not exceed the original size. - Parameter targetSize: The target size within which the original size should fit. - Returns: A new size fitting within `targetSize` and not exceeding the original size. Use-cases: - Scaling images without distortion. - Adapting a UI element size to fit within certain bounds without exceeding its original dimensions. */ func aspectFitInside(_ targetSize: Self) -> Self { let originalAspectRatio = width / height let targetAspectRatio = targetSize.width / targetSize.height var newSize = if targetAspectRatio > originalAspectRatio { CGSize(width: targetSize.height * originalAspectRatio, height: targetSize.height) } else { CGSize(width: targetSize.width, height: targetSize.width / originalAspectRatio) } // Ensure the size is not larger than the original. newSize.width = min(newSize.width, width) newSize.height = min(newSize.height, height) return newSize } } extension SetAlgebra { /** Insert the `value` if it doesn't exist, otherwise remove it. */ mutating func toggleExistence(_ value: Element) { if contains(value) { remove(value) } else { insert(value) } } /** Insert the `value` if `shouldExist` is true, otherwise remove it. */ mutating func toggleExistence(_ value: Element, shouldExist: Bool) { if shouldExist { insert(value) } else { remove(value) } } } private struct WindowAccessor: NSViewRepresentable { private final class WindowAccessorView: NSView { @Binding var windowBinding: NSWindow? init(binding: Binding) { self._windowBinding = binding super.init(frame: .zero) } override func viewWillMove(toWindow newWindow: NSWindow?) { super.viewWillMove(toWindow: newWindow) guard let newWindow else { return } windowBinding = newWindow } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() windowBinding = window } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("") // swiftlint:disable:this fatal_error_message } } @Binding var window: NSWindow? init(_ window: Binding) { self._window = window } func makeNSView(context: Context) -> NSView { WindowAccessorView(binding: $window) } func updateNSView(_ nsView: NSView, context: Context) {} } extension View { /** Bind the native backing-window of a SwiftUI window to a property. */ func bindHostingWindow(_ window: Binding) -> some View { background(WindowAccessor(window)) } } private struct WindowViewModifier: ViewModifier { @State private var window: NSWindow? let onWindow: (NSWindow?) -> Void func body(content: Content) -> some View { // We're intentionally not using `.onChange` as we need it to execute for every SwiftUI change as the window properties can be changed at any time by SwiftUI. onWindow(window) return content .bindHostingWindow($window) } } extension View { /** Access the native backing-window of a SwiftUI window. */ func accessHostingWindow(_ onWindow: @escaping (NSWindow?) -> Void) -> some View { modifier(WindowViewModifier(onWindow: onWindow)) } /** Set the window tabbing mode of a SwiftUI window. */ func windowTabbingMode(_ tabbingMode: NSWindow.TabbingMode) -> some View { accessHostingWindow { $0?.tabbingMode = tabbingMode } } /** Set whether the SwiftUI window should be resizable. Setting this to false disables the green zoom button on the window. */ func windowIsResizable(_ isResizable: Bool = true) -> some View { accessHostingWindow { $0?.styleMask.toggleExistence(.resizable, shouldExist: isResizable) } } /** Set whether the SwiftUI window should be restorable. */ func windowIsRestorable(_ isRestorable: Bool = true) -> some View { accessHostingWindow { $0?.isRestorable = isRestorable } } /** Make a SwiftUI window draggable by clicking and dragging anywhere in the window. */ func windowIsMovableByWindowBackground(_ isMovableByWindowBackground: Bool = true) -> some View { accessHostingWindow { $0?.isMovableByWindowBackground = isMovableByWindowBackground } } /** Set whether to show the title bar appears transparent. */ func windowTitlebarAppearsTransparent(_ isActive: Bool = true) -> some View { accessHostingWindow { window in window?.titlebarAppearsTransparent = isActive } } /** Set the collection behavior of a SwiftUI window. */ func windowCollectionBehavior(_ collectionBehavior: NSWindow.CollectionBehavior) -> some View { accessHostingWindow { window in window?.collectionBehavior = collectionBehavior // This is needed on windows with `.windowResizability(.contentSize)`. (macOS 13.4) // If it's not set, the window will not show in fullscreen mode for some reason. DispatchQueue.main.async { window?.collectionBehavior = collectionBehavior } } } func windowIsVibrant() -> some View { accessHostingWindow { $0?.makeVibrant() } } } extension NSColor { convenience init(light: NSColor, dark: NSColor?) { self.init(name: nil) { $0.isDarkMode ? (dark ?? light) : light } } } extension Color { init(dynamicProvider: @escaping (Bool) -> Self) { self.init( NSColor(name: nil) { NSColor(dynamicProvider($0.isDarkMode)) } ) } } extension Color { init(light: Self, dark: Self?) { self.init { $0 ? (dark ?? light) : light } } } extension NSAppearance { var isDarkMode: Bool { bestMatch(from: [.darkAqua, .aqua]) == .darkAqua } } extension FloatingPointFormatStyle.Percent { /** Do not show fraction. */ var noFraction: Self { precision(.fractionLength(0)) } } private struct EqualWidthWithBindingPreferenceKey: PreferenceKey { static let defaultValue = 0.0 static func reduce(value: inout Double, nextValue: () -> Double) { value = nextValue() } } private struct EqualWidthWithBinding: ViewModifier { @Binding var width: Double? let alignment: Alignment func body(content: Content) -> some View { content .frame(width: width?.nilIfZero?.toCGFloat, alignment: alignment) .background { GeometryReader { Color.clear .preference( key: EqualWidthWithBindingPreferenceKey.self, value: $0.size.width ) } } .onPreferenceChange(EqualWidthWithBindingPreferenceKey.self) { width = max(width ?? 0, $0) } } } extension View { func equalWidthWithBinding( _ width: Binding, alignment: Alignment = .center ) -> some View { modifier(EqualWidthWithBinding(width: width, alignment: alignment)) } } extension PrimitiveButtonStyle where Self == WidthButtonStyle { /** Make button have equal width. */ static func equalWidth( _ width: Binding, minimumWidth: Double? = nil ) -> Self { .init( width: width, minimumWidth: minimumWidth ) } } struct WidthButtonStyle: PrimitiveButtonStyle { @Binding var width: Double? var minimumWidth: Double? func makeBody(configuration: Configuration) -> some View { Button(role: configuration.role) { configuration.trigger() } label: { configuration.label .frame(minWidth: minimumWidth?.toCGFloat) .equalWidthWithBinding($width) } } } extension StringProtocol { @inlinable var isWhitespace: Bool { allSatisfy(\.isWhitespace) } @inlinable var isEmptyOrWhitespace: Bool { isEmpty || isWhitespace } } extension Collection { /** Works on strings too, since they're just collections. */ @inlinable var nilIfEmpty: Self? { isEmpty ? nil : self } } extension StringProtocol { @inlinable var nilIfEmptyOrWhitespace: Self? { isEmptyOrWhitespace ? nil : self } } extension AdditiveArithmetic { /** Returns `nil` if the value is `0`. */ @inlinable var nilIfZero: Self? { self == .zero ? nil : self } } extension CGSize { /** Returns `nil` if the value is `0`. */ @inlinable var nilIfZero: Self? { self == .zero ? nil : self } } extension CGRect { /** Returns `nil` if the value is `0`. */ @inlinable var nilIfZero: Self? { self == .zero ? nil : self } } struct CopyButton: View { @State private var isShowingSuccess = false private let action: () -> Void init(_ action: @escaping () -> Void) { self.action = action } var body: some View { Button { isShowingSuccess = true Task { try? await Task.sleep(for: .seconds(1)) isShowingSuccess = false } action() } label: { Label("Copy", systemImage: "doc.on.doc") .opacity(isShowingSuccess ? 0 : 1) .overlay { if isShowingSuccess { Image(systemName: "checkmark") .bold() } } } .disabled(isShowingSuccess) .animation(.easeInOut(duration: 0.3), value: isShowingSuccess) } } extension IntentFile { /** Write the data to a unique temporary path and return the `URL`. */ func writeToUniqueTemporaryFile() throws -> URL { try data.writeToUniqueTemporaryFile( filename: filename, contentType: type ?? .data ) } } extension Data { /** Create an `IntentFile` from the data. */ func toIntentFile( contentType: UTType, filename: String? = nil ) -> IntentFile { .init( data: self, filename: filename ?? "file", type: contentType ) } } extension Data { /** Write the data to a unique temporary path and return the `URL`. By default, the file has no file extension. */ func writeToUniqueTemporaryFile( filename: String? = nil, contentType: UTType = .data ) throws -> URL { let destinationUrl = try URL.uniqueTemporaryDirectory() .appendingPathComponent(filename ?? "file", conformingTo: contentType) try write(to: destinationUrl) return destinationUrl } } extension URL { /** Creates a unique temporary directory and returns the URL. The URL is unique for each call. The system ensures the directory is not cleaned up until after the app quits. */ static func uniqueTemporaryDirectory( appropriateFor: Self? = nil ) throws -> Self { try FileManager.default.url( for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: appropriateFor ?? URL.temporaryDirectory, create: true ) } /** Copy the file at the current URL to a unique temporary directory and return the new URL. */ func copyToUniqueTemporaryDirectory(filename: String? = nil) throws -> Self { let destinationUrl = try Self.uniqueTemporaryDirectory(appropriateFor: self) .appendingPathComponent(filename ?? lastPathComponent, isDirectory: false) try FileManager.default.copyItem(at: self, to: destinationUrl) return destinationUrl } } extension View { @ViewBuilder func `if`( _ condition: @autoclosure () -> Bool, modify: (Self) -> some View ) -> some View { if condition() { modify(self) } else { self } } func `if`( _ condition: @autoclosure () -> Bool, modify: (Self) -> Self ) -> Self { condition() ? modify(self) : self } } extension View { @ViewBuilder func `if`( _ condition: @autoclosure () -> Bool, if modifyIf: (Self) -> some View, else modifyElse: (Self) -> some View ) -> some View { if condition() { modifyIf(self) } else { modifyElse(self) } } func `if`( _ condition: @autoclosure () -> Bool, if modifyIf: (Self) -> Self, else modifyElse: (Self) -> Self ) -> Self { condition() ? modifyIf(self) : modifyElse(self) } } extension ProgressViewStyleConfiguration { var isFinished: Bool { (fractionCompleted ?? 0) >= 1 } } struct CircularProgressViewStyle: ProgressViewStyle { private struct CheckmarkShape: Shape { func path(in rect: CGRect) -> Path { Path { $0.move(to: CGPoint(x: rect.width * 0.3, y: rect.height * 0.52)) $0.addLine(to: CGPoint(x: rect.width * 0.48, y: rect.height * 0.68)) $0.addLine(to: CGPoint(x: rect.width * 0.7, y: rect.height * 0.34)) } } } private let fill: AnyShapeStyle private let lineWidth: Double private let text: String? init( fill: (some ShapeStyle)? = nil, lineWidth: Double? = nil, text: String? = nil ) { self.fill = fill.flatMap(AnyShapeStyle.init) ?? AnyShapeStyle(LinearGradient(gradient: .init(colors: [.purple, .blue]), startPoint: .top, endPoint: .bottom)) self.lineWidth = lineWidth ?? 12 self.text = text } func makeBody(configuration: Configuration) -> some View { let progress = configuration.fractionCompleted ?? 0 ZStack { // Background Circle() .stroke(lineWidth: lineWidth) .opacity(0.3) .foregroundStyle(.secondary) .visualEffectsViewVibrancy(0.5) // Progress Circle() .trim(from: 0, to: progress) .stroke(fill, style: .init(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) .rotationEffect(.init(degrees: 270)) .saturation((progress * 2).clamped(to: 0.5...1.2)) .animation(.easeInOut, value: progress) if !configuration.isFinished { if let text { Text(text) .fontDesign(.rounded) .minimumScaleFactor(0.4) .foregroundStyle(.secondary) } else { Text(progress.formatted(.percent.precision(.fractionLength(0)))) .font(.system(size: 30, weight: .bold, design: .rounded)) .monospacedDigit() } } CheckmarkShape() .stroke(style: .init(lineWidth: lineWidth / 1.5, lineCap: .round, lineJoin: .round)) .scaleEffect(configuration.isFinished ? 1 : 0.4) .animation(.spring(response: 0.55, dampingFraction: 0.35).speed(1.3), value: configuration.isFinished) .opacity(configuration.isFinished ? 1 : 0) .animation(.easeInOut, value: configuration.isFinished) .scaledToFit() } } } extension ProgressViewStyle where Self == CircularProgressViewStyle { static func ssCircular( fill: (some ShapeStyle)? = nil, lineWidth: Double? = nil, text: String? = nil ) -> Self { .init( fill: fill, lineWidth: lineWidth, text: text ) } } extension View { /** Add a keyboard shortcut to a view, not a button. */ func onKeyboardShortcut( _ shortcut: KeyboardShortcut?, perform action: @escaping () -> Void ) -> some View { overlay { Button("", action: action) .labelsHidden() .opacity(0) .frame(width: 0, height: 0) .keyboardShortcut(shortcut) .accessibilityHidden(true) } } /** Add a keyboard shortcut to a view, not a button. */ func onKeyboardShortcut( _ key: KeyEquivalent, modifiers: SwiftUI.EventModifiers = .command, isEnabled: Bool = true, perform action: @escaping () -> Void ) -> some View { onKeyboardShortcut(isEnabled ? .init(key, modifiers: modifiers) : nil, perform: action) } } extension Device { static var isReduceMotionEnabled: Bool { #if os(macOS) NSWorkspace.shared.accessibilityDisplayShouldReduceMotion #else UIAccessibility.isReduceMotionEnabled #endif } } func withAnimationIf( _ condition: Bool, animation: Animation? = .default, _ body: () throws -> Result ) rethrows -> Result { condition ? try withAnimation(animation, body) : try body() } func withAnimationWhenNotReduced( _ animation: Animation? = .default, _ body: () throws -> Result ) rethrows -> Result { try withAnimationIf( !Device.isReduceMotionEnabled, animation: animation, body ) } struct AnyDropDelegate: DropDelegate { var isTargeted: Binding? var onValidate: ((DropInfo) -> Bool)? let onPerform: (DropInfo) -> Bool var onEntered: ((DropInfo) -> Void)? var onExited: ((DropInfo) -> Void)? var onUpdated: ((DropInfo) -> DropProposal?)? func performDrop(info: DropInfo) -> Bool { isTargeted?.wrappedValue = false return onPerform(info) } func validateDrop(info: DropInfo) -> Bool { onValidate?(info) ?? true } func dropEntered(info: DropInfo) { isTargeted?.wrappedValue = true onEntered?(info) } func dropExited(info: DropInfo) { isTargeted?.wrappedValue = false onExited?(info) } func dropUpdated(info: DropInfo) -> DropProposal? { onUpdated?(info) } } extension DropInfo { /** This is useful as `DropInfo` usually on has `NSItemProvider` items and they have to be fetched async, while the validation has to happen synchronously. */ func fileURLsConforming(to contentTypes: [UTType]) -> [URL] { NSPasteboard(name: .drag).fileURLs(contentTypes: contentTypes) } /** Indicates whether at least one file URL conforms to at least one of the specified uniform type identifiers. */ func hasFileURLsConforming(to contentTypes: [UTType]) -> Bool { !fileURLsConforming(to: contentTypes).isEmpty } } extension CGSize { var toInt: (width: Int, height: Int) { (Int(width), Int(height)) } var videoSizeDescription: String { "\(Int(width))x\(Int(height))" } } extension ClosedRange { var toInt: ClosedRange { Int(lowerBound)...Int(upperBound) } } extension Range { var toInt: Range { Int(lowerBound)..( for keyPath: KeyPath, options: NSKeyValueObservingOptions = [.initial, .new] ) -> some AsyncSequence { publisher(for: keyPath, options: options).toAsyncSequence } } protocol ReflectiveEquatable: Equatable {} extension ReflectiveEquatable { var reflectedValue: String { String(reflecting: self) } static func == (lhs: Self, rhs: Self) -> Bool { lhs.reflectedValue == rhs.reflectedValue } } protocol ReflectiveHashable: Hashable, ReflectiveEquatable {} extension ReflectiveHashable { func hash(into hasher: inout Hasher) { hasher.combine(reflectedValue) } } extension CGSize { /** Calculates a new size that maintains the aspect ratio, based on given width or height constraints. If only one dimension is provided, calculates the other dimension accordingly to preserve the aspect ratio. If both dimensions are provided, adjusts them to fit within the given dimensions while maintaining the aspect ratio. */ func aspectFittedSize(targetWidth: Double?, targetHeight: Double?) -> Self { let originalAspectRatio = width / height switch (targetWidth, targetHeight) { case (let width?, nil): return CGSize( width: width, height: width / originalAspectRatio ) case (nil, let height?): return CGSize( width: height * originalAspectRatio, height: height ) case (let width?, let height?): let targetAspectRatio = width / height if originalAspectRatio > targetAspectRatio { return CGSize( width: width, height: width / originalAspectRatio ) } return CGSize( width: height * originalAspectRatio, height: height ) default: return self } } func aspectFittedSize(targetWidthHeight: Double) -> Self { aspectFittedSize( targetWidth: targetWidthHeight, targetHeight: targetWidthHeight ) } func aspectFittedSize(targetWidth: Int?, targetHeight: Int?) -> Self { aspectFittedSize( targetWidth: targetWidth.flatMap { Double($0) }, targetHeight: targetHeight.flatMap { Double($0) } ) } } @dynamicMemberLookup struct Tuple3 { let (first, second, third): (A, B, C) init(_ first: A, _ second: B, _ third: C) { (self.first, self.second, self.third) = (first, second, third) } subscript(dynamicMember keyPath: KeyPath<(A, B, C), T>) -> T { (first, second, third)[keyPath: keyPath] } } extension Tuple3: Equatable where A: Equatable, B: Equatable, C: Equatable {} extension Tuple3: Hashable where A: Hashable, B: Hashable, C: Hashable {} extension Tuple3: Encodable where A: Encodable, B: Encodable, C: Encodable {} extension Tuple3: Decodable where A: Decodable, B: Decodable, C: Decodable {} extension Tuple3: Sendable where A: Sendable, B: Sendable, C: Sendable {} @propertyWrapper struct ViewStorage: DynamicProperty { private final class ValueBox { var value: Value init(_ value: Value) { self.value = value } } @State private var valueBox: ValueBox var wrappedValue: Value { get { valueBox.value } nonmutating set { valueBox.value = newValue } } var projectedValue: Binding { .init( get: { wrappedValue }, set: { wrappedValue = $0 } ) } init(wrappedValue value: @autoclosure @escaping () -> Value) { self._valueBox = .init(wrappedValue: ValueBox(value())) } } extension SSApp { final class Activity { private let activity: NSObjectProtocol init( _ options: ProcessInfo.ActivityOptions = [], reason: String ) { self.activity = ProcessInfo.processInfo.beginActivity(options: options, reason: reason) } deinit { ProcessInfo.processInfo.endActivity(activity) } } static func beginActivity( _ options: ProcessInfo.ActivityOptions = [], reason: String ) -> Activity { .init(options, reason: reason) } } extension View { func activity( _ isActive: Bool = true, options: ProcessInfo.ActivityOptions = [], reason: String ) -> some View { modifier( AppActivityModifier( isActive: isActive, options: options, reason: reason ) ) } } private struct AppActivityModifier: ViewModifier { @ViewStorage private var activity: SSApp.Activity? let isActive: Bool let options: ProcessInfo.ActivityOptions let reason: String func body(content: Content) -> some View { content .task(id: Tuple3(isActive, options, reason)) { // TODO: Use a tuple here when it can be equatable. activity = isActive ? SSApp.beginActivity(options, reason: reason) : nil } } } func greatestCommonDivisor(_ a: T, _ b: T) -> T { let result = a % b return result == 0 ? b : greatestCommonDivisor(b, result) } extension View { func staticPopover( isPresented: Binding, @ViewBuilder content: @escaping () -> some View ) -> some View { modifier( StaticPopover( isPresented: isPresented, popoverContent: content ) ) } } /** Use the size of the select box when it is opened, so the popover doesn't move as the select box changes shape. */ struct StaticPopover: ViewModifier { @State private var size: CGSize? @State private var visibleSize: CGSize? @Binding var isPresented: Bool let popoverContent: () -> PopoverContent func body(content: Content) -> some View { ZStack(alignment: .trailing) { content .readSize(into: $size) .onChange(of: isPresented) { visibleSize = size } if isPresented { Color.clear .fillFrame() .frame(width: visibleSize?.width, height: visibleSize?.height) .popover2(isPresented: $isPresented, arrowEdge: .bottom) { popoverContent() } } } } } extension View { func readSize(_ onChange: @escaping (CGSize) -> Void) -> some View { onGeometryChange(for: CGSize.self) { proxy in proxy.size } action: { onChange($0) } } func readSize(into binding: Binding) -> some View { readSize { binding.wrappedValue = $0 } } } extension ColorScheme { var isDark: Bool { self == .dark } } extension Color { var ciColor: CIColor? { CIColor(color: NSColor(self)) } var simd4: SIMD4 { let color = NSColor(self) return .init( x: Float(color.redComponent), y: Float(color.greenComponent), z: Float(color.blueComponent), w: Float(color.alphaComponent) ) } } extension CVPixelBuffer { var planeCount: Int { CVPixelBufferGetPlaneCount(self) } var width: Int { CVPixelBufferGetWidth(self) } var height: Int { CVPixelBufferGetHeight(self) } var pixelFormatType: OSType { CVPixelBufferGetPixelFormatType(self) } var bytesPerRow: Int { CVPixelBufferGetBytesPerRow(self) } var baseAddress: UnsafeMutableRawPointer? { CVPixelBufferGetBaseAddress(self) } var creationAttributes: [String: Any] { CVPixelBufferCopyCreationAttributes(self) as NSDictionary as? [String: Any] ?? [:] } var attachments: [String: Any] { guard let attachments = CVBufferCopyAttachments(self, .shouldPropagate) else { return [:] } return attachments as NSDictionary as? [String: Any] ?? [:] } func baseAddressOfPlane(_ plane: Int) -> UnsafeMutableRawPointer? { CVPixelBufferGetBaseAddressOfPlane(self, plane) } func bytesPerRowOfPlane(_ plane: Int) -> Int { CVPixelBufferGetBytesPerRowOfPlane(self, plane) } func heightOfPlane(_ plane: Int) -> Int { CVPixelBufferGetHeightOfPlane(self, plane) } var colorSpace: CGColorSpace? { attachments[kCVImageBufferCGColorSpaceKey as String] as! CGColorSpace? } static func create( width: Int, height: Int, pixelFormatType: OSType, pixelBufferAttributes: [String: Any]? = nil // swiftlint:disable:this discouraged_optional_collection ) throws(CreationError) -> CVPixelBuffer { var out: CVPixelBuffer? let status = CVPixelBufferCreate( kCFAllocatorDefault, width, height, pixelFormatType, pixelBufferAttributes as CFDictionary?, &out ) guard status == kCVReturnSuccess else { throw .creationError(status: status) } guard let out else { throw .noBuffer } return out } enum CreationError: Error { case creationError(status: CVReturn) case noBuffer } func copy(to destination: CVPixelBuffer) throws { try withLockedPlanes(flags: [.readOnly]) { sourcePlanes in try destination.withLockedPlanes(flags: []) { destinationPlanes in guard sourcePlanes.count == destinationPlanes.count else { throw CopyError.planesMismatch } for (sourcePlane, destinationPlane) in zip(sourcePlanes, destinationPlanes) { try sourcePlane.copy(to: destinationPlane) } } } } func lockBaseAddress(flags: CVPixelBufferLockFlags = []) throws(LockError) { let status = CVPixelBufferLockBaseAddress(self, flags) guard status == kCVReturnSuccess else { throw .lockFailed(status: status) } } enum LockError: Error { case lockFailed(status: CVReturn) case noBaseAddress } func unlockBaseAddress(flags: CVPixelBufferLockFlags = []) { CVPixelBufferUnlockBaseAddress(self, flags) } enum CopyError: Error { case planesMismatch case heightMismatch } func withLockedBaseAddress( flags: CVPixelBufferLockFlags = [], _ body: (CVPixelBuffer) throws -> T ) throws -> T { try lockBaseAddress(flags: flags) defer { self.unlockBaseAddress(flags: flags) } return try body(self) } func withLockedPlanes( flags: CVPixelBufferLockFlags = [], _ body: ([LockedPlane]) throws -> T ) throws -> T { try withLockedBaseAddress(flags: flags) { buffer in let planeCount = buffer.planeCount if planeCount == 0 { guard let baseAddress = buffer.baseAddress else { throw LockError.noBaseAddress } return try body([ .init( base: baseAddress, bytesPerRow: buffer.bytesPerRow, height: buffer.height ) ]) } let planes = try (0.. LockedPlane? in guard let baseAddress = buffer.baseAddressOfPlane(planeIndex) else { throw LockError.noBaseAddress } return .init( base: baseAddress, bytesPerRow: buffer.bytesPerRowOfPlane(planeIndex), height: buffer.heightOfPlane(planeIndex) ) } guard planes.count == planeCount else { throw CopyError.planesMismatch } return try body(planes) } } func makeCompatibleBuffer() throws(CreationError) -> CVPixelBuffer { try Self.create( width: width, height: height, pixelFormatType: pixelFormatType, pixelBufferAttributes: creationAttributes ) } struct LockedPlane { let base: UnsafeMutableRawPointer let bytesPerRow: Int let height: Int func copy(to destination: Self) throws(CopyError) { guard height == destination.height else { throw .heightMismatch } guard bytesPerRow != destination.bytesPerRow else { memcpy(destination.base, base, height * bytesPerRow) return } var destinationBase = destination.base var sourceBase = base let minBytesPerRow = min(bytesPerRow, destination.bytesPerRow) for _ in 0.. CGImageSource { guard let imageSource = CGImageSourceCreateWithData( data as CFData, options as CFDictionary? ) else { throw .failedToCreateImageSource } return imageSource } var count: Int { CGImageSourceGetCount(self) } func createImage( atIndex index: Int, options: [String: Any]? = nil // swiftlint:disable:this discouraged_optional_collection ) throws(CreateImageError) -> CGImage { guard let image = CGImageSourceCreateImageAtIndex( self, index, options as CFDictionary? ) else { throw .failedToCreateImage(status: CGImageSourceGetStatusAtIndex(self, index)) } return image } } extension CGImage { func convertToData( withNewType type: String, destinationOptions: [String: Any]? = nil, // swiftlint:disable:this discouraged_optional_collection addOptions: [String: Any]? = nil // swiftlint:disable:this discouraged_optional_collection ) throws -> Data { let mutableData = NSMutableData() let destination = try CGImageDestination.from( withData: mutableData, type: type, count: 1, options: destinationOptions ) destination.addImage(self, properties: addOptions) try destination.finalize() return mutableData as Data } } extension CGImageDestination { static func from( withData: NSMutableData, type: String, count: Int = 1, options: [String: Any]? = nil // swiftlint:disable:this discouraged_optional_collection ) throws(CreateError) -> CGImageDestination { guard let imageDestination = CGImageDestinationCreateWithData( withData, type as CFString, count, options as CFDictionary? ) else { throw .failedToCreate } return imageDestination } enum CreateError: Error { case failedToCreate } func addImage( _ image: CGImage, properties: [String: Any]? = nil // swiftlint:disable:this discouraged_optional_collection ) { CGImageDestinationAddImage( self, image, properties as CFDictionary? ) } enum FinalizeError: Error { case failedToFinalize } func finalize() throws(FinalizeError) { guard CGImageDestinationFinalize(self) else { throw .failedToFinalize } } } extension Data { func readLittleEndianUInt24(_ start: Int) -> UInt32 { UInt32(self[start]) | UInt32(self[start + 1]) << 8 | UInt32(self[start + 2]) << 16 } } extension MTLCommandBuffer { typealias RenderError = MTLCommandBufferRenderError /** Submits the commands to the GPU and awaits completion. */ func commit() async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in addCompletedHandler { [weak self] _ in guard let self else { continuation.resume(throwing: RenderError.functionOutlivedTheCommandBuffer) return } guard status == .completed else { continuation.resume(throwing: RenderError.failedToRender(status: status)) return } continuation.resume() } commit() } } /** Creates a render command encoder, runs your operation, then ends encoding with `endEncoding`. */ func withRenderCommandEncoder( renderPassDescriptor: MTLRenderPassDescriptor, operation: (MTLRenderCommandEncoder) throws -> Void ) throws { guard let renderEncoder = makeRenderCommandEncoder(descriptor: renderPassDescriptor) else { throw RenderError.failedToMakeRenderCommandEncoder } defer { renderEncoder.endEncoding() } try operation(renderEncoder) } } enum MTLCommandBufferRenderError: Error { case failedToRender(status: MTLCommandBufferStatus) case functionOutlivedTheCommandBuffer case failedToMakeRenderCommandEncoder } extension MTLCommandQueue { typealias RenderError = MTLCommandQueueRenderError /** Creates a command buffer, runs your operation, commits (sends commands to the GPU), and awaits completion */ func withCommandBuffer( isolated actor: isolated any Actor, operation: (MTLCommandBuffer) throws -> Void ) async throws { guard let commandBuffer = makeCommandBuffer() else { throw MTLCommandQueueRenderError.failedToCreateCommandBuffer } try operation(commandBuffer) try await commandBuffer.commit() } } enum MTLCommandQueueRenderError: Error { case failedToCreateCommandBuffer } extension CGPoint { var simdFloat2: SIMD2 { .init(x: Float(x), y: Float(y)) } func nearestPointInsideRectBounds(_ rect: CGRect) -> Self { .init( x: x.clamped(from: rect.minX, to: rect.maxX), y: y.clamped(from: rect.minY, to: rect.maxY) ) } static func / (lhs: CGPoint, rhs: CGSize) -> CGPoint { .init(x: lhs.x / rhs.width, y: lhs.y / rhs.height) } static prefix func - (lhs: CGPoint) -> CGPoint { .init(x: -lhs.x, y: -lhs.y) } } extension CGSize { var simdFloat2: SIMD2 { .init(x: Float(width), y: Float(height)) } func clamped(within rect: CGRect) -> CGPoint { .init( x: width.clamped(from: rect.minX, to: rect.maxX), y: height.clamped(from: rect.minY, to: rect.maxY) ) } } /** We need to keep a strong reference to the `CVMetalTexture` until the GPU command completes. This struct ensures that the `CVMetalTexture` is not garbage collected as long as the `MTLTexture` is around. [See](https://developer.apple.com/documentation/corevideo/cvmetaltexturecachecreatetexturefromimage(_:_:_:_:_:_:_:_:_:)) */ struct CVMetalTextureReference { private let coreVideoTextureReference: CVMetalTexture let texture: MTLTexture fileprivate init( coreVideoTextureReference: CVMetalTexture, texture: MTLTexture ) { self.coreVideoTextureReference = coreVideoTextureReference self.texture = texture } } extension CVMetalTextureCache { enum Error: LocalizedError { case invalidArgument case allocationFailed case unsupported case invalidPixelFormat case invalidPixelBufferAttributes case invalidSize case pixelBufferNotMetalCompatible case failedToCreateTexture case unknown(CVReturn) init(cvReturn: CVReturn) { self = switch cvReturn { case kCVReturnInvalidArgument: .invalidArgument case kCVReturnAllocationFailed: .allocationFailed case kCVReturnUnsupported: .unsupported case kCVReturnInvalidPixelFormat: .invalidPixelFormat case kCVReturnInvalidPixelBufferAttributes: .invalidPixelBufferAttributes case kCVReturnInvalidSize: .invalidSize case kCVReturnPixelBufferNotMetalCompatible: .pixelBufferNotMetalCompatible default: .unknown(cvReturn) } } var errorDescription: String? { switch self { case .invalidArgument: "Invalid argument provided to CVMetalTextureCache" case .allocationFailed: "Memory allocation failed" case .unsupported: "Operation not supported" case .invalidPixelFormat: "Invalid pixel format" case .invalidPixelBufferAttributes: "Invalid pixel buffer attributes" case .invalidSize: "Invalid size" case .pixelBufferNotMetalCompatible: "Pixel buffer is not Metal compatible" case .failedToCreateTexture: "Failed to create Metal texture" case .unknown(let cvReturn): "Unknown CVReturn error: \(cvReturn)" } } } func createTexture( from image: CVPixelBuffer, pixelFormat: MTLPixelFormat, textureAttributes: [String: Any]? = nil // swiftlint:disable:this discouraged_optional_collection ) throws(Error) -> CVMetalTextureReference { var coreVideoTextureReference: CVMetalTexture? let result = CVMetalTextureCacheCreateTextureFromImage( nil, self, image, textureAttributes as CFDictionary?, pixelFormat, image.width, image.height, 0, &coreVideoTextureReference ) guard result == kCVReturnSuccess else { throw .init(cvReturn: result) } guard let coreVideoTextureReference, let texture = CVMetalTextureGetTexture(coreVideoTextureReference) else { throw .failedToCreateTexture } return .init( coreVideoTextureReference: coreVideoTextureReference, texture: texture ) } } /** Support for [Adaptable Scalable Texture Compression (ASTC)](https://www.khronos.org/opengl/wiki/ASTC_Texture_Compression) images. ASTC files have a [16-byte header](https://github.com/ARM-software/astc-encoder/blob/main/Docs/FileFormat.md). We parse it to get render information, like the size of the blocks and the image size. */ struct ASTCImage { enum CreateError: Error { case invalidDataSize case notASTCData } enum WriteError: Error { case failedToGetASTCBaseAddress } private static let headerSize = 16 private static let astcBlockSize = 16 private let data: Data private let blockSize: (UInt8, UInt8, UInt8) private let imageSize: (Int, Int, Int) init(data: Data) throws(CreateError) { self.data = data guard data.count >= Self.headerSize else { throw .invalidDataSize } // Check the magic number guard data[0] == 0x13, data[1] == 0xAB, data[2] == 0xA1, data[3] == 0x5C else { throw .notASTCData } self.blockSize = (data[4], data[5], data[6]) self.imageSize = ( Int(data.readLittleEndianUInt24(7)), Int(data.readLittleEndianUInt24(10)), Int(data.readLittleEndianUInt24(13)) ) } var width: Int { imageSize.0 } var height: Int { imageSize.1 } /** A Metal descriptor that describes this image. */ func descriptor() throws(MTLPixelFormat.ASTCPixelFormatError) -> MTLTextureDescriptor { MTLTextureDescriptor.texture2DDescriptor( pixelFormat: try .astcLowDynamicRange(fromBlockSize: blockSize), width: width, height: height, mipmapped: false ) } func write(to texture: MTLTexture) throws { try data.withUnsafeBytes { bytes in guard let baseAddress = bytes.baseAddress else { throw WriteError.failedToGetASTCBaseAddress } let imageDataStart = baseAddress.advanced(by: Self.headerSize) texture.replace( region: MTLRegionMake2D(0, 0, width, height), mipmapLevel: 0, withBytes: imageDataStart, bytesPerRow: bytesPerRow ) } } private var bytesPerRow: Int { blocksPerRow * Self.astcBlockSize } private var blocksPerRow: Int { Int(ceil(Double(width) / Double(blockSize.0))) } } extension MTLPixelFormat { enum ASTCPixelFormatError: Error { case notImplemented } /** [ASTC](https://registry.khronos.org/OpenGL/extensions/OES/OES_texture_compression_astc.txt) low dynamic range at a given block size: "...the number of bits per pixel that ASTC takes up is determined by the block size used. So the 4x4 version of ASTC, the smallest block size, takes up 8 bits per pixel, while the 12x12 version takes up only 0.89bpp." [*](https://www.khronos.org/opengl/wiki/ASTC_Texture_Compression) - Parameters: - blockSize: Block size in (x, y ,z) - Returns: the appropriate pixel format given an astc block size. */ static func astcLowDynamicRange( fromBlockSize blockSize: (UInt8, UInt8, UInt8) ) throws(ASTCPixelFormatError) -> Self { let (width, height, depth) = blockSize guard depth == 1 else { throw .notImplemented } if width == 4, height == 4 { return .astc_4x4_ldr } if width == 8, height == 8 { return .astc_8x8_ldr } throw .notImplemented } } /** A task with a progress AsyncStream. */ struct ProgressableTask: Sendable { let progress: AsyncStream let task: Task init( operation: @escaping (AsyncStream.Continuation) async throws -> Result ) { let (progressStream, progressContinuation) = AsyncStream.makeStream() self.progress = progressStream self.task = Task { do { let out = try await operation(progressContinuation) progressContinuation.finish() return out } catch { progressContinuation.finish() throw error } } } func cancel() { task.cancel() } var value: Result { get async throws { try await task.value } } } extension ProgressableTask where Progress == Double { func monitorProgressWithCancellation( progressWeight: Double = 1, progressOffset: Double = 0, progressContinuation: AsyncStream.Continuation, ) async throws -> Result { await withTaskCancellationHandler { for await currentProgress in progress { progressContinuation.yield(progressOffset + currentProgress * progressWeight) } } onCancel: { cancel() } try Task.checkCancellation() return try await value } /** Compose this task with another task, weighting the progress of the task by `weight`. */ func then( progressWeight: Double = 0.5, composeWith nextTask: @Sendable @escaping (Result) async throws -> ProgressableTask ) -> ProgressableTask { ProgressableTask { progressContinuation in let result1 = try await monitorProgressWithCancellation( progressWeight: progressWeight, progressContinuation: progressContinuation ) try Task.checkCancellation() let task2 = try await nextTask(result1) try Task.checkCancellation() return try await task2.monitorProgressWithCancellation( progressWeight: 1 - progressWeight, progressOffset: progressWeight, progressContinuation: progressContinuation ) } } } /** Protocol for preview equivalence comparison using the `~=` operator. This ignores transient properties that don't affect visual output. */ protocol PreviewComparable { static func ~= (lhs: Self, rhs: Self) -> Bool } extension CompositePreviewFragmentUniforms: Equatable { init() { self.init( videoOrigin: .one, videoSize: .one, firstColor: .zero, secondColor: .one, gridSize: 1 ) } init(isDarkMode: Bool, videoBounds: CGRect) { self.init( videoOrigin: videoBounds.origin.nearestPointInsideRectBounds(.init(origin: .zero, width: .infinity, height: .infinity)).simdFloat2, videoSize: videoBounds.size.clamped(within: .init(origin: .zero, width: .infinity, height: .infinity)).simdFloat2, firstColor: (isDarkMode ? CheckerboardViewConstants.firstColorDark : CheckerboardViewConstants.firstColorLight).simd4, secondColor: (isDarkMode ? CheckerboardViewConstants.secondColorDark : CheckerboardViewConstants.secondColorLight).simd4, gridSize: Int32(CheckerboardViewConstants.gridSize).clamped(from: 1, to: .max) ) } public static func == (lhs: CompositePreviewFragmentUniforms, rhs: CompositePreviewFragmentUniforms) -> Bool { lhs.videoOrigin == rhs.videoOrigin && lhs.videoSize == rhs.videoSize && lhs.firstColor == rhs.firstColor && lhs.secondColor == rhs.secondColor && lhs.gridSize == rhs.gridSize } } extension CGAffineTransform { init(scaledBy size: CGSize) { self = Self(scaleX: size.width, y: size.height) } func translated(by point: CGPoint) -> Self { translatedBy(x: point.x, y: point.y) } } extension ClosedRange { public static func - (lhs: ClosedRange, rhs: Double) -> ClosedRange { (lhs.lowerBound - rhs) ... (lhs.upperBound - rhs) } public static func + (lhs: ClosedRange, rhs: Double) -> ClosedRange { (lhs.lowerBound + rhs) ... (lhs.upperBound + rhs) } public static func * (lhs: ClosedRange, rhs: Double) -> ClosedRange { (lhs.lowerBound * rhs) ... (lhs.upperBound * rhs) } } extension ToolbarContent { nonisolated func ss_sharedBackgroundVisibility_hidden() -> some ToolbarContent { if #available(macOS 26, iOS 26, tvOS 26, watchOS 26, visionOS 26, *) { return sharedBackgroundVisibility(.hidden) } return self } } ================================================ FILE: Gifski/VideoValidator.swift ================================================ import AVFoundation enum VideoValidator { static func validate(_ inputUrl: URL) async throws -> (asset: AVAsset, metadata: AVAsset.VideoMetadata) { // Crashlytics.record( // key: "Does input file exist", // value: inputUrl.exists // ) // Crashlytics.record( // key: "Is input file reachable", // value: try? inputUrl.checkResourceIsReachable() // ) // Crashlytics.record( // key: "Is input file readable", // value: inputUrl.isReadable // ) // Crashlytics.record( // key: "File size", // value: inputUrl.fileSize // ) guard inputUrl.fileSize > 0 else { throw NSError.appError( "The selected file is empty.", recoverySuggestion: "Try selecting a different file." ) } // This is very unlikely to happen. We have a lot of file type filters in place, so the only way this can happen is if the user right-clicks a non-video in Finder, chooses "Open With", then "Other…", chooses "All Applications", and then selects Gifski. Yet, some people are doing this… guard inputUrl.contentType?.conforms(to: .movie) == true else { throw NSError.appError( "The selected file could not be converted because it's not a video.", recoverySuggestion: "Try again with a video file, usually with the file extension “mp4”, “m4v”, or “mov”." ) } let asset = AVURLAsset( url: inputUrl, options: [ AVURLAssetPreferPreciseDurationAndTimingKey: true ] ) let hasProtectedContent = try await asset.load(.hasProtectedContent) // Crashlytics.record(key: "AVAsset debug info", value: asset.debugInfo) guard try await asset.videoCodec != .appleAnimation else { throw NSError.appError( "The QuickTime Animation format is not supported.", recoverySuggestion: "Re-export or convert the video to ProRes 4444 XQ instead. It's more efficient, more widely supported, and like QuickTime Animation, it also supports alpha channel. To convert an existing video, open it in QuickTime Player, which will automatically convert it, and then save it." ) } // TODO: Parallelize these checks. if try await asset.hasAudio, try await !asset.hasVideo { throw NSError.appError( "Audio files are not supported.", recoverySuggestion: "Gifski converts video files but the provided file is audio-only. Please provide a file that contains video." ) } guard let firstVideoTrack = try await asset.firstVideoTrack else { throw NSError.appError( "Could not read any video from the video file.", recoverySuggestion: "Either the video format is unsupported by macOS or the file is corrupt." ) } guard !hasProtectedContent else { throw NSError.appError("The video is DRM-protected and cannot be converted.") } let cannotReadVideoExplanation = "This could happen if the video is corrupt or the codec profile level is not supported. macOS unfortunately doesn't provide Gifski a reason for why the video could not be decoded. Try re-exporting using a different configuration or try converting the video to HEVC (MP4) with the free HandBrake app." let codecTitle = try await firstVideoTrack.codecTitle // We already specify the UTIs we support, so this can only happen on invalid video files or unsupported codecs. guard try await asset.isVideoDecodable else { if let codec = try await firstVideoTrack.codec, codec.isSupported { throw NSError.appError( "The video could not be decoded even though its codec “\(codec)” is supported.", recoverySuggestion: cannotReadVideoExplanation ) } guard let codecTitle else { throw NSError.appError( "The video file is not supported.", recoverySuggestion: "I'm trying to figure out why this happens. It would be amazing if you could email the below details to sindresorhus@gmail.com\n\n\(try await asset.debugInfo)" ) } guard codecTitle != "hev1" else { throw NSError.appError( "This variant of the HEVC video codec is not supported by macOS.", recoverySuggestion: "The video uses the “hev1” variant of HEVC while macOS only supports “hvc1”. Try re-exporting the video using a different configuration or use the free HandBrake app to convert the video to the supported HEVC variant." ) } throw NSError.appError( "The video codec “\(codecTitle)” is not supported.", recoverySuggestion: "Re-export or convert the video to a supported format. For the best possible quality, export to ProRes 4444 XQ (supports alpha). Alternatively, use the free HandBrake app to convert the video to HEVC (MP4)." ) } // AVFoundation reports some videos as `.isReadable == true` even though they are not. We detect this through missing codec info. See "Fixture 211". (macOS 13.1) guard codecTitle != nil else { throw NSError.appError( "The video file is not supported.", recoverySuggestion: cannotReadVideoExplanation ) } guard let oldVideoMetadata = try await asset.videoMetadata else { throw NSError.appError( "The video metadata is not readable.", recoverySuggestion: "Please open an issue on https://github.com/sindresorhus/Gifski or email sindresorhus@gmail.com. ZIP the video and attach it.\n\nInclude this info:\n\n\(try await asset.debugInfo)" ) } guard let dimensions = try await asset.dimensions, dimensions.width >= 4, dimensions.height >= 4 else { throw NSError.appError( "The video dimensions must be at least 4×4.", recoverySuggestion: "The dimensions of the video are \((try? await asset.dimensions?.formatted) ?? "0×0")." ) } // We extract the video track into a new asset to remove the audio and to prevent problems if the video track duration is shorter than the total asset duration. If we don't do this, the video will show as black in the trim view at the duration where there's no video track, and it will confuse users. Also, if the user trims the video to just the black no video track part, the conversion would continue, but there's nothing to convert, so it would be stuck at 0%. guard let newAsset = try await firstVideoTrack.extractToNewAsset(), var newVideoMetadata = try await newAsset.videoMetadata else { throw NSError.appError( "Could not read the video.", recoverySuggestion: "This should not happen. Email sindresorhus@gmail.com and include this info:\n\n\(try await asset.debugInfo)" ) } newVideoMetadata.hasAudio = oldVideoMetadata.hasAudio // Trim asset do { let trimmedAsset = try await newAsset.trimmingBlankFramesFromFirstVideoTrack() return (trimmedAsset, newVideoMetadata) } catch AVAssetTrack.VideoTrimmingError.codecNotSupported { // Allow user to continue return (newAsset, newVideoMetadata) } catch { throw NSError.appError( "Could not trim empty leading frames from video.", recoverySuggestion: "\(error.localizedDescription)\n\nThis should not happen. Email sindresorhus@gmail.com and include this info:\n\n\(try await newAsset.debugInfo)" ) } } } ================================================ FILE: Gifski.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 77; objects = { /* Begin PBXBuildFile section */ 0CBD7F2E2E0F044C00E2C5E4 /* ExportModifiedVideo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBD7F2D2E0F044C00E2C5E4 /* ExportModifiedVideo.swift */; }; 0E7925202329BDBE00058B94 /* ShareController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E79251F2329BDBE00058B94 /* ShareController.swift */; }; 0E7925282329BDBE00058B94 /* Share Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0E79251B2329BDBE00058B94 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 8588EB0D22A424B800030A59 /* ResizableDimensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8588EB0C22A424B800030A59 /* ResizableDimensions.swift */; }; 85A5C44822CA41B500CAA94D /* VideoValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85A5C44722CA41B500CAA94D /* VideoValidator.swift */; }; C2040B8920435871004EE259 /* GifskiWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2040B8820435871004EE259 /* GifskiWrapper.swift */; }; E30C8EEF29387E7A002E053F /* Gifski.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30C8EEE29387E7A002E053F /* Gifski.swift */; }; E31054142DCBEBA1008B7E7F /* libgifski_static.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E310540F2DCBEB4A008B7E7F /* libgifski_static.a */; }; E31A4F3124AD36870097B1A5 /* InternetAccessPolicy.json in Resources */ = {isa = PBXBuildFile; fileRef = E31A4F2C24AD36870097B1A5 /* InternetAccessPolicy.json */; }; E3339E932395766800303839 /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = E3339E922395766800303839 /* Defaults */; }; E3339E9D2395789500303839 /* DockProgress in Frameworks */ = {isa = PBXBuildFile; productRef = E3339E9C2395789500303839 /* DockProgress */; }; E33552EF2ACAC3190023AAE9 /* MainScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E33552EE2ACAC3190023AAE9 /* MainScreen.swift */; }; E33552F12ACAC3280023AAE9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E33552F02ACAC3280023AAE9 /* AppState.swift */; }; E33552F32ACAC5D80023AAE9 /* StartScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E33552F22ACAC5D80023AAE9 /* StartScreen.swift */; }; E339F011203820ED003B78FB /* GIFGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E339F010203820ED003B78FB /* GIFGenerator.swift */; }; E36BD7A52B9E2C2400B8D86C /* ExtendedAttributes in Frameworks */ = {isa = PBXBuildFile; productRef = E36BD7A42B9E2C2400B8D86C /* ExtendedAttributes */; }; E37F68E02ACAD9D1007F1A7F /* CompletedScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E37F68DF2ACAD9D1007F1A7F /* CompletedScreen.swift */; }; E37F68E22ACAD9F1007F1A7F /* ConversionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E37F68E12ACAD9F1007F1A7F /* ConversionScreen.swift */; }; E37F68E42ACADA40007F1A7F /* EditScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E37F68E32ACADA40007F1A7F /* EditScreen.swift */; }; E3908B7426754568000723A7 /* EstimatedFileSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3908B7326754568000723A7 /* EstimatedFileSize.swift */; }; E3961F802AC9F2A700708EB7 /* Intents.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3961F7F2AC9F2A700708EB7 /* Intents.swift */; }; E3A6BD112245345C00F62256 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3A6BD102245345C00F62256 /* Constants.swift */; }; E3AE62871E5CD2F300035A2F /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AE62861E5CD2F300035A2F /* App.swift */; }; E3AE62891E5CD2F300035A2F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E3AE62881E5CD2F300035A2F /* Assets.xcassets */; }; E3AE7E9B2E8AE01F00D22FF8 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = E3AE7E9A2E8AE01F00D22FF8 /* Sentry */; }; E3AE7E9D2E8AE0A100D22FF8 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = E3AE7E9C2E8AE0A100D22FF8 /* AppIcon.icon */; }; E3C3DB4F203F154300CB8BB9 /* Credits.rtf in Resources */ = {isa = PBXBuildFile; fileRef = E3C3DB4E203F154300CB8BB9 /* Credits.rtf */; }; E3D08F6E1E5D7BFD00F465DF /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3D08F6D1E5D7BFD00F465DF /* Utilities.swift */; }; E3E9A7D6256EBE0800E2B9FD /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3E9A7D5256EBE0800E2B9FD /* Utilities.swift */; }; E3FC365C2377FA0000CF7C59 /* Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3FC365B2377FA0000CF7C59 /* Shared.swift */; }; E3FC365E2377FA9F00CF7C59 /* Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3FC365B2377FA0000CF7C59 /* Shared.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ 0E7925262329BDBE00058B94 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = E3AE627B1E5CD2F300035A2F /* Project object */; proxyType = 1; remoteGlobalIDString = 0E79251A2329BDBE00058B94; remoteInfo = ShareExtension; }; 5FF0DFFC278BA5E200A80F09 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5F6ABD7D278BA5A20040DDF0 /* gifski.xcodeproj */; proxyType = 1; remoteGlobalIDString = CA007E48158959EA34BF617B; remoteInfo = "gifski-staticlib"; }; E310540E2DCBEB4A008B7E7F /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5F6ABD7D278BA5A20040DDF0 /* gifski.xcodeproj */; proxyType = 2; remoteGlobalIDString = CA007E4815895A689885C260; remoteInfo = "gifski.a (static library)"; }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ 0E7925292329BDBE00058B94 /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 13; files = ( 0E7925282329BDBE00058B94 /* Share Extension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ 0CBD7F2D2E0F044C00E2C5E4 /* ExportModifiedVideo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportModifiedVideo.swift; sourceTree = ""; }; 0E79251B2329BDBE00058B94 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 0E79251F2329BDBE00058B94 /* ShareController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = ShareController.swift; sourceTree = ""; usesTabs = 1; }; 0E7925242329BDBE00058B94 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 0E7925252329BDBE00058B94 /* Share_Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Share_Extension.entitlements; sourceTree = ""; }; 5F6ABD7D278BA5A20040DDF0 /* gifski.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = gifski.xcodeproj; path = "gifski-api/gifski.xcodeproj"; sourceTree = SOURCE_ROOT; }; 8588EB0C22A424B800030A59 /* ResizableDimensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = ResizableDimensions.swift; sourceTree = ""; usesTabs = 1; }; 85A5C44722CA41B500CAA94D /* VideoValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = VideoValidator.swift; sourceTree = ""; usesTabs = 1; }; C2040B8820435871004EE259 /* GifskiWrapper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = GifskiWrapper.swift; sourceTree = ""; usesTabs = 1; }; E304EB8725F3A4D2003BCE1F /* gifski.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = gifski.h; path = "gifski-api/gifski.h"; sourceTree = SOURCE_ROOT; }; E30C8EEE29387E7A002E053F /* Gifski.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Gifski.swift; sourceTree = ""; }; E31A4F2C24AD36870097B1A5 /* InternetAccessPolicy.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = InternetAccessPolicy.json; sourceTree = ""; }; E33552EE2ACAC3190023AAE9 /* MainScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainScreen.swift; sourceTree = ""; }; E33552F02ACAC3280023AAE9 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; E33552F22ACAC5D80023AAE9 /* StartScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartScreen.swift; sourceTree = ""; }; E339F010203820ED003B78FB /* GIFGenerator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = GIFGenerator.swift; sourceTree = ""; usesTabs = 1; }; E37F68DF2ACAD9D1007F1A7F /* CompletedScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletedScreen.swift; sourceTree = ""; }; E37F68E12ACAD9F1007F1A7F /* ConversionScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversionScreen.swift; sourceTree = ""; }; E37F68E32ACADA40007F1A7F /* EditScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditScreen.swift; sourceTree = ""; }; E3805F542466E68900489E6C /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; E3908B7326754568000723A7 /* EstimatedFileSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EstimatedFileSize.swift; sourceTree = ""; }; E3961F7F2AC9F2A700708EB7 /* Intents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Intents.swift; sourceTree = ""; }; E3A6BD102245345C00F62256 /* Constants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = Constants.swift; sourceTree = ""; usesTabs = 1; }; E3ACE84E2F0EC74C004F95CC /* maintaining.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = maintaining.md; sourceTree = ""; }; E3AE62831E5CD2F300035A2F /* Gifski.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Gifski.app; sourceTree = BUILT_PRODUCTS_DIR; }; E3AE62861E5CD2F300035A2F /* App.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = App.swift; sourceTree = ""; usesTabs = 1; }; E3AE62881E5CD2F300035A2F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; E3AE628D1E5CD2F300035A2F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; E3AE7E9C2E8AE0A100D22FF8 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; }; E3BF14CC1E5CD5A30049FD4B /* Gifski.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Gifski.entitlements; sourceTree = ""; }; E3C3DB4E203F154300CB8BB9 /* Credits.rtf */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; path = Credits.rtf; sourceTree = ""; }; E3D08F6D1E5D7BFD00F465DF /* Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = Utilities.swift; sourceTree = ""; usesTabs = 1; }; E3E9A7D5256EBE0800E2B9FD /* Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utilities.swift; sourceTree = ""; }; E3FC365B2377FA0000CF7C59 /* Shared.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; name = Shared.swift; path = Gifski/Shared.swift; sourceTree = SOURCE_ROOT; usesTabs = 1; }; E3FD6190201BCBC30087160A /* Gifski-Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; lineEnding = 0; path = "Gifski-Bridging-Header.h"; sourceTree = ""; usesTabs = 1; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ E31053F12DCBD2EA008B7E7F /* Crop */ = { isa = PBXFileSystemSynchronizedRootGroup; path = Crop; sourceTree = ""; }; E31053F82DCBD325008B7E7F /* Components */ = { isa = PBXFileSystemSynchronizedRootGroup; path = Components; sourceTree = ""; }; E39128F52E0DF96C0010E6CF /* Preview */ = { isa = PBXFileSystemSynchronizedRootGroup; path = Preview; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ 0E7925182329BDBE00058B94 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; E3AE62801E5CD2F300035A2F /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( E3AE7E9B2E8AE01F00D22FF8 /* Sentry in Frameworks */, E31054142DCBEBA1008B7E7F /* libgifski_static.a in Frameworks */, E36BD7A52B9E2C2400B8D86C /* ExtendedAttributes in Frameworks */, E3339E932395766800303839 /* Defaults in Frameworks */, E3339E9D2395789500303839 /* DockProgress in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 0E79251C2329BDBE00058B94 /* Share Extension */ = { isa = PBXGroup; children = ( 0E79251F2329BDBE00058B94 /* ShareController.swift */, E3E9A7D5256EBE0800E2B9FD /* Utilities.swift */, 0E7925242329BDBE00058B94 /* Info.plist */, 0E7925252329BDBE00058B94 /* Share_Extension.entitlements */, ); path = "Share Extension"; sourceTree = ""; }; 5F6ABD7E278BA5A20040DDF0 /* Products */ = { isa = PBXGroup; children = ( E310540F2DCBEB4A008B7E7F /* libgifski_static.a */, ); name = Products; sourceTree = ""; }; E356A15D21028942000148AD /* Other */ = { isa = PBXGroup; children = ( E3AE7E9C2E8AE0A100D22FF8 /* AppIcon.icon */, E3FD6190201BCBC30087160A /* Gifski-Bridging-Header.h */, E304EB8725F3A4D2003BCE1F /* gifski.h */, E3C3DB4E203F154300CB8BB9 /* Credits.rtf */, E3AE628D1E5CD2F300035A2F /* Info.plist */, E3BF14CC1E5CD5A30049FD4B /* Gifski.entitlements */, E31A4F2C24AD36870097B1A5 /* InternetAccessPolicy.json */, 5F6ABD7D278BA5A20040DDF0 /* gifski.xcodeproj */, ); name = Other; sourceTree = ""; }; E36BD79F2B9E243800B8D86C /* Frameworks */ = { isa = PBXGroup; children = ( ); name = Frameworks; sourceTree = ""; }; E3AE627A1E5CD2F300035A2F = { isa = PBXGroup; children = ( E3ACE84E2F0EC74C004F95CC /* maintaining.md */, E3805F542466E68900489E6C /* Config.xcconfig */, E3AE62851E5CD2F300035A2F /* Gifski */, 0E79251C2329BDBE00058B94 /* Share Extension */, E3AE62841E5CD2F300035A2F /* Products */, E36BD79F2B9E243800B8D86C /* Frameworks */, ); sourceTree = ""; usesTabs = 1; }; E3AE62841E5CD2F300035A2F /* Products */ = { isa = PBXGroup; children = ( E3AE62831E5CD2F300035A2F /* Gifski.app */, 0E79251B2329BDBE00058B94 /* Share Extension.appex */, ); name = Products; sourceTree = ""; }; E3AE62851E5CD2F300035A2F /* Gifski */ = { isa = PBXGroup; children = ( E3AE62861E5CD2F300035A2F /* App.swift */, E3A6BD102245345C00F62256 /* Constants.swift */, E33552F02ACAC3280023AAE9 /* AppState.swift */, E3FC365B2377FA0000CF7C59 /* Shared.swift */, E33552EE2ACAC3190023AAE9 /* MainScreen.swift */, E33552F22ACAC5D80023AAE9 /* StartScreen.swift */, E37F68E32ACADA40007F1A7F /* EditScreen.swift */, 0CBD7F2D2E0F044C00E2C5E4 /* ExportModifiedVideo.swift */, E37F68E12ACAD9F1007F1A7F /* ConversionScreen.swift */, E37F68DF2ACAD9D1007F1A7F /* CompletedScreen.swift */, E3908B7326754568000723A7 /* EstimatedFileSize.swift */, E339F010203820ED003B78FB /* GIFGenerator.swift */, E30C8EEE29387E7A002E053F /* Gifski.swift */, C2040B8820435871004EE259 /* GifskiWrapper.swift */, 8588EB0C22A424B800030A59 /* ResizableDimensions.swift */, 85A5C44722CA41B500CAA94D /* VideoValidator.swift */, E31053F12DCBD2EA008B7E7F /* Crop */, E31053F82DCBD325008B7E7F /* Components */, E3961F7F2AC9F2A700708EB7 /* Intents.swift */, E39128F52E0DF96C0010E6CF /* Preview */, E3D08F6D1E5D7BFD00F465DF /* Utilities.swift */, E3AE62881E5CD2F300035A2F /* Assets.xcassets */, E356A15D21028942000148AD /* Other */, ); path = Gifski; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 0E79251A2329BDBE00058B94 /* Share Extension */ = { isa = PBXNativeTarget; buildConfigurationList = 0E7925302329BDBE00058B94 /* Build configuration list for PBXNativeTarget "Share Extension" */; buildPhases = ( 0E7925172329BDBE00058B94 /* Sources */, 0E7925182329BDBE00058B94 /* Frameworks */, 0E7925192329BDBE00058B94 /* Resources */, ); buildRules = ( ); dependencies = ( ); name = "Share Extension"; productName = ShareExtension; productReference = 0E79251B2329BDBE00058B94 /* Share Extension.appex */; productType = "com.apple.product-type.app-extension"; }; E3AE62821E5CD2F300035A2F /* Gifski */ = { isa = PBXNativeTarget; buildConfigurationList = E3AE62901E5CD2F300035A2F /* Build configuration list for PBXNativeTarget "Gifski" */; buildPhases = ( E36D89991EFF79F7005042A8 /* SwiftLint */, E3AE627F1E5CD2F300035A2F /* Sources */, E3AE62801E5CD2F300035A2F /* Frameworks */, E3AE62811E5CD2F300035A2F /* Resources */, 0E7925292329BDBE00058B94 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( 5FF0DFFD278BA5E200A80F09 /* PBXTargetDependency */, 0E7925272329BDBE00058B94 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( E31053F12DCBD2EA008B7E7F /* Crop */, E31053F82DCBD325008B7E7F /* Components */, E39128F52E0DF96C0010E6CF /* Preview */, ); name = Gifski; packageProductDependencies = ( E3339E922395766800303839 /* Defaults */, E3339E9C2395789500303839 /* DockProgress */, E36BD7A42B9E2C2400B8D86C /* ExtendedAttributes */, E3AE7E9A2E8AE01F00D22FF8 /* Sentry */, ); productName = Gifski; productReference = E3AE62831E5CD2F300035A2F /* Gifski.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ E3AE627B1E5CD2F300035A2F /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1100; LastUpgradeCheck = 2600; ORGANIZATIONNAME = "Sindre Sorhus"; TargetAttributes = { 0E79251A2329BDBE00058B94 = { CreatedOnToolsVersion = 11.0; }; E3AE62821E5CD2F300035A2F = { CreatedOnToolsVersion = 8.2.1; LastSwiftMigration = 1020; SystemCapabilities = { com.apple.HardenedRuntime = { enabled = 1; }; com.apple.Sandbox = { enabled = 1; }; }; }; }; }; buildConfigurationList = E3AE627E1E5CD2F300035A2F /* Build configuration list for PBXProject "Gifski" */; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = E3AE627A1E5CD2F300035A2F; minimizedProjectReferenceProxies = 1; packageReferences = ( E3339E912395766800303839 /* XCRemoteSwiftPackageReference "Defaults" */, E3339E9B2395789500303839 /* XCRemoteSwiftPackageReference "DockProgress" */, E3998CF02ACD7148009F8117 /* XCRemoteSwiftPackageReference "sentry-cocoa" */, E36BD7A32B9E2C1600B8D86C /* XCRemoteSwiftPackageReference "ExtendedAttributes" */, ); preferredProjectObjectVersion = 77; productRefGroup = E3AE62841E5CD2F300035A2F /* Products */; projectDirPath = ""; projectReferences = ( { ProductGroup = 5F6ABD7E278BA5A20040DDF0 /* Products */; ProjectRef = 5F6ABD7D278BA5A20040DDF0 /* gifski.xcodeproj */; }, ); projectRoot = ""; targets = ( E3AE62821E5CD2F300035A2F /* Gifski */, 0E79251A2329BDBE00058B94 /* Share Extension */, ); }; /* End PBXProject section */ /* Begin PBXReferenceProxy section */ E310540F2DCBEB4A008B7E7F /* libgifski_static.a */ = { isa = PBXReferenceProxy; fileType = archive.ar; path = libgifski_static.a; remoteRef = E310540E2DCBEB4A008B7E7F /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXReferenceProxy section */ /* Begin PBXResourcesBuildPhase section */ 0E7925192329BDBE00058B94 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; E3AE62811E5CD2F300035A2F /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( E3AE62891E5CD2F300035A2F /* Assets.xcassets in Resources */, E3AE7E9D2E8AE0A100D22FF8 /* AppIcon.icon in Resources */, E31A4F3124AD36870097B1A5 /* InternetAccessPolicy.json in Resources */, E3C3DB4F203F154300CB8BB9 /* Credits.rtf in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ E36D89991EFF79F7005042A8 /* SwiftLint */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( ); name = SwiftLint; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "PATH=\"/opt/homebrew/bin/:${PATH}\"\nswiftlint\n"; showEnvVarsInLog = 0; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 0E7925172329BDBE00058B94 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( E3E9A7D6256EBE0800E2B9FD /* Utilities.swift in Sources */, E3FC365E2377FA9F00CF7C59 /* Shared.swift in Sources */, 0E7925202329BDBE00058B94 /* ShareController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; E3AE627F1E5CD2F300035A2F /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( E3D08F6E1E5D7BFD00F465DF /* Utilities.swift in Sources */, E339F011203820ED003B78FB /* GIFGenerator.swift in Sources */, E33552F12ACAC3280023AAE9 /* AppState.swift in Sources */, E3A6BD112245345C00F62256 /* Constants.swift in Sources */, C2040B8920435871004EE259 /* GifskiWrapper.swift in Sources */, 8588EB0D22A424B800030A59 /* ResizableDimensions.swift in Sources */, E3908B7426754568000723A7 /* EstimatedFileSize.swift in Sources */, E33552F32ACAC5D80023AAE9 /* StartScreen.swift in Sources */, E37F68E42ACADA40007F1A7F /* EditScreen.swift in Sources */, E3961F802AC9F2A700708EB7 /* Intents.swift in Sources */, E30C8EEF29387E7A002E053F /* Gifski.swift in Sources */, 0CBD7F2E2E0F044C00E2C5E4 /* ExportModifiedVideo.swift in Sources */, E3FC365C2377FA0000CF7C59 /* Shared.swift in Sources */, E37F68E02ACAD9D1007F1A7F /* CompletedScreen.swift in Sources */, E33552EF2ACAC3190023AAE9 /* MainScreen.swift in Sources */, E37F68E22ACAD9F1007F1A7F /* ConversionScreen.swift in Sources */, 85A5C44822CA41B500CAA94D /* VideoValidator.swift in Sources */, E3AE62871E5CD2F300035A2F /* App.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ 0E7925272329BDBE00058B94 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 0E79251A2329BDBE00058B94 /* Share Extension */; targetProxy = 0E7925262329BDBE00058B94 /* PBXContainerItemProxy */; }; 5FF0DFFD278BA5E200A80F09 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = "gifski-staticlib"; targetProxy = 5FF0DFFC278BA5E200A80F09 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ 0E79252A2329BDBE00058B94 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = "Share Extension/Share_Extension.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = YG56YK5RN5; ENABLE_HARDENED_RUNTIME = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Share Extension/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Gifski; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.sindresorhus.Gifski.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; }; name = Debug; }; 0E79252B2329BDBE00058B94 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = "Share Extension/Share_Extension.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = YG56YK5RN5; ENABLE_HARDENED_RUNTIME = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Share Extension/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Gifski; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.sindresorhus.Gifski.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; }; name = Release; }; E3AE628E1E5CD2F300035A2F /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = E3805F542466E68900489E6C /* Config.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = YG56YK5RN5; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 15.3; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; }; E3AE628F1E5CD2F300035A2F /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = E3805F542466E68900489E6C /* Config.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = YG56YK5RN5; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 15.3; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; }; name = Release; }; E3AE62911E5CD2F300035A2F /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Gifski/Gifski.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = YG56YK5RN5; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_USER_SELECTED_FILES = readwrite; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)", "$(PROJECT_DIR)/Frameworks", ); GENERATE_INFOPLIST_FILE = YES; HEADER_SEARCH_PATHS = ""; INFOPLIST_FILE = "$(SRCROOT)/Gifski/Info.plist"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); LIBRARY_SEARCH_PATHS = "$(inherited)"; MTL_LANGUAGE_REVISION = Metal20; PRODUCT_BUNDLE_IDENTIFIER = com.sindresorhus.Gifski; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "Gifski/Gifski-Bridging-Header.h"; SWIFT_VERSION = 5.0; }; name = Debug; }; E3AE62921E5CD2F300035A2F /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Gifski/Gifski.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = YG56YK5RN5; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_USER_SELECTED_FILES = readwrite; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)", "$(PROJECT_DIR)/Frameworks", ); GENERATE_INFOPLIST_FILE = YES; HEADER_SEARCH_PATHS = ""; INFOPLIST_FILE = "$(SRCROOT)/Gifski/Info.plist"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); LIBRARY_SEARCH_PATHS = "$(inherited)"; MTL_LANGUAGE_REVISION = Metal20; PRODUCT_BUNDLE_IDENTIFIER = com.sindresorhus.Gifski; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "Gifski/Gifski-Bridging-Header.h"; SWIFT_VERSION = 5.0; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 0E7925302329BDBE00058B94 /* Build configuration list for PBXNativeTarget "Share Extension" */ = { isa = XCConfigurationList; buildConfigurations = ( 0E79252A2329BDBE00058B94 /* Debug */, 0E79252B2329BDBE00058B94 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; E3AE627E1E5CD2F300035A2F /* Build configuration list for PBXProject "Gifski" */ = { isa = XCConfigurationList; buildConfigurations = ( E3AE628E1E5CD2F300035A2F /* Debug */, E3AE628F1E5CD2F300035A2F /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; E3AE62901E5CD2F300035A2F /* Build configuration list for PBXNativeTarget "Gifski" */ = { isa = XCConfigurationList; buildConfigurations = ( E3AE62911E5CD2F300035A2F /* Debug */, E3AE62921E5CD2F300035A2F /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ E3339E912395766800303839 /* XCRemoteSwiftPackageReference "Defaults" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/sindresorhus/Defaults"; requirement = { kind = upToNextMajorVersion; minimumVersion = 9.0.0; }; }; E3339E9B2395789500303839 /* XCRemoteSwiftPackageReference "DockProgress" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/sindresorhus/DockProgress"; requirement = { kind = upToNextMajorVersion; minimumVersion = 4.3.0; }; }; E36BD7A32B9E2C1600B8D86C /* XCRemoteSwiftPackageReference "ExtendedAttributes" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/sindresorhus/ExtendedAttributes"; requirement = { kind = upToNextMajorVersion; minimumVersion = 1.0.0; }; }; E3998CF02ACD7148009F8117 /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/getsentry/sentry-cocoa"; requirement = { kind = upToNextMajorVersion; minimumVersion = 8.21.0; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ E3339E922395766800303839 /* Defaults */ = { isa = XCSwiftPackageProductDependency; package = E3339E912395766800303839 /* XCRemoteSwiftPackageReference "Defaults" */; productName = Defaults; }; E3339E9C2395789500303839 /* DockProgress */ = { isa = XCSwiftPackageProductDependency; package = E3339E9B2395789500303839 /* XCRemoteSwiftPackageReference "DockProgress" */; productName = DockProgress; }; E36BD7A42B9E2C2400B8D86C /* ExtendedAttributes */ = { isa = XCSwiftPackageProductDependency; package = E36BD7A32B9E2C1600B8D86C /* XCRemoteSwiftPackageReference "ExtendedAttributes" */; productName = ExtendedAttributes; }; E3AE7E9A2E8AE01F00D22FF8 /* Sentry */ = { isa = XCSwiftPackageProductDependency; package = E3998CF02ACD7148009F8117 /* XCRemoteSwiftPackageReference "sentry-cocoa" */; productName = Sentry; }; /* End XCSwiftPackageProductDependency section */ }; rootObject = E3AE627B1E5CD2F300035A2F /* Project object */; } ================================================ FILE: Gifski.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: Gifski.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: Gifski.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved ================================================ { "originHash" : "0c90c366404e200efeaefbb832e9ea0e7faea9e1f96c7c74f6e39e86d8f25618", "pins" : [ { "identity" : "defaults", "kind" : "remoteSourceControl", "location" : "https://github.com/sindresorhus/Defaults", "state" : { "revision" : "9a1675508a69eea31ec12f7902c004a6449e6dd1", "version" : "9.0.5" } }, { "identity" : "dockprogress", "kind" : "remoteSourceControl", "location" : "https://github.com/sindresorhus/DockProgress", "state" : { "revision" : "d4f23b5a8f5ca0fac393eb7ba78c2fe3e32e52da", "version" : "4.3.1" } }, { "identity" : "extendedattributes", "kind" : "remoteSourceControl", "location" : "https://github.com/sindresorhus/ExtendedAttributes", "state" : { "revision" : "bf0cade5654fbdc0cfcb3ac34bcc644c156fb902", "version" : "1.1.0" } }, { "identity" : "sentry-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/getsentry/sentry-cocoa", "state" : { "revision" : "9e193ac0b71760603aa666bad7e9e303dd7031a8", "version" : "8.56.2" } }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { "revision" : "4799286537280063c85a32f09884cfbca301b1a1", "version" : "602.0.0" } } ], "version" : 3 } ================================================ FILE: Gifski.xcodeproj/xcshareddata/xcschemes/Gifski.xcscheme ================================================ ================================================ FILE: Gifski.xcodeproj/xcshareddata/xcschemes/Share Extension.xcscheme ================================================ ================================================ FILE: Share Extension/Info.plist ================================================ ITSAppUsesNonExemptEncryption NSExtension NSExtensionAttributes NSExtensionActivationRule SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments, $attachment, ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.mpeg-4" || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.apple.m4v-video" || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.apple.quicktime-movie" ).@count == $extensionItem.attachments.@count ).@count == 1 NSExtensionPointIdentifier com.apple.share-services NSExtensionPrincipalClass $(PRODUCT_MODULE_NAME).ShareController ================================================ FILE: Share Extension/ShareController.swift ================================================ import SwiftUI final class ShareController: ExtensionController { override func run(_ context: NSExtensionContext) async throws -> [NSExtensionItem] { guard let url = try await (context.attachments.first { $0.hasItemConforming(to: .url) })?.loadTransferable(type: URL.self) else { context.cancel() return [] } let filename = url.lastPathComponent guard let appGroupShareVideoURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Shared.appGroupIdentifier)?.appendingPathComponent(filename, isDirectory: false) else { context.cancel() return [] } try? FileManager.default.removeItem(at: appGroupShareVideoURL) try FileManager.default.copyItem(at: url, to: appGroupShareVideoURL) let gifskiURL = createMainAppUrl( queryItems: [ URLQueryItem(name: "path", value: filename) ] ) NSWorkspace.shared.open(gifskiURL) return [] } private func createMainAppUrl(queryItems: [URLQueryItem]) -> URL { var components = URLComponents() components.scheme = "gifski" components.host = "shareExtension" components.queryItems = queryItems return components.url! } } ================================================ FILE: Share Extension/Share_Extension.entitlements ================================================ com.apple.security.app-sandbox com.apple.security.application-groups group.com.sindresorhus.Gifski ================================================ FILE: Share Extension/Utilities.swift ================================================ import SwiftUI import UniformTypeIdentifiers extension Sequence where Element: Sequence { func flatten() -> [Element.Element] { flatMap(\.self) } } extension NSExtensionContext { var inputItemsTyped: [NSExtensionItem] { inputItems as! [NSExtensionItem] } var attachments: [NSItemProvider] { inputItemsTyped.compactMap(\.attachments).flatten() } } // Strongly-typed versions of some of the methods. extension NSItemProvider { func hasItemConforming(to contentType: UTType) -> Bool { hasItemConformingToTypeIdentifier(contentType.identifier) } } extension NSError { static let userCancelled = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError, userInfo: nil) } extension NSExtensionContext { func cancel() { cancelRequest(withError: NSError.userCancelled) } } extension NSItemProvider { func loadTransferable(type transferableType: T.Type) async throws -> T { try await withCheckedThrowingContinuation { continuation in _ = loadTransferable(type: transferableType) { continuation.resume(with: $0) } } } } class ExtensionController: NSViewController { // swiftlint:disable:this final_class init() { super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError() // swiftlint:disable:this fatal_error_message } override func loadView() { Task { @MainActor in // Not sure if this is needed, but added just in case. do { extensionContext!.completeRequest( returningItems: try await run(extensionContext!), completionHandler: nil ) } catch { extensionContext!.cancelRequest(withError: error) } } } func run(_ context: NSExtensionContext) async throws -> [NSExtensionItem] { [] } } // TODO: Check if any of these can be removed when targeting macOS 15. extension NSItemProvider: @retroactive @unchecked Sendable {} ================================================ FILE: app-store-description.txt ================================================ Convert videos to high-quality GIFs. Gifski converts videos to animated GIFs that use thousands of colors per frame. This is made possible by some fancy features for efficient cross-frame palettes and temporal dithering. Keep in mind that Gifski is a converter, not a GIF creator. It will never support features like adding text and elements to a GIF. That's better done in a proper video editing app. ■ Features - Video trimming - Precise control of dimensions - Control over GIF looping and bouncing (yo-yo) playback - Adjust the speed - Copy, share, or drag the GIF - Share extension - System service - Optionally produce smaller lower quality GIFs - Generate up to 50 FPS GIFs (for showing off design work on Dribbble) - Shortcuts support ■ To convert, either: - Drag and drop your video onto the window or the Dock icon. - Click the “Open” button in the window or in the “File” menu and then choose a video. - Right-click a video in Finder and select this app in the “Open With” menu. Gifski supports all the video formats that macOS supports (.mp4 or .mov with H264, HEVC, ProRes, etc). The QuickTime Animation format is not supported. Use ProRes 4444 XQ instead. It's more efficient, more widely supported, and like QuickTime Animation, it also supports alpha channel. ■ Share extension Gifski includes a share extension that lets you share videos to Gifski. Just select Gifski from the Share menu of any macOS app. You can share a macOS screen recording with Gifski by clicking on the thumbnail that pops up once you are done recording and selecting “Share” from there. ■ System service Gifski includes a system service that lets you quickly convert a video to GIF from the “Services” menu in any app that provides a compatible video file. ■ Bounce (yo-yo) GIF playback Gifski includes the option to create GIFs that bounce back and forth between forward and backward playback. This option doubles the number of frames in the GIF so the file size will double as well. ■ Tips ‣ Quickly copy or save the GIF After converting, press Command+C to copy the GIF or Command+S to save it. ‣ Change GIF dimensions with the keyboard In the width/height input fields in the editor view, press the arrow up/down keys to change the value by 1. Hold the Option key meanwhile to change it by 10. ■ FAQ ‣ The generated GIFs are huge! The GIF image format is very space inefficient. It works best with short video clips. Try reducing the dimensions, FPS, or quality. ‣ Why are 60 FPS and higher not supported? Browsers throttle frame rates above 50 FPS, playing them at 10 FPS. ■ Support Click the “Send Feedback” button in the “Help” menu in the app. ================================================ FILE: app-store-keywords.txt ================================================ gif,video,movie,image,convert,converter,mp4,mov,photo,picture,photography,resize,design,bounce,yoyo ================================================ FILE: contributing.md ================================================ ## Contributing ### New features New features should use SwiftUI and Combine. ================================================ FILE: gifski-api/.github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: cargo directory: "/" schedule: interval: monthly open-pull-requests-limit: 10 ignore: - dependency-name: lodepng versions: - 3.4.5 ================================================ FILE: gifski-api/.gitignore ================================================ /target/ **/*.rs.bk # Snap packaging specific rules /snap/.snapcraft/ /parts/ /stage/ /prime/ /*.snap /*_source.tar.bz2 ================================================ FILE: gifski-api/Cargo.toml ================================================ [package] authors = ["Kornel "] categories = ["multimedia::video", "command-line-utilities"] description = "pngquant-based GIF maker for nice-looking animGIFs" documentation = "https://docs.rs/gifski" homepage = "https://gif.ski" include = ["/README.md", "/Cargo.toml", "/src/**/*.rs", "/src/bin/*.rs"] keywords = ["gif", "encoder", "converter", "maker", "gifquant"] license = "AGPL-3.0-or-later" name = "gifski" readme = "README.md" repository = "https://github.com/ImageOptim/gifski" version = "1.34.0" autobins = false edition = "2021" rust-version = "1.74" [[bin]] doctest = false name = "gifski" required-features = ["binary"] [dependencies] clap = { version = "4.5.32", features = ["cargo"], optional = true } gif = { version = "0.13.1", default-features = false, features = ["std", "raii_no_panic"] } gif-dispose = "5.0.1" imagequant = "4.3.4" lodepng = { version = "3.11.0", optional = true } natord = { version = "1.0.9", optional = true } pbr = { version = "1.1.1", optional = true } quick-error = "2.0.1" resize = { version = "0.8.8", features = ["rayon"] } rgb = { version = "0.8.50", default-features = false, features = ["bytemuck"] } dunce = { version = "1.0.5", optional = true } crossbeam-channel = "0.5.14" imgref = "1.11.0" loop9 = "0.1.5" # noisy-float 0.2 bug num-traits = { version = "0.2.19", features = ["i128", "std"] } crossbeam-utils = "0.8.21" ordered-channel = { version = "1.2.0", features = ["crossbeam-channel"] } wild = { version = "2.2.1", optional = true, features = ["glob-quoted-on-windows"] } y4m = { version = "0.8.0", optional = true } yuv = { version = "0.1.9", optional = true } [dependencies.ffmpeg] package = "ffmpeg-next" version = "6" optional = true default-features = false features = ["codec", "format", "filter", "software-resampling", "software-scaling"] [dev-dependencies] lodepng = "3.11.0" [features] # `cargo build` will skip the binaries with missing `required-features` # so all CLI dependencies have to be enabled by default. default = ["gifsicle", "binary"] # You can disable this feture when using gifski as a library. binary = ["dep:clap", "dep:yuv", "dep:y4m", "png", "pbr", "dep:wild", "dep:natord", "dep:dunce"] capi = [] # internal for cargo-c only png = ["dep:lodepng"] # Links dynamically to ffmpeg. Needs ffmpeg devel package installed on the system. video = ["dep:ffmpeg"] # Builds ffmpeg from source. Needs a C compiler, and all of ffmpeg's source dependencies. video-static = ["video", "ffmpeg/build"] # If you're lucky, this one might work with ffmpeg from vcpkg. video-prebuilt-static = ["video", "ffmpeg/static"] # Support lossy LZW encoding when lower quality is set gifsicle = [] [lib] path = "src/lib.rs" crate-type = ["lib", "staticlib", "cdylib"] [profile.dev] debug = 1 opt-level = 1 [profile.dev.package.'*'] opt-level = 2 debug = false [profile.release] panic = "abort" lto = true debug = false opt-level = 3 strip = true [package.metadata.docs.rs] targets = ["x86_64-unknown-linux-gnu"] [package.metadata.capi.header] subdirectory = false generation = false [package.metadata.capi.install.include] asset = [{from = "gifski.h"}] [patch.crates-io] # ffmpeg-sys-next does not support cross-compilation, which I use to produce binaries https://github.com/zmwangx/rust-ffmpeg-sys/pull/30 ffmpeg-sys-next = { rev = "fd5784d645df2ebe022a204ac36582074da1edf7", git = "https://github.com/kornelski/rust-ffmpeg-sys-1"} ================================================ FILE: gifski-api/LICENSE ================================================ Let [me](https://kornel.ski/contact) know if you'd like to use it in a product incompatible with this license. I can offer alternative licensing options. ---- ### GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 © 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. ### Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. ### TERMS AND CONDITIONS #### 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. #### 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. #### 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. #### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. #### 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. #### 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: - a) The work must carry prominent notices stating that you modified it, and giving a relevant date. - b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". - c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. - d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. #### 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: - a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. - b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. - c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. - d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. - e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. #### 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: - a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or - b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or - c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or - d) Limiting the use for publicity purposes of names of licensors or authors of the material; or - e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or - f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. #### 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. #### 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. #### 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. #### 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. #### 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. #### 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. #### 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. #### 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. #### 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. #### 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. ================================================ FILE: gifski-api/README.md ================================================ # [gif.ski](https://gif.ski) Highest-quality GIF encoder based on [pngquant](https://pngquant.org). **[gifski](https://gif.ski)** converts video frames to GIF animations using pngquant's fancy features for efficient cross-frame palettes and temporal dithering. It produces animated GIFs that use thousands of colors per frame. ![(CC) Blender Foundation | gooseberry.blender.org](https://gif.ski/demo.gif) It's a CLI tool, but it can also be compiled [as a C library](https://docs.rs/gifski) for seamless use in other apps. ## Download and install See [releases](https://github.com/ImageOptim/gifski/releases) page for executables. If you have [Homebrew](https://brew.sh/), you can also get it with `brew install gifski`. If you have [Rust from rustup](https://www.rust-lang.org/install.html) (1.63+), you can also build it from source with [`cargo install gifski`](https://lib.rs/crates/gifski). ## Usage gifski is a command-line tool. If you're not comfortable with a terminal, try the GUI version for [Windows][winmsi] or for [macOS][macapp]. [winmsi]: https://github.com/ImageOptim/gifski/releases/download/1.14.4/gifski_1.14.4_x64_en-US.msi [macapp]: https://sindresorhus.com/gifski ### From ffmpeg video > Tip: Instead of typing file paths, you can drag'n'drop files into the terminal window! If you have ffmpeg installed, you can use it to stream a video directly to the gifski command by adding `-f yuv4mpegpipe` to `ffmpeg`'s arguments: ```sh ffmpeg -i video.mp4 -f yuv4mpegpipe - | gifski -o anim.gif - ``` Replace "video.mp4" in the above code with actual path to your video. Note that there's `-` at the end of the command. This tells `gifski` to read from standard input. Reading a `.y4m` file from disk would work too, but these files are huge. `gifski` may automatically downsize the video if it has resolution too high for a GIF. Use `--width=1280` if you can tolerate getting huge file sizes. ### From PNG frames A directory full of PNG frames can be used as an input too. You can export them from any animation software. If you have `ffmpeg` installed, you can also export frames with it: ```sh ffmpeg -i video.webm frame%04d.png ``` and then make the GIF from the frames: ```sh gifski -o anim.gif frame*.png ``` Note that `*` is a special wildcard character, and it won't work when placed inside quoted string (`"*"`). You can also resize frames (with `-W ` option). If the input was ever encoded using a lossy video codec it's recommended to at least halve size of the frames to hide compression artefacts and counter chroma subsampling that was done by the video codec. See `gifski --help` for more options. ### Tips for smaller GIF files Expect to lose a lot of quality for little gain. GIF just isn't that good at compressing, no matter how much you compromise. * Use `--width` and `--height` to make the animation smaller. This makes the biggest difference. * Add `--quality=80` (or a lower number) to lower overall quality. You can fine-tune the quality with: * `--lossy-quality=60` lower values make animations noisier/grainy, but reduce file sizes. * `--motion-quality=60` lower values cause smearing or banding in frames with motion, but reduce file sizes. If you need to make a GIF that fits a predefined file size, you have to experiment with different sizes and quality settings. The command line tool will display estimated total file size during compression, but keep in mind that the estimate is very imprecise. ## Building 1. [Install Rust via rustup](https://www.rust-lang.org/en-US/install.html). This project only supports up-to-date versions of Rust. You may get errors about "unstable" features if your compiler version is too old. Run `rustup update`. 2. Clone the repository: `git clone https://github.com/ImageOptim/gifski` 3. In the cloned directory, run: `cargo build --release`. This will build in `./target/release`. ### Using from C [See `gifski.h`](https://github.com/ImageOptim/gifski/blob/main/gifski.h) for [the C API](https://docs.rs/gifski/latest/gifski/c_api/#functions). To build the library, run: ```sh rustup update cargo build --release ``` and link with `target/release/libgifski.a`. Please observe the [LICENSE](LICENSE). ### C dynamic library for package maintainers The build process uses [`cargo-c`](https://lib.rs/cargo-c) for building the dynamic library correctly and generating the pkg-config file. ```sh rustup update cargo install cargo-c # build cargo cbuild --prefix=/usr --release # install cargo cinstall --prefix=/usr --release --destdir=pkgroot ``` The `cbuild` command can be omitted, since `cinstall` will trigger a build if it hasn't been done already. ## License AGPL 3 or later. I can offer alternative licensing options, including [commercial licenses](https://supso.org/projects/pngquant). Let [me](https://kornel.ski/contact) know if you'd like to use it in a product incompatible with this license. ## With built-in video support The tool optionally supports decoding video directly, but unfortunately it relies on ffmpeg 6.x, which may be *very hard* to get working, so it's not enabled by default. You must have `ffmpeg` and `libclang` installed, both with their C headers installed in default system include paths. Details depend on the platform and version, but you usually need to install packages such as `libavformat-dev`, `libavfilter-dev`, `libavdevice-dev`, `libclang-dev`, `clang`. Please note that installation of these dependencies may be quite difficult. Especially on macOS and Windows it takes *expert knowledge* to just get them installed without wasting several hours on endless stupid installation and compilation errors, which I can't help with. If you're cross-compiling, try uncommenting `[patch.crates-io]` section at the end of `Cargo.toml`, which includes some experimental fixes for ffmpeg. Once you have dependencies installed, compile with `cargo build --release --features=video` or `cargo build --release --features=video-static`. When compiled with video support [ffmpeg licenses](https://www.ffmpeg.org/legal.html) apply. You may need to have a patent license to use H.264/H.265 video (I recommend using VP9/WebM instead). ```sh gifski -o out.gif video.mp4 ``` ## Cross-compilation for iOS The easy option is to use the included `gifski.xcodeproj` file to build the library automatically for all Apple platforms. Add it as a [subproject](https://lib.rs/crates/cargo-xcode) to your Xcode project, and link with `gifski-staticlib` Xcode target. See [the GUI app](https://github.com/sindresorhus/Gifski) for an example how to integrate the library. ### Cross-compilation for iOS manually Make sure you have Rust installed via [rustup](https://rustup.rs/). Run once: ```sh rustup target add aarch64-apple-ios ``` and then to build the library: ```sh rustup update cargo build --lib --release --target=aarch64-apple-ios ``` The build may print "dropping unsupported crate type `cdylib`" warning. This is expected when building for iOS. This will create a static library in `./target/aarch64-apple-ios/release/libgifski.a`. You can add this library to your Xcode project. See [gifski.app](https://github.com/sindresorhus/Gifski) for an example how to use libgifski from Swift. ================================================ FILE: gifski-api/gifski.h ================================================ #include #include #include #include #ifdef __cplusplus extern "C" { #endif struct gifski; typedef struct gifski gifski; /** How to use from C ```c gifski *g = gifski_new(&(GifskiSettings){ .quality = 90, }); gifski_set_file_output(g, "file.gif"); for(int i=0; i < frames; i++) { int res = gifski_add_frame_rgba(g, i, width, height, buffer, 5); if (res != GIFSKI_OK) break; } int res = gifski_finish(g); if (res != GIFSKI_OK) return; ``` It's safe and efficient to call `gifski_add_frame_*` in a loop as fast as you can get frames, because it blocks and waits until previous frames are written. To cancel processing, make progress callback return 0 and call `gifski_finish()`. The write callback may still be called between the cancellation and `gifski_finish()` returning. To build as a library: ```bash cargo build --release --lib ``` it will create `target/release/libgifski.a` (static library) and `target/release/libgifski.so`/`dylib` or `gifski.dll` (dynamic library) Static is recommended. To build for iOS: ```bash rustup target add aarch64-apple-ios cargo build --release --lib --target aarch64-apple-ios ``` it will build `target/aarch64-apple-ios/release/libgifski.a` (ignore the warning about cdylib). */ /** * Settings for creating a new encoder instance. See `gifski_new` */ typedef struct GifskiSettings { /** * Resize to max this width if non-0. */ uint32_t width; /** * Resize to max this height if width is non-0. Note that aspect ratio is not preserved. */ uint32_t height; /** * 1-100, but useful range is 50-100. Recommended to set to 90. */ uint8_t quality; /** * Lower quality, but faster encode. */ bool fast; /** * If negative, looping is disabled. The number of times the sequence is repeated. 0 to loop forever. */ int16_t repeat; } GifskiSettings; enum GifskiError { GIFSKI_OK = 0, /** one of input arguments was NULL */ GIFSKI_NULL_ARG, /** a one-time function was called twice, or functions were called in wrong order */ GIFSKI_INVALID_STATE, /** internal error related to palette quantization */ GIFSKI_QUANT, /** internal error related to gif composing */ GIFSKI_GIF, /** internal error - unexpectedly aborted */ GIFSKI_THREAD_LOST, /** I/O error: file or directory not found */ GIFSKI_NOT_FOUND, /** I/O error: permission denied */ GIFSKI_PERMISSION_DENIED, /** I/O error: file already exists */ GIFSKI_ALREADY_EXISTS, /** invalid arguments passed to function */ GIFSKI_INVALID_INPUT, /** misc I/O error */ GIFSKI_TIMED_OUT, /** misc I/O error */ GIFSKI_WRITE_ZERO, /** misc I/O error */ GIFSKI_INTERRUPTED, /** misc I/O error */ GIFSKI_UNEXPECTED_EOF, /** progress callback returned 0, writing aborted */ GIFSKI_ABORTED, /** should not happen, file a bug */ GIFSKI_OTHER, }; /* workaround for a wrong definition in an older version of this header. Please use GIFSKI_ABORTED directly */ #ifndef ABORTED #define ABORTED GIFSKI_ABORTED #endif typedef enum GifskiError GifskiError; /** * Call to start the process * * See `gifski_add_frame_png_file` and `gifski_end_adding_frames` * * Returns a handle for the other functions, or `NULL` on error (if the settings are invalid). */ gifski *gifski_new(const GifskiSettings *settings); /** Quality 1-100 of temporal denoising. Lower values reduce motion. Defaults to `settings.quality`. * * Only valid immediately after calling `gifski_new`, before any frames are added. */ GifskiError gifski_set_motion_quality(gifski *handle, uint8_t quality); /** Quality 1-100 of gifsicle compression. Lower values add noise. Defaults to `settings.quality`. * Has no effect if the `gifsicle` feature hasn't been enabled. * Only valid immediately after calling `gifski_new`, before any frames are added. */ GifskiError gifski_set_lossy_quality(gifski *handle, uint8_t quality); /** If `true`, encoding will be significantly slower, but may look a bit better. * * Only valid immediately after calling `gifski_new`, before any frames are added. */ GifskiError gifski_set_extra_effort(gifski *handle, bool extra); /** * Adds a frame to the animation. This function is asynchronous. * * File path must be valid UTF-8. * * `frame_number` orders frames (consecutive numbers starting from 0). * You can add frames in any order, and they will be sorted by their `frame_number`. * * Presentation timestamp (PTS) is time in seconds, since start of the file, when this frame is to be displayed. * For a 20fps video it could be `frame_number/20.0`. * Frames with duplicate or out-of-order PTS will be skipped. * * The first frame should have PTS=0. If the first frame has PTS > 0, it'll be used as a delay after the last frame. * * This function may block and wait until the frame is processed. Make sure to call `gifski_set_write_callback` or `gifski_set_file_output` first to avoid a deadlock. * * Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error. */ GifskiError gifski_add_frame_png_file(gifski *handle, uint32_t frame_number, const char *file_path, double presentation_timestamp); /** * Adds a frame to the animation. This function is asynchronous. * * `pixels` is an array width×height×4 bytes large. * The array is copied, so you can free/reuse it immediately after this function returns. * * `frame_number` orders frames (consecutive numbers starting from 0). * You can add frames in any order, and they will be sorted by their `frame_number`. * However, out-of-order frames are buffered in RAM, and will cause high memory usage * if there are gaps in the frame numbers. * * Presentation timestamp (PTS) is time in seconds, since start of the file, when this frame is to be displayed. * For a 20fps video it could be `frame_number/20.0`. First frame must have PTS=0. * Frames with duplicate or out-of-order PTS will be skipped. * * The first frame should have PTS=0. If the first frame has PTS > 0, it'll be used as a delay after the last frame. * * Colors are in sRGB, uncorrelated RGBA, with alpha byte last. * * This function may block and wait until the frame is processed. Make sure to call `gifski_set_write_callback` or `gifski_set_file_output` first to avoid a deadlock. * * Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error. */ GifskiError gifski_add_frame_rgba(gifski *handle, uint32_t frame_number, uint32_t width, uint32_t height, const unsigned char *pixels, double presentation_timestamp); /** Same as `gifski_add_frame_rgba`, but with bytes per row arg */ GifskiError gifski_add_frame_rgba_stride(gifski *handle, uint32_t frame_number, uint32_t width, uint32_t height, uint32_t bytes_per_row, const unsigned char *pixels, double presentation_timestamp); /** Same as `gifski_add_frame_rgba_stride`, except it expects components in ARGB order. Bytes per row must be multiple of 4, and greater or equal width×4. If the bytes per row value is invalid (e.g. an odd number), frames may look sheared/skewed. Colors are in sRGB, uncorrelated ARGB, with alpha byte first. `gifski_add_frame_rgba` is preferred over this function. */ GifskiError gifski_add_frame_argb(gifski *handle, uint32_t frame_number, uint32_t width, uint32_t bytes_per_row, uint32_t height, const unsigned char *pixels, double presentation_timestamp); /** Same as `gifski_add_frame_rgba_stride`, except it expects RGB components (3 bytes per pixel) Bytes per row must be multiple of 3, and greater or equal width×3. If the bytes per row value is invalid (not multiple of 3), frames may look sheared/skewed. Colors are in sRGB, red byte first. `gifski_add_frame_rgba` is preferred over this function. */ GifskiError gifski_add_frame_rgb(gifski *handle, uint32_t frame_number, uint32_t width, uint32_t bytes_per_row, uint32_t height, const unsigned char *pixels, double presentation_timestamp); /** * Get a callback for frame processed, and abort processing if desired. * * The callback is called once per input frame, * even if the encoder decides to skip some frames. * * It gets arbitrary pointer (`user_data`) as an argument. `user_data` can be `NULL`. * * The callback must return `1` to continue processing, or `0` to abort. * * The callback must be thread-safe (it will be called from another thread). * It must remain valid at all times, until `gifski_finish` completes. * * This function must be called before `gifski_set_file_output()` to take effect. */ void gifski_set_progress_callback(gifski *handle, int (*progress_callback)(void *user_data), void *user_data); /** * Get a callback when an error occurs. * This is intended mostly for logging and debugging, not for user interface. * * The callback function has the following arguments: * * A `\0`-terminated C string in UTF-8 encoding. The string is only valid for the duration of the call. Make a copy if you need to keep it. * * An arbitrary pointer (`user_data`). `user_data` can be `NULL`. * * The callback must be thread-safe (it will be called from another thread). * It must remain valid at all times, until `gifski_finish` completes. * * If the callback is not set, errors will be printed to stderr. * * This function must be called before `gifski_set_file_output()` to take effect. */ GifskiError gifski_set_error_message_callback(gifski *handle, void (*error_message_callback)(const char*, void*), void *user_data); /** * Start writing to the file at `destination_path` (overwrites if needed). * The file path must be ASCII or valid UTF-8. * * This function has to be called before any frames are added. * This call will not block. * * Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error. */ GifskiError gifski_set_file_output(gifski *handle, const char *destination_path); /** * Start writing via callback (any buffer, file, whatever you want). This has to be called before any frames are added. * This call will not block. * * The callback function receives 3 arguments: * - size of the buffer to write, in bytes. IT MAY BE ZERO (when it's zero, either do nothing, or flush internal buffers if necessary). * - pointer to the buffer. * - context pointer to arbitrary user data, same as passed in to this function. * * The callback should return 0 (`GIFSKI_OK`) on success, and non-zero on error. * * The callback function must be thread-safe. It must remain valid at all times, until `gifski_finish` completes. * * Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error. */ GifskiError gifski_set_write_callback(gifski *handle, int (*write_callback)(size_t buffer_length, const uint8_t *buffer, void *user_data), void *user_data); /** * The last step: * - stops accepting any more frames (gifski_add_frame_* calls are blocked) * - blocks and waits until all already-added frames have finished writing * * Returns final status of write operations. Remember to check the return value! * * Must always be called, otherwise it will leak memory. * After this call, the handle is freed and can't be used any more. * * Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error. */ GifskiError gifski_finish(gifski *g); #ifdef __cplusplus } #endif ================================================ FILE: gifski-api/gifski.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 90; objects = { /* Begin PBXBuildFile section */ CA00E74C7D4159EA34BF617B /* Cargo.toml in Sources */ = {isa = PBXBuildFile; fileRef = CAF9AE29BDC33EF4668187A5 /* Cargo.toml */; settings = {COMPILER_FLAGS = "--lib"; }; }; CA01E74C7D41A82EB53EFF50 /* Cargo.toml in Sources */ = {isa = PBXBuildFile; fileRef = CAF9AE29BDC33EF4668187A5 /* Cargo.toml */; settings = {COMPILER_FLAGS = "--lib"; }; }; CA02E74C7D4162D760BFA4D3 /* Cargo.toml in Sources */ = {isa = PBXBuildFile; fileRef = CAF9AE29BDC33EF4668187A5 /* Cargo.toml */; settings = {COMPILER_FLAGS = "--bin 'gifski' --features 'binary'"; }; }; /* End PBXBuildFile section */ /* Begin PBXBuildRule section */ CAF4AE29BDC3AC6C1400ACA8 /* PBXBuildRule */ = { isa = PBXBuildRule; compilerSpec = com.apple.compilers.proxy.script; dependencyFile = "$(DERIVED_FILE_DIR)/$(ARCHS)-$(EXECUTABLE_NAME).d"; filePatterns = "*/Cargo.toml"; fileType = pattern.proxy; isEditable = 0; name = "Cargo project build"; outputFiles = ( "$(TARGET_BUILD_DIR)/$(EXECUTABLE_NAME)", ); runOncePerArchitecture = 0; script = ( "# generated with cargo-xcode 1.11.0", "set -euo pipefail;", "export PATH=\"$HOME/.cargo/bin:$PATH:/usr/local/bin:/opt/homebrew/bin\";", "# don't use ios/watchos linker for build scripts and proc macros", "export CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER=/usr/bin/ld", "export CARGO_TARGET_X86_64_APPLE_DARWIN_LINKER=/usr/bin/ld", "export NO_COLOR=1", "", "case \"$PLATFORM_NAME\" in", " \"macosx\")", " CARGO_XCODE_TARGET_OS=darwin", " if [ \"${IS_MACCATALYST-NO}\" = YES ]; then", " CARGO_XCODE_TARGET_OS=ios-macabi", " fi", " ;;", " \"iphoneos\") CARGO_XCODE_TARGET_OS=ios ;;", " \"iphonesimulator\") CARGO_XCODE_TARGET_OS=ios-sim ;;", " \"appletvos\" | \"appletvsimulator\") CARGO_XCODE_TARGET_OS=tvos ;;", " \"watchos\") CARGO_XCODE_TARGET_OS=watchos ;;", " \"watchsimulator\") CARGO_XCODE_TARGET_OS=watchos-sim ;;", " \"xros\") CARGO_XCODE_TARGET_OS=visionos ;;", " \"xrsimulator\") CARGO_XCODE_TARGET_OS=visionos-sim ;;", " *)", " CARGO_XCODE_TARGET_OS=\"$PLATFORM_NAME\"", " echo >&2 \"warning: cargo-xcode needs to be updated to handle $PLATFORM_NAME\"", " ;;", "esac", "", "CARGO_XCODE_TARGET_TRIPLES=\"\"", "CARGO_XCODE_TARGET_FLAGS=\"\"", "LIPO_ARGS=\"\"", "for arch in $ARCHS; do", " if [[ \"$arch\" == \"arm64\" ]]; then arch=aarch64; fi", " if [[ \"$arch\" == \"i386\" && \"$CARGO_XCODE_TARGET_OS\" != \"ios\" ]]; then arch=i686; fi", " triple=\"${arch}-apple-$CARGO_XCODE_TARGET_OS\"", " CARGO_XCODE_TARGET_TRIPLES+=\" $triple\"", " CARGO_XCODE_TARGET_FLAGS+=\" --target=$triple\"", " LIPO_ARGS+=\"$CARGO_TARGET_DIR/$triple/$CARGO_XCODE_BUILD_PROFILE/$CARGO_XCODE_CARGO_FILE_NAME", "\"", "done", "", "echo >&2 \"Cargo $CARGO_XCODE_BUILD_PROFILE $ACTION for $PLATFORM_NAME $ARCHS =$CARGO_XCODE_TARGET_TRIPLES; using ${SDK_NAMES:-}. \\$PATH is:\"", "tr >&2 : '\\n' <<<\"$PATH\"", "", "if command -v rustup &> /dev/null; then", " for triple in $CARGO_XCODE_TARGET_TRIPLES; do", " if ! rustup target list --installed | grep -Eq \"^$triple$\"; then", " echo >&2 \"warning: this build requires rustup toolchain for $triple, but it isn't installed (will try rustup next)\"", " rustup target add \"$triple\" || {", " echo >&2 \"warning: can't install $triple, will try nightly -Zbuild-std\";", " OTHER_INPUT_FILE_FLAGS+=\" -Zbuild-std\";", " if [ -z \"${RUSTUP_TOOLCHAIN:-}\" ]; then", " export RUSTUP_TOOLCHAIN=nightly", " fi", " break;", " }", " fi", " done", "fi", "", "if [ \"$CARGO_XCODE_BUILD_PROFILE\" = release ]; then", " OTHER_INPUT_FILE_FLAGS=\"$OTHER_INPUT_FILE_FLAGS --release\"", "fi", "", "if [ \"$ACTION\" = clean ]; then", " cargo clean --verbose --manifest-path=\"$SCRIPT_INPUT_FILE\" $CARGO_XCODE_TARGET_FLAGS $OTHER_INPUT_FILE_FLAGS;", " rm -f \"$SCRIPT_OUTPUT_FILE_0\"", " exit 0", "fi", "", "{ cargo build --manifest-path=\"$SCRIPT_INPUT_FILE\" --features=\"${CARGO_XCODE_FEATURES:-}\" $CARGO_XCODE_TARGET_FLAGS $OTHER_INPUT_FILE_FLAGS --verbose --message-format=short 2>&1 | sed -E 's/^([^ :]+:[0-9]+:[0-9]+: error)/\\1: /' >&2; } || { echo >&2 \"$SCRIPT_INPUT_FILE: error: cargo-xcode project build failed; $CARGO_XCODE_TARGET_TRIPLES\"; exit 1; }", "", "tr '\\n' '\\0' <<<\"$LIPO_ARGS\" | xargs -0 lipo -create -output \"$SCRIPT_OUTPUT_FILE_0\"", "", "if [ ${LD_DYLIB_INSTALL_NAME:+1} ]; then", " install_name_tool -id \"$LD_DYLIB_INSTALL_NAME\" \"$SCRIPT_OUTPUT_FILE_0\"", "fi", "", "DEP_FILE_DST=\"$DERIVED_FILE_DIR/${ARCHS}-${EXECUTABLE_NAME}.d\"", "echo \"\" > \"$DEP_FILE_DST\"", "for triple in $CARGO_XCODE_TARGET_TRIPLES; do", " BUILT_SRC=\"$CARGO_TARGET_DIR/$triple/$CARGO_XCODE_BUILD_PROFILE/$CARGO_XCODE_CARGO_FILE_NAME\"", "", " # cargo generates a dep file, but for its own path, so append our rename to it", " DEP_FILE_SRC=\"$CARGO_TARGET_DIR/$triple/$CARGO_XCODE_BUILD_PROFILE/$CARGO_XCODE_CARGO_DEP_FILE_NAME\"", " if [ -f \"$DEP_FILE_SRC\" ]; then", " cat \"$DEP_FILE_SRC\" >> \"$DEP_FILE_DST\"", " fi", " echo >> \"$DEP_FILE_DST\" \"${SCRIPT_OUTPUT_FILE_0/ /\\\\ /}: ${BUILT_SRC/ /\\\\ /}\"", "done", "cat \"$DEP_FILE_DST\"", "", "echo \"success: $ACTION of $SCRIPT_OUTPUT_FILE_0 for $CARGO_XCODE_TARGET_TRIPLES\"", "", "", ); }; /* End PBXBuildRule section */ /* Begin PBXFileReference section */ CA007E4815895A689885C260 /* libgifski_static.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libgifski_static.a; sourceTree = BUILT_PRODUCTS_DIR; }; CA013DB14D7B8559E8DD8BDF /* gifski.dylib */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.dylib"; includeInIndex = 0; path = gifski.dylib; sourceTree = BUILT_PRODUCTS_DIR; }; CA026E6D6F94D179B4D3744F /* gifski */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = gifski; sourceTree = BUILT_PRODUCTS_DIR; }; CAF9AE29BDC33EF4668187A5 /* Cargo.toml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Cargo.toml; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ CAF0AE29BDC3D65BC3C892A8 = { isa = PBXGroup; children = ( CAF9AE29BDC33EF4668187A5 /* Cargo.toml */, CAF1AE29BDC322869D176AE5 /* Products */, CAF2AE29BDC398AF0B5890DB /* Frameworks */, ); sourceTree = ""; }; CAF1AE29BDC322869D176AE5 /* Products */ = { isa = PBXGroup; children = ( CA007E4815895A689885C260 /* libgifski_static.a */, CA013DB14D7B8559E8DD8BDF /* gifski.dylib */, CA026E6D6F94D179B4D3744F /* gifski */, ); name = Products; sourceTree = ""; }; CAF2AE29BDC398AF0B5890DB /* Frameworks */ = { isa = PBXGroup; children = ( ); name = Frameworks; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ CA007E48158959EA34BF617B /* gifski.a (static library) */ = { isa = PBXNativeTarget; buildConfigurationList = CA007084E6B259EA34BF617B /* Build configuration list for PBXNativeTarget "gifski.a (static library)" */; buildPhases = ( CA00A0D466D559EA34BF617B /* Sources */, ); buildRules = ( CAF4AE29BDC3AC6C1400ACA8 /* PBXBuildRule */, ); name = "gifski.a (static library)"; productName = libgifski_static.a; productReference = CA007E4815895A689885C260 /* libgifski_static.a */; productType = "com.apple.product-type.library.static"; }; CA013DB14D7BA82EB53EFF50 /* gifski.dylib (cdylib) */ = { isa = PBXNativeTarget; buildConfigurationList = CA017084E6B2A82EB53EFF50 /* Build configuration list for PBXNativeTarget "gifski.dylib (cdylib)" */; buildPhases = ( CA01A0D466D5A82EB53EFF50 /* Sources */, ); buildRules = ( CAF4AE29BDC3AC6C1400ACA8 /* PBXBuildRule */, ); name = "gifski.dylib (cdylib)"; productName = gifski.dylib; productReference = CA013DB14D7B8559E8DD8BDF /* gifski.dylib */; productType = "com.apple.product-type.library.dynamic"; }; CA026E6D6F9462D760BFA4D3 /* gifski (standalone executable) */ = { isa = PBXNativeTarget; buildConfigurationList = CA027084E6B262D760BFA4D3 /* Build configuration list for PBXNativeTarget "gifski (standalone executable)" */; buildPhases = ( CA02A0D466D562D760BFA4D3 /* Sources */, ); buildRules = ( CAF4AE29BDC3AC6C1400ACA8 /* PBXBuildRule */, ); name = "gifski (standalone executable)"; productName = gifski; productReference = CA026E6D6F94D179B4D3744F /* gifski */; productType = "com.apple.product-type.tool"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ CAF3AE29BDC3E04653AD465F /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 2600; TargetAttributes = { CA007E48158959EA34BF617B = { CreatedOnToolsVersion = 9.2; ProvisioningStyle = Automatic; }; CA013DB14D7BA82EB53EFF50 = { CreatedOnToolsVersion = 9.2; ProvisioningStyle = Automatic; }; CA026E6D6F9462D760BFA4D3 = { CreatedOnToolsVersion = 9.2; ProvisioningStyle = Automatic; }; }; }; buildConfigurationList = CAF6AE29BDC380E02D6C7F57 /* Build configuration list for PBXProject "gifski" */; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = CAF0AE29BDC3D65BC3C892A8; minimizedProjectReferenceProxies = 1; preferredProjectObjectVersion = 90; productRefGroup = CAF1AE29BDC322869D176AE5 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( CA007E48158959EA34BF617B /* gifski.a (static library) */, CA013DB14D7BA82EB53EFF50 /* gifski.dylib (cdylib) */, CA026E6D6F9462D760BFA4D3 /* gifski (standalone executable) */, ); }; /* End PBXProject section */ /* Begin PBXSourcesBuildPhase section */ CA00A0D466D559EA34BF617B /* Sources */ = { isa = PBXSourcesBuildPhase; files = ( CA00E74C7D4159EA34BF617B /* Cargo.toml in Sources */, ); }; CA01A0D466D5A82EB53EFF50 /* Sources */ = { isa = PBXSourcesBuildPhase; files = ( CA01E74C7D41A82EB53EFF50 /* Cargo.toml in Sources */, ); }; CA02A0D466D562D760BFA4D3 /* Sources */ = { isa = PBXSourcesBuildPhase; files = ( CA02E74C7D4162D760BFA4D3 /* Cargo.toml in Sources */, ); }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ CA008F2BE1C459EA34BF617B /* Debug configuration for PBXNativeTarget "gifski.a (static library)" */ = { isa = XCBuildConfiguration; buildSettings = { CARGO_XCODE_CARGO_DEP_FILE_NAME = libgifski.d; CARGO_XCODE_CARGO_FILE_NAME = libgifski.a; CLANG_ENABLE_OBJC_WEAK = YES; DEAD_CODE_STRIPPING = YES; INSTALL_GROUP = ""; INSTALL_MODE_FLAG = ""; INSTALL_OWNER = ""; MACOSX_DEPLOYMENT_TARGET = 15.3; PRODUCT_NAME = gifski_static; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "xrsimulator xros watchsimulator watchos macosx iphonesimulator iphoneos driverkit appletvsimulator appletvos"; }; name = Debug; }; CA009A4E111D59EA34BF617B /* Release configuration for PBXNativeTarget "gifski.a (static library)" */ = { isa = XCBuildConfiguration; buildSettings = { CARGO_XCODE_CARGO_DEP_FILE_NAME = libgifski.d; CARGO_XCODE_CARGO_FILE_NAME = libgifski.a; CLANG_ENABLE_OBJC_WEAK = YES; DEAD_CODE_STRIPPING = YES; INSTALL_GROUP = ""; INSTALL_MODE_FLAG = ""; INSTALL_OWNER = ""; MACOSX_DEPLOYMENT_TARGET = 15.3; PRODUCT_NAME = gifski_static; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "xrsimulator xros watchsimulator watchos macosx iphonesimulator iphoneos driverkit appletvsimulator appletvos"; }; name = Release; }; CA018F2BE1C4A82EB53EFF50 /* Debug configuration for PBXNativeTarget "gifski.dylib (cdylib)" */ = { isa = XCBuildConfiguration; buildSettings = { CARGO_XCODE_CARGO_DEP_FILE_NAME = libgifski.d; CARGO_XCODE_CARGO_FILE_NAME = libgifski.dylib; CLANG_ENABLE_OBJC_WEAK = YES; DEAD_CODE_STRIPPING = YES; MACOSX_DEPLOYMENT_TARGET = 15.3; PRODUCT_NAME = gifski; SUPPORTED_PLATFORMS = "macosx iphonesimulator iphoneos"; }; name = Debug; }; CA019A4E111DA82EB53EFF50 /* Release configuration for PBXNativeTarget "gifski.dylib (cdylib)" */ = { isa = XCBuildConfiguration; buildSettings = { CARGO_XCODE_CARGO_DEP_FILE_NAME = libgifski.d; CARGO_XCODE_CARGO_FILE_NAME = libgifski.dylib; CLANG_ENABLE_OBJC_WEAK = YES; DEAD_CODE_STRIPPING = YES; MACOSX_DEPLOYMENT_TARGET = 15.3; PRODUCT_NAME = gifski; SUPPORTED_PLATFORMS = "macosx iphonesimulator iphoneos"; }; name = Release; }; CA028F2BE1C462D760BFA4D3 /* Debug configuration for PBXNativeTarget "gifski (standalone executable)" */ = { isa = XCBuildConfiguration; buildSettings = { CARGO_XCODE_CARGO_DEP_FILE_NAME = gifski.d; CARGO_XCODE_CARGO_FILE_NAME = gifski; CLANG_ENABLE_OBJC_WEAK = YES; DEAD_CODE_STRIPPING = YES; MACOSX_DEPLOYMENT_TARGET = 15.3; PRODUCT_NAME = gifski; SUPPORTED_PLATFORMS = macosx; }; name = Debug; }; CA029A4E111D62D760BFA4D3 /* Release configuration for PBXNativeTarget "gifski (standalone executable)" */ = { isa = XCBuildConfiguration; buildSettings = { CARGO_XCODE_CARGO_DEP_FILE_NAME = gifski.d; CARGO_XCODE_CARGO_FILE_NAME = gifski; CLANG_ENABLE_OBJC_WEAK = YES; DEAD_CODE_STRIPPING = YES; MACOSX_DEPLOYMENT_TARGET = 15.3; PRODUCT_NAME = gifski; SUPPORTED_PLATFORMS = macosx; }; name = Release; }; CAF7D702CA573CC16B37690B /* Release configuration for PBXProject "gifski" */ = { isa = XCBuildConfiguration; buildSettings = { "ADDITIONAL_SDKS[sdk=a*]" = macosx; "ADDITIONAL_SDKS[sdk=i*]" = macosx; "ADDITIONAL_SDKS[sdk=w*]" = macosx; "ADDITIONAL_SDKS[sdk=x*]" = macosx; ALWAYS_SEARCH_USER_PATHS = NO; CARGO_TARGET_DIR = "$(PROJECT_TEMP_DIR)/cargo_target"; CARGO_XCODE_BUILD_PROFILE = release; CARGO_XCODE_FEATURES = ""; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CURRENT_PROJECT_VERSION = 1.32; DEAD_CODE_STRIPPING = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MARKETING_VERSION = 1.32.1; PRODUCT_NAME = gifski; RUSTUP_TOOLCHAIN = ""; SDKROOT = macosx; STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTS_MACCATALYST = YES; }; name = Release; }; CAF8D702CA57228BE02872F8 /* Debug configuration for PBXProject "gifski" */ = { isa = XCBuildConfiguration; buildSettings = { "ADDITIONAL_SDKS[sdk=a*]" = macosx; "ADDITIONAL_SDKS[sdk=i*]" = macosx; "ADDITIONAL_SDKS[sdk=w*]" = macosx; "ADDITIONAL_SDKS[sdk=x*]" = macosx; ALWAYS_SEARCH_USER_PATHS = NO; CARGO_TARGET_DIR = "$(PROJECT_TEMP_DIR)/cargo_target"; CARGO_XCODE_BUILD_PROFILE = debug; CARGO_XCODE_FEATURES = ""; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CURRENT_PROJECT_VERSION = 1.32; DEAD_CODE_STRIPPING = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MARKETING_VERSION = 1.32.1; ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = gifski; RUSTUP_TOOLCHAIN = ""; SDKROOT = macosx; STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTS_MACCATALYST = YES; }; name = Debug; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ CA007084E6B259EA34BF617B /* Build configuration list for PBXNativeTarget "gifski.a (static library)" */ = { isa = XCConfigurationList; buildConfigurations = ( CA009A4E111D59EA34BF617B /* Release configuration for PBXNativeTarget "gifski.a (static library)" */, CA008F2BE1C459EA34BF617B /* Debug configuration for PBXNativeTarget "gifski.a (static library)" */, ); defaultConfigurationName = Release; }; CA017084E6B2A82EB53EFF50 /* Build configuration list for PBXNativeTarget "gifski.dylib (cdylib)" */ = { isa = XCConfigurationList; buildConfigurations = ( CA019A4E111DA82EB53EFF50 /* Release configuration for PBXNativeTarget "gifski.dylib (cdylib)" */, CA018F2BE1C4A82EB53EFF50 /* Debug configuration for PBXNativeTarget "gifski.dylib (cdylib)" */, ); defaultConfigurationName = Release; }; CA027084E6B262D760BFA4D3 /* Build configuration list for PBXNativeTarget "gifski (standalone executable)" */ = { isa = XCConfigurationList; buildConfigurations = ( CA029A4E111D62D760BFA4D3 /* Release configuration for PBXNativeTarget "gifski (standalone executable)" */, CA028F2BE1C462D760BFA4D3 /* Debug configuration for PBXNativeTarget "gifski (standalone executable)" */, ); defaultConfigurationName = Release; }; CAF6AE29BDC380E02D6C7F57 /* Build configuration list for PBXProject "gifski" */ = { isa = XCConfigurationList; buildConfigurations = ( CAF7D702CA573CC16B37690B /* Release configuration for PBXProject "gifski" */, CAF8D702CA57228BE02872F8 /* Debug configuration for PBXProject "gifski" */, ); defaultConfigurationName = Release; }; /* End XCConfigurationList section */ }; rootObject = CAF3AE29BDC3E04653AD465F /* Project object */; } ================================================ FILE: gifski-api/gifski.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: gifski-api/gifski.xcodeproj/xcshareddata/xcschemes/gifski.a (static library).xcscheme ================================================ ================================================ FILE: gifski-api/snapcraft.yaml ================================================ name: gifski summary: gifski description: | GIF encoder based on libimagequant (pngquant). Squeezes maximum possible quality from the awful GIF format. https://gif.ski version: git grade: stable base: core22 confinement: strict apps: gifski: command: bin/gifski plugs: - home - removable-media parts: gifski: source: https://github.com/ImageOptim/gifski.git plugin: rust rust-features: - video build-packages: - pkg-config - ffmpeg - libavcodec-dev - libavdevice-dev - libavfilter-dev - libavformat-dev - libavutil-dev - libswresample-dev - libswscale-dev - libclang-15-dev stage-packages: - ffmpeg - freeglut3 # dep leak from one of ffmpeg dev libs? - libglu1-mesa ================================================ FILE: gifski-api/src/bin/ffmpeg_source.rs ================================================ use crate::source::{Fps, Source}; use crate::{BinResult, SrcPath}; use gifski::{Collector, Settings}; use imgref::*; use rgb::*; pub struct FfmpegDecoder { input_context: ffmpeg::format::context::Input, frames: u64, rate: Fps, settings: Settings, } impl Source for FfmpegDecoder { fn total_frames(&self) -> Option { Some(self.frames) } fn collect(&mut self, dest: &mut Collector) -> BinResult<()> { self.collect_frames(dest) } } impl FfmpegDecoder { pub fn new(src: SrcPath, rate: Fps, settings: Settings) -> BinResult { ffmpeg::init().map_err(|e| format!("Unable to initialize ffmpeg: {}", e))?; let input_context = match src { SrcPath::Path(path) => ffmpeg::format::input(&path) .map_err(|e| format!("Unable to open video file {}: {}", path.display(), e))?, SrcPath::Stdin(_) => return Err("Video files must be specified as a path on disk. Input via stdin is not supported".into()), }; // take fps override into account let filter_fps = rate.fps / rate.speed; let stream = input_context.streams().best(ffmpeg::media::Type::Video).ok_or("The file has no video tracks")?; let time_base = stream.time_base().numerator() as f64 / stream.time_base().denominator() as f64; let frames = (stream.duration() as f64 * time_base * filter_fps as f64).ceil() as u64; Ok(Self { input_context, frames, rate, settings }) } #[inline(never)] pub fn collect_frames(&mut self, dest: &mut Collector) -> BinResult<()> { let (stream_index, mut decoder, mut filter) = { let filter_fps = self.rate.fps / self.rate.speed; let stream = self.input_context.streams().best(ffmpeg::media::Type::Video).ok_or("The file has no video tracks")?; let mut codec_context = ffmpeg::codec::context::Context::new(); codec_context.set_parameters(stream.parameters())?; let decoder = codec_context.decoder().video().map_err(|e| format!("Unable to decode the codec used in the video: {}", e))?; let (dest_width, dest_height) = self.settings.dimensions_for_image(decoder.width() as _, decoder.height() as _); let buffer_args = format!("width={}:height={}:video_size={}x{}:pix_fmt={}:time_base={}:sar={}", dest_width, dest_height, decoder.width(), decoder.height(), decoder.format().descriptor().ok_or("ffmpeg format error")?.name(), stream.time_base(), (|sar: ffmpeg::util::rational::Rational| match sar.numerator() { 0 => "1".to_string(), _ => format!("{}/{}", sar.numerator(), sar.denominator()), })(decoder.aspect_ratio()), ); let mut filter = ffmpeg::filter::Graph::new(); filter.add(&ffmpeg::filter::find("buffer").ok_or("ffmpeg format error")?, "in", &buffer_args)?; filter.add(&ffmpeg::filter::find("buffersink").ok_or("ffmpeg format error")?, "out", "")?; filter.output("in", 0)?.input("out", 0)?.parse(&format!("fps=fps={},format=rgba", filter_fps))?; filter.validate()?; (stream.index(), decoder, filter) }; let add_frame = |rgba_frame: &ffmpeg::util::frame::Video, pts: f64, pos: i64| -> BinResult<()> { let stride = rgba_frame.stride(0) as usize; if stride % 4 != 0 { Err("incompatible video")?; } let rgba_frame = ImgVec::new_stride( rgba_frame.data(0).as_rgba().to_owned(), rgba_frame.width() as usize, rgba_frame.height() as usize, stride / 4, ); Ok(dest.add_frame_rgba(pos as usize, rgba_frame, pts)?) }; let mut vid_frame = ffmpeg::util::frame::Video::empty(); let mut filt_frame = ffmpeg::util::frame::Video::empty(); let mut i = 0; let mut pts_last_packet = 0; let pts_frame_step = 1.0 / self.rate.fps as f64; let packets = self.input_context.packets().filter_map(|(s, packet)| { if s.index() != stream_index { // ignore irrelevant streams None } else { pts_last_packet = packet.pts()? + packet.duration(); Some(packet) } }) // extra packet to flush remaining frames .chain(std::iter::once(ffmpeg::Packet::empty())); for packet in packets { decoder.send_packet(&packet)?; loop { match decoder.receive_frame(&mut vid_frame) { Ok(()) => (), Err(ffmpeg::Error::Other { errno: ffmpeg::error::EAGAIN }) | Err(ffmpeg::Error::Eof) => break, Err(e) => return Err(Box::new(e)), } filter.get("in").ok_or("ffmpeg format error")?.source().add(&vid_frame)?; let mut out = filter.get("out").ok_or("ffmpeg format error")?; let mut out = out.sink(); while let Ok(..) = out.frame(&mut filt_frame) { add_frame(&filt_frame, pts_frame_step * i as f64, i)?; i += 1; } } } // now flush filter's buffer filter.get("in").ok_or("ffmpeg format error")?.source().close(pts_last_packet)?; let mut out = filter.get("out").ok_or("ffmpeg format error")?; let mut out = out.sink(); while let Ok(..) = out.frame(&mut filt_frame) { add_frame(&filt_frame, pts_frame_step * i as f64, i)?; i += 1; } Ok(()) } } ================================================ FILE: gifski-api/src/bin/gif_source.rs ================================================ //! This is for reading GIFs as an input for re-encoding as another GIF use crate::source::{Fps, Source}; use crate::{BinResult, SrcPath}; use gif::Decoder; use gifski::Collector; use std::io::Read; pub struct GifDecoder { speed: f32, decoder: Decoder>, screen: gif_dispose::Screen, } impl GifDecoder { pub fn new(src: SrcPath, fps: Fps) -> BinResult { let input = match src { SrcPath::Path(path) => Box::new(std::fs::File::open(path)?) as Box, SrcPath::Stdin(buf) => Box::new(buf), }; let mut gif_opts = gif::DecodeOptions::new(); // Important: gif_opts.set_color_output(gif::ColorOutput::Indexed); let decoder = gif_opts.read_info(input)?; let screen = gif_dispose::Screen::new_decoder(&decoder); Ok(Self { speed: fps.speed, decoder, screen, }) } } impl Source for GifDecoder { fn total_frames(&self) -> Option { None } fn collect(&mut self, c: &mut Collector) -> BinResult<()> { let mut idx = 0; let mut delay_ts = 0; while let Some(frame) = self.decoder.read_next_frame()? { self.screen.blit_frame(frame)?; let pixels = self.screen.pixels_rgba().map_buf(|b| b.to_owned()); let presentation_timestamp = f64::from(delay_ts) * (1. / (100. * f64::from(self.speed))); c.add_frame_rgba(idx, pixels, presentation_timestamp)?; idx += 1; delay_ts += u32::from(frame.delay); } Ok(()) } } ================================================ FILE: gifski-api/src/bin/gifski.rs ================================================ #![allow(clippy::bool_to_int_with_if)] #![allow(clippy::cast_possible_truncation)] #![allow(clippy::enum_glob_use)] #![allow(clippy::match_same_arms)] #![allow(clippy::missing_errors_doc)] #![allow(clippy::module_name_repetitions)] #![allow(clippy::needless_pass_by_value)] #![allow(clippy::redundant_closure_for_method_calls)] #![allow(clippy::wildcard_imports)] use clap::builder::NonEmptyStringValueParser; use clap::error::ErrorKind::MissingRequiredArgument; use clap::value_parser; use yuv::color::MatrixCoefficients; use gifski::{Repeat, Settings}; use std::io::stdin; use std::io::BufRead; use std::io::BufReader; use std::io::IsTerminal; use std::io::Read; use std::io::StdinLock; use std::io::Stdout; #[cfg(feature = "video")] mod ffmpeg_source; mod gif_source; mod png; mod source; mod y4m_source; use crate::source::Source; use gifski::progress::{NoProgress, ProgressReporter}; pub type BinResult> = Result; use clap::{Arg, ArgAction, Command}; use std::env; use std::fmt; use std::fs::File; use std::io; use std::path::{Path, PathBuf}; use std::thread; use std::time::Duration; #[cfg(feature = "video")] const VIDEO_FRAMES_ARG_HELP: &str = "one video file supported by FFmpeg, or multiple PNG image files"; #[cfg(not(feature = "video"))] const VIDEO_FRAMES_ARG_HELP: &str = "PNG image files for the animation frames, or a .y4m file"; fn main() { if let Err(e) = bin_main() { eprintln!("error: {e}"); if let Some(e) = e.source() { eprintln!("error: {e}"); } std::process::exit(1); } } #[allow(clippy::float_cmp)] fn bin_main() -> BinResult<()> { let matches = Command::new(clap::crate_name!()) .version(clap::crate_version!()) .about("https://gif.ski by Kornel Lesiński") .arg_required_else_help(true) .allow_negative_numbers(true) .arg(Arg::new("output") .long("output") .short('o') .help("Destination file to write to; \"-\" means stdout") .num_args(1) .value_name("a.gif") .value_parser(value_parser!(PathBuf)) .required(true)) .arg(Arg::new("fps") .long("fps") .short('r') .help("Frame rate of animation. If using PNG files as \ input, this means the speed, as all frames are \ kept.\nIf video is used, it will be resampled to \ this constant rate by dropping and/or duplicating \ frames") .value_parser(value_parser!(f32)) .value_name("num") .default_value("20")) .arg(Arg::new("fast-forward") .long("fast-forward") .help("Multiply speed of video by a factor") .value_parser(value_parser!(f32)) .value_name("x") .default_value("1")) .arg(Arg::new("fast") .num_args(0) .action(ArgAction::SetTrue) .long("fast") .help("50% faster encoding, but 10% worse quality and larger file size")) .arg(Arg::new("extra") .long("extra") .conflicts_with("fast") .num_args(0) .action(ArgAction::SetTrue) .help("50% slower encoding, but 1% better quality and usually larger file size")) .arg(Arg::new("quality") .long("quality") .short('Q') .value_name("1-100") .value_parser(value_parser!(u8).range(1..=100)) .num_args(1) .default_value("90") .help("Lower quality may give smaller file")) .arg(Arg::new("motion-quality") .long("motion-quality") .value_name("1-100") .value_parser(value_parser!(u8).range(1..=100)) .num_args(1) .help("Lower values reduce motion")) .arg(Arg::new("lossy-quality") .long("lossy-quality") .value_name("1-100") .value_parser(value_parser!(u8).range(1..=100)) .num_args(1) .help("Lower values introduce noise and streaks")) .arg(Arg::new("width") .long("width") .short('W') .num_args(1) .value_parser(value_parser!(u32)) .value_name("px") .help("Maximum width.\nBy default anims are limited to about 800x600")) .arg(Arg::new("height") .long("height") .short('H') .num_args(1) .value_parser(value_parser!(u32)) .value_name("px") .help("Maximum height (stretches if the width is also set)")) .arg(Arg::new("nosort") .alias("nosort") .long("no-sort") .num_args(0) .action(ArgAction::SetTrue) .hide_short_help(true) .help("Use files exactly in the order given, rather than sorted")) .arg(Arg::new("quiet") .long("quiet") .short('q') .num_args(0) .action(ArgAction::SetTrue) .help("Do not display anything on standard output/console")) .arg(Arg::new("FILES") .help(VIDEO_FRAMES_ARG_HELP) .num_args(1..) .value_parser(NonEmptyStringValueParser::new()) .use_value_delimiter(false) .required(true)) .arg(Arg::new("repeat") .long("repeat") .help("Number of times the animation is repeated (-1 none, 0 forever or repetitions") .num_args(1) .value_parser(value_parser!(i16)) .value_name("num")) .arg(Arg::new("bounce") .long("bounce") .num_args(0) .action(ArgAction::SetTrue) .hide_short_help(true) .help("Make animation play forwards then backwards")) .arg(Arg::new("fixed-color") .long("fixed-color") .help("Always include this color in the palette") .hide_short_help(true) .num_args(1) .action(ArgAction::Append) .value_parser(parse_colors) .value_name("RGBHEX")) .arg(Arg::new("matte") .long("matte") .help("Background color for semitransparent pixels") .num_args(1) .value_parser(parse_color) .value_name("RGBHEX")) .arg(Arg::new("y4m-color-override") .long("y4m-color-override") .help("The color space of the input YUV4MPEG2 video\n\ Possible values: bt709 fcc bt470bg bt601 smpte240 ycgco\n\ Defaults to bt709 for HD and bt601 for SD resolutions") .num_args(1) .hide_short_help(true) .value_parser(parse_color_space) .value_name("bt709")) .try_get_matches_from(wild::args_os()) .unwrap_or_else(|e| { if e.kind() == MissingRequiredArgument && !stdin().is_terminal() { eprintln!("If you're trying to pipe a file, use \"-\" as the input file name"); } e.exit() }); let mut frames: Vec<&str> = matches.get_many::("FILES").ok_or("?")?.map(|s| s.as_str()).collect(); let bounce = matches.get_flag("bounce"); if !matches.get_flag("nosort") && frames.len() > 1 { frames.sort_by(|a, b| natord::compare(a, b)); } let mut frames: Vec<_> = frames.into_iter().map(PathBuf::from).collect(); let output_path = DestPath::new(matches.get_one::("output").ok_or("?")?); let width = matches.get_one::("width").copied(); let height = matches.get_one::("height").copied(); let repeat_int = matches.get_one::("repeat").copied().unwrap_or(0); let repeat = match repeat_int { -1 => Repeat::Finite(0), 0 => Repeat::Infinite, _ => Repeat::Finite(repeat_int as u16), }; let extra = matches.get_flag("extra"); let motion_quality = matches.get_one::("motion-quality").copied(); let lossy_quality = matches.get_one::("lossy-quality").copied(); let fast = matches.get_flag("fast"); let settings = Settings { width, height, quality: matches.get_one::("quality").copied().unwrap_or(100), fast, repeat, }; let quiet = matches.get_flag("quiet") || output_path == DestPath::Stdout; let fps: f32 = matches.get_one::("fps").copied().ok_or("?")?; let speed: f32 = matches.get_one::("fast-forward").copied().ok_or("?")?; let fixed_colors = matches.get_many::>("fixed-color"); let matte = matches.get_one::("matte"); let in_color_space = matches.get_one::("y4m-color-override").copied(); let rate = source::Fps { fps, speed }; if settings.quality < 20 { if settings.quality < 1 { return Err("Quality too low".into()); } else if !quiet { eprintln!("warning: quality {} will give really bad results", settings.quality); } } else if settings.quality > 100 { return Err("Quality 100 is maximum".into()); } if speed > 1000.0 || speed <= 0.0 { return Err("Fast-forward must be 0..1000".into()); } if fps > 100.0 || fps <= 0.0 { return Err("100 fps is maximum".into()); } else if !quiet && fps > 50.0 { eprintln!("warning: web browsers support max 50 fps"); } check_if_paths_exist(&frames)?; std::thread::scope(move |scope| { let (mut collector, mut writer) = gifski::new(settings)?; if let Some(fixed_colors) = fixed_colors { for f in fixed_colors.flatten() { writer.add_fixed_color(*f); } } if let Some(matte) = matte { #[allow(deprecated)] writer.set_matte_color(*matte); } if extra { #[allow(deprecated)] writer.set_extra_effort(true); } if let Some(motion_quality) = motion_quality { #[allow(deprecated)] writer.set_motion_quality(motion_quality); } if let Some(lossy_quality) = lossy_quality { #[allow(deprecated)] writer.set_lossy_quality(lossy_quality); } let (decoder_ready_send, decoder_ready_recv) = crossbeam_channel::bounded(1); let decode_thread = thread::Builder::new().name("decode".into()).spawn_scoped(scope, move || { let mut decoder = if let [path] = &frames[..] { if bounce { eprintln!("warning: the bounce flag is supported only for individual files, not pipe or video"); } let mut src = if path.as_os_str() == "-" { let fd = stdin().lock(); if fd.is_terminal() { eprintln!("warning: used '-' as the input path, but the stdin is a terminal, not a file."); } SrcPath::Stdin(BufReader::new(fd)) } else { SrcPath::Path(path.clone()) }; match file_type(&mut src).unwrap_or(FileType::Other) { FileType::PNG | FileType::JPEG => return Err("Only a single image file was given as an input. This is not enough to make an animation.".into()), FileType::GIF => { if !quiet && (width.is_none() && settings.quality > 50) { eprintln!("warning: reading an existing GIF as an input. This can only worsen the quality. Use PNG frames instead."); } Box::new(gif_source::GifDecoder::new(src, rate)?) }, _ if path.is_dir() => { return Err(format!("{} is a directory, not a PNG file", path.display()).into()); }, other_type => get_video_decoder(other_type, src, rate, in_color_space, settings)?, } } else { if bounce { let mut extra: Vec<_> = frames.iter().skip(1).rev().cloned().collect(); frames.append(&mut extra); } if speed != 1.0 { eprintln!("warning: --fast-forward option is for videos. It doesn't make sense for images. Use --fps only."); } let file_type = file_type(&mut SrcPath::Path(frames[0].clone())).unwrap_or(FileType::Other); match file_type { FileType::JPEG => { return Err("JPEG format is unsuitable for conversion to GIF.\n\n\ JPEG's compression artifacts and color space are very problematic for palette-based\n\ compression. Please don't use JPEG for making GIF animations. Please re-export\n\ your animation using the PNG format.".into()) }, FileType::GIF => return unexpected("GIF"), FileType::Y4M => return unexpected("Y4M"), _ => Box::new(png::Lodecoder::new(frames, rate)), } }; decoder_ready_send.send(decoder.total_frames())?; decoder.collect(&mut collector) })?; let mut file_tmp; let mut stdio_tmp; let mut print_terminal_err = false; let out: &mut dyn io::Write = match output_path { DestPath::Path(path) => { file_tmp = File::create(path) .map_err(|err| { let mut msg = format!("Can't write to \"{}\": {err}", path.display()); let canon = path.canonicalize(); if let Some(parent) = canon.as_deref().unwrap_or(path).parent() { if parent.as_os_str() != "" { use std::fmt::Write; match parent.try_exists() { Ok(true) => {}, Ok(false) => { let _ = write!(&mut msg, " (directory \"{}\" doesn't exist)", parent.display()); }, Err(err) => { let _ = write!(&mut msg, " (directory \"{}\" is not accessible: {err})", parent.display()); }, } } } msg })?; &mut file_tmp }, DestPath::Stdout => { stdio_tmp = io::stdout().lock(); print_terminal_err = stdio_tmp.is_terminal(); &mut stdio_tmp }, }; let total_frames = match decoder_ready_recv.recv() { Ok(t) => t, Err(_) => { // if the decoder failed to start, // writer won't have any interesting error to report return decode_thread.join().map_err(panic_err)?; } }; let mut pb; let mut nopb = NoProgress {}; let progress: &mut dyn ProgressReporter = if quiet { &mut nopb } else { pb = ProgressBar::new(total_frames); &mut pb }; if print_terminal_err { eprintln!("warning: used '-' as the output path, but the stdout is a terminal, not a file"); std::thread::sleep(Duration::from_secs(3)); } let write_result = writer.write(io::BufWriter::new(out), progress); let thread_result = decode_thread.join().map_err(panic_err)?; check_errors(write_result, thread_result)?; progress.done(&format!("gifski created {output_path}")); Ok(()) }) } fn check_errors(err1: Result<(), gifski::Error>, err2: BinResult<()>) -> BinResult<()> { use gifski::Error::*; match err1 { Ok(()) => err2, Err(ThreadSend | Aborted | NoFrames) if err2.is_err() => err2, Err(err1) => Err(err1.into()), } } #[cold] fn unexpected(ftype: &'static str) -> BinResult<()> { Err(format!("Too many arguments. Unexpectedly got a {ftype} as an input frame. Only PNG format is supported for individual frames.").into()) } #[cold] fn panic_err(err: Box) -> String { err.downcast::().map(|s| *s) .unwrap_or_else(|e| e.downcast_ref::<&str>().copied().unwrap_or("panic").to_owned()) } fn parse_color(c: &str) -> Result { let c = c.trim_matches(|c: char| c.is_ascii_whitespace()); let c = c.strip_prefix('#').unwrap_or(c); if c.len() != 6 { return Err(format!("color must be 6-char hex format, not '{c}'")); } let mut c = c.as_bytes().chunks_exact(2) .map(|c| u8::from_str_radix(std::str::from_utf8(c).unwrap_or_default(), 16).map_err(|e| e.to_string())); Ok(rgb::RGB8::new( c.next().ok_or_else(String::new)??, c.next().ok_or_else(String::new)??, c.next().ok_or_else(String::new)??, )) } fn parse_colors(colors: &str) -> Result, String> { colors.split([' ', ',']) .filter(|c| !c.is_empty()) .map(parse_color) .collect() } #[test] fn color_parser() { assert_eq!(parse_colors("#123456 78abCD,, ,").unwrap(), vec![rgb::RGB8::new(0x12, 0x34, 0x56), rgb::RGB8::new(0x78, 0xab, 0xcd)]); assert!(parse_colors("#12345").is_err()); } fn parse_color_space(value: &str) -> Result { let value = value.to_lowercase(); let value = value.trim(); let matrix = match value { "bt709" => MatrixCoefficients::BT709, "fcc" => MatrixCoefficients::FCC, "bt470bg" => MatrixCoefficients::BT470BG, "bt601" => MatrixCoefficients::BT601, "smpte240" => MatrixCoefficients::SMPTE240, "ycgco" => MatrixCoefficients::YCgCo, _ => return Err("unsupported color space".into()), }; Ok(matrix) } #[allow(clippy::upper_case_acronyms)] #[derive(PartialEq)] enum FileType { PNG, GIF, JPEG, Y4M, Other, } fn file_type(src: &mut SrcPath) -> BinResult { let mut buf = [0; 4]; match src { SrcPath::Path(path) => match path.extension() { Some(e) if e.eq_ignore_ascii_case("y4m") => return Ok(FileType::Y4M), Some(e) if e.eq_ignore_ascii_case("png") => return Ok(FileType::PNG), _ => { let mut file = std::fs::File::open(path)?; file.read_exact(&mut buf)?; }, }, SrcPath::Stdin(stdin) => { let buf_in = stdin.fill_buf()?; let max_len = buf_in.len().min(4); buf[..max_len].copy_from_slice(&buf_in[..max_len]); // don't consume }, } if &buf == b"\x89PNG" { return Ok(FileType::PNG); } if &buf == b"GIF8" { return Ok(FileType::GIF); } if &buf == b"YUV4" { return Ok(FileType::Y4M); } if buf[..2] == [0xFF, 0xD8] { return Ok(FileType::JPEG); } Ok(FileType::Other) } fn check_if_paths_exist(paths: &[PathBuf]) -> BinResult<()> { for path in paths { // stdin is ok if path.as_os_str() == "-" && paths.len() == 1 { break; } let mut msg = match path.try_exists() { Ok(true) => continue, Ok(false) => format!("Unable to find the input file: \"{}\"", path.display()), Err(err) => format!("Unable to access the input file \"{}\": {err}", path.display()), }; let canon = path.canonicalize(); if let Some(parent) = canon.as_deref().unwrap_or(path).parent() { if parent.as_os_str() != "" && matches!(path.try_exists(), Ok(false)) { use std::fmt::Write; if msg.len() > 80 { msg.push('\n'); } write!(&mut msg, " (directory \"{}\" doesn't exist either)", parent.display())?; } } if path.to_str().is_some_and(|p| p.contains(['*', '?', '['])) { msg += "\nThe wildcard pattern did not match any files."; } else if path.is_relative() { use std::fmt::Write; write!(&mut msg, " (searched in \"{}\")", env::current_dir()?.display())?; } if path.extension() == Some("gif".as_ref()) { msg = format!("\nDid you mean to use -o \"{}\" to specify it as the output file instead?", path.display()); } return Err(msg.into()); } Ok(()) } #[derive(PartialEq)] enum DestPath<'a> { Path(&'a Path), Stdout, } enum SrcPath { Path(PathBuf), Stdin(BufReader>), } impl<'a> DestPath<'a> { pub fn new(path: &'a Path) -> Self { if path.as_os_str() == "-" { Self::Stdout } else { Self::Path(Path::new(path)) } } } impl fmt::Display for DestPath<'_> { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { Self::Path(orig_path) => { let abs_path = dunce::canonicalize(orig_path); abs_path.as_ref().map(|p| p.as_path()).unwrap_or(orig_path).display().fmt(f) }, Self::Stdout => f.write_str("stdout"), } } } #[cfg(feature = "video")] fn get_video_decoder(ftype: FileType, src: SrcPath, fps: source::Fps, in_color_space: Option, settings: Settings) -> BinResult> { Ok(if ftype == FileType::Y4M { Box::new(y4m_source::Y4MDecoder::new(src, fps, in_color_space)?) } else { Box::new(ffmpeg_source::FfmpegDecoder::new(src, fps, settings)?) }) } #[cfg(not(feature = "video"))] #[cold] fn get_video_decoder(ftype: FileType, src: SrcPath, fps: source::Fps, in_color_space: Option, _: Settings) -> BinResult> { if ftype == FileType::Y4M { Ok(Box::new(y4m_source::Y4MDecoder::new(src, fps, in_color_space)?)) } else { let path = match &src { SrcPath::Path(path) => path, SrcPath::Stdin(_) => Path::new("video.mp4"), }; let rel_path = path.file_name().map_or(path, Path::new); Err(format!(r#"Video support is permanently disabled in this distribution of gifski. The only 'video' format supported at this time is YUV4MPEG2, which can be piped from ffmpeg: ffmpeg -i "{src}" -f yuv4mpegpipe - | gifski -o "{gif}" - To enable full video decoding you need to recompile gifski from source. https://github.com/imageoptim/gifski Alternatively, use ffmpeg or other tool to export PNG frames, and then specify the PNG files as input for this executable. Instructions on https://gif.ski "#, src = path.display(), gif = rel_path.with_extension("gif").display() ).into()) } } struct ProgressBar { pb: pbr::ProgressBar, frames: u64, total: Option, previous_estimate: u64, displayed_estimate: u64, } impl ProgressBar { fn new(total: Option) -> Self { let mut pb = pbr::ProgressBar::new(total.unwrap_or(100)); pb.show_speed = false; pb.show_percent = false; pb.format(" #_. "); pb.message("Frame "); pb.set_max_refresh_rate(Some(Duration::from_millis(250))); Self { pb, frames: 0, total, previous_estimate: 0, displayed_estimate: 0, } } } impl ProgressReporter for ProgressBar { fn increase(&mut self) -> bool { self.frames += 1; if self.total.is_none() { self.pb.total = (self.frames + 50).max(100); } self.pb.inc(); true } fn written_bytes(&mut self, bytes: u64) { let min_frames = self.total.map_or(10, |t| (t / 16).clamp(5, 50)); if self.frames > min_frames { let total_size = bytes * self.pb.total / self.frames; let new_estimate = if total_size >= self.previous_estimate { total_size } else { (self.previous_estimate + total_size) / 2 }; self.previous_estimate = new_estimate; if self.displayed_estimate.abs_diff(new_estimate) > new_estimate / 10 { self.displayed_estimate = new_estimate; let (num, unit, x) = if new_estimate > 1_000_000 { (new_estimate as f64 / 1_000_000., "MB", if new_estimate > 10_000_000 { 0 } else { 1 }) } else { (new_estimate as f64 / 1_000., "KB", 0) }; self.pb.message(&format!("{num:.x$}{unit} GIF; Frame ")); } } } fn done(&mut self, msg: &str) { self.pb.finish_print(msg); } } ================================================ FILE: gifski-api/src/bin/png.rs ================================================ use crate::source::{Fps, Source}; use crate::BinResult; use gifski::Collector; use std::path::PathBuf; pub struct Lodecoder { frames: Vec, fps: f64, } impl Lodecoder { pub fn new(frames: Vec, params: Fps) -> Self { Self { frames, fps: f64::from(params.fps) * f64::from(params.speed), } } } impl Source for Lodecoder { fn total_frames(&self) -> Option { Some(self.frames.len() as u64) } #[inline(never)] fn collect(&mut self, dest: &mut Collector) -> BinResult<()> { let dest = &*dest; let f = std::mem::take(&mut self.frames); for (i, frame) in f.into_iter().enumerate() { dest.add_frame_png_file(i, frame, i as f64 / self.fps)?; } Ok(()) } } ================================================ FILE: gifski-api/src/bin/source.rs ================================================ use crate::BinResult; use gifski::Collector; pub trait Source { fn total_frames(&self) -> Option; fn collect(&mut self, dest: &mut Collector) -> BinResult<()>; } #[derive(Debug, Copy, Clone)] pub struct Fps { /// output rate pub fps: f32, /// skip frames pub speed: f32, } ================================================ FILE: gifski-api/src/bin/y4m_source.rs ================================================ use std::io::BufReader; use std::io::Read; use imgref::ImgVec; use gifski::Collector; use y4m::{Colorspace, Decoder, ParseError}; use yuv::color::{MatrixCoefficients, Range}; use yuv::convert::RGBConvert; use yuv::YUV; use crate::{SrcPath, BinResult}; use crate::source::{Fps, Source}; pub struct Y4MDecoder { fps: Fps, in_color_space: Option, decoder: Decoder>>, file_size: Option, } impl Y4MDecoder { pub fn new(src: SrcPath, fps: Fps, in_color_space: Option) -> BinResult { let mut file_size = None; let reader = match src { SrcPath::Path(path) => { let f = std::fs::File::open(path)?; let m = f.metadata()?; #[cfg(unix)] { use std::os::unix::fs::MetadataExt; file_size = Some(m.size()); } #[cfg(windows)] { use std::os::windows::fs::MetadataExt; file_size = Some(m.file_size()); } Box::new(BufReader::new(f)) as Box> }, SrcPath::Stdin(buf) => Box::new(buf) as Box>, }; Ok(Self { file_size, fps, in_color_space, decoder: Decoder::new(reader).map_err(|e| match e { y4m::Error::EOF => "The y4m file is truncated or invalid", y4m::Error::BadInput => "The y4m file contains invalid metadata", y4m::Error::UnknownColorspace => "y4m uses an unusual color format that is not supported", y4m::Error::OutOfMemory => "Out of memory, or the y4m file has bogus dimensions", y4m::Error::ParseError(ParseError::InvalidY4M) => "The input is not a y4m file", y4m::Error::ParseError(error) => return format!("y4m contains invalid data: {error}"), y4m::Error::IoError(error) => return format!("I/O error when reading a y4m file: {error}"), }.to_string())?, }) } } enum Samp { Mono, S1x1, S2x1, S2x2, } impl Source for Y4MDecoder { fn total_frames(&self) -> Option { self.file_size.map(|file_size| { let w = self.decoder.get_width(); let h = self.decoder.get_height(); let d = self.decoder.get_bytes_per_sample(); let s = match self.decoder.get_colorspace() { Colorspace::Cmono => 4, Colorspace::Cmono12 => 4, Colorspace::C420 => 6, Colorspace::C420p10 => 6, Colorspace::C420p12 => 6, Colorspace::C420jpeg => 6, Colorspace::C420paldv => 6, Colorspace::C420mpeg2 => 6, Colorspace::C422 => 8, Colorspace::C422p10 => 8, Colorspace::C422p12 => 8, Colorspace::C444 => 12, Colorspace::C444p10 => 12, Colorspace::C444p12 => 12, _ => 12, }; file_size.saturating_sub(self.decoder.get_raw_params().len() as _) / (w * h * d * s / 4 + 6) as u64 }) } fn collect(&mut self, c: &mut Collector) -> BinResult<()> { let fps = self.decoder.get_framerate(); let frame_time = 1. / (fps.num as f64 / fps.den as f64); let wanted_frame_time = 1. / f64::from(self.fps.fps); let width = self.decoder.get_width(); let height = self.decoder.get_height(); let raw_params_str = &*String::from_utf8_lossy(self.decoder.get_raw_params()).into_owned(); let range = raw_params_str.split_once("COLORRANGE=").map(|(_, r)| { if r.starts_with("FULL") { Range::Full } else { Range::Limited } }); let matrix = self.in_color_space.unwrap_or({ if height <= 480 && width <= 720 { MatrixCoefficients::BT601 } else { MatrixCoefficients::BT709 } }); let (samp, conv) = match self.decoder.get_colorspace() { Colorspace::Cmono => (Samp::Mono, RGBConvert::::new(range.unwrap_or(Range::Limited), MatrixCoefficients::Identity)), Colorspace::Cmono12 => return Err("Y4M with Cmono12 is not supported yet".into()), Colorspace::C420 => (Samp::S2x2, RGBConvert::::new(range.unwrap_or(Range::Limited), matrix)), Colorspace::C420p10 => return Err("Y4M with C420p10 is not supported yet".into()), Colorspace::C420p12 => return Err("Y4M with C420p12 is not supported yet".into()), Colorspace::C420jpeg => (Samp::S2x2, RGBConvert::::new(range.unwrap_or(Range::Limited), matrix)), Colorspace::C420paldv => (Samp::S2x2, RGBConvert::::new(range.unwrap_or(Range::Limited), matrix)), Colorspace::C420mpeg2 => (Samp::S2x2, RGBConvert::::new(range.unwrap_or(Range::Limited), matrix)), Colorspace::C422 => (Samp::S2x1, RGBConvert::::new(range.unwrap_or(Range::Limited), matrix)), Colorspace::C422p10 => return Err("Y4M with C422p10 is not supported yet".into()), Colorspace::C422p12 => return Err("Y4M with C422p12 is not supported yet".into()), Colorspace::C444 => (Samp::S1x1, RGBConvert::::new(range.unwrap_or(Range::Limited), matrix)), Colorspace::C444p10 => return Err("Y4M with C444p10 is not supported yet".into()), Colorspace::C444p12 => return Err("Y4M with C444p12 is not supported yet".into()), _ => return Err(format!("Y4M uses unsupported color mode {raw_params_str}").into()), }; let conv = conv?; if width == 0 || width > u16::MAX as _ || height == 0 || height > u16::MAX as _ { return Err("Video too large".into()); } #[cold] fn bad_frame(mode: &str) -> BinResult<()> { Err(format!("Bad Y4M frame (using {mode})").into()) } let mut idx = 0; let mut presentation_timestamp = 0.0; let mut wanted_pts = 0.0; loop { match self.decoder.read_frame() { Ok(frame) => { let this_frame_pts = presentation_timestamp / f64::from(self.fps.speed); presentation_timestamp += frame_time; if presentation_timestamp < wanted_pts { continue; // skip a frame } wanted_pts += wanted_frame_time; let y = frame.get_y_plane(); if y.is_empty() { return bad_frame(raw_params_str); } let u = frame.get_u_plane(); let v = frame.get_v_plane(); if v.len() != u.len() { return bad_frame(raw_params_str); } let mut out = Vec::new(); out.try_reserve(width * height)?; match samp { Samp::Mono => todo!(), Samp::S1x1 => { if v.len() != y.len() { return bad_frame(raw_params_str); } let y = y.chunks_exact(width); let u = u.chunks_exact(width); let v = v.chunks_exact(width); if y.len() != v.len() { return bad_frame(raw_params_str); } for (y, (u, v)) in y.zip(u.zip(v)) { out.extend( y.iter().copied().zip(u.iter().copied().zip(v.iter().copied())) .map(|(y, (u, v))| { conv.to_rgb(YUV {y, u, v}).with_alpha(255) })); } }, Samp::S2x1 => { let y = y.chunks_exact(width); let u = u.chunks_exact(width.div_ceil(2)); let v = v.chunks_exact(width.div_ceil(2)); if y.len() != v.len() { return bad_frame(raw_params_str); } for (y, (u, v)) in y.zip(u.zip(v)) { let u = u.iter().copied().flat_map(|x| [x, x]); let v = v.iter().copied().flat_map(|x| [x, x]); out.extend( y.iter().copied().zip(u.zip(v)) .map(|(y, (u, v))| { conv.to_rgb(YUV {y, u, v}).with_alpha(255) })); } }, Samp::S2x2 => { let y = y.chunks_exact(width); let u = u.chunks_exact(width.div_ceil(2)).flat_map(|r| [r, r]); let v = v.chunks_exact(width.div_ceil(2)).flat_map(|r| [r, r]); for (y, (u, v)) in y.zip(u.zip(v)) { let u = u.iter().copied().flat_map(|x| [x, x]); let v = v.iter().copied().flat_map(|x| [x, x]); out.extend( y.iter().copied().zip(u.zip(v)) .map(|(y, (u, v))| { conv.to_rgb(YUV {y, u, v}).with_alpha(255) })); } }, } if out.len() != width * height { return bad_frame(raw_params_str); } let pixels = ImgVec::new(out, width, height); c.add_frame_rgba(idx, pixels, this_frame_pts)?; idx += 1; }, Err(y4m::Error::EOF) => break, Err(e) => return Err(e.into()), } } Ok(()) } } ================================================ FILE: gifski-api/src/c_api/c_api_error.rs ================================================ use crate::GifResult; use std::fmt; use std::io; use std::os::raw::c_int; #[repr(C)] #[derive(Copy, Clone, Debug, Eq, PartialEq)] #[allow(non_camel_case_types)] #[allow(clippy::upper_case_acronyms)] pub enum GifskiError { OK = 0, NULL_ARG, INVALID_STATE, QUANT, GIF, THREAD_LOST, NOT_FOUND, PERMISSION_DENIED, ALREADY_EXISTS, INVALID_INPUT, TIMED_OUT, WRITE_ZERO, INTERRUPTED, UNEXPECTED_EOF, ABORTED, OTHER, } impl From for io::Error { #[cold] fn from(g: GifskiError) -> Self { use std::io::ErrorKind as EK; use GifskiError::*; match g { OK => panic!("wrong err code"), NOT_FOUND => EK::NotFound, PERMISSION_DENIED => EK::PermissionDenied, ALREADY_EXISTS => EK::AlreadyExists, INVALID_INPUT => EK::InvalidInput, TIMED_OUT => EK::TimedOut, WRITE_ZERO => EK::WriteZero, INTERRUPTED => EK::Interrupted, UNEXPECTED_EOF => EK::UnexpectedEof, _ => return Self::other(g), }.into() } } impl From for GifskiError { #[cold] fn from(res: c_int) -> Self { use GifskiError::*; match res { x if x == OK as c_int => OK, x if x == NULL_ARG as c_int => NULL_ARG, x if x == INVALID_STATE as c_int => INVALID_STATE, x if x == QUANT as c_int => QUANT, x if x == GIF as c_int => GIF, x if x == THREAD_LOST as c_int => THREAD_LOST, x if x == NOT_FOUND as c_int => NOT_FOUND, x if x == PERMISSION_DENIED as c_int => PERMISSION_DENIED, x if x == ALREADY_EXISTS as c_int => ALREADY_EXISTS, x if x == INVALID_INPUT as c_int => INVALID_INPUT, x if x == TIMED_OUT as c_int => TIMED_OUT, x if x == WRITE_ZERO as c_int => WRITE_ZERO, x if x == INTERRUPTED as c_int => INTERRUPTED, x if x == UNEXPECTED_EOF as c_int => UNEXPECTED_EOF, x if x == ABORTED as c_int => ABORTED, _ => OTHER, } } } impl From> for GifskiError { #[cold] fn from(res: GifResult<()>) -> Self { use crate::error::Error::*; match res { Ok(()) => GifskiError::OK, Err(err) => match err { Quant(_) => GifskiError::QUANT, Pal(_) => GifskiError::GIF, ThreadSend => GifskiError::THREAD_LOST, Io(ref err) => err.kind().into(), Aborted => GifskiError::ABORTED, Gifsicle | Gif(_) => GifskiError::GIF, NoFrames => GifskiError::INVALID_STATE, WrongSize(_) => GifskiError::INVALID_INPUT, PNG(_) => GifskiError::OTHER, }, } } } impl From for GifskiError { #[cold] fn from(res: io::ErrorKind) -> Self { use std::io::ErrorKind as EK; match res { EK::NotFound => GifskiError::NOT_FOUND, EK::PermissionDenied => GifskiError::PERMISSION_DENIED, EK::AlreadyExists => GifskiError::ALREADY_EXISTS, EK::InvalidInput | EK::InvalidData => GifskiError::INVALID_INPUT, EK::TimedOut => GifskiError::TIMED_OUT, EK::WriteZero => GifskiError::WRITE_ZERO, EK::Interrupted => GifskiError::INTERRUPTED, EK::UnexpectedEof => GifskiError::UNEXPECTED_EOF, _ => GifskiError::OTHER, } } } impl std::error::Error for GifskiError {} impl fmt::Display for GifskiError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fmt::Debug::fmt(self, f) } } ================================================ FILE: gifski-api/src/c_api.rs ================================================ #![allow(clippy::missing_safety_doc)] //! How to use from C //! //! ```c //! gifski *g = gifski_new(&(GifskiSettings){ //! .quality = 90, //! }); //! gifski_set_file_output(g, "file.gif"); //! //! for(int i=0; i < frames; i++) { //! int res = gifski_add_frame_rgba(g, i, width, height, buffer, 5); //! if (res != GIFSKI_OK) break; //! } //! int res = gifski_finish(g); //! if (res != GIFSKI_OK) return; //! ``` //! //! It's safe and efficient to call `gifski_add_frame_*` in a loop as fast as you can get frames, //! because it blocks and waits until previous frames are written. //! //! //! To cancel processing, make progress callback return 0 and call `gifski_finish()`. The write callback //! may still be called between the cancellation and `gifski_finish()` returning. //! //! To build as a library: //! //! ```bash //! cargo build --release --lib //! ``` //! //! it will create `target/release/libgifski.a` (static library) //! and `target/release/libgifski.so`/`dylib` or `gifski.dll` (dynamic library) //! //! Static is recommended. //! //! To build for iOS: //! //! ```bash //! rustup target add aarch64-apple-ios //! cargo build --release --lib --target aarch64-apple-ios //! ``` //! //! it will build `target/aarch64-apple-ios/release/libgifski.a` (ignore the warning about cdylib). use crate::{Collector, NoProgress, ProgressCallback, ProgressReporter, Repeat, Settings, Writer}; use imgref::{Img, ImgVec}; use rgb::{RGB8, RGBA8}; use std::fs; use std::ffi::{CStr, CString}; use std::fs::File; use std::io; use std::io::Write; use std::mem; use std::os::raw::{c_char, c_int, c_void}; use std::path::{Path, PathBuf}; use std::ptr; use std::slice; use std::thread; use std::sync::{Arc, Mutex}; mod c_api_error; use self::c_api_error::GifskiError; use std::panic::catch_unwind; /// Settings for creating a new encoder instance. See `gifski_new` #[repr(C)] #[derive(Copy, Clone)] pub struct GifskiSettings { /// Resize to max this width if non-0. pub width: u32, /// Resize to max this height if width is non-0. Note that aspect ratio is not preserved. pub height: u32, /// 1-100, but useful range is 50-100. Recommended to set to 90. pub quality: u8, /// Lower quality, but faster encode. pub fast: bool, /// If negative, looping is disabled. The number of times the sequence is repeated. 0 to loop forever. pub repeat: i16, } #[repr(C)] #[derive(Copy, Clone)] pub struct ARGB8 { pub a: u8, pub r: u8, pub g: u8, pub b: u8, } /// Opaque handle used in methods. Note that the handle pointer is actually `Arc`, /// but `Arc::into_raw` is nice enough to point past the counter. #[repr(C)] pub struct GifskiHandle { _opaque: usize, } pub struct GifskiHandleInternal { writer: Mutex>, collector: Mutex>, progress: Mutex>, error_callback: Mutex>>, /// Bool set to true when the thread has been set up, /// prevents re-setting of the thread after `finish()` write_thread: Mutex<(bool, Option>)>, } /// Call to start the process /// /// See `gifski_add_frame_png_file` and `gifski_end_adding_frames` /// /// Returns a handle for the other functions, or `NULL` on error (if the settings are invalid). #[no_mangle] pub unsafe extern "C" fn gifski_new(settings: *const GifskiSettings) -> *const GifskiHandle { let Some(settings) = settings.as_ref() else { return ptr::null_mut(); }; let s = Settings { width: if settings.width > 0 { Some(settings.width) } else { None }, height: if settings.height > 0 { Some(settings.height) } else { None }, quality: settings.quality, fast: settings.fast, repeat: if settings.repeat == -1 { Repeat::Finite(0) } else if settings.repeat == 0 { Repeat::Infinite } else { Repeat::Finite(settings.repeat as u16) }, }; if let Ok((collector, writer)) = crate::new(s) { Arc::into_raw(Arc::new(GifskiHandleInternal { writer: Mutex::new(Some(writer)), write_thread: Mutex::new((false, None)), collector: Mutex::new(Some(collector)), progress: Mutex::new(None), error_callback: Mutex::new(None), })) .cast::() } else { ptr::null_mut() } } /// Quality 1-100 of temporal denoising. Lower values reduce motion. Defaults to `settings.quality`. /// /// Only valid immediately after calling `gifski_new`, before any frames are added. #[no_mangle] pub unsafe extern "C" fn gifski_set_motion_quality(handle: *mut GifskiHandle, quality: u8) -> GifskiError { let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG }; if let Ok(Some(w)) = g.writer.lock().as_deref_mut() { #[allow(deprecated)] w.set_motion_quality(quality); GifskiError::OK } else { GifskiError::INVALID_STATE } } /// Quality 1-100 of gifsicle compression. Lower values add noise. Defaults to `settings.quality`. /// /// Has no effect if the `gifsicle` feature hasn't been enabled. /// Only valid immediately after calling `gifski_new`, before any frames are added. #[no_mangle] pub unsafe extern "C" fn gifski_set_lossy_quality(handle: *mut GifskiHandle, quality: u8) -> GifskiError { let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG }; if let Ok(Some(w)) = g.writer.lock().as_deref_mut() { #[allow(deprecated)] w.set_lossy_quality(quality); GifskiError::OK } else { GifskiError::INVALID_STATE } } /// If `true`, encoding will be significantly slower, but may look a bit better. /// /// Only valid immediately after calling `gifski_new`, before any frames are added. #[no_mangle] pub unsafe extern "C" fn gifski_set_extra_effort(handle: *mut GifskiHandle, extra: bool) -> GifskiError { let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG }; if let Ok(Some(w)) = g.writer.lock().as_deref_mut() { #[allow(deprecated)] w.set_extra_effort(extra); GifskiError::OK } else { GifskiError::INVALID_STATE } } /// Adds a fixed color that will be kept in the palette at all times. /// /// Only valid immediately after calling `gifski_new`, before any frames are added. #[no_mangle] pub unsafe extern "C" fn gifski_add_fixed_color(handle: *mut GifskiHandle, col_r: u8, col_g: u8, col_b: u8) -> GifskiError { let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG; }; if let Ok(Some(w)) = g.writer.lock().as_deref_mut() { w.add_fixed_color(RGB8::new(col_r, col_g, col_b)); GifskiError::OK } else { GifskiError::INVALID_STATE } } /// Adds a frame to the animation. This function is asynchronous. /// /// File path must be valid UTF-8. /// /// `frame_number` orders frames (consecutive numbers starting from 0). /// You can add frames in any order, and they will be sorted by their `frame_number`. /// /// Presentation timestamp (PTS) is time in seconds, since start of the file, when this frame is to be displayed. /// For a 20fps video it could be `frame_number/20.0`. /// Frames with duplicate or out-of-order PTS will be skipped. /// /// The first frame should have PTS=0. If the first frame has PTS > 0, it'll be used as a delay after the last frame. /// /// This function may block and wait until the frame is processed. Make sure to call `gifski_set_write_callback` or `gifski_set_file_output` first to avoid a deadlock. /// /// Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error. #[no_mangle] #[cfg(feature = "png")] pub unsafe extern "C" fn gifski_add_frame_png_file(handle: *const GifskiHandle, frame_number: u32, file_path: *const c_char, presentation_timestamp: f64) -> GifskiError { if file_path.is_null() { return GifskiError::NULL_ARG; } let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG }; let path = if let Ok(s) = CStr::from_ptr(file_path).to_str() { PathBuf::from(s) } else { return GifskiError::INVALID_INPUT; }; if let Ok(Some(c)) = g.collector.lock().as_deref_mut() { c.add_frame_png_file(frame_number as usize, path, presentation_timestamp).into() } else { g.print_error(format!("frame {frame_number} can't be added any more, because gifski_end_adding_frames has been called already")); GifskiError::INVALID_STATE } } /// Pixels is an array width×height×4 bytes large. The array is copied, so you can free/reuse it immediately. /// /// Presentation timestamp (PTS) is time in seconds, since start of the file (at 0), when this frame is to be displayed. /// For a 20fps video it could be `frame_number/20.0`. /// Frames with duplicate or out-of-order PTS will be skipped. /// /// The first frame should have PTS=0. If the first frame has PTS > 0, it'll be used as a delay after the last frame. /// /// Colors are in sRGB, uncorrelated RGBA, with alpha byte last. /// /// This function may block and wait until the frame is processed. Make sure to call `gifski_set_write_callback` or `gifski_set_file_output` first to avoid a deadlock. /// /// Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error. #[no_mangle] pub unsafe extern "C" fn gifski_add_frame_rgba(handle: *const GifskiHandle, frame_number: u32, width: u32, height: u32, pixels: *const RGBA8, presentation_timestamp: f64) -> GifskiError { if pixels.is_null() { return GifskiError::NULL_ARG; } if width == 0 || height == 0 || width > 0xFFFF || height > 0xFFFF { return GifskiError::INVALID_INPUT; } let width = width as usize; let height = height as usize; let pixels = slice::from_raw_parts(pixels, width * height); add_frame_rgba(handle, frame_number, Img::new(pixels.into(), width, height), presentation_timestamp) } /// Same as `gifski_add_frame_rgba`, but with bytes per row arg. #[no_mangle] pub unsafe extern "C" fn gifski_add_frame_rgba_stride(handle: *const GifskiHandle, frame_number: u32, width: u32, height: u32, bytes_per_row: u32, pixels: *const RGBA8, presentation_timestamp: f64) -> GifskiError { let (pixels, stride) = match pixels_slice(pixels, width, height, bytes_per_row) { Ok(v) => v, Err(err) => return err, }; let img = ImgVec::new_stride(pixels.into(), width as _, height as _, stride); add_frame_rgba(handle, frame_number, img, presentation_timestamp) } unsafe fn pixels_slice<'a, T>(pixels: *const T, width: u32, height: u32, bytes_per_row: u32) -> Result<(&'a [T], usize), GifskiError> { if pixels.is_null() { return Err(GifskiError::NULL_ARG); } let stride = bytes_per_row as usize / mem::size_of::(); let width = width as usize; let height = height as usize; if stride < width || width == 0 || height == 0 || width > 0xFFFF || height > 0xFFFF { return Err(GifskiError::INVALID_INPUT); } let pixels = slice::from_raw_parts(pixels, stride * height + width - stride); Ok((pixels, stride)) } fn add_frame_rgba(handle: *const GifskiHandle, frame_number: u32, frame: ImgVec, presentation_timestamp: f64) -> GifskiError { let Some(g) = (unsafe { borrow(handle) }) else { return GifskiError::NULL_ARG }; if let Ok(Some(c)) = g.collector.lock().as_deref_mut() { c.add_frame_rgba(frame_number as usize, frame, presentation_timestamp).into() } else { g.print_error(format!("frame {frame_number} can't be added any more, because gifski_end_adding_frames has been called already")); GifskiError::INVALID_STATE } } /// Same as `gifski_add_frame_rgba`, except it expects components in ARGB order. /// /// Bytes per row must be multiple of 4 and greater or equal width×4. /// /// Colors are in sRGB, uncorrelated ARGB, with alpha byte first. /// /// `gifski_add_frame_rgba` is preferred over this function. #[no_mangle] pub unsafe extern "C" fn gifski_add_frame_argb(handle: *const GifskiHandle, frame_number: u32, width: u32, bytes_per_row: u32, height: u32, pixels: *const ARGB8, presentation_timestamp: f64) -> GifskiError { let (pixels, stride) = match pixels_slice(pixels, width, height, bytes_per_row) { Ok(v) => v, Err(err) => return err, }; let width = width as usize; let height = height as usize; let img = ImgVec::new(pixels.chunks(stride).flat_map(|r| r[0..width].iter().map(|p| RGBA8 { r: p.r, g: p.g, b: p.b, a: p.a, })).collect(), width, height); add_frame_rgba(handle, frame_number, img, presentation_timestamp) } /// Same as `gifski_add_frame_rgba`, except it expects RGB components (3 bytes per pixel). /// /// Bytes per row must be multiple of 3 and greater or equal width×3. /// /// Colors are in sRGB, red byte first. /// This function may block and wait until the frame is processed. Make sure to call `gifski_set_write_callback` first to avoid a deadlock. /// /// `gifski_add_frame_rgba` is preferred over this function. #[no_mangle] pub unsafe extern "C" fn gifski_add_frame_rgb(handle: *const GifskiHandle, frame_number: u32, width: u32, bytes_per_row: u32, height: u32, pixels: *const RGB8, presentation_timestamp: f64) -> GifskiError { let (pixels, stride) = match pixels_slice(pixels, width, height, bytes_per_row) { Ok(v) => v, Err(err) => return err, }; let width = width as usize; let height = height as usize; let img = ImgVec::new(pixels.chunks(stride).flat_map(|r| r[0..width].iter().map(|&p| p.with_alpha(255))).collect(), width, height); add_frame_rgba(handle, frame_number, img, presentation_timestamp) } /// Get a callback for frame processed, and abort processing if desired. /// /// The callback is called once per input frame, /// even if the encoder decides to skip some frames. /// /// It gets arbitrary pointer (`user_data`) as an argument. `user_data` can be `NULL`. /// /// The callback must return `1` to continue processing, or `0` to abort. /// /// The callback must be thread-safe (it will be called from another thread). /// It must remain valid at all times, until `gifski_finish` completes. /// /// This function must be called before `gifski_set_file_output()` to take effect. #[no_mangle] pub unsafe extern "C" fn gifski_set_progress_callback(handle: *const GifskiHandle, cb: unsafe extern "C" fn(*mut c_void) -> c_int, user_data: *mut c_void) -> GifskiError { let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG }; if g.write_thread.lock().map_or(true, |t| t.0) { g.print_error("tried to set progress callback after writing has already started".into()); return GifskiError::INVALID_STATE; } match g.progress.lock() { Ok(mut progress) => { *progress = Some(ProgressCallback::new(cb, user_data)); GifskiError::OK }, Err(_) => GifskiError::THREAD_LOST, } } /// Get a callback when an error occurs. /// This is intended mostly for logging and debugging, not for user interface. /// /// The callback function has the following arguments: /// * A `\0`-terminated C string in UTF-8 encoding. The string is only valid for the duration of the call. Make a copy if you need to keep it. /// * An arbitrary pointer (`user_data`). `user_data` can be `NULL`. /// /// The callback must be thread-safe (it will be called from another thread). /// It must remain valid at all times, until `gifski_finish` completes. /// /// If the callback is not set, errors will be printed to stderr. /// /// This function must be called before `gifski_set_file_output()` to take effect. #[no_mangle] pub unsafe extern "C" fn gifski_set_error_message_callback(handle: *const GifskiHandle, cb: unsafe extern "C" fn(*const c_char, *mut c_void), user_data: *mut c_void) -> GifskiError { let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG }; let user_data = SendableUserData(user_data); match g.error_callback.lock() { Ok(mut error_callback) => { *error_callback = Some(Box::new(move |mut s: String| { s.reserve_exact(1); s.push('\0'); let cstring = CString::from_vec_with_nul(s.into_bytes()).unwrap_or_default(); unsafe { cb(cstring.as_ptr(), user_data.clone().0) } // the clone is a no-op, only to force closure to own it })); GifskiError::OK }, Err(_) => GifskiError::THREAD_LOST, } } #[derive(Clone)] struct SendableUserData(*mut c_void); unsafe impl Send for SendableUserData {} unsafe impl Sync for SendableUserData {} /// Start writing to the `destination`. This has to be called before any frames are added. /// /// This call will not block. /// /// Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error. #[no_mangle] pub unsafe extern "C" fn gifski_set_file_output(handle: *const GifskiHandle, destination: *const c_char) -> GifskiError { let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG }; catch_unwind(move || { let (file, path) = match prepare_for_file_writing(g, destination) { Ok(res) => res, Err(err) => return err, }; gifski_write_thread_start(g, file, Some(path)).err().unwrap_or(GifskiError::OK) }) .map_err(move |e| g.print_panic(e)).unwrap_or(GifskiError::THREAD_LOST) } fn prepare_for_file_writing(g: &GifskiHandleInternal, destination: *const c_char) -> Result<(File, PathBuf), GifskiError> { if destination.is_null() { return Err(GifskiError::NULL_ARG); } let path = if let Ok(s) = unsafe { CStr::from_ptr(destination).to_str() } { Path::new(s) } else { return Err(GifskiError::INVALID_INPUT); }; let t = g.write_thread.lock().map_err(|_| GifskiError::THREAD_LOST)?; if t.0 { g.print_error("tried to start writing for the second time, after it has already started".into()); return Err(GifskiError::INVALID_STATE); } match File::create(path) { Ok(file) => Ok((file, path.into())), Err(err) => Err(err.kind().into()), } } struct CallbackWriter { cb: unsafe extern "C" fn(usize, *const u8, *mut c_void) -> c_int, user_data: *mut c_void, } unsafe impl Send for CallbackWriter {} impl io::Write for CallbackWriter { fn write(&mut self, buf: &[u8]) -> io::Result { match unsafe { (self.cb)(buf.len(), buf.as_ptr(), self.user_data) } { 0 => Ok(buf.len()), x => Err(GifskiError::from(x).into()), } } fn flush(&mut self) -> io::Result<()> { match unsafe { (self.cb)(0, ptr::null(), self.user_data) } { 0 => Ok(()), x => Err(GifskiError::from(x).into()), } } } /// Start writing via callback (any buffer, file, whatever you want). This has to be called before any frames are added. /// This call will not block. /// /// The callback function receives 3 arguments: /// - size of the buffer to write, in bytes. IT MAY BE ZERO (when it's zero, either do nothing, or flush internal buffers if necessary). /// - pointer to the buffer. /// - context pointer to arbitrary user data, same as passed in to this function. /// /// The callback should return 0 (`GIFSKI_OK`) on success, and non-zero on error. /// /// The callback function must be thread-safe. It must remain valid at all times, until `gifski_finish` completes. /// /// Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error. #[no_mangle] pub unsafe extern "C" fn gifski_set_write_callback(handle: *const GifskiHandle, cb: Option c_int>, user_data: *mut c_void) -> GifskiError { let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG }; catch_unwind(move || { let Some(cb) = cb else { return GifskiError::NULL_ARG }; let writer = CallbackWriter { cb, user_data }; gifski_write_thread_start(g, writer, None).err().unwrap_or(GifskiError::OK) }) .map_err(move |e| g.print_panic(e)).unwrap_or(GifskiError::THREAD_LOST) } fn gifski_write_thread_start(g: &GifskiHandleInternal, file: W, path: Option) -> Result<(), GifskiError> { let mut t = g.write_thread.lock().map_err(|_| GifskiError::THREAD_LOST)?; if t.0 { g.print_error("gifski_set_file_output/gifski_set_write_callback has been called already".into()); return Err(GifskiError::INVALID_STATE); } let writer = g.writer.lock().map_err(|_| GifskiError::THREAD_LOST)?.take(); let mut user_progress = g.progress.lock().map_err(|_| GifskiError::THREAD_LOST)?.take(); let handle = thread::Builder::new().name("c-write".into()).spawn(move || { if let Some(writer) = writer { let progress = user_progress.as_mut().map(|m| m as &mut dyn ProgressReporter); match writer.write(file, progress.unwrap_or(&mut NoProgress {})).into() { res @ (GifskiError::OK | GifskiError::ALREADY_EXISTS) => res, err => { if let Some(path) = path { let _ = fs::remove_file(path); // clean up unfinished file } err }, } } else { eprintln!("gifski_set_file_output/gifski_set_write_callback has been called already"); GifskiError::INVALID_STATE } }); match handle { Ok(handle) => { *t = (true, Some(handle)); Ok(()) }, Err(_) => Err(GifskiError::THREAD_LOST), } } unsafe fn borrow<'a>(handle: *const GifskiHandle) -> Option<&'a GifskiHandleInternal> { let g = handle.cast::(); g.as_ref() } /// The last step: /// - stops accepting any more frames (`gifski_add_frame_*` calls are blocked) /// - blocks and waits until all already-added frames have finished writing /// /// Returns final status of write operations. Remember to check the return value! /// /// Must always be called, otherwise it will leak memory. /// After this call, the handle is freed and can't be used any more. /// /// Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error. #[no_mangle] pub unsafe extern "C" fn gifski_finish(g: *const GifskiHandle) -> GifskiError { if g.is_null() { return GifskiError::NULL_ARG; } let g = Arc::from_raw(g.cast::()); catch_unwind(|| { match g.collector.lock() { // dropping of the collector (if any) completes writing Ok(mut lock) => *lock = None, Err(_) => { g.print_error("warning: collector thread crashed".into()); }, } let thread = match g.write_thread.lock() { Ok(mut writer) => writer.1.take(), Err(_) => return GifskiError::THREAD_LOST, }; if let Some(thread) = thread { thread.join().map_err(|e| g.print_panic(e)).unwrap_or(GifskiError::THREAD_LOST) } else { g.print_error("warning: gifski_finish called before any output has been set".into()); GifskiError::OK // this will become INVALID_STATE once sync write support is dropped } }) .map_err(move |e| g.print_panic(e)).unwrap_or(GifskiError::THREAD_LOST) } impl GifskiHandleInternal { fn print_error(&self, mut err: String) { if let Ok(Some(cb)) = self.error_callback.lock().as_deref() { cb(err); } else { err.reserve_exact(1); err.push('\n'); let _ = std::io::stderr().write_all(err.as_bytes()); } } fn print_panic(&self, e: Box) { let msg = e.downcast_ref::().map(|s| s.as_str()) .or_else(|| e.downcast_ref::<&str>().copied()).unwrap_or("unknown panic"); self.print_error(format!("writer crashed (this is a bug): {msg}")); } } #[test] fn c_cb() { use rgb::RGB; let g = unsafe { gifski_new(&GifskiSettings { width: 1, height: 1, quality: 100, fast: false, repeat: -1, }) }; assert!(!g.is_null()); let mut write_called = false; unsafe extern "C" fn cb(_s: usize, _buf: *const u8, user_data: *mut c_void) -> c_int { let write_called = user_data.cast::(); *write_called = true; 0 } let mut progress_called = 0u32; unsafe extern "C" fn pcb(user_data: *mut c_void) -> c_int { let progress_called = user_data.cast::(); *progress_called += 1; 1 } unsafe { assert_eq!(GifskiError::OK, gifski_set_progress_callback(g, pcb, ptr::addr_of_mut!(progress_called).cast())); assert_eq!(GifskiError::OK, gifski_set_write_callback(g, Some(cb), ptr::addr_of_mut!(write_called).cast())); assert_eq!(GifskiError::INVALID_STATE, gifski_set_progress_callback(g, pcb, ptr::addr_of_mut!(progress_called).cast())); assert_eq!(GifskiError::OK, gifski_add_frame_rgb(g, 0, 1, 3, 1, &RGB::new(0,0,0), 3.)); assert_eq!(GifskiError::OK, gifski_add_frame_rgb(g, 0, 1, 3, 1, &RGB::new(0,0,0), 10.)); assert_eq!(GifskiError::OK, gifski_finish(g)); } assert!(write_called); assert_eq!(2, progress_called); } #[test] fn progress_abort() { use rgb::RGB; let g = unsafe { gifski_new(&GifskiSettings { width: 1, height: 1, quality: 100, fast: false, repeat: -1, }) }; assert!(!g.is_null()); unsafe extern "C" fn cb(_size: usize, _buf: *const u8, _user_data: *mut c_void) -> c_int { 0 } unsafe extern "C" fn pcb(_user_data: *mut c_void) -> c_int { 0 } unsafe { assert_eq!(GifskiError::OK, gifski_set_progress_callback(g, pcb, ptr::null_mut())); assert_eq!(GifskiError::OK, gifski_set_write_callback(g, Some(cb), ptr::null_mut())); assert_eq!(GifskiError::OK, gifski_add_frame_rgb(g, 0, 1, 3, 1, &RGB::new(0, 0, 0), 3.)); assert_eq!(GifskiError::OK, gifski_add_frame_rgb(g, 0, 1, 3, 1, &RGB::new(0, 0, 0), 10.)); assert_eq!(GifskiError::ABORTED, gifski_finish(g)); } } #[test] fn cant_write_after_finish() { let g = unsafe { gifski_new(&GifskiSettings { width: 1, height: 1, quality: 100, fast: false, repeat: -1, })}; assert!(!g.is_null()); unsafe extern "C" fn cb(_s: usize, _buf: *const u8, u1: *mut c_void) -> c_int { assert_eq!(u1 as usize, 1); 0 } unsafe { assert_eq!(GifskiError::OK, gifski_set_write_callback(g, Some(cb), 1 as _)); assert_eq!(GifskiError::INVALID_STATE, gifski_finish(g)); } } #[test] fn c_write_failure_propagated() { use rgb::RGB; let g = unsafe { gifski_new(&GifskiSettings { width: 1, height: 1, quality: 100, fast: false, repeat: -1, })}; assert!(!g.is_null()); unsafe extern "C" fn cb(_s: usize, _buf: *const u8, _user: *mut c_void) -> c_int { GifskiError::WRITE_ZERO as c_int } unsafe { assert_eq!(GifskiError::OK, gifski_set_write_callback(g, Some(cb), ptr::null_mut())); assert_eq!(GifskiError::OK, gifski_add_frame_rgb(g, 0, 1, 3, 1, &RGB::new(0, 0, 0), 5.0)); assert_eq!(GifskiError::WRITE_ZERO, gifski_finish(g)); } } #[test] fn test_error_callback() { let g = unsafe { gifski_new(&GifskiSettings { width: 1, height: 1, quality: 100, fast: false, repeat: -1, })}; assert!(!g.is_null()); unsafe extern "C" fn cb(_s: usize, _buf: *const u8, u1: *mut c_void) -> c_int { assert_eq!(u1 as usize, 1); 0 } unsafe extern "C" fn errcb(msg: *const c_char, user_data: *mut c_void) { let callback_msg = user_data.cast::>(); *callback_msg = Some(CStr::from_ptr(msg).to_str().unwrap().to_string()); } let mut callback_msg: Option = None; unsafe { assert_eq!(GifskiError::OK, gifski_set_error_message_callback(g, errcb, std::ptr::addr_of_mut!(callback_msg) as _)); assert_eq!(GifskiError::OK, gifski_set_write_callback(g, Some(cb), 1 as _)); assert_eq!(GifskiError::INVALID_STATE, gifski_set_write_callback(g, Some(cb), 1 as _)); assert_eq!(GifskiError::INVALID_STATE, gifski_finish(g)); assert_eq!("gifski_set_file_output/gifski_set_write_callback has been called already", callback_msg.unwrap()); } } #[test] fn cant_write_twice() { let g = unsafe { gifski_new(&GifskiSettings { width: 1, height: 1, quality: 100, fast: false, repeat: -1, })}; assert!(!g.is_null()); unsafe extern "C" fn cb(_s: usize, _buf: *const u8, _user: *mut c_void) -> c_int { GifskiError::WRITE_ZERO as c_int } unsafe { assert_eq!(GifskiError::OK, gifski_set_write_callback(g, Some(cb), ptr::null_mut())); assert_eq!(GifskiError::INVALID_STATE, gifski_set_write_callback(g, Some(cb), ptr::null_mut())); } } #[test] fn c_incomplete() { use rgb::RGB; let g = unsafe { gifski_new(&GifskiSettings { width: 0, height: 0, quality: 100, fast: true, repeat: 0, })}; assert_eq!(3, mem::size_of::()); assert!(!g.is_null()); unsafe { assert_eq!(GifskiError::NULL_ARG, gifski_add_frame_rgba(g, 0, 1, 1, ptr::null(), 5.0)); } extern "C" fn cb(_: *mut c_void) -> c_int { 1 } unsafe { gifski_set_progress_callback(g, cb, ptr::null_mut()); assert_eq!(GifskiError::OK, gifski_add_frame_rgba(g, 0, 1, 1, &RGBA8::new(0, 0, 0, 0), 5.0)); assert_eq!(GifskiError::OK, gifski_add_frame_rgb(g, 1, 1, 3, 1, &RGB::new(0, 0, 0), 5.0)); assert_eq!(GifskiError::OK, gifski_finish(g)); } } ================================================ FILE: gifski-api/src/collector.rs ================================================ //! For adding frames to the encoder //! //! [`gifski::new()`][crate::new] returns the [`Collector`] that collects animation frames, //! and a [`Writer`][crate::Writer] that performs compression and I/O. pub use imgref::ImgVec; pub use rgb::{RGB8, RGBA8}; use crate::error::GifResult; use crossbeam_channel::Sender; #[cfg(feature = "png")] use std::path::PathBuf; pub(crate) enum FrameSource { Pixels(ImgVec), #[cfg(feature = "png")] PngData(Vec), #[cfg(all(feature = "png", not(target_arch = "wasm32")))] Path(PathBuf), } pub(crate) struct InputFrame { /// The pixels to resize and encode pub frame: FrameSource, /// Time in seconds when to display the frame. First frame should start at 0. pub presentation_timestamp: f64, pub frame_index: usize, } pub(crate) struct InputFrameResized { /// The pixels to encode pub frame: ImgVec, /// The same as above, but with smart blur applied (for denoiser) pub frame_blurred: ImgVec, /// Time in seconds when to display the frame. First frame should start at 0. pub presentation_timestamp: f64, } /// Collect frames that will be encoded /// /// Note that writing will finish only when the collector is dropped. /// Collect frames on another thread, or call `drop(collector)` before calling `writer.write()`! pub struct Collector { pub(crate) queue: Sender, } impl Collector { /// Frame index starts at 0. /// /// Set each frame (index) only once, but you can set them in any order. However, out-of-order frames /// will be buffered in RAM, and big gaps in frame indices will cause high memory usage. /// /// Presentation timestamp is time in seconds (since file start at 0) when this frame is to be displayed. /// /// If the first frame doesn't start at pts=0, the delay will be used for the last frame. /// /// If this function appears to be stuck after a few frames, it's because [`crate::Writer::write()`] is not running. #[cfg_attr(debug_assertions, track_caller)] pub fn add_frame_rgba(&self, frame_index: usize, frame: ImgVec, presentation_timestamp: f64) -> GifResult<()> { debug_assert!(frame_index == 0 || presentation_timestamp > 0.); self.queue.send(InputFrame { frame_index, frame: FrameSource::Pixels(frame), presentation_timestamp, })?; Ok(()) } /// Decode a frame from in-memory PNG-compressed data. /// /// Frame index starts at 0. /// Set each frame (index) only once, but you can set them in any order. However, out-of-order frames /// will be buffered in RAM, and big gaps in frame indices will cause high memory usage. /// /// Presentation timestamp is time in seconds (since file start at 0) when this frame is to be displayed. /// /// If the first frame doesn't start at pts=0, the delay will be used for the last frame. /// /// If this function appears to be stuck after a few frames, it's because [`crate::Writer::write()`] is not running. #[cfg(feature = "png")] #[inline] pub fn add_frame_png_data(&self, frame_index: usize, png_data: Vec, presentation_timestamp: f64) -> GifResult<()> { self.queue.send(InputFrame { frame: FrameSource::PngData(png_data), presentation_timestamp, frame_index, })?; Ok(()) } /// Read and decode a PNG file from disk. /// /// Frame index starts at 0. /// Set each frame (index) only once, but you can set them in any order. /// /// Presentation timestamp is time in seconds (since file start at 0) when this frame is to be displayed. /// /// If the first frame doesn't start at pts=0, the delay will be used for the last frame. /// /// If this function appears to be stuck after a few frames, it's because [`crate::Writer::write()`] is not running. #[cfg(feature = "png")] pub fn add_frame_png_file(&self, frame_index: usize, path: PathBuf, presentation_timestamp: f64) -> GifResult<()> { self.queue.send(InputFrame { frame: FrameSource::Path(path), presentation_timestamp, frame_index, })?; Ok(()) } } ================================================ FILE: gifski-api/src/denoise.rs ================================================ use std::collections::VecDeque; use crate::PushInCapacity; pub use imgref::ImgRef; use imgref::ImgVec; use loop9::loop9_img; use rgb::ComponentMap; use rgb::RGB8; pub use rgb::RGBA8; const LOOKAHEAD: usize = 5; #[derive(Debug, Default, Copy, Clone)] pub struct Acc { px_blur: [(RGB8, RGB8); LOOKAHEAD], alpha_bits: u8, can_stay_for: u8, stayed_for: u8, /// The last pixel used (currently on screen) bg_set: RGBA8, } impl Acc { /// Actual pixel + blurred pixel #[inline(always)] pub fn get(&self, idx: usize) -> Option<(RGB8, RGB8)> { if idx >= LOOKAHEAD { debug_assert!(idx < LOOKAHEAD); return None; } if self.alpha_bits & (1 << idx) == 0 { Some(self.px_blur[idx]) } else { None } } #[inline(always)] pub fn append(&mut self, val: RGBA8, val_blur: RGB8) { for n in 1..LOOKAHEAD { self.px_blur[n - 1] = self.px_blur[n]; } self.alpha_bits >>= 1; if val.a < 128 { self.alpha_bits |= 1 << (LOOKAHEAD - 1); } else { self.px_blur[LOOKAHEAD - 1] = (val.rgb(), val_blur); } } } pub enum Denoised { // Feed more frames NotYet, // No more Done, Frame { frame: ImgVec, importance_map: ImgVec, meta: T, }, } pub struct Denoiser { /// the algo starts outputting on 3rd frame frames: usize, threshold: u32, splat: ImgVec, processed: VecDeque<(ImgVec, ImgVec)>, metadatas: VecDeque, } #[derive(Debug)] pub struct WrongSizeError; impl Denoiser { #[inline] pub fn new(width: usize, height: usize, quality: u8) -> Result { let area = width.checked_mul(height).ok_or(WrongSizeError)?; let clear = Acc { px_blur: [(RGB8::new(0, 0, 0), RGB8::new(0, 0, 0)); LOOKAHEAD], alpha_bits: (1 << LOOKAHEAD) - 1, bg_set: RGBA8::default(), stayed_for: 0, can_stay_for: 0, }; Ok(Self { frames: 0, processed: VecDeque::with_capacity(LOOKAHEAD), metadatas: VecDeque::with_capacity(LOOKAHEAD), threshold: (55 - u32::from(quality) / 2).pow(2), splat: ImgVec::new(vec![clear; area], width, height), }) } fn quick_append(&mut self, frame: ImgRef, frame_blurred: ImgRef) { for ((acc, src), src_blur) in self.splat.pixels_mut().zip(frame.pixels()).zip(frame_blurred.pixels()) { acc.append(src, src_blur); } } /// Generate last few frames #[inline(never)] pub fn flush(&mut self) { while self.processed.len() < self.metadatas.len() { let mut median1 = Vec::with_capacity(self.splat.width() * self.splat.height()); let mut imp_map1 = Vec::with_capacity(self.splat.width() * self.splat.height()); let odd_frame = self.frames & 1 != 0; for acc in self.splat.pixels_mut() { acc.append(RGBA8::new(0, 0, 0, 0), RGB8::new(0, 0, 0)); let (m, i) = acc.next_pixel(self.threshold, odd_frame); median1.push_in_cap(m); imp_map1.push_in_cap(i); } // may need to push down first if there were not enough frames to fill the pipeline self.frames += 1; if self.frames >= LOOKAHEAD { let median1 = ImgVec::new(median1, self.splat.width(), self.splat.height()); let imp_map1 = ImgVec::new(imp_map1, self.splat.width(), self.splat.height()); self.processed.push_front((median1, imp_map1)); } } } #[cfg(test)] fn push_frame_test(&mut self, frame: ImgRef, frame_metadata: T) -> Result<(), WrongSizeError> { let frame_blurred = smart_blur(frame); self.push_frame(frame, frame_blurred.as_ref(), frame_metadata) } #[inline(never)] pub fn push_frame(&mut self, frame: ImgRef, frame_blurred: ImgRef, frame_metadata: T) -> Result<(), WrongSizeError> { if frame.width() != self.splat.width() || frame.height() != self.splat.height() { return Err(WrongSizeError); } self.metadatas.push_front(frame_metadata); self.frames += 1; // Can't output anything yet if self.frames < LOOKAHEAD { self.quick_append(frame, frame_blurred); return Ok(()); } let mut median = Vec::with_capacity(frame.width() * frame.height()); let mut imp_map = Vec::with_capacity(frame.width() * frame.height()); let odd_frame = self.frames & 1 != 0; for ((acc, src), src_blur) in self.splat.pixels_mut().zip(frame.pixels()).zip(frame_blurred.pixels()) { acc.append(src, src_blur); let (m, i) = acc.next_pixel(self.threshold, odd_frame); median.push_in_cap(m); imp_map.push_in_cap(i); } let median = ImgVec::new(median, frame.width(), frame.height()); let imp_map = ImgVec::new(imp_map, frame.width(), frame.height()); self.processed.push_front((median, imp_map)); Ok(()) } #[inline] pub fn pop(&mut self) -> Denoised { if let Some((frame, importance_map)) = self.processed.pop_back() { let meta = self.metadatas.pop_back().expect("meta"); Denoised::Frame { frame, importance_map, meta } } else if !self.metadatas.is_empty() { Denoised::NotYet } else { Denoised::Done } } } impl Acc { fn next_pixel(&mut self, threshold: u32, odd_frame: bool) -> (RGBA8, u8) { // No previous bg set, so find a new one if let Some((curr, curr_blur)) = self.get(0) { let my_turn = cohort(curr) != odd_frame; let threshold = if my_turn { threshold } else { threshold * 2 }; let diff_with_bg = if self.bg_set.a > 0 { let bg = color_diff(self.bg_set.rgb(), curr); let bg_blur = color_diff(self.bg_set.rgb(), curr_blur); if bg < bg_blur { bg } else { (bg + bg_blur) / 2 } } else { 1<<20 }; if self.stayed_for < self.can_stay_for { // If this is the second, corrective frame, then // give it weight proportional to its staying duration let max = if self.stayed_for > 0 { 0 } else { [0, 40, 80, 100, 110][self.can_stay_for.min(4) as usize] }; // min == 0 may wipe pixels totally clear, so give them at least a second chance, // if quality setting allows let min = match threshold { 0..300 if self.stayed_for < 3 => 1, // q >= 75 300..500 if self.stayed_for < 2 => 1, 400..900 if self.stayed_for < 1 => 1, // q >= 50 _ => 0, }; self.stayed_for += 1; return (self.bg_set, pixel_importance(diff_with_bg, threshold, min, max)); } // if it's still good, keep rolling with it if diff_with_bg < threshold { return (self.bg_set, 0); } // See how long this bg can stay let mut stays_frames = 0; for i in 1..LOOKAHEAD { if self.get(i).is_some_and(|(c, blurred)| color_diff(c, curr) < threshold || color_diff(blurred, curr_blur) < threshold) { stays_frames = i; } else { break; } } // fast path for regular changing pixel if stays_frames == 0 { self.bg_set = curr.with_alpha(255); return (self.bg_set, pixel_importance(diff_with_bg, threshold, 10, 110)); } let imp = if stays_frames <= 1 { pixel_importance(diff_with_bg, threshold, 5, 80) } else if stays_frames == 2 { pixel_importance(diff_with_bg, threshold, 15, 190) } else { pixel_importance(diff_with_bg, threshold, 50, 205) }; // set the new current (bg) color to the median of the frames it matches self.bg_set = get_medians(&self.px_blur, stays_frames).with_alpha(255); // shorten stay-for to use overlapping ranges for smoother transitions self.can_stay_for = (stays_frames as u8).min(LOOKAHEAD as u8 - 1); self.stayed_for = 0; (self.bg_set, imp) } else { // pixels with importance == 0 are totally ignored, but that could skip frames // which need to set background to clear let imp = if self.bg_set.a > 0 { self.bg_set.a = 0; self.can_stay_for = 0; 1 } else { 0 }; (RGBA8::new(0,0,0,0), imp) } } } /// Median of 9 neighboring pixels macro_rules! median_channel { ($top:expr, $mid:expr, $bot:expr, $chan:ident) => { *[ if $top.prev.a > 0 { $top.prev.$chan } else { $mid.curr.$chan }, if $top.curr.a > 0 { $top.curr.$chan } else { $mid.curr.$chan }, if $top.next.a > 0 { $top.next.$chan } else { $mid.curr.$chan }, if $mid.prev.a > 0 { $mid.prev.$chan } else { $mid.curr.$chan }, $mid.curr.$chan, // if the center pixel is transparent, the result won't be used if $mid.next.a > 0 { $mid.next.$chan } else { $mid.curr.$chan }, if $bot.prev.a > 0 { $bot.prev.$chan } else { $mid.curr.$chan }, if $bot.curr.a > 0 { $bot.curr.$chan } else { $mid.curr.$chan }, if $bot.next.a > 0 { $bot.next.$chan } else { $mid.curr.$chan }, ].select_nth_unstable(4).1 } } /// Average of 9 neighboring pixels macro_rules! blur_channel { ($top:expr, $mid:expr, $bot:expr, $chan:ident) => {{ let mut tmp = 0u16; tmp += u16::from(if $top.prev.a > 0 { $top.prev.$chan } else { $mid.curr.$chan }); tmp += u16::from(if $top.curr.a > 0 { $top.curr.$chan } else { $mid.curr.$chan }); tmp += u16::from(if $top.next.a > 0 { $top.next.$chan } else { $mid.curr.$chan }); tmp += u16::from(if $mid.prev.a > 0 { $mid.prev.$chan } else { $mid.curr.$chan }); tmp += u16::from($mid.curr.$chan); // if the center pixel is transparent, the result won't be used tmp += u16::from(if $mid.next.a > 0 { $mid.next.$chan } else { $mid.curr.$chan }); tmp += u16::from(if $bot.prev.a > 0 { $bot.prev.$chan } else { $mid.curr.$chan }); tmp += u16::from(if $bot.curr.a > 0 { $bot.curr.$chan } else { $mid.curr.$chan }); tmp += u16::from(if $bot.next.a > 0 { $bot.next.$chan } else { $mid.curr.$chan }); (tmp / 9) as u8 }} } #[inline(never)] pub(crate) fn smart_blur(frame: ImgRef) -> ImgVec { let mut out = Vec::with_capacity(frame.width() * frame.height()); loop9_img(frame, |_, _, top, mid, bot| { out.push_in_cap(if mid.curr.a > 0 { let median_r = median_channel!(top, mid, bot, r); let median_g = median_channel!(top, mid, bot, g); let median_b = median_channel!(top, mid, bot, b); let blurred = RGB8::new(median_r, median_g, median_b); if color_diff(mid.curr.rgb(), blurred) < 16 * 16 * 6 { blurred } else { mid.curr.rgb() } } else { RGB8::new(255, 0, 255) }); }); ImgVec::new(out, frame.width(), frame.height()) } #[inline(never)] pub(crate) fn less_smart_blur(frame: ImgRef) -> ImgVec { let mut out = Vec::with_capacity(frame.width() * frame.height()); loop9_img(frame, |_, _, top, mid, bot| { out.push_in_cap(if mid.curr.a > 0 { let median_r = blur_channel!(top, mid, bot, r); let median_g = blur_channel!(top, mid, bot, g); let median_b = blur_channel!(top, mid, bot, b); let blurred = RGB8::new(median_r, median_g, median_b); if color_diff(mid.curr.rgb(), blurred) < 16 * 16 * 6 { blurred } else { mid.curr.rgb() } } else { RGB8::new(255, 0, 255) }); }); ImgVec::new(out, frame.width(), frame.height()) } /// The idea is to split colors into two arbitrary groups, and flip-flop weight between them. /// This might help quantization have less unique colors per frame, and catch up in the next frame. #[inline(always)] fn cohort(color: RGB8) -> bool { (color.r / 2 > color.g) != (color.b > 127) } /// importance = how much it exceeds percetible threshold #[inline(always)] fn pixel_importance(diff_with_bg: u32, threshold: u32, min: u8, max: u8) -> u8 { debug_assert!((u32::from(min) + u32::from(max)) <= 255); let exceeds = diff_with_bg.saturating_sub(threshold); min + (exceeds.saturating_mul(u32::from(max)) / (threshold.saturating_mul(48))).min(u32::from(max)) as u8 } #[inline(always)] fn avg8(a: u8, b: u8) -> u8 { ((u16::from(a) + u16::from(b)) / 2) as u8 } #[inline(always)] fn zip(zip: impl Fn(fn(&(RGB8, RGB8)) -> u8) -> u8) -> RGB8 { RGB8 { r: zip(|px| px.0.r), g: zip(|px| px.0.g), b: zip(|px| px.0.b), } } #[inline(always)] fn get_medians(src: &[(RGB8, RGB8); LOOKAHEAD], len_minus_one: usize) -> RGB8 { match len_minus_one { 0 => src[0].0, 1 => zip(|ch| avg8(ch(&src[0]), ch(&src[1]))), 2 => zip(|ch| { let mut tmp: [u8; 3] = std::array::from_fn(|i| ch(&src[i])); tmp.sort_unstable(); tmp[1] }), 3 => zip(|ch| { let mut tmp: [u8; 3] = std::array::from_fn(|i| ch(&src[i])); tmp.sort_unstable(); avg8(tmp[1], tmp[2]) }), 4 => zip(|ch| { let mut tmp: [u8; 3] = std::array::from_fn(|i| ch(&src[i])); tmp.sort_unstable(); tmp[2] }), _ => { debug_assert!(false); src[0].0 }, } } #[inline] fn color_diff(x: RGB8, y: RGB8) -> u32 { let x = x.map(i32::from); let y = y.map(i32::from); (x.r - y.r).pow(2) as u32 * 2 + (x.g - y.g).pow(2) as u32 * 3 + (x.b - y.b).pow(2) as u32 } #[track_caller] #[cfg(test)] fn px(f: Denoised) -> (RGBA8, T) { if let Denoised::Frame { frame, meta, .. } = f { (frame.pixels().next().unwrap(), meta) } else { panic!("no frame") } } #[test] fn one() { let mut d = Denoiser::new(1, 1, 100).unwrap(); let w = RGBA8::new(255, 255, 255, 255); let frame = ImgVec::new(vec![w], 1, 1); let frame_blurred = smart_blur(frame.as_ref()); d.push_frame(frame.as_ref(), frame_blurred.as_ref(), 0).unwrap(); assert!(matches!(d.pop(), Denoised::NotYet)); d.flush(); assert_eq!(px(d.pop()), (w, 0)); assert!(matches!(d.pop(), Denoised::Done)); } #[test] fn two() { let mut d = Denoiser::new(1,1, 100).unwrap(); let w = RGBA8::new(254,253,252,255); let b = RGBA8::new(8,7,0,255); d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), 0).unwrap(); d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), 1).unwrap(); assert!(matches!(d.pop(), Denoised::NotYet)); d.flush(); assert_eq!(px(d.pop()), (w, 0)); assert_eq!(px(d.pop()), (b, 1)); assert!(matches!(d.pop(), Denoised::Done)); } #[test] fn three() { let mut d = Denoiser::new(1,1, 100).unwrap(); let w = RGBA8::new(254,253,252,255); let b = RGBA8::new(8,7,0,255); d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), 0).unwrap(); d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), 1).unwrap(); d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), 2).unwrap(); assert!(matches!(d.pop(), Denoised::NotYet)); d.flush(); assert_eq!(px(d.pop()), (w, 0)); assert_eq!(px(d.pop()), (b, 1)); assert_eq!(px(d.pop()), (b, 2)); assert!(matches!(d.pop(), Denoised::Done)); } #[test] fn four() { let mut d = Denoiser::new(1,1, 100).unwrap(); let w = RGBA8::new(254,253,252,255); let b = RGBA8::new(8,7,0,255); let t = RGBA8::new(0,0,0,0); d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), 0).unwrap(); d.push_frame_test(ImgVec::new(vec![t], 1, 1).as_ref(), 1).unwrap(); d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), 2).unwrap(); d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), 3).unwrap(); assert!(matches!(d.pop(), Denoised::NotYet)); d.flush(); assert_eq!(px(d.pop()), (w, 0)); assert_eq!(px(d.pop()), (t, 1)); assert_eq!(px(d.pop()), (b, 2)); assert_eq!(px(d.pop()), (w, 3)); assert!(matches!(d.pop(), Denoised::Done)); } #[test] fn five() { let mut d = Denoiser::new(1,1, 100).unwrap(); let w = RGBA8::new(254,253,252,255); let b = RGBA8::new(8,7,0,255); let t = RGBA8::new(0,0,0,0); d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), 0).unwrap(); d.push_frame_test(ImgVec::new(vec![t], 1, 1).as_ref(), 1).unwrap(); d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), 2).unwrap(); d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), 3).unwrap(); assert!(matches!(d.pop(), Denoised::NotYet)); d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), 4).unwrap(); assert_eq!(px(d.pop()), (w, 0)); d.flush(); assert_eq!(px(d.pop()), (t, 1)); assert_eq!(px(d.pop()), (b, 2)); assert_eq!(px(d.pop()), (b, 3)); assert_eq!(px(d.pop()), (w, 4)); assert!(matches!(d.pop(), Denoised::Done)); } #[test] fn six() { let mut d = Denoiser::new(1,1, 100).unwrap(); let w = RGBA8::new(254,253,252,255); let b = RGBA8::new(8,7,0,255); let t = RGBA8::new(0,0,0,0); let x = RGBA8::new(4,5,6,255); d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), 0).unwrap(); assert!(matches!(d.pop(), Denoised::NotYet)); d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), 1).unwrap(); assert!(matches!(d.pop(), Denoised::NotYet)); d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), 2).unwrap(); assert!(matches!(d.pop(), Denoised::NotYet)); d.push_frame_test(ImgVec::new(vec![t], 1, 1).as_ref(), 3).unwrap(); assert!(matches!(d.pop(), Denoised::NotYet)); d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), 4).unwrap(); assert_eq!(px(d.pop()), (w, 0)); d.push_frame_test(ImgVec::new(vec![x], 1, 1).as_ref(), 5).unwrap(); d.flush(); assert_eq!(px(d.pop()), (b, 1)); assert_eq!(px(d.pop()), (b, 2)); assert_eq!(px(d.pop()), (t, 3)); assert_eq!(px(d.pop()), (w, 4)); assert_eq!(px(d.pop()), (x, 5)); assert!(matches!(d.pop(), Denoised::Done)); } #[test] fn many() { let mut d = Denoiser::new(1,1, 100).unwrap(); let w = RGBA8::new(255,254,253,255); let b = RGBA8::new(1,2,3,255); let t = RGBA8::new(0,0,0,0); d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), "w0").unwrap(); assert!(matches!(d.pop(), Denoised::NotYet)); d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), "w1").unwrap(); assert!(matches!(d.pop(), Denoised::NotYet)); d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), "b2").unwrap(); assert!(matches!(d.pop(), Denoised::NotYet)); d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), "b3").unwrap(); assert!(matches!(d.pop(), Denoised::NotYet)); d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), "b4").unwrap(); assert_eq!(px(d.pop()), (w, "w0")); d.push_frame_test(ImgVec::new(vec![t], 1, 1).as_ref(), "t5").unwrap(); assert_eq!(px(d.pop()), (w, "w1")); d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), "b6").unwrap(); assert_eq!(px(d.pop()), (b, "b2")); d.flush(); assert_eq!(px(d.pop()), (b, "b3")); assert_eq!(px(d.pop()), (b, "b4")); assert_eq!(px(d.pop()), (t, "t5")); assert_eq!(px(d.pop()), (b, "b6")); assert!(matches!(d.pop(), Denoised::Done)); } ================================================ FILE: gifski-api/src/encoderust.rs ================================================ use crate::error::CatResult; use crate::{GIFFrame, Settings, SettingsExt}; use rgb::RGB8; use std::cell::Cell; use std::io::Write; use std::iter::repeat; use std::rc::Rc; #[cfg(feature = "gifsicle")] use crate::gifsicle; struct CountingWriter { writer: W, written: Rc>, } impl Write for CountingWriter { #[inline(always)] fn write(&mut self, buf: &[u8]) -> Result { let len = self.writer.write(buf)?; self.written.set(self.written.get() + len as u64); Ok(len) } #[inline(always)] fn flush(&mut self) -> Result<(), std::io::Error> { self.writer.flush() } } pub(crate) struct RustEncoder { writer: Option, written: Rc>, gif_enc: Option>>, } impl RustEncoder { pub fn new(writer: W, written: Rc>) -> Self { Self { written, writer: Some(writer), gif_enc: None, } } } impl RustEncoder { #[inline(never)] #[cfg_attr(debug_assertions, track_caller)] pub fn compress_frame(f: GIFFrame, settings: &SettingsExt) -> CatResult> { let GIFFrame {left, top, pal, image, dispose, transparent_index} = f; let (buffer, width, height) = image.into_contiguous_buf(); let mut pal_rgb = rgb::bytemuck::cast_slice(&pal).to_vec(); // Palette should be power-of-two sized if pal.len() != 256 { let needed_size = 3 * pal.len().max(2).next_power_of_two(); pal_rgb.extend(repeat([115, 107, 105, 46, 103, 105, 102]).flatten().take(needed_size - pal_rgb.len())); debug_assert_eq!(needed_size, pal_rgb.len()); } let mut frame = gif::Frame { delay: 1, // TBD dispose, transparent: transparent_index, needs_user_input: false, top, left, width: width as u16, height: height as u16, interlaced: false, palette: Some(pal_rgb), buffer: buffer.into(), }; #[allow(unused)] let loss = settings.gifsicle_loss(); #[cfg(feature = "gifsicle")] if loss > 0 { Self::compress_gifsicle(&mut frame, loss)?; return Ok(frame); } frame.make_lzw_pre_encoded(); Ok(frame) } #[cfg(feature = "gifsicle")] #[inline(never)] fn compress_gifsicle(frame: &mut gif::Frame<'static>, loss: u32) -> CatResult<()> { use crate::Error; use gifsicle::{GiflossyImage, GiflossyWriter}; let pal = frame.palette.as_ref().ok_or(Error::Gifsicle)?; let g_pal = pal.chunks_exact(3) .map(|c| RGB8 { r: c[0], g: c[1], b: c[2], }) .collect::>(); let gif_img = GiflossyImage::new(&frame.buffer, frame.width, frame.height, frame.transparent, Some(&g_pal)); let mut lossy_writer = GiflossyWriter { loss }; frame.buffer = lossy_writer.write(&gif_img, None)?.into(); Ok(()) } pub fn write_frame(&mut self, mut frame: gif::Frame<'static>, delay: u16, screen_width: u16, screen_height: u16, settings: &Settings) -> CatResult<()> { frame.delay = delay; // the delay wasn't known let writer = &mut self.writer; let enc = match self.gif_enc { None => { let w = CountingWriter { writer: writer.take().ok_or(crate::Error::ThreadSend)?, written: self.written.clone(), }; let mut enc = gif::Encoder::new(w, screen_width, screen_height, &[])?; enc.write_extension(gif::ExtensionData::Repetitions(settings.repeat))?; enc.write_raw_extension(gif::Extension::Comment.into(), &[b"gif.ski"])?; self.gif_enc.get_or_insert(enc) }, Some(ref mut enc) => enc, }; enc.write_lzw_pre_encoded_frame(&frame)?; Ok(()) } } ================================================ FILE: gifski-api/src/error.rs ================================================ use crate::WrongSizeError; use quick_error::quick_error; use std::io; use std::num::TryFromIntError; quick_error! { #[derive(Debug)] pub enum Error { /// Internal error ThreadSend { display("Internal error; unexpectedly aborted") } Aborted { display("aborted") } Gifsicle { display("gifsicle failure") } Gif(err: gif::EncodingError) { display("GIF encoding error: {}", err) } NoFrames { display("Found no usable frames to encode") } Io(err: io::Error) { from() from(_oom: std::collections::TryReserveError) -> (io::ErrorKind::OutOfMemory.into()) display("I/O: {}", err) } PNG(msg: String) { display("{}", msg) } WrongSize(msg: String) { display("{}", msg) from(e: TryFromIntError) -> (e.to_string()) from(_e: WrongSizeError) -> ("wrong size".to_string()) from(e: resize::Error) -> (e.to_string()) } Quant(liq: imagequant::liq_error) { from() display("pngquant error: {}", liq) } Pal(gif: gif_dispose::Error) { from() display("gif dispose error: {}", gif) } } } #[doc(hidden)] pub type CatResult = Result; /// Alias for `Result` with gifski's [`Error`] pub type GifResult = Result; impl From for Error { #[cold] fn from(err: gif::EncodingError) -> Self { match err { gif::EncodingError::Io(err) => err.into(), other => Self::Gif(other), } } } impl From> for Error { #[cold] fn from(_: ordered_channel::SendError) -> Self { Self::ThreadSend } } impl From for Error { #[cold] fn from(_: ordered_channel::RecvError) -> Self { Self::Aborted } } impl From> for Error { #[cold] fn from(_panic: Box) -> Self { Self::ThreadSend } } ================================================ FILE: gifski-api/src/gifsicle.rs ================================================ pub struct GiflossyImage<'data> { img: &'data [u8], width: u16, height: u16, interlace: bool, transparent: Option, pal: Option<&'data [RGB8]>, } use rgb::RGB8; use crate::Error; pub type LzwCode = u16; #[derive(Clone, Copy)] pub struct GiflossyWriter { pub loss: u32, } struct CodeTable { pub nodes: Vec, pub links_used: usize, pub clear_code: LzwCode, } type NodeId = u16; struct Node { pub code: LzwCode, pub suffix: u8, pub children: Vec, } type RgbDiff = rgb::RGB; #[inline] fn color_diff(a: RGB8, b: RGB8, a_transparent: bool, b_transparent: bool, dither: RgbDiff) -> u32 { if a_transparent != b_transparent { return (1 << 25) as u32; } if a_transparent { return 0; } let dith = ((i32::from(a.r) - i32::from(b.r) + i32::from(dither.r)) * (i32::from(a.r) - i32::from(b.r) + i32::from(dither.r)) + (i32::from(a.g) - i32::from(b.g) + i32::from(dither.g)) * (i32::from(a.g) - i32::from(b.g) + i32::from(dither.g)) + (i32::from(a.b) - i32::from(b.b) + i32::from(dither.b)) * (i32::from(a.b) - i32::from(b.b) + i32::from(dither.b))) as u32; let undith = ((i32::from(a.r) - i32::from(b.r) + i32::from(dither.r) / 2) * (i32::from(a.r) - i32::from(b.r) + i32::from(dither.r) / 2) + (i32::from(a.g) - i32::from(b.g) + i32::from(dither.g) / 2) * (i32::from(a.g) - i32::from(b.g) + i32::from(dither.g) / 2) + (i32::from(a.b) - i32::from(b.b) + i32::from(dither.b) / 2) * (i32::from(a.b) - i32::from(b.b) + i32::from(dither.b) / 2)) as u32; if dith < undith { dith } else { undith } } #[inline] fn diffused_difference( a: RGB8, b: RGB8, a_transparent: bool, b_transparent: bool, dither: RgbDiff, ) -> RgbDiff { if a_transparent || b_transparent { RgbDiff { r: 0, g: 0, b: 0 } } else { RgbDiff { r: (i32::from(a.r) - i32::from(b.r) + i32::from(dither.r) * 3 / 4) as i16, g: (i32::from(a.g) - i32::from(b.g) + i32::from(dither.g) * 3 / 4) as i16, b: (i32::from(a.b) - i32::from(b.b) + i32::from(dither.b) * 3 / 4) as i16, } } } impl CodeTable { #[inline] fn define(&mut self, work_node_id: NodeId, suffix: u8, next_code: LzwCode) { let id = self.nodes.len() as u16; self.nodes.push(Node { code: next_code, suffix, children: Vec::new(), }); self.nodes[work_node_id as usize].children.push(id); } #[cold] fn reset(&mut self) { self.links_used = 0; self.nodes.clear(); self.nodes.extend((0..usize::from(self.clear_code)).map(|i| Node { code: i as u16, suffix: i as u8, children: Vec::new(), })); } } struct Lookup<'a> { pub code_table: &'a CodeTable, pub pal: &'a [RGB8], pub image: &'a GiflossyImage<'a>, pub max_diff: u32, pub best_node: NodeId, pub best_pos: usize, pub best_total_diff: u64, } impl Lookup<'_> { pub fn lossy_node(&mut self, pos: usize, node_id: NodeId, total_diff: u64, dither: RgbDiff) { let Some(px) = self.image.px_at_pos(pos) else { return; }; self.code_table.nodes[node_id as usize].children.iter().copied().for_each(|node_id| { self.try_node( pos, node_id, px, dither, total_diff, ); }); } #[inline] fn try_node( &mut self, pos: usize, node_id: NodeId, px: u8, dither: RgbDiff, total_diff: u64, ) { let node = &self.code_table.nodes[node_id as usize]; let next_px = node.suffix; let diff = if px == next_px { 0 } else { color_diff( self.pal[px as usize], self.pal[next_px as usize], Some(px) == self.image.transparent, Some(next_px) == self.image.transparent, dither, ) }; if diff <= self.max_diff { let new_dither = diffused_difference( self.pal[px as usize], self.pal[next_px as usize], Some(px) == self.image.transparent, Some(next_px) == self.image.transparent, dither, ); let new_pos = pos + 1; let new_diff = total_diff + u64::from(diff); if new_pos > self.best_pos || new_pos == self.best_pos && new_diff < self.best_total_diff { self.best_node = node_id; self.best_pos = new_pos; self.best_total_diff = new_diff; } self.lossy_node(new_pos, node_id, new_diff, new_dither); } } } const RUN_EWMA_SHIFT: usize = 4; const RUN_EWMA_SCALE: usize = 19; const RUN_INV_THRESH: usize = (1 << RUN_EWMA_SCALE) / 3000; impl GiflossyWriter { pub fn write(&mut self, image: &GiflossyImage, global_pal: Option<&[RGB8]>) -> Result, Error> { let mut buf = Vec::new(); buf.try_reserve((image.height as usize * image.width as usize / 4).next_power_of_two())?; let mut run = 0; let mut run_ewma = 0; let mut next_code = 0; let pal = image.pal.or(global_pal).unwrap(); let min_code_size = (pal.len() as u32).max(3).next_power_of_two().trailing_zeros() as u8; buf.push(min_code_size); let mut bufpos_bits = 8; let mut code_table = CodeTable { clear_code: 1 << u16::from(min_code_size), links_used: 0, nodes: Vec::new(), }; code_table.reset(); let mut cur_code_bits = min_code_size + 1; let mut output_code = code_table.clear_code as LzwCode; let mut clear_bufpos_bits = bufpos_bits; let mut pos = 0; let mut clear_pos = pos; loop { let endpos_bits = bufpos_bits + (cur_code_bits as usize); loop { if bufpos_bits & 7 != 0 { buf[bufpos_bits / 8] |= (output_code << (bufpos_bits & 7)) as u8; } else { buf.push((output_code >> (bufpos_bits + (cur_code_bits as usize) - endpos_bits)) as u8); } bufpos_bits = bufpos_bits + 8 - (bufpos_bits & 7); if bufpos_bits >= endpos_bits { break; } } bufpos_bits = endpos_bits; if output_code == code_table.clear_code { cur_code_bits = min_code_size + 1; next_code = (code_table.clear_code + 2) as LzwCode; run_ewma = 1 << RUN_EWMA_SCALE; code_table.reset(); clear_bufpos_bits = 0; clear_pos = clear_bufpos_bits; } else { if output_code == (code_table.clear_code + 1) { break; } if next_code > (1 << cur_code_bits) && cur_code_bits < 12 { cur_code_bits += 1; } run = (((run as u32) << RUN_EWMA_SCALE) + (1 << (RUN_EWMA_SHIFT - 1) as u32)) as usize; if run < run_ewma { run_ewma = run_ewma - ((run_ewma - run) >> RUN_EWMA_SHIFT); } else { run_ewma = run_ewma + ((run - run_ewma) >> RUN_EWMA_SHIFT); } } if let Some(px) = image.px_at_pos(pos) { let mut l = Lookup { code_table: &code_table, pal, image, max_diff: self.loss, best_node: u16::from(px), best_pos: pos + 1, best_total_diff: 0, }; l.lossy_node(pos + 1, u16::from(px), 0, RgbDiff { r: 0, g: 0, b: 0 }); run = l.best_pos - pos; pos = l.best_pos; let selected_node = &code_table.nodes[l.best_node as usize]; output_code = selected_node.code; if let Some(px) = image.px_at_pos(pos) { if next_code < 0x1000 { code_table.define(l.best_node, px, next_code); next_code += 1; } else { next_code = 0x1001; } if next_code >= 0x0FFF { let pixels_left = image.img.len() - pos - 1; let do_clear = pixels_left != 0 && (run_ewma < (36 << RUN_EWMA_SCALE) / (min_code_size as usize) || pixels_left > (0x7FFF_FFFF * 2 + 1) / RUN_INV_THRESH || run_ewma < pixels_left * RUN_INV_THRESH); if (do_clear || run < 7) && clear_pos == 0 { clear_pos = pos - run; clear_bufpos_bits = bufpos_bits; } else if !do_clear && run > 50 { clear_bufpos_bits = 8; // buf contains min code clear_pos = 0; } if do_clear { output_code = code_table.clear_code; pos = clear_pos; bufpos_bits = clear_bufpos_bits; buf.truncate(bufpos_bits.div_ceil(8)); if buf.len() > bufpos_bits / 8 { buf[bufpos_bits / 8] &= (1 << (bufpos_bits & 7)) - 1; } continue; } } run = (((run as u32) << RUN_EWMA_SCALE) + (1 << (RUN_EWMA_SHIFT - 1) as u32)) as usize; if run < run_ewma { run_ewma = run_ewma - ((run_ewma - run) >> RUN_EWMA_SHIFT); } else { run_ewma = run_ewma + ((run - run_ewma) >> RUN_EWMA_SHIFT); } } } else { run = 0; output_code = code_table.clear_code + 1; } } Ok(buf) } } impl<'a> GiflossyImage<'a> { #[must_use] #[cfg_attr(debug_assertions, track_caller)] pub fn new( img: &'a [u8], width: u16, height: u16, transparent: Option, pal: Option<&'a [RGB8]>, ) -> Self { assert_eq!(img.len(), width as usize * height as usize); GiflossyImage { img, width, height, interlace: false, transparent, pal, } } #[inline] fn px_at_pos(&self, pos: usize) -> Option { if !self.interlace { self.img.get(pos).copied() } else { let y = pos / self.width as usize; let x = pos - (y * self.width as usize); self.img.get(self.width as usize * interlaced_line(y, self.height as usize) + x).copied() } } } fn interlaced_line(line: usize, height: usize) -> usize { if line > height / 2 { line * 2 - (height | 1) } else if line > height / 4 { return line * 4 - (height & !1 | 2); } else if line > height / 8 { return line * 8 - (height & !3 | 4); } else { return line * 8; } } ================================================ FILE: gifski-api/src/lib.rs ================================================ /* gifski pngquant-based GIF encoder © 2017 Kornel Lesiński This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ //! gif.ski library allows creation of GIF animations from [arbitrary pixels][ImgVec], //! or [PNG files][Collector::add_frame_png_file]. //! //! See the [`new`] function to get started. #![doc(html_logo_url = "https://gif.ski/icon.png")] #![allow(clippy::bool_to_int_with_if)] #![allow(clippy::cast_possible_truncation)] #![allow(clippy::enum_glob_use)] #![allow(clippy::if_not_else)] #![allow(clippy::inline_always)] #![allow(clippy::match_same_arms)] #![allow(clippy::missing_errors_doc)] #![allow(clippy::module_name_repetitions)] #![allow(clippy::needless_pass_by_value)] #![allow(clippy::redundant_closure_for_method_calls)] #![allow(clippy::wildcard_imports)] use encoderust::RustEncoder; use gif::DisposalMethod; use imagequant::{Attributes, Image, QuantizationResult}; use imgref::*; use rgb::*; mod error; pub use crate::error::*; use ordered_channel::bounded as ordqueue_new; use ordered_channel::Receiver as OrdQueueIter; use ordered_channel::Sender as OrdQueue; pub mod progress; use crate::progress::*; pub mod c_api; mod denoise; use crate::denoise::*; pub mod collector; mod encoderust; #[doc(inline)] pub use crate::collector::Collector; use crate::collector::{FrameSource, InputFrame, InputFrameResized}; #[cfg(feature = "gifsicle")] mod gifsicle; mod minipool; use crossbeam_channel::{Receiver, Sender}; use std::cell::Cell; use std::io::prelude::*; use std::num::NonZeroU8; use std::rc::Rc; use std::sync::atomic::Ordering::Relaxed; use std::thread; /// Number of repetitions pub type Repeat = gif::Repeat; /// Encoding settings for the `new()` function #[derive(Copy, Clone)] pub struct Settings { /// Resize to max this width if non-0. pub width: Option, /// Resize to max this height if width is non-0. Note that aspect ratio is not preserved. pub height: Option, /// 1-100, but useful range is 50-100. Recommended to set to 100. pub quality: u8, /// Lower quality, but faster encode. pub fast: bool, /// Sets the looping method for the image sequence. pub repeat: Repeat, } #[derive(Copy, Clone)] #[non_exhaustive] struct SettingsExt { pub s: Settings, pub max_threads: NonZeroU8, pub extra_effort: bool, pub motion_quality: u8, pub giflossy_quality: u8, pub matte: Option, } impl Settings { /// quality is used in other places, like gifsicle or frame differences, /// and it's better to lower quality there before ruining quantization pub(crate) fn color_quality(&self) -> u8 { (u16::from(self.quality) * 4 / 3).min(100) as u8 } /// `add_frame` is going to resize the images to this size. #[must_use] #[inline] pub fn dimensions_for_image(&self, width: usize, height: usize) -> (usize, usize) { dimensions_for_image((width, height), (self.width, self.height)) } } impl SettingsExt { pub(crate) fn gifsicle_loss(&self) -> u32 { if cfg!(feature = "gifsicle") && self.giflossy_quality < 100 { ((100. / 5. - f32::from(self.giflossy_quality) / 5.).powf(1.8).ceil() as u32 + 10) * 10 } else { 0 } } pub(crate) fn dithering_level(&self) -> f32 { let gifsicle_quality = if cfg!(feature = "gifsicle") { self.giflossy_quality } else { 100 }; debug_assert!(gifsicle_quality <= 100); // lossy LZW adds its own dithering, so the input could be less nosiy to compensate // but don't change dithering unless gifsicle quality < 90, and don't completely disable it let gifsicle_factor = 0.25 + f32::from(gifsicle_quality) * (1. / 100. * 1. / 0.9 * 0.75); (f32::from(self.s.quality) * (1. / 50. * gifsicle_factor) - 1.).clamp(0.2, 1.) } } impl Default for Settings { #[inline] fn default() -> Self { Self { width: None, height: None, quality: 100, fast: false, repeat: Repeat::Infinite, } } } /// Perform GIF writing pub struct Writer { /// Input frame decoder results queue_iter: Option>, settings: SettingsExt, /// Colors the caller has specified as fixed (i.e. key colours) /// This can't be in settings because that would cause it to lose Copy. /// Additionally to avoid breaking C API compatibility this has to be mutable there too. fixed_colors: Vec, } struct GIFFrame { left: u16, top: u16, image: ImgVec, pal: Vec, dispose: DisposalMethod, transparent_index: Option, } /// Frame before quantization struct DiffMessage { /// 1.. ordinal_frame_number: usize, pts: f64, frame_duration: f64, image: ImgVec, importance_map: Vec, } struct QuantizeMessage { /// 1.. with holes ordinal_frame_number: usize, /// 0.. no holes frame_index: u32, first_frame_has_transparency: bool, image: ImgVec, importance_map: Vec, prev_frame_keeps: bool, dispose: gif::DisposalMethod, end_pts: f64, has_next_frame: bool, } /// Frame post quantization, before remap struct RemapMessage { /// 1.. ordinal_frame_number: usize, end_pts: f64, dispose: DisposalMethod, liq: Attributes, remap: QuantizationResult, liq_image: Image<'static>, out_buf: Vec, has_next_frame: bool, } /// Frame post quantization and remap struct FrameMessage { /// 0.. frame_index: usize, /// 1.. ordinal_frame_number: usize, end_pts: f64, frame: GIFFrame, screen_width: u16, screen_height: u16, } /// Start new encoding on two threads. /// /// Encoding is always multi-threaded, and the `Collector` and `Writer` /// must be used on sepate threads. /// /// You feed input frames to the [`Collector`], and ask the [`Writer`] to /// start writing the GIF. /// /// If you don't start writing, then adding frames will block forever. /// /// /// ```rust,no_run /// use gifski::*; /// /// let (collector, writer) = gifski::new(Settings::default())?; /// std::thread::scope(|t| -> Result<(), Error> { /// let frames_thread = t.spawn(move || { /// for i in 0..10 { /// collector.add_frame_png_file(i, format!("frame{i:04}.png").into(), i as f64 * 0.1)?; /// } /// drop(collector); /// Ok(()) /// }); /// /// writer.write(std::fs::File::create("demo.gif")?, &mut progress::NoProgress {})?; /// frames_thread.join().unwrap() /// })?; /// Ok::<_, Error>(()) /// ``` #[inline] pub fn new(settings: Settings) -> GifResult<(Collector, Writer)> { if settings.quality == 0 || settings.quality > 100 { return Err(Error::WrongSize("quality must be 1-100".into())); // I forgot to add a better error variant } if settings.width.unwrap_or(0) > 1 << 16 || settings.height.unwrap_or(0) > 1 << 16 { return Err(Error::WrongSize("image size too large".into())); } let max_threads = thread::available_parallelism().map(|t| t.get().min(255) as u8).unwrap_or(8); let (queue, queue_iter) = crossbeam_channel::bounded(5.min(max_threads.into())); // should be sufficient for denoiser lookahead Ok(( Collector { queue, }, Writer { queue_iter: Some(queue_iter), settings: SettingsExt { s: settings, max_threads: max_threads.try_into()?, motion_quality: settings.quality, giflossy_quality: settings.quality, extra_effort: false, matte: None, }, fixed_colors: Vec::new(), }, )) } #[inline(never)] #[cfg_attr(debug_assertions, track_caller)] fn resized_binary_alpha(image: ImgVec, width: Option, height: Option, matte: Option) -> CatResult> { let (width, height) = dimensions_for_image((image.width(), image.height()), (width, height)); let mut image = if width != image.width() || height != image.height() { let tmp = image.as_ref(); let (buf, img_width, img_height) = tmp.to_contiguous_buf(); assert_eq!(buf.len(), img_width * img_height); let mut r = resize::new(img_width, img_height, width, height, resize::Pixel::RGBA8P, resize::Type::Lanczos3)?; let mut dst = vec![RGBA8::new(0, 0, 0, 0); width * height]; r.resize(&buf, &mut dst)?; ImgVec::new(dst, width, height) } else { image }; if let Some(matte) = matte { image.pixels_mut().filter(|px| px.a < 255 && px.a > 0).for_each(move |px| { let alpha = u16::from(px.a); let inv_alpha = 255 - alpha; *px = RGBA8 { r: ((u16::from(px.r) * alpha + u16::from(matte.r) * inv_alpha) / 255) as u8, g: ((u16::from(px.g) * alpha + u16::from(matte.g) * inv_alpha) / 255) as u8, b: ((u16::from(px.b) * alpha + u16::from(matte.b) * inv_alpha) / 255) as u8, a: 255, }; }); } else { dither_image(image.as_mut()); } Ok(image) } #[allow(clippy::identity_op)] #[allow(clippy::erasing_op)] #[inline(never)] fn dither_image(mut image: ImgRefMut) { let width = image.width(); let height = image.height(); // dithering of anti-aliased edges can look very fuzzy, so disable it near the edges let mut anti_aliasing = vec![false; width * height]; loop9::loop9(image.as_ref(), 0, 0, width, height, |x, y, top, mid, bot| { if mid.curr.a != 255 && mid.curr.a != 0 { fn is_edge(a: u8, b: u8) -> bool { a < 12 && b >= 240 || b < 12 && a >= 240 } if is_edge(top.curr.a, bot.curr.a) || is_edge(mid.prev.a, mid.next.a) || is_edge(top.prev.a, bot.next.a) || is_edge(top.next.a, bot.prev.a) { anti_aliasing[x + y * width] = true; } } }); // this table is already biased, so that px.a doesn't need to be changed const DITHER: [u8; 64] = [ 0*2+8,48*2+8,12*2+8,60*2+8, 3*2+8,51*2+8,15*2+8,63*2+8, 32*2+8,16*2+8,44*2+8,28*2+8,35*2+8,19*2+8,47*2+8,31*2+8, 8*2+8,56*2+8, 4*2+8,52*2+8,11*2+8,59*2+8, 7*2+8,55*2+8, 40*2+8,24*2+8,36*2+8,20*2+8,43*2+8,27*2+8,39*2+8,23*2+8, 2*2+8,50*2+8,14*2+8,62*2+8, 1*2+8,49*2+8,13*2+8,61*2+8, 34*2+8,18*2+8,46*2+8,30*2+8,33*2+8,17*2+8,45*2+8,29*2+8, 10*2+8,58*2+8, 6*2+8,54*2+8, 9*2+8,57*2+8, 5*2+8,53*2+8, 42*2+8,26*2+8,38*2+8,22*2+8,41*2+8,25*2+8,37*2+8,21*2+8]; // Make transparency binary for (y, (row, aa)) in image.rows_mut().zip(anti_aliasing.chunks_exact(width)).enumerate() { for (x, (px, aa)) in row.iter_mut().zip(aa.iter().copied()).enumerate() { if px.a < 255 { if aa { px.a = if px.a < 89 { 0 } else { 255 }; } else { px.a = if px.a < DITHER[(y & 7) * 8 + (x & 7)] { 0 } else { 255 }; } } } } } /// `add_frame` is going to resize the image to this size. /// The `Option` args are user-specified max width and max height #[inline(never)] fn dimensions_for_image((img_w, img_h): (usize, usize), resize_to: (Option, Option)) -> (usize, usize) { match resize_to { (None, None) => { let factor = ((img_w * img_h + 800 * 600 / 2) as f64 / f64::from(800 * 600)).sqrt().round() as usize; if factor > 1 { (img_w / factor, img_h / factor) } else { (img_w, img_h) } }, (Some(w), Some(h)) => { ((w as usize).min(img_w), (h as usize).min(img_h)) }, (Some(w), None) => { let w = (w as usize).min(img_w); (w, img_h * w / img_w) }, (None, Some(h)) => { let h = (h as usize).min(img_h); (img_w * h / img_h, h) }, } } #[derive(Copy, Clone)] enum LastFrameDuration { FixedOffset(f64), FrameRate(f64), } impl LastFrameDuration { #[inline] pub fn value(&self) -> f64 { match self { Self::FixedOffset(val) | Self::FrameRate(val) => *val, } } #[inline] pub fn shift_every_pts_by(&self) -> f64 { match self { Self::FixedOffset(offset) => *offset, Self::FrameRate(_) => 0., } } } /// Encode collected frames impl Writer { #[deprecated(note = "please don't use, it will be in Settings eventually")] #[doc(hidden)] pub fn set_extra_effort(&mut self, enabled: bool) { self.settings.extra_effort = enabled; } #[deprecated(note = "please don't use, it will be in Settings eventually")] #[doc(hidden)] pub fn set_motion_quality(&mut self, q: u8) { self.settings.motion_quality = q; } #[deprecated(note = "please don't use, it will be in Settings eventually")] #[doc(hidden)] pub fn set_lossy_quality(&mut self, q: u8) { self.settings.giflossy_quality = q; } /// Adds a fixed color that will be kept in the palette at all times. /// /// This may increase file size, because every frame will use a larger palette. /// Max 255 allowed, because one more is reserved for transparency. pub fn add_fixed_color(&mut self, col: RGB8) { if self.fixed_colors.len() < 255 { self.fixed_colors.push(col); } } #[deprecated(note = "please don't use, it will be in Settings eventually")] #[doc(hidden)] pub fn set_matte_color(&mut self, col: RGB8) { self.settings.matte = Some(col); } /// `importance_map` is computed from previous and next frame. /// Improves quality of pixels visible for longer. /// Avoids wasting palette on pixels identical to the background. /// /// `background` is the previous frame. fn quantize(&self, image: ImgVec, importance_map: &[u8], first_frame: bool, needs_transparency: bool, prev_frame_keeps: bool) -> CatResult<(Attributes, QuantizationResult, Image<'static>, Vec)> { let mut liq = Attributes::new(); if self.settings.s.fast && !first_frame { liq.set_speed(10)?; } else if self.settings.extra_effort { liq.set_speed(1)?; } let quality = if !first_frame { self.settings.s.color_quality() } else { 100 // the first frame is too important to ruin it }; liq.set_quality(0, quality)?; if self.settings.s.quality < 50 { let min_colors = 5 + self.fixed_colors.len() as u32; liq.set_max_colors(u32::from(self.settings.s.quality * 2).max(min_colors).next_power_of_two().min(256))?; } let (buf, width, height) = image.into_contiguous_buf(); let mut img = liq.new_image(buf, width, height, 0.)?; // only later remapping tracks which area has been damaged by transparency // so for previous-transparent background frame the importance map may be invalid // because there's a transparent hole in the background not taken into account, // and palette may lack colors to fill that hole if first_frame || prev_frame_keeps { img.set_importance_map(importance_map)?; } // first frame may be transparent too, so it's not just for diffs if needs_transparency { img.add_fixed_color(RGBA8::new(0, 0, 0, 0))?; } // user may have colors which need to be preserved and left undithered for color in &self.fixed_colors { img.add_fixed_color(RGBA8::new(color.r, color.g, color.b, 255))?; } let mut res = liq.quantize(&mut img)?; // GIF only stores power-of-two palette sizes if self.settings.extra_effort { let len = res.palette_len(); // it has little impact on compression (128c -> 64c is only 7% smaller) if (len < 128 || len > 220) && len != len.next_power_of_two() { liq.set_max_colors(len.next_power_of_two() as _)?; liq.set_quality(0, 100)?; res = liq.quantize(&mut img)?; } } res.set_dithering_level(self.settings.dithering_level())?; let mut out = Vec::new(); out.try_reserve_exact(width * height).map_err(imagequant::liq_error::from)?; res.optionally_prepare_for_dithering_with_background_set(&mut img, &mut out.spare_capacity_mut()[..width * height])?; Ok((liq, res, img, out)) } fn remap<'a>(&self, liq: Attributes, mut res: QuantizationResult, mut img: Image<'a>, background: Option>, mut pal_img: Vec) -> CatResult<(ImgVec, Vec)> { if let Some(bg) = background { img.set_background(Image::new_stride_borrowed(&liq, bg.buf(), bg.width(), bg.height(), bg.stride(), 0.)?)?; } let pal = res.remap_into_vec(&mut img, &mut pal_img)?; debug_assert_eq!(img.width() * img.height(), pal_img.len()); Ok((Img::new(pal_img, img.width(), img.height()), pal)) } #[inline(never)] fn write_frames(&self, write_queue: Receiver, writer: &mut dyn Write, reporter: &mut dyn ProgressReporter) -> CatResult<()> { let (lzw_queue, lzw_recv) = ordqueue_new(2); minipool::new_scope((if self.settings.s.fast || self.settings.gifsicle_loss() > 0 { 3 } else { 1 }).try_into().unwrap(), "lzw", move || { let mut pts_in_delay_units = 0_u64; let written = Rc::new(Cell::new(0)); let mut enc = RustEncoder::new(writer, written.clone()); let mut n_done = 0; for tmp in lzw_recv { let (end_pts, ordinal_frame_number, frame, screen_width, screen_height): (f64, _, _, _, _) = tmp; // delay=1 doesn't work, and it's too late to drop frames now let delay = ((end_pts * 100_f64).round() as u64) .saturating_sub(pts_in_delay_units) .clamp(2, 30000) as u16; pts_in_delay_units += u64::from(delay); enc.write_frame(frame, delay, screen_width, screen_height, &self.settings.s)?; reporter.written_bytes(written.get()); // loop to report skipped frames too while n_done < ordinal_frame_number { n_done += 1; if !reporter.increase() { return Err(Error::Aborted); } } } if n_done == 0 { Err(Error::NoFrames) } else { Ok(()) } }, move |abort| { for FrameMessage {frame, frame_index, ordinal_frame_number, end_pts, screen_width, screen_height } in write_queue { if abort.load(Relaxed) { return Err(Error::Aborted); } let frame = RustEncoder::<&mut dyn std::io::Write>::compress_frame(frame, &self.settings)?; lzw_queue.send(frame_index, (end_pts, ordinal_frame_number, frame, screen_width, screen_height))?; } Ok(()) }) } /// Start writing frames. This function will not return until the [`Collector`] is dropped. /// /// `outfile` can be any writer, such as `File` or `&mut Vec`. /// /// `ProgressReporter.increase()` is called each time a new frame is being written. #[inline] pub fn write(mut self, mut writer: W, reporter: &mut dyn ProgressReporter) -> GifResult<()> { let decode_queue_recv = self.queue_iter.take().ok_or(Error::Aborted)?; self.write_inner(decode_queue_recv, &mut writer, reporter) } #[inline(never)] fn write_inner(&self, decode_queue_recv: Receiver, writer: &mut dyn Write, reporter: &mut dyn ProgressReporter) -> CatResult<()> { thread::scope(|s| { let (diff_queue, diff_queue_recv) = ordqueue_new(0); let resize_thread = thread::Builder::new().name("resize".into()).spawn_scoped(s, move || { self.make_resize(decode_queue_recv, diff_queue) })?; let (quant_queue, quant_queue_recv) = crossbeam_channel::bounded(0); let diff_thread = thread::Builder::new().name("diff".into()).spawn_scoped(s, move || { self.make_diffs(diff_queue_recv, quant_queue) })?; let (remap_queue, remap_queue_recv) = ordqueue_new(0); let quant_thread = thread::Builder::new().name("quant".into()).spawn_scoped(s, move || { self.quantize_frames(quant_queue_recv, remap_queue) })?; let (write_queue, write_queue_recv) = crossbeam_channel::bounded(0); let remap_thread = thread::Builder::new().name("remap".into()).spawn_scoped(s, move || { self.remap_frames(remap_queue_recv, write_queue) })?; let res0 = self.write_frames(write_queue_recv, writer, reporter); let res1 = resize_thread.join().map_err(handle_join_error)?; let res2 = diff_thread.join().map_err(handle_join_error)?; let res3 = quant_thread.join().map_err(handle_join_error)?; let res4 = remap_thread.join().map_err(handle_join_error)?; combine_res(combine_res(combine_res(res0, res1), combine_res(res2, res3)), res4) }) } /// Apply resizing and crate a blurred version for the diff/denoise phase fn make_resize(&self, inputs: Receiver, diff_queue: OrdQueue) -> CatResult<()> { minipool::new_scope(self.settings.max_threads.min(if self.settings.s.fast || self.settings.extra_effort { 6 } else { 4 }.try_into()?), "resize", move || { Ok(()) }, move |abort| { for frame in inputs { if abort.load(Relaxed) { return Err(Error::Aborted); } let image = match frame.frame { FrameSource::Pixels(image) => image, #[cfg(feature = "png")] FrameSource::PngData(data) => { let image = lodepng::decode32(&data) .map_err(|err| Error::PNG(format!("Can't load PNG: {err}")))?; Img::new(image.buffer, image.width, image.height) }, #[cfg(feature = "png")] FrameSource::Path(path) => { let image = lodepng::decode32_file(&path) .map_err(|err| Error::PNG(format!("Can't load {}: {err}", path.display())))?; Img::new(image.buffer, image.width, image.height) }, }; let resized = resized_binary_alpha(image, self.settings.s.width, self.settings.s.height, self.settings.matte)?; let frame_blurred = if self.settings.extra_effort { smart_blur(resized.as_ref()) } else { less_smart_blur(resized.as_ref()) }; diff_queue.send(frame.frame_index, InputFrameResized { frame: resized, frame_blurred, presentation_timestamp: frame.presentation_timestamp, })?; } Ok(()) }) } /// Find differences between frames, and compute importance maps fn make_diffs(&self, mut inputs: OrdQueueIter, diffs: Sender) -> CatResult<()> { let first_frame = inputs.next().ok_or(Error::NoFrames)?; let mut last_frame_duration = if first_frame.presentation_timestamp > 1. / 100. { // this is gifski's weird rule that a non-zero first-frame pts // shifts the whole anim and is the delay of the last frame LastFrameDuration::FixedOffset(first_frame.presentation_timestamp) } else { LastFrameDuration::FrameRate(0.) }; let mut denoiser = Denoiser::new(first_frame.frame.width(), first_frame.frame.height(), self.settings.motion_quality)?; let mut ordinal_frame_number = 0; let mut last_frame_pts = 0.; let mut next_frame = Some(first_frame); loop { // NB! There are two interleaved loops here: // - one to feed the denoiser // - the other to process denoised frames // // The denoiser buffers five frames, so these two loops process different frames! // But need to be interleaved in one `loop{}` to get frames falling out of denoiser's buffer. ////////////////////// Feed denoiser: ///////////////////// if let Some(InputFrameResized { frame, frame_blurred, presentation_timestamp: raw_pts }) = next_frame { ordinal_frame_number += 1; let pts = raw_pts - last_frame_duration.shift_every_pts_by(); if let LastFrameDuration::FrameRate(duration) = &mut last_frame_duration { *duration = pts - last_frame_pts; } last_frame_pts = pts; denoiser.push_frame(frame.as_ref(), frame_blurred.as_ref(), (ordinal_frame_number, pts, last_frame_duration)).map_err(|_| { Error::WrongSize(format!("Frame {ordinal_frame_number} has wrong size ({}×{})", frame.width(), frame.height())) })?; } else { denoiser.flush(); } ////////////////////// Consume denoised frames ///////////////////// match denoiser.pop() { Denoised::Done => { debug_assert!(inputs.next().is_none()); break; }, Denoised::NotYet => {}, Denoised::Frame { importance_map, frame: image, meta: (ordinal_frame_number, pts, last_frame_duration) } => { let (importance_map, ..) = importance_map.into_contiguous_buf(); diffs.send(DiffMessage { importance_map, ordinal_frame_number, image, pts, frame_duration: last_frame_duration.value().max(1. / 100.), })?; }, } next_frame = inputs.next(); } Ok(()) } fn quantize_frames(&self, inputs: Receiver, remap_queue: OrdQueue) -> CatResult<()> { minipool::new_channel(self.settings.max_threads.min(4.try_into()?), "quant", move |quant_queue| { let mut inputs = inputs.into_iter(); let next_frame = inputs.next().ok_or(Error::NoFrames)?; let DiffMessage {image: first_frame, ..} = &next_frame; let first_frame_has_transparency = first_frame.pixels().any(|px| px.a < 128); let mut prev_frame_keeps = false; let mut frame_index = 0; let mut importance_map = None; let mut next_frame = Some(next_frame); while let Some(DiffMessage { image, pts, frame_duration, ordinal_frame_number, importance_map: new_importance_map }) = next_frame { next_frame = inputs.next(); if importance_map.is_none() { importance_map = Some(new_importance_map); } let dispose = if let Some(DiffMessage { image: next_image, .. }) = &next_frame { // Skip identical frames if next_image.as_ref() == image.as_ref() { // this keeps importance_map of the previous frame in the identical-frame series // (important, because subsequent identical frames have all-zero importance_map and would be dropped too) continue; } // If the next frame becomes transparent, this frame has to clear to bg for it if next_image.pixels().zip(image.pixels()).any(|(next, curr)| next.a < curr.a) { DisposalMethod::Background } else { DisposalMethod::Keep } } else if first_frame_has_transparency { // Last frame should reset to background to avoid breaking transparent looped anims DisposalMethod::Background } else { // macOS preview gets Background wrong DisposalMethod::Keep }; let importance_map = importance_map.take().ok_or(Error::ThreadSend)?; // always set at the beginning if !prev_frame_keeps || importance_map.iter().any(|&px| px > 0) { let end_pts = if let Some(&DiffMessage { pts: next_pts, .. }) = next_frame.as_ref() { next_pts } else { pts + frame_duration }; debug_assert!(end_pts > 0.); quant_queue.send(QuantizeMessage { image, ordinal_frame_number, frame_index, first_frame_has_transparency, importance_map, prev_frame_keeps, dispose, end_pts, has_next_frame: next_frame.is_some(), })?; frame_index += 1; prev_frame_keeps = dispose == DisposalMethod::Keep; } } Ok(()) }, move |QuantizeMessage { end_pts, mut image, importance_map, ordinal_frame_number, frame_index, dispose, first_frame_has_transparency, prev_frame_keeps, has_next_frame }| { if prev_frame_keeps { // if denoiser says the background didn't change, then believe it // (except higher quality settings, which try to improve it every time) let bg_keep_likelihood = u32::from(self.settings.s.quality.saturating_sub(80) / 4); if self.settings.s.fast || (self.settings.s.quality < 100 && (frame_index % 5) >= bg_keep_likelihood) { image.pixels_mut().zip(&importance_map).filter(|&(_, &m)| m == 0).for_each(|(px, _)| *px = RGBA8::new(0,0,0,0)); } } let needs_transparency = frame_index > 0 || (frame_index == 0 && first_frame_has_transparency); let (liq, remap, liq_image, out_buf) = self.quantize(image, &importance_map, frame_index == 0, needs_transparency, prev_frame_keeps)?; Ok(remap_queue.send(frame_index as usize, RemapMessage { ordinal_frame_number, end_pts, dispose, liq, remap, liq_image, out_buf, has_next_frame, })?) }) } fn remap_frames(&self, mut inputs: OrdQueueIter, write_queue: Sender) -> CatResult<()> { let mut frame_index = 0; let first_frame = inputs.next().ok_or(Error::NoFrames)?; let mut screen = gif_dispose::Screen::new(first_frame.liq_image.width(), first_frame.liq_image.height(), None); #[cfg(debug_assertions)] let mut debug_screen = gif_dispose::Screen::new(first_frame.liq_image.width(), first_frame.liq_image.height(), None); let mut next_frame = Some(first_frame); while let Some(RemapMessage {ordinal_frame_number, end_pts, dispose, liq, remap, liq_image, out_buf, has_next_frame}) = next_frame { let pixels = screen.pixels_rgba(); let screen_width = pixels.width() as u16; let screen_height = pixels.height() as u16; let mut screen_after_dispose = screen.dispose_only(); let (mut image8, image8_pal) = { let bg = if frame_index != 0 { Some(screen_after_dispose.pixels_rgba()) } else { None }; self.remap(liq, remap, liq_image, bg, out_buf)? }; let (image8_pal, transparent_index) = transparent_index_from_palette(image8_pal, image8.as_mut()); #[cfg(debug_assertions)] debug_screen.blit(Some(&image8_pal), dispose, 0, 0, image8.as_ref(), transparent_index)?; let (left, top) = if frame_index != 0 && has_next_frame { let (left, top, new_width, new_height) = trim_image(image8.as_ref(), &image8_pal, transparent_index, dispose, screen_after_dispose.pixels_rgba()) .unwrap_or((0, 0, 1, 1)); if new_width != image8.width() || new_height != image8.height() { let new_buf = image8.sub_image(left.into(), top.into(), new_width, new_height).to_contiguous_buf().0.into_owned(); image8 = ImgVec::new(new_buf, new_width, new_height); } (left, top) } else { // must keep first and last frame (0, 0) }; screen_after_dispose.then_blit(Some(&image8_pal), dispose, left, top, image8.as_ref(), transparent_index)?; #[cfg(debug_assertions)] debug_assert!(debug_screen.pixels_rgba() == screen.pixels_rgba(), "fr {ordinal_frame_number} {left}/{top} {}x{}", image8.width(), image8.height()); write_queue.send(FrameMessage { frame_index, ordinal_frame_number, end_pts, screen_width, screen_height, frame: GIFFrame { left, top, image: image8, pal: image8_pal, transparent_index, dispose, }, })?; frame_index += 1; next_frame = inputs.next(); } Ok(()) } } fn transparent_index_from_palette(mut image8_pal: Vec, mut image8: ImgRefMut) -> (Vec, Option) { // Palette may have multiple transparent indices :( let mut transparent_index = None; for (i, p) in image8_pal.iter_mut().enumerate() { if p.a <= 128 { *p = RGBA8::new(71, 80, 76, 0); let new_index = i as u8; if let Some(old_index) = transparent_index { image8.pixels_mut().filter(|px| **px == new_index).for_each(|px| *px = old_index); } else { transparent_index = Some(new_index); } } } // Check that palette is fine and has no duplicate transparent indices debug_assert!(image8_pal.iter().enumerate().all(|(idx, color)| { Some(idx as u8) == transparent_index || color.a > 128 || !image8.pixels().any(|px| px == idx as u8) })); (image8_pal.into_iter().map(|r| r.rgb()).collect(), transparent_index) } /// When one thread unexpectedly fails, all other threads fail with Aborted, but that Aborted isn't the relevant cause #[inline] fn combine_res(res1: Result<(), Error>, res2: Result<(), Error>) -> Result<(), Error> { use Error::*; match (res1, res2) { (Err(e), Ok(())) | (Ok(()), Err(e)) => Err(e), (Err(ThreadSend), res) | (res, Err(ThreadSend)) => res, (Err(Aborted), res) | (res, Err(Aborted)) => res, (Err(NoFrames), res) | (res, Err(NoFrames)) => res, (_, res2) => res2, } } fn trim_image(mut image_trimmed: ImgRef, image8_pal: &[RGB8], transparent_index: Option, dispose: DisposalMethod, mut screen: ImgRef) -> Option<(u16, u16, usize, usize)> { debug_assert_eq!(image_trimmed.width(), screen.width()); debug_assert_eq!(image_trimmed.height(), screen.height()); let is_matching_pixel = move |px: u8, bg: RGBA8| -> bool { if Some(px) == transparent_index { if dispose == DisposalMethod::Keep { // if dispose == keep, then transparent pixels do nothing, so they can be cropped out true } else { debug_assert_eq!(dispose, DisposalMethod::Background); // if disposing to background, then transparent pixels paint transparency, so bg has to actually be transparent to match bg.a == 0 } } else { let Some(pal_px) = image8_pal.get(px as usize) else { debug_assert!(false, "{px} > {}", image8_pal.len()); return false; }; pal_px.with_alpha(255) == bg } }; let bottom = image_trimmed.rows().zip(screen.rows()).rev() .take_while(|(img_row, screen_row)| { img_row.iter().copied().zip(screen_row.iter().copied()) .all(|(px, bg)| is_matching_pixel(px, bg)) }) .count(); if bottom > 0 { if bottom == image_trimmed.height() { return None; } image_trimmed = image_trimmed.sub_image(0, 0, image_trimmed.width(), image_trimmed.height() - bottom); screen = screen.sub_image(0, 0, screen.width(), screen.height() - bottom); } let top = image_trimmed.rows().zip(screen.rows()) .take_while(|(img_row, screen_row)| { img_row.iter().copied().zip(screen_row.iter().copied()) .all(|(px, bg)| is_matching_pixel(px, bg)) }) .count(); if top > 0 { debug_assert_ne!(image_trimmed.height(), top); image_trimmed = image_trimmed.sub_image(0, top, image_trimmed.width(), image_trimmed.height() - top); screen = screen.sub_image(0, top, screen.width(), screen.height() - top); } let left = (0..image_trimmed.width() - 1) .take_while(|&x| { (0..image_trimmed.height()).all(|y| { let px = image_trimmed[(x, y)]; is_matching_pixel(px, screen[(x, y)]) }) }).count(); if left > 0 { debug_assert!(image_trimmed.width() > left); image_trimmed = image_trimmed.sub_image(left, 0, image_trimmed.width() - left, image_trimmed.height()); screen = screen.sub_image(left, 0, screen.width() - left, screen.height()); } debug_assert_eq!(image_trimmed.width(), screen.width()); debug_assert_eq!(image_trimmed.height(), screen.height()); let right = (1..image_trimmed.width()).rev() .take_while(|&x| { (0..image_trimmed.height()).all(|y| { let px = image_trimmed[(x, y)]; is_matching_pixel(px, screen[(x, y)]) }) }).count(); if right > 0 { debug_assert!(image_trimmed.width() > right); image_trimmed = image_trimmed.sub_image(0, 0, image_trimmed.width() - right, image_trimmed.height()); } Some((left as _, top as _, image_trimmed.width(), image_trimmed.height())) } trait PushInCapacity { fn push_in_cap(&mut self, val: T); } impl PushInCapacity for Vec { #[inline(always)] #[cfg_attr(debug_assertions, track_caller)] fn push_in_cap(&mut self, val: T) { debug_assert!(self.capacity() != self.len()); if self.capacity() != self.len() { self.push(val); } } } #[cold] fn handle_join_error(err: Box) -> Error { let msg = err.downcast_ref::().map(|s| s.as_str()) .or_else(|| err.downcast_ref::<&str>().copied()).unwrap_or("unknown panic"); eprintln!("thread crashed (this is a bug): {msg}"); Error::ThreadSend } #[test] fn sendable() { fn is_send() {} is_send::(); is_send::(); } ================================================ FILE: gifski-api/src/minipool.rs ================================================ use crate::Error; use crossbeam_channel::Sender; use std::num::NonZeroU8; use std::panic::catch_unwind; use std::sync::atomic::{AtomicBool, Ordering::Relaxed}; #[inline] pub fn new_channel(num_threads: NonZeroU8, name: &str, producer: P, mut consumer: C) -> Result where M: Send, C: Clone + Send + FnMut(M) -> Result<(), Error> + std::panic::UnwindSafe, P: FnOnce(Sender) -> Result, { let (s, r) = crossbeam_channel::bounded(2); new_scope(num_threads, name, move || producer(s), move |should_abort| { for m in r { if should_abort.load(Relaxed) { break; } consumer(m)?; } Ok(()) }) } pub fn new_scope(num_threads: NonZeroU8, name: &str, waiter: P, consumer: C) -> Result where C: Clone + Send + FnOnce(&AtomicBool) -> Result<(), Error> + std::panic::UnwindSafe, P: FnOnce() -> Result, { let failed = &AtomicBool::new(false); std::thread::scope(move |scope| { let thread = move || { catch_unwind(move || consumer(failed)) .map_err(|_| Error::ThreadSend).and_then(|x| x) .map_err(|e| { failed.store(true, Relaxed); e }) }; let handles = std::iter::repeat(thread).enumerate() .take(num_threads.get().into()) .map(move |(n, thread)| { std::thread::Builder::new().name(format!("{name}{n}")).spawn_scoped(scope, thread) }) .collect::, _>>() .map_err(move |_| { failed.store(true, Relaxed); Error::ThreadSend })?; let res = waiter().map_err(|e| { failed.store(true, Relaxed); e }); handles.into_iter().try_for_each(|h| h.join().map_err(|_| Error::ThreadSend)?)?; res }) } ================================================ FILE: gifski-api/src/progress.rs ================================================ //! For tracking conversion progress and aborting early #[cfg(feature = "pbr")] #[doc(hidden)] #[deprecated(note = "The pbr dependency is no longer exposed. Please use a newtype pattern and write your own trait impl for it")] pub use pbr::ProgressBar; use std::os::raw::{c_int, c_void}; /// A trait that is used to report progress to some consumer. pub trait ProgressReporter: Send { /// Called after each frame has been written. /// /// This method may return `false` to abort processing. fn increase(&mut self) -> bool; /// File size so far fn written_bytes(&mut self, _current_file_size_in_bytes: u64) {} /// Not used :( /// Writing is done when `Writer::write()` call returns fn done(&mut self, _msg: &str) {} } /// No-op progress reporter pub struct NoProgress {} /// For C pub struct ProgressCallback { callback: unsafe extern "C" fn(*mut c_void) -> c_int, arg: *mut c_void, } unsafe impl Send for ProgressCallback {} impl ProgressCallback { pub fn new(callback: unsafe extern "C" fn(*mut c_void) -> c_int, arg: *mut c_void) -> Self { Self { callback, arg } } } impl ProgressReporter for NoProgress { fn increase(&mut self) -> bool { true } fn done(&mut self, _msg: &str) {} } impl ProgressReporter for ProgressCallback { fn increase(&mut self) -> bool { unsafe { (self.callback)(self.arg) == 1 } } fn done(&mut self, _msg: &str) {} } /// Implement the progress reporter trait for a progress bar, /// to make it usable for frame processing reporting. #[cfg(feature = "pbr")] impl ProgressReporter for ProgressBar where T: std::io::Write + Send { fn increase(&mut self) -> bool { self.inc(); true } fn done(&mut self, msg: &str) { self.finish_print(msg); } } ================================================ FILE: license ================================================ MIT License Copyright (c) Sindre Sorhus (https://sindresorhus.com) Copyright (c) Kornel Lesiński (https://gif.ski) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: maintaining.md ================================================ ## Maintaining ### Testing system services First, we need to install the app: 1. Archive the app. 2. Once you have your archive in the Organizer window, right-click it, and click **Show in Finder**. 3. Right-click again, now on the latest `Gifski_DATE_.xcarchive`, and click **Show Package Contents**. 4. Open `/Products/Applications` and move `Gifski.app` to your `Applications` directory. Then, we need to check if our system has the latest service installed: 1. In your terminal, enter the command: ```bash /System/Library/CoreServices/pbs -dump | grep Gifski.app ``` 2. If you see `NSBundlePath = "/Applications/Gifski.app”` - you're good to go. 3. If you don't see the line above, try updating the cache: ```bash /System/Library/CoreServices/pbs -update ``` ### Troubleshooting system services Sometimes the service doesn't work and it's really hard to understand why without any tools. You can use a debug flag on the instance of `Finder` app and see the logs it dumps: ```bash /System/Library/CoreServices/Finder.app/Contents/MacOS/Finder -NSDebugServices com.sindresorhus.Gifski ``` ### Video rotation handling Videos can have a `preferredTransform` that rotates the raw frames (e.g., portrait videos filmed on phones). There are two coordinate spaces: 1. **Natural space**: Raw frame dimensions (`naturalSize`), unrotated (e.g., 1920x1080) 2. **Preferred space**: How the user sees the video after rotation (e.g., 1080x1920 for portrait) In this app: - UI dimensions (`metadata.dimensions`) are in **preferred space** (already rotated) - Crop rect from UI is defined in **preferred space** - `AVAssetImageGenerator` with `appliesPreferredTrackTransform = true` returns images in **preferred space** - Preview manually applies transform, so images are also in **preferred space** - `AVComposition` layer instructions operate in **natural space** (must transform crop back) When cropping images: Apply crop directly (images are pre-rotated). When exporting video: Transform crop from preferred → natural space first. ================================================ FILE: readme.md ================================================

Gifski

Convert videos to high-quality GIFs on your Mac




This is a macOS app for the [`gifski` encoder](https://gif.ski), which converts videos to GIF animations using [`pngquant`](https://pngquant.org)'s fancy features for efficient cross-frame palettes and temporal dithering. It produces animated GIFs that use thousands of colors per frame and up to 50 FPS (useful for showing off design work on Dribbble). You can also produce smaller lower quality GIFs when needed with the “Quality” slider, thanks to [`gifsicle`](https://github.com/kohler/gifsicle). Gifski supports all the video formats that macOS supports (`.mp4` or `.mov` with H264, HEVC, ProRes, etc). The [QuickTime Animation format](https://en.wikipedia.org/wiki/QuickTime_Animation) is not supported. Use [ProRes 4444 XQ](https://en.wikipedia.org/wiki/Apple_ProRes) instead. It's more efficient, more widely supported, and like QuickTime Animation, it also supports alpha channel. Gifski has a bunch of settings like changing dimensions, speed, frame rate, quality, looping, and more. ## Download [![](https://sindresorhus.com/assets/download-on-app-store-badge.svg)](https://apps.apple.com/app/id1351639930) Requires macOS 14 or later. **Older versions** - [2.23.0](https://github.com/sindresorhus/Gifski/releases/download/v2.23.0/Gifski.2.23.0.-.macOS.13.zip) for macOS 13+ - [2.22.3](https://github.com/sindresorhus/Gifski/releases/download/v2.22.3/Gifski.2.22.3.-.macOS.12.zip) for macOS 12+ - [2.21.2](https://github.com/sindresorhus/Gifski/releases/download/v2.21.2/Gifski.2.21.2.-.macOS.11.zip) for macOS 11+ - [2.20.2](https://github.com/sindresorhus/Gifski/releases/download/v2.20.2/Gifski.2.20.2.-.macOS.10.15.zip) for macOS 10.15+ - [2.16.0](https://github.com/sindresorhus/Gifski/releases/download/v2.16.0/Gifski.2.16.0.-.macOS.10.14.zip) for macOS 10.14+ - [2.4.0](https://github.com/sindresorhus/Gifski/files/3991913/Gifski.2.4.0.-.High.Sierra.zip) for macOS 10.13+ **Non-App Store version** A special version for users that cannot access the App Store. It won't receive automatic updates. I will update it here once a year. [Download](https://github.com/sindresorhus/meta/files/13539147/Gifski-2.23.0-1692807940.zip) *(2.23.0 · macOS 13+)* ## Features ### Share extension Gifski includes a share extension that lets you share videos to Gifski. Just select Gifski from the Share menu of any macOS app. > Tip: You can share a macOS screen recording with Gifski by clicking on the thumbnail that pops up once you are done recording and selecting “Share” from there. ### System service Gifski includes a [system service](https://www.computerworld.com/article/2476298/os-x-a-quick-guide-to-services-on-your-mac.html) that lets you quickly convert a video to GIF from the **Services** menu in any app that provides a compatible video file. ### Bounce (yo-yo) GIF playback Gifski includes the option to create GIFs that bounce back and forth between forward and backward playback. This is a similar effect to the bounce effect in [iOS's Live Photo effects](https://support.apple.com/en-us/HT207310). This option doubles the number of frames in the GIF so the file size will double as well. ## Tips #### Quickly copy or save the GIF After converting, press Command+C to copy the GIF or Command+S to save it. #### Change GIF dimensions with the keyboard In the width/height input fields in the editor view, press the arrow up/down keys to change the value by 1. Hold the Option key meanwhile to change it by 10. ## Screenshots ## Building from source To build the app in Xcode, you need to have [Rust](https://www.rust-lang.org) installed first: ```sh curl https://sh.rustup.rs -sSf | sh brew install SwiftLint xcode-select --install ``` ## Tips ## Quick Action shortcut Convert videos to GIFs directly from Finder using the built-in [Quick Action](https://support.apple.com/en-mz/guide/mac-help/mchl97ff9142/mac) shortcut. It works without opening Gifski, and you can create multiple shortcuts with different settings, such as quality, dimensions, or looping, to match your workflow. [Download shortcut](https://www.icloud.com/shortcuts/8a00497b180742139474d5470857d699) **Requires the [TestFlight version](https://testflight.apple.com/join/iCyHNNIA) of Gifski** ## FAQ #### The generated GIFs are huge! The GIF image format is very space inefficient. It works best with short video clips. Try reducing the dimensions, FPS, or quality. #### Why are 60 FPS and higher not supported? Browsers throttle frame rates above 50 FPS, playing them at 10 FPS. [Read more](https://github.com/sindresorhus/Gifski/issues/161#issuecomment-552547771). #### How can I convert a sequence of PNG images to a GIF? Install [FFmpeg](https://www.ffmpeg.org/) (with Homebrew: `brew install ffmpeg`) and then run this command: ``` TMPFILE="$(mktemp /tmp/XXXXXXXXXXX).mov"; \ ffmpeg -f image2 -framerate 30 -i image_%06d.png -c:v prores_ks -profile:v 5 "$TMPFILE" \ && open -a Gifski "$TMPFILE" ``` Ensure the images are named in the format `image_000001.png` and adjust the `-framerate` accordingly. [*Command explanation.*](https://avpres.net/FFmpeg/sq_ProRes.html) #### How can I run multiple conversions at the same time? This is unfortunately not supported in the app itself, but you can do it from the Shortcuts app using the shortcut action that comes with the app. If you know how to run a terminal command, you could also run `open -na Gifski` multiple times to open multiple instances of Gifski, where each instance can convert a separate video. You should not have the editor view open in multiple instances though, as changing the quality, for example, will change it in all the instances. #### Is it possible to convert from WebM? Gifski supports the video formats macOS supports, which does not include WebM. You can convert your video to MP4 first with [this app](https://apps.apple.com/app/id1518836004). #### Can I contribute localizations? We don't plan to localize the app. #### Can you support Windows and Linux? No, but there's a [cross-platform command-line tool](https://github.com/ImageOptim/gifski) available. #### [More FAQs…](https://sindresorhus.com/apps/faq) ## Press - [Five Mac Apps Worth Checking Out - September 2019 - MacRumors](https://www.macrumors.com/2019/09/04/five-mac-apps-sept-2019/) ## Built with - [gifski library](https://github.com/ImageOptim/gifski) - High-quality GIF encoder - [Defaults](https://github.com/sindresorhus/Defaults) - Swifty and modern UserDefaults - [DockProgress](https://github.com/sindresorhus/DockProgress) - Show progress in your app's Dock icon ## Maintainers - [Sindre Sorhus](https://github.com/sindresorhus) - [Kornel Lesiński](https://github.com/kornelski) ## Related - [Sindre's apps](https://sindresorhus.com/apps) ## License MIT (the Mac app) + [gifski library license](https://github.com/ImageOptim/gifski/blob/master/LICENSE)