Full Code of sindresorhus/Gifski for AI

main 93177b0f2453 cached
95 files
674.0 KB
186.4k tokens
236 symbols
1 requests
Download .txt
Showing preview only (706K chars total). Download the full file or copy to clipboard to get everything.
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<Bool> {
		.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<Int>?
	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<Int>?

	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<Double>) -> 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<Double>?
	private let playerItem: AVPlayerItem
	fileprivate let player: LoopingPlayer
	private let controlsStyle: AVPlayerViewControlsStyle
	private let timeRangeDidChange: ((ClosedRange<Double>) -> Void)?
	private var cancellables = Set<AnyCancellable>()
	private var currentItemDurationRange: ClosedRange<Double>?

	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<Double>) -> 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<Double>?) {
		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<Double>) -> 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<DragHandleView>

	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<Double>("outputQuality", default: 1)
	static let outputSpeed = Key<Double>("outputSpeed", default: 1)
	static let outputFPS = Key<Int>("outputFPS", default: 10)
	static let loopGIF = Key<Bool>("loopGif", default: true)
	static let bounceGIF = Key<Bool>("bounceGif", default: false)
	static let suppressKeyframeWarning = Key<Bool>("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<Bool>,
		cropRect: Binding<CropRect>,
		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<UnitPoint, CGFloat> {
			switch self {
			case .horizontal:
				\.x
			case .vertical:
				\.y
			}
		}

		var size: WritableKeyPath<UnitSize, Double> {
			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<CGSize, CGFloat> {
		aspectRatio >= 1.0 ? \.width : \.height
	}

	private static func scaleBounds(
		videoDimensions dimensions: CGSize,
		center: CGPoint,
		normalizedAspect: CGSize
	) -> ClosedRange<Double> {
		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<Double>,
		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<Double> {
		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<CGSize, CGFloat>
	@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> {
		Int(CropRect.minRectWidthHeight)...Int(dimensions[keyPath: side])
	}

	var unitSizeSide: WritableKeyPath<UnitSize, Double> {
		isWidth ? \.width : \.height
	}
}

private struct CustomAspectField: View {
	@Binding var customAspectRatio: PickerAspectRatio?
	@Binding var modifiedCustomField: CustomFieldType?
	let side: WritableKeyPath<PickerAspectRatio, Int>

	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<Int> {
		CropRect.defaultAspectRatioBounds
	}

	var isWidth: Bool {
		side == \.width
	}

	var unitSizeSide: WritableKeyPath<UnitSize, Double> {
		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<Int>
	) -> 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<Int>
	) -> 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<Double>?
	@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<CropRect>,
		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<Double> {
		.fromGraceful(
			Constants.allowedFrameRate.lowerBound,
			maxFrameRate
		)
	}

	// TODO: Make extension for this conversion.
	private var intRange: ClosedRange<Int> {
		.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<Bool> {
		.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<Bool> {
		.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<Void, Never>, 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..<frameCount).map { index in
			let presentationTimestamp = startTime + (frameStep * Double(index))
			return CMTime(
				seconds: presentationTimestamp,
				preferredTimescale: timescale
			)
		}

		// We don't do this when "bounce" is enabled as the bounce calculations are not able to handle this.
		if !conversion.bounce {
			// Ensure we include the last frame. For example, the above might have calculated `[..., 6.25, 6.3]`, but the duration is `6.3647`, so we might miss the last frame if it appears for a short time.
			frameForTimes.append(CMTime(seconds: duration, preferredTimescale: timescale))
		}
//
//		record(
//			jobKey: jobKey,
//			key: "frameRate",
//			value: frameRate
//		)
//		record(
//			jobKey: jobKey,
//			key: "videoRange",
//			value: videoRange
//		)
//		record(
//			jobKey: jobKey,
//			key: "frameCount",
//			value: frameCount
//		)
//		record(
//			jobKey: jobKey,
//			key: "frameForTimes",
//			value: frameForTimes.map(\.seconds)
//		)

		return (generator, frameForTimes, Int(frameRate))
	}

	private func totalFrameCount(for conversion: Conversion, sourceFrameCount: Int) -> 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<Double>?
		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<Double, Data> {
		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
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>com.apple.security.application-groups</key>
	<array>
		<string>group.com.sindresorhus.Gifski</string>
	</array>
</dict>
</plist>


================================================
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<Data> {}

	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<UInt8>) -> Int

	private let pointer: OpaquePointer
	private var unmanagedSelf: Unmanaged<GifskiWrapper>!
	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<GifskiWrapper>.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<GifskiWrapper>.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<GifskiWrapper>.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
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>CFBundleDocumentTypes</key>
	<array>
		<dict>
			<key>CFBundleTypeName</key>
			<string>Video</string>
			<key>CFBundleTypeRole</key>
			<string>Viewer</string>
			<key>LSHandlerRank</key>
			<string>Alternate</string>
			<key>LSItemContentTypes</key>
			<array>
				<string>public.mpeg-4</string>
				<string>com.apple.m4v-video</string>
				<string>com.apple.quicktime-movie</string>
			</array>
			<key>NSExportableTypes</key>
			<array>
				<string>com.compuserve.gif</string>
				<string>public.mpeg-4</string>
			</array>
		</dict>
	</array>
	<key>CFBundleURLTypes</key>
	<array>
		<dict>
			<key>CFBundleTypeRole</key>
			<string>Viewer</string>
			<key>CFBundleURLName</key>
			<string>com.sindresorhus.Gifski</string>
			<key>CFBundleURLSchemes</key>
			<array>
				<string>gifski</string>
			</array>
		</dict>
	</array>
	<key>ITSAppUsesNonExemptEncryption</key>
	<false/>
	<key>MDItemKeywords</key>
	<string>gif,convert,video</string>
	<key>NSServices</key>
	<array>
		<dict>
			<key>NSMenuItem</key>
			<dict>
				<key>default</key>
				<string>Convert to GIF with Gifski</string>
			</dict>
			<key>NSMessage</key>
			<string>convertToGIF</string>
			<key>NSPortName</key>
			<string>${EXECUTABLE_NAME}</string>
			<key>NSRequiredContext</key>
			<dict/>
			<key>NSSendFileTypes</key>
			<array>
				<string>public.mpeg-4</string>
				<string>com.apple.m4v-video</string>
				<string>com.apple.quicktime-movie</string>
			</array>
		</dict>
	</array>
</dict>
</plist>


================================================
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<Crop_AppEntity> {
		.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<IntentFile> {
		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 <metal_stdlib>
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 <simd/simd.h>
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<FullPreviewGenerationEvent>.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<Void, Never>?

	/**
	Incremented on every new request.
	*/
	private var automaticRequestID = 0

	private func newID() -> Int {
		automaticRequestID += 1
		return automaticRequestID
	}

	let eventStream: AsyncStream<FullPreviewGenerationEvent>

	init() {
		// The output stream. This is a stream of `FullPreviewGenerationEvents`.
		(self.eventStream, self.stateStreamContinuation) = AsyncStream<FullPreviewGenerationEvent>.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<Double, [SendableTexture?]> {
		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<Float>(
				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<CompositePreviewVertexUniforms>.stride,
						index: 0
					)
				}

				do {
					// Send our data to the fragment shader. Mostly about the checkerboard pattern.
					var fragmentUniforms = fragmentUniforms

					renderEncoder.setFragmentBytes(
						&fragmentUniforms,
						length: MemoryLayout<CompositePreviewFragmentUniforms>.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
		}

		r
Download .txt
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
Download .txt
SYMBOL INDEX (236 symbols across 18 files)

FILE: Gifski/Preview/CompositePreviewShared.h
  type float2 (line 6) | typedef float2 shared_float2;
  type float3 (line 7) | typedef float3 shared_float3;
  type float4 (line 8) | typedef float4 shared_float4;
  type uint (line 9) | typedef uint shared_uint;
  type simd_float2 (line 15) | typedef simd_float2 shared_float2;
  type simd_float3 (line 16) | typedef simd_float3 shared_float3;
  type simd_float4 (line 17) | typedef simd_float4 shared_float4;
  type shared_uint (line 18) | typedef uint32_t shared_uint;
  type CompositePreviewFragmentUniforms (line 24) | typedef struct {
  type CompositePreviewVertexUniforms (line 43) | typedef struct {

FILE: gifski-api/gifski.h
  type gifski (line 11) | struct gifski
  type gifski (line 12) | typedef struct gifski gifski;
  type GifskiSettings (line 62) | typedef struct GifskiSettings {
  type GifskiError (line 85) | enum GifskiError {
  type GifskiError (line 124) | typedef enum GifskiError GifskiError;

FILE: gifski-api/src/bin/ffmpeg_source.rs
  type FfmpegDecoder (line 7) | pub struct FfmpegDecoder {
    method new (line 25) | pub fn new(src: SrcPath, rate: Fps, settings: Settings) -> BinResult<S...
    method collect_frames (line 42) | pub fn collect_frames(&mut self, dest: &mut Collector) -> BinResult<()> {
  method total_frames (line 15) | fn total_frames(&self) -> Option<u64> {
  method collect (line 19) | fn collect(&mut self, dest: &mut Collector) -> BinResult<()> {

FILE: gifski-api/src/bin/gif_source.rs
  type GifDecoder (line 9) | pub struct GifDecoder {
    method new (line 16) | pub fn new(src: SrcPath, fps: Fps) -> BinResult<Self> {
  method total_frames (line 38) | fn total_frames(&self) -> Option<u64> { None }
  method collect (line 39) | fn collect(&mut self, c: &mut Collector) -> BinResult<()> {

FILE: gifski-api/src/bin/gifski.rs
  type BinResult (line 34) | pub type BinResult<T, E = Box<dyn std::error::Error + Send + Sync>> = Re...
  constant VIDEO_FRAMES_ARG_HELP (line 47) | const VIDEO_FRAMES_ARG_HELP: &str = "one video file supported by FFmpeg,...
  constant VIDEO_FRAMES_ARG_HELP (line 49) | const VIDEO_FRAMES_ARG_HELP: &str = "PNG image files for the animation f...
  function main (line 51) | fn main() {
  function bin_main (line 62) | fn bin_main() -> BinResult<()> {
  function check_errors (line 405) | fn check_errors(err1: Result<(), gifski::Error>, err2: BinResult<()>) ->...
  function unexpected (line 415) | fn unexpected(ftype: &'static str) -> BinResult<()> {
  function panic_err (line 420) | fn panic_err(err: Box<dyn std::any::Any + Send>) -> String {
  function parse_color (line 425) | fn parse_color(c: &str) -> Result<rgb::RGB8, String> {
  function parse_colors (line 441) | fn parse_colors(colors: &str) -> Result<Vec<rgb::RGB8>, String> {
  function color_parser (line 449) | fn color_parser() {
  function parse_color_space (line 454) | fn parse_color_space(value: &str) -> Result<MatrixCoefficients, String> {
  type FileType (line 471) | enum FileType {
  function file_type (line 475) | fn file_type(src: &mut SrcPath) -> BinResult<FileType> {
  function check_if_paths_exist (line 509) | fn check_if_paths_exist(paths: &[PathBuf]) -> BinResult<()> {
  type DestPath (line 545) | enum DestPath<'a> {
  type SrcPath (line 550) | enum SrcPath {
  function new (line 556) | pub fn new(path: &'a Path) -> Self {
  function fmt (line 566) | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
  function get_video_decoder (line 578) | fn get_video_decoder(ftype: FileType, src: SrcPath, fps: source::Fps, in...
  function get_video_decoder (line 588) | fn get_video_decoder(ftype: FileType, src: SrcPath, fps: source::Fps, in...
  type ProgressBar (line 615) | struct ProgressBar {
    method new (line 623) | fn new(total: Option<u64>) -> Self {
  method increase (line 637) | fn increase(&mut self) -> bool {
  method written_bytes (line 646) | fn written_bytes(&mut self, bytes: u64) {
  method done (line 664) | fn done(&mut self, msg: &str) {

FILE: gifski-api/src/bin/png.rs
  type Lodecoder (line 6) | pub struct Lodecoder {
    method new (line 12) | pub fn new(frames: Vec<PathBuf>, params: Fps) -> Self {
  method total_frames (line 21) | fn total_frames(&self) -> Option<u64> {
  method collect (line 26) | fn collect(&mut self, dest: &mut Collector) -> BinResult<()> {

FILE: gifski-api/src/bin/source.rs
  type Source (line 4) | pub trait Source {
    method total_frames (line 5) | fn total_frames(&self) -> Option<u64>;
    method collect (line 6) | fn collect(&mut self, dest: &mut Collector) -> BinResult<()>;
  type Fps (line 10) | pub struct Fps {

FILE: gifski-api/src/bin/y4m_source.rs
  type Y4MDecoder (line 12) | pub struct Y4MDecoder {
    method new (line 20) | pub fn new(src: SrcPath, fps: Fps, in_color_space: Option<MatrixCoeffi...
  type Samp (line 56) | enum Samp {
  method total_frames (line 64) | fn total_frames(&self) -> Option<u64> {
  method collect (line 90) | fn collect(&mut self, c: &mut Collector) -> BinResult<()> {

FILE: gifski-api/src/c_api.rs
  type GifskiSettings (line 67) | pub struct GifskiSettings {
  type ARGB8 (line 82) | pub struct ARGB8 {
  type GifskiHandle (line 92) | pub struct GifskiHandle {
  type GifskiHandleInternal (line 95) | pub struct GifskiHandleInternal {
    method print_error (line 581) | fn print_error(&self, mut err: String) {
    method print_panic (line 591) | fn print_panic(&self, e: Box<dyn std::any::Any + Send>) {
  function gifski_new (line 111) | pub unsafe extern "C" fn gifski_new(settings: *const GifskiSettings) -> ...
  function gifski_set_motion_quality (line 141) | pub unsafe extern "C" fn gifski_set_motion_quality(handle: *mut GifskiHa...
  function gifski_set_lossy_quality (line 158) | pub unsafe extern "C" fn gifski_set_lossy_quality(handle: *mut GifskiHan...
  function gifski_set_extra_effort (line 174) | pub unsafe extern "C" fn gifski_set_extra_effort(handle: *mut GifskiHand...
  function gifski_add_fixed_color (line 190) | pub unsafe extern "C" fn gifski_add_fixed_color(handle: *mut GifskiHandl...
  function gifski_add_frame_png_file (line 221) | pub unsafe extern "C" fn gifski_add_frame_png_file(handle: *const Gifski...
  function gifski_add_frame_rgba (line 254) | pub unsafe extern "C" fn gifski_add_frame_rgba(handle: *const GifskiHand...
  function gifski_add_frame_rgba_stride (line 269) | pub unsafe extern "C" fn gifski_add_frame_rgba_stride(handle: *const Gif...
  function pixels_slice (line 278) | unsafe fn pixels_slice<'a, T>(pixels: *const T, width: u32, height: u32,...
  function add_frame_rgba (line 292) | fn add_frame_rgba(handle: *const GifskiHandle, frame_number: u32, frame:...
  function gifski_add_frame_argb (line 311) | pub unsafe extern "C" fn gifski_add_frame_argb(handle: *const GifskiHand...
  function gifski_add_frame_rgb (line 337) | pub unsafe extern "C" fn gifski_add_frame_rgb(handle: *const GifskiHandl...
  function gifski_set_progress_callback (line 362) | pub unsafe extern "C" fn gifski_set_progress_callback(handle: *const Gif...
  function gifski_set_error_message_callback (line 392) | pub unsafe extern "C" fn gifski_set_error_message_callback(handle: *cons...
  type SendableUserData (line 411) | struct SendableUserData(*mut c_void);
  function gifski_set_file_output (line 421) | pub unsafe extern "C" fn gifski_set_file_output(handle: *const GifskiHan...
  function prepare_for_file_writing (line 433) | fn prepare_for_file_writing(g: &GifskiHandleInternal, destination: *cons...
  type CallbackWriter (line 453) | struct CallbackWriter {
    method write (line 461) | fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
    method flush (line 468) | fn flush(&mut self) -> io::Result<()> {
  function gifski_set_write_callback (line 490) | pub unsafe extern "C" fn gifski_set_write_callback(handle: *const Gifski...
  function gifski_write_thread_start (line 501) | fn gifski_write_thread_start<W: 'static +  Write + Send>(g: &GifskiHandl...
  function borrow (line 535) | unsafe fn borrow<'a>(handle: *const GifskiHandle) -> Option<&'a GifskiHa...
  function gifski_finish (line 551) | pub unsafe extern "C" fn gifski_finish(g: *const GifskiHandle) -> Gifski...
  function c_cb (line 599) | fn c_cb() {
  function progress_abort (line 636) | fn progress_abort() {
  function cant_write_after_finish (line 664) | fn cant_write_after_finish() {
  function c_write_failure_propagated (line 683) | fn c_write_failure_propagated() {
  function test_error_callback (line 703) | fn test_error_callback() {
  function cant_write_twice (line 730) | fn cant_write_twice() {
  function c_incomplete (line 748) | fn c_incomplete() {

FILE: gifski-api/src/c_api/c_api_error.rs
  type GifskiError (line 10) | pub enum GifskiError {
    method from (line 51) | fn from(res: c_int) -> Self {
    method from (line 76) | fn from(res: GifResult<()>) -> Self {
    method from (line 97) | fn from(res: io::ErrorKind) -> Self {
    method fmt (line 116) | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  function from (line 31) | fn from(g: GifskiError) -> Self {

FILE: gifski-api/src/collector.rs
  type FrameSource (line 15) | pub(crate) enum FrameSource {
  type InputFrame (line 23) | pub(crate) struct InputFrame {
  type InputFrameResized (line 31) | pub(crate) struct InputFrameResized {
  type Collector (line 44) | pub struct Collector {
    method add_frame_rgba (line 60) | pub fn add_frame_rgba(&self, frame_index: usize, frame: ImgVec<RGBA8>,...
    method add_frame_png_data (line 83) | pub fn add_frame_png_data(&self, frame_index: usize, png_data: Vec<u8>...
    method add_frame_png_file (line 103) | pub fn add_frame_png_file(&self, frame_index: usize, path: PathBuf, pr...

FILE: gifski-api/src/denoise.rs
  constant LOOKAHEAD (line 10) | const LOOKAHEAD: usize = 5;
  type Acc (line 13) | pub struct Acc {
    method get (line 25) | pub fn get(&self, idx: usize) -> Option<(RGB8, RGB8)> {
    method append (line 38) | pub fn append(&mut self, val: RGBA8, val_blur: RGB8) {
    method next_pixel (line 179) | fn next_pixel(&mut self, threshold: u32, odd_frame: bool) -> (RGBA8, u...
  type Denoised (line 52) | pub enum Denoised<T> {
  type Denoiser (line 64) | pub struct Denoiser<T> {
  type WrongSizeError (line 74) | pub struct WrongSizeError;
  function new (line 78) | pub fn new(width: usize, height: usize, quality: u8) -> Result<Self, Wro...
  function quick_append (line 96) | fn quick_append(&mut self, frame: ImgRef<RGBA8>, frame_blurred: ImgRef<R...
  function flush (line 104) | pub fn flush(&mut self) {
  function push_frame_test (line 128) | fn push_frame_test(&mut self, frame: ImgRef<RGBA8>, frame_metadata: T) -...
  function push_frame (line 134) | pub fn push_frame(&mut self, frame: ImgRef<RGBA8>, frame_blurred: ImgRef...
  function pop (line 166) | pub fn pop(&mut self) -> Denoised<T> {
  function smart_blur (line 290) | pub(crate) fn smart_blur(frame: ImgRef<RGBA8>) -> ImgVec<RGB8> {
  function less_smart_blur (line 312) | pub(crate) fn less_smart_blur(frame: ImgRef<RGBA8>) -> ImgVec<RGB8> {
  function cohort (line 336) | fn cohort(color: RGB8) -> bool {
  function pixel_importance (line 342) | fn pixel_importance(diff_with_bg: u32, threshold: u32, min: u8, max: u8)...
  function avg8 (line 349) | fn avg8(a: u8, b: u8) -> u8 {
  function zip (line 354) | fn zip(zip: impl Fn(fn(&(RGB8, RGB8)) -> u8) -> u8) -> RGB8 {
  function get_medians (line 363) | fn get_medians(src: &[(RGB8, RGB8); LOOKAHEAD], len_minus_one: usize) ->...
  function color_diff (line 390) | fn color_diff(x: RGB8, y: RGB8) -> u32 {
  function px (line 401) | fn px<T>(f: Denoised<T>) -> (RGBA8, T) {
  function one (line 408) | fn one() {
  function two (line 422) | fn two() {
  function three (line 436) | fn three() {
  function four (line 452) | fn four() {
  function five (line 471) | fn five() {
  function six (line 492) | fn six() {
  function many (line 519) | fn many() {

FILE: gifski-api/src/encoderust.rs
  type CountingWriter (line 12) | struct CountingWriter<W> {
  method write (line 19) | fn write(&mut self, buf: &[u8]) -> Result<usize, std::io::Error> {
  method flush (line 26) | fn flush(&mut self) -> Result<(), std::io::Error> {
  type RustEncoder (line 31) | pub(crate) struct RustEncoder<W: Write> {
  function new (line 38) | pub fn new(writer: W, written: Rc<Cell<u64>>) -> Self {
  function compress_frame (line 50) | pub fn compress_frame(f: GIFFrame, settings: &SettingsExt) -> CatResult<...
  function compress_gifsicle (line 90) | fn compress_gifsicle(frame: &mut gif::Frame<'static>, loss: u32) -> CatR...
  function write_frame (line 111) | pub fn write_frame(&mut self, mut frame: gif::Frame<'static>, delay: u16...

FILE: gifski-api/src/error.rs
  type CatResult (line 51) | pub type CatResult<T, E = Error> = Result<T, E>;
  type GifResult (line 54) | pub type GifResult<T, E = Error> = Result<T, E>;
  method from (line 58) | fn from(err: gif::EncodingError) -> Self {
  method from (line 68) | fn from(_: ordered_channel::SendError<T>) -> Self {
  method from (line 75) | fn from(_: ordered_channel::RecvError) -> Self {
  method from (line 82) | fn from(_panic: Box<dyn std::any::Any + Send>) -> Self {

FILE: gifski-api/src/gifsicle.rs
  type GiflossyImage (line 1) | pub struct GiflossyImage<'data> {
  type LzwCode (line 13) | pub type LzwCode = u16;
  type GiflossyWriter (line 16) | pub struct GiflossyWriter {
    method write (line 174) | pub fn write(&mut self, image: &GiflossyImage, global_pal: Option<&[RG...
  type CodeTable (line 20) | struct CodeTable {
    method define (line 79) | fn define(&mut self, work_node_id: NodeId, suffix: u8, next_code: LzwC...
    method reset (line 90) | fn reset(&mut self) {
  type NodeId (line 26) | type NodeId = u16;
  type Node (line 28) | struct Node {
  type RgbDiff (line 34) | type RgbDiff = rgb::RGB<i16>;
  function color_diff (line 37) | fn color_diff(a: RGB8, b: RGB8, a_transparent: bool, b_transparent: bool...
  function diffused_difference (line 59) | fn diffused_difference(
  type Lookup (line 101) | struct Lookup<'a> {
  function lossy_node (line 112) | pub fn lossy_node(&mut self, pos: usize, node_id: NodeId, total_diff: u6...
  function try_node (line 128) | fn try_node(
  constant RUN_EWMA_SHIFT (line 169) | const RUN_EWMA_SHIFT: usize = 4;
  constant RUN_EWMA_SCALE (line 170) | const RUN_EWMA_SCALE: usize = 19;
  constant RUN_INV_THRESH (line 171) | const RUN_INV_THRESH: usize = (1 << RUN_EWMA_SCALE) / 3000;
  function new (line 302) | pub fn new(
  function px_at_pos (line 321) | fn px_at_pos(&self, pos: usize) -> Option<u8> {
  function interlaced_line (line 332) | fn interlaced_line(line: usize, height: usize) -> usize {

FILE: gifski-api/src/lib.rs
  type Repeat (line 71) | pub type Repeat = gif::Repeat;
  type Settings (line 75) | pub struct Settings {
    method color_quality (line 102) | pub(crate) fn color_quality(&self) -> u8 {
    method dimensions_for_image (line 109) | pub fn dimensions_for_image(&self, width: usize, height: usize) -> (us...
  type SettingsExt (line 90) | struct SettingsExt {
    method gifsicle_loss (line 115) | pub(crate) fn gifsicle_loss(&self) -> u32 {
    method dithering_level (line 123) | pub(crate) fn dithering_level(&self) -> f32 {
  method default (line 136) | fn default() -> Self {
  type Writer (line 147) | pub struct Writer {
    method set_extra_effort (line 413) | pub fn set_extra_effort(&mut self, enabled: bool) {
    method set_motion_quality (line 419) | pub fn set_motion_quality(&mut self, q: u8) {
    method set_lossy_quality (line 425) | pub fn set_lossy_quality(&mut self, q: u8) {
    method add_fixed_color (line 433) | pub fn add_fixed_color(&mut self, col: RGB8) {
    method set_matte_color (line 441) | pub fn set_matte_color(&mut self, col: RGB8) {
    method quantize (line 450) | fn quantize(&self, image: ImgVec<RGBA8>, importance_map: &[u8], first_...
    method remap (line 506) | fn remap<'a>(&self, liq: Attributes, mut res: QuantizationResult, mut ...
    method write_frames (line 518) | fn write_frames(&self, write_queue: Receiver<FrameMessage>, writer: &m...
    method write (line 571) | pub fn write<W: Write>(mut self, mut writer: W, reporter: &mut dyn Pro...
    method write_inner (line 577) | fn write_inner(&self, decode_queue_recv: Receiver<InputFrame>, writer:...
    method make_resize (line 605) | fn make_resize(&self, inputs: Receiver<InputFrame>, diff_queue: OrdQue...
    method make_diffs (line 641) | fn make_diffs(&self, mut inputs: OrdQueueIter<InputFrameResized>, diff...
    method quantize_frames (line 707) | fn quantize_frames(&self, inputs: Receiver<DiffMessage>, remap_queue: ...
    method remap_frames (line 796) | fn remap_frames(&self, mut inputs: OrdQueueIter<RemapMessage>, write_q...
  type GIFFrame (line 157) | struct GIFFrame {
  type DiffMessage (line 167) | struct DiffMessage {
  type QuantizeMessage (line 175) | struct QuantizeMessage {
  type RemapMessage (line 190) | struct RemapMessage {
  type FrameMessage (line 203) | struct FrameMessage {
  function new (line 244) | pub fn new(settings: Settings) -> GifResult<(Collector, Writer)> {
  function resized_binary_alpha (line 275) | fn resized_binary_alpha(image: ImgVec<RGBA8>, width: Option<u32>, height...
  function dither_image (line 313) | fn dither_image(mut image: ImgRefMut<RGBA8>) {
  function dimensions_for_image (line 362) | fn dimensions_for_image((img_w, img_h): (usize, usize), resize_to: (Opti...
  type LastFrameDuration (line 387) | enum LastFrameDuration {
    method value (line 394) | pub fn value(&self) -> f64 {
    method shift_every_pts_by (line 401) | pub fn shift_every_pts_by(&self) -> f64 {
  function transparent_index_from_palette (line 861) | fn transparent_index_from_palette(mut image8_pal: Vec<RGBA8>, mut image8...
  function combine_res (line 886) | fn combine_res(res1: Result<(), Error>, res2: Result<(), Error>) -> Resu...
  function trim_image (line 897) | fn trim_image(mut image_trimmed: ImgRef<u8>, image8_pal: &[RGB8], transp...
  type PushInCapacity (line 978) | trait PushInCapacity<T> {
    method push_in_cap (line 979) | fn push_in_cap(&mut self, val: T);
  function push_in_cap (line 985) | fn push_in_cap(&mut self, val: T) {
  function handle_join_error (line 994) | fn handle_join_error(err: Box<dyn std::any::Any + Send>) -> Error {
  function sendable (line 1002) | fn sendable() {

FILE: gifski-api/src/minipool.rs
  function new_channel (line 8) | pub fn new_channel<P, C, M, R>(num_threads: NonZeroU8, name: &str, produ...
  function new_scope (line 26) | pub fn new_scope<P, C, R>(num_threads: NonZeroU8, name: &str, waiter: P,...

FILE: gifski-api/src/progress.rs
  type ProgressReporter (line 11) | pub trait ProgressReporter: Send {
    method increase (line 15) | fn increase(&mut self) -> bool;
    method written_bytes (line 18) | fn written_bytes(&mut self, _current_file_size_in_bytes: u64) {}
    method done (line 22) | fn done(&mut self, _msg: &str) {}
    method increase (line 43) | fn increase(&mut self) -> bool {
    method done (line 47) | fn done(&mut self, _msg: &str) {}
    method increase (line 51) | fn increase(&mut self) -> bool {
    method done (line 55) | fn done(&mut self, _msg: &str) {}
    method increase (line 62) | fn increase(&mut self) -> bool {
    method done (line 67) | fn done(&mut self, msg: &str) {
  type NoProgress (line 26) | pub struct NoProgress {}
  type ProgressCallback (line 29) | pub struct ProgressCallback {
    method new (line 37) | pub fn new(callback: unsafe extern "C" fn(*mut c_void) -> c_int, arg: ...
Condensed preview — 95 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (753K chars).
[
  {
    "path": ".editorconfig",
    "chars": 175,
    "preview": "root = true\n\n[*]\nindent_style = tab\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newlin"
  },
  {
    "path": ".gitattributes",
    "chars": 88,
    "preview": "* text=auto eol=lf\n*.pdf binary\n*.ai binary\n*.psd binary\ngifski-api/* linguist-vendored\n"
  },
  {
    "path": ".gitignore",
    "chars": 31,
    "preview": "xcuserdata/\n/gifski-api/tests/\n"
  },
  {
    "path": ".swiftlint.yml",
    "chars": 6399,
    "preview": "only_rules:\n  - accessibility_trait_for_button\n  - array_init\n  - blanket_disable_command\n  - block_based_kvo\n  - class_"
  },
  {
    "path": "Config.xcconfig",
    "chars": 56,
    "preview": "MARKETING_VERSION = 2.23.1\nCURRENT_PROJECT_VERSION = 73\n"
  },
  {
    "path": "Gifski/App.swift",
    "chars": 2477,
    "preview": "import SwiftUI\n\n@main\nstruct AppMain: App {\n\tprivate let appState = AppState.shared\n\t@NSApplicationDelegateAdaptor(AppDe"
  },
  {
    "path": "Gifski/AppIcon.icon/icon.json",
    "chars": 1375,
    "preview": "{\n  \"fill-specializations\" : [\n    {\n      \"value\" : {\n        \"solid\" : \"gray:0.20000,1.00000\"\n      }\n    },\n    {\n   "
  },
  {
    "path": "Gifski/AppState.swift",
    "chars": 4384,
    "preview": "import SwiftUI\nimport UserNotifications\nimport DockProgress\n\n@MainActor\n@Observable\nfinal class AppState {\n\tstatic let s"
  },
  {
    "path": "Gifski/Assets.xcassets/Contents.json",
    "chars": 63,
    "preview": "{\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "Gifski/CompletedScreen.swift",
    "chars": 5225,
    "preview": "import SwiftUI\nimport UserNotifications\nimport StoreKit\n\nstruct CompletedScreen: View {\n\t@Environment(AppState.self) pri"
  },
  {
    "path": "Gifski/Components/CheckerboardView.swift",
    "chars": 1627,
    "preview": "import SwiftUI\n\nenum CheckerboardViewConstants {\n\tstatic let gridSize = 8\n\n\t/**\n\tI tried just using firstColor directly "
  },
  {
    "path": "Gifski/Components/IntTextField.swift",
    "chars": 3926,
    "preview": "import SwiftUI\n\n// TODO: This does not correctly prevent larger numbers than `minMax`.\n\nstruct IntTextField: NSViewRepre"
  },
  {
    "path": "Gifski/Components/TrimmingAVPlayer.swift",
    "chars": 20021,
    "preview": "import AVKit\nimport SwiftUI\n\nstruct TrimmingAVPlayer: NSViewControllerRepresentable {\n\ttypealias NSViewControllerType = "
  },
  {
    "path": "Gifski/Constants.swift",
    "chars": 1034,
    "preview": "import SwiftUI\nimport CoreTransferable\nimport AVFoundation\n\nenum Constants {\n\tstatic let allowedFrameRate = 3.0...50.0\n\t"
  },
  {
    "path": "Gifski/ConversionScreen.swift",
    "chars": 3856,
    "preview": "import SwiftUI\nimport AVFoundation\nimport DockProgress\n\nstruct ConversionScreen: View {\n\t@Environment(\\.dismiss) private"
  },
  {
    "path": "Gifski/Credits.rtf",
    "chars": 970,
    "preview": "{\\rtf1\\ansi\\ansicpg1252\\cocoartf2638\n\\cocoatextscaling0\\cocoaplatform0{\\fonttbl\\f0\\fswiss\\fcharset0 Helvetica;\\f1\\fswiss"
  },
  {
    "path": "Gifski/Crop/CropDragGestureModifier.swift",
    "chars": 1250,
    "preview": "import SwiftUI\n\nextension View {\n\tfunc cropDragGesture(\n\t\tisDragging: Binding<Bool>,\n\t\tcropRect: Binding<CropRect>,\n\t\tfr"
  },
  {
    "path": "Gifski/Crop/CropHandlePosition.swift",
    "chars": 2596,
    "preview": "import SwiftUI\n\nenum CropHandlePosition: CaseIterable {\n\tcase top\n\tcase topRight\n\tcase right\n\tcase bottomRight\n\tcase bot"
  },
  {
    "path": "Gifski/Crop/CropOverlayView.swift",
    "chars": 9482,
    "preview": "import SwiftUI\nimport AVFoundation\nimport AVKit\n\nstruct CropOverlayView: View {\n\t@State private var dragMode = CropRect."
  },
  {
    "path": "Gifski/Crop/CropRect.swift",
    "chars": 15605,
    "preview": "import SwiftUI\n\n/**\nRepresents a crop rect.\n\nBoth size and origin are unit points, so it does not matter what the aspect"
  },
  {
    "path": "Gifski/Crop/CropSettings.swift",
    "chars": 1901,
    "preview": "import Foundation\nimport AVKit\n\nprotocol CropSettings {\n\tvar dimensions: (width: Int, height: Int)? { get }\n\tvar trackPr"
  },
  {
    "path": "Gifski/Crop/CropToolBarItems.swift",
    "chars": 9662,
    "preview": "import SwiftUI\nimport AVFoundation\n\nstruct CropToolbarItems: View {\n\t@State private var showCropTooltip = false\n\n\t@Bindi"
  },
  {
    "path": "Gifski/Crop/PickerAspectRatio.swift",
    "chars": 3562,
    "preview": "import Foundation\n\nstruct PickerAspectRatio: Hashable {\n\tvar width: Int\n\tvar height: Int\n\n\tinit(_ width: Int, _ height: "
  },
  {
    "path": "Gifski/EditScreen.swift",
    "chars": 24054,
    "preview": "import SwiftUI\nimport AVFoundation\n\nstruct EditScreen: View {\n\t@Environment(AppState.self) private var appState\n\t@State "
  },
  {
    "path": "Gifski/EstimatedFileSize.swift",
    "chars": 3690,
    "preview": "import SwiftUI\n\n// TODO: Rewrite the whole estimation thing.\n\n@MainActor\n@Observable\nfinal class EstimatedFileSizeModel "
  },
  {
    "path": "Gifski/ExportModifiedVideo.swift",
    "chars": 7556,
    "preview": "import Foundation\nimport AVKit\nimport SwiftUI\n\nstruct ExportModifiedVideoView: View {\n\t@Environment(AppState.self) priva"
  },
  {
    "path": "Gifski/GIFGenerator.swift",
    "chars": 16412,
    "preview": "import Foundation\nimport AVFoundation\n\nactor GIFGenerator {\n\tprivate var gifski: Gifski?\n\tprivate(set) var sizeMultiplie"
  },
  {
    "path": "Gifski/Gifski-Bridging-Header.h",
    "chars": 55,
    "preview": "#import \"gifski.h\"\n#include \"CompositePreviewShared.h\"\n"
  },
  {
    "path": "Gifski/Gifski.entitlements",
    "chars": 306,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
  },
  {
    "path": "Gifski/Gifski.swift",
    "chars": 2533,
    "preview": "import SwiftUI\n\n// TODO: Actor\nfinal class Gifski {\n\tenum Loop {\n\t\tcase forever\n\t\tcase never\n\t\tcase count(Int)\n\t}\n\n\tpriv"
  },
  {
    "path": "Gifski/GifskiWrapper.swift",
    "chars": 5015,
    "preview": "import Foundation\n\n// TODO: Make it an actor.\nfinal class GifskiWrapper {\n\tenum PixelFormat {\n\t\tcase rgba\n\t\tcase argb\n\t\t"
  },
  {
    "path": "Gifski/Info.plist",
    "chars": 1658,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
  },
  {
    "path": "Gifski/Intents.swift",
    "chars": 11666,
    "preview": "import AppIntents\nimport AVFoundation\n\nstruct Crop_AppEntity: Hashable, Codable, AppEntity {\n\tvar mode: CropMode_AppEnum"
  },
  {
    "path": "Gifski/InternetAccessPolicy.json",
    "chars": 443,
    "preview": "{\n\t\"ApplicationDescription\": \"Gifski converts videos to high-quality GIFs.\",\n\t\"DeveloperName\": \"Sindre Sorhus\",\n\t\"Websit"
  },
  {
    "path": "Gifski/MainScreen.swift",
    "chars": 3090,
    "preview": "import SwiftUI\n\nstruct MainScreen: View {\n\t@Environment(AppState.self) private var appState\n\t@State private var isDropTa"
  },
  {
    "path": "Gifski/Preview/CVPixelBuffer+convertToGIF.swift",
    "chars": 1578,
    "preview": "import Foundation\nimport AVKit\n\nextension CVPixelBuffer {\n\tenum ConvertToGIFError: Error {\n\t\tcase failedToCreateCGContex"
  },
  {
    "path": "Gifski/Preview/CompositePreviewShared.h",
    "chars": 845,
    "preview": "#pragma once\n#ifdef __METAL_VERSION__\n// Metal types\n#include <metal_stdlib>\nusing namespace metal;\ntypedef float2 share"
  },
  {
    "path": "Gifski/Preview/FullPreviewGenerationEvent.swift",
    "chars": 5705,
    "preview": "import Foundation\nimport AVFoundation\nimport Metal\n\n/**\nEvents that will be emitted by `PreviewStream`, which represent "
  },
  {
    "path": "Gifski/Preview/FullPreviewStream.swift",
    "chars": 4125,
    "preview": "import Foundation\nimport AVFoundation\nimport Compression\n\nactor FullPreviewStream {\n\tprivate let stateStreamContinuation"
  },
  {
    "path": "Gifski/Preview/PreviewRenderer.swift",
    "chars": 4687,
    "preview": "import Foundation\nimport Metal\nimport MetalKit\n\nactor PreviewRenderer {\n\tprivate static var sharedRenderer: PreviewRende"
  },
  {
    "path": "Gifski/Preview/PreviewRendererContext.swift",
    "chars": 5697,
    "preview": "import Foundation\nimport MetalKit\n\n/**\nThe static state context we setup at runtime and use later.\n*/\nstruct PreviewRend"
  },
  {
    "path": "Gifski/Preview/PreviewVideoCompositor.swift",
    "chars": 4334,
    "preview": "import Foundation\nimport AVFoundation\nimport CoreImage\n\n/**\nA video compositor to composite the preview over the origina"
  },
  {
    "path": "Gifski/Preview/PreviewableComposition.swift",
    "chars": 1808,
    "preview": "import Foundation\nimport AVFoundation\n\n/**\nAdds `PreviewVideoCompositor` to a `AVComposition`, setting up the instructio"
  },
  {
    "path": "Gifski/Preview/SendableTexture.swift",
    "chars": 5074,
    "preview": "import Foundation\nimport Metal\nimport MetalKit\n\n/**\nTextures that can only be accessed on the `PreviewRenderer` actor.\n*"
  },
  {
    "path": "Gifski/Preview/SettingsForFullPreview.swift",
    "chars": 3613,
    "preview": "import Foundation\nimport CoreMedia\nimport AVFoundation\n\n/**\nWhen creating a full preview, you don't need the some settin"
  },
  {
    "path": "Gifski/Preview/compositePreview.metal",
    "chars": 3718,
    "preview": "#include <metal_stdlib>\n#include <metal_graphics>\n#include \"CompositePreviewShared.h\"\n\nusing namespace metal;\n\nstruct Ve"
  },
  {
    "path": "Gifski/ResizableDimensions.swift",
    "chars": 4293,
    "preview": "import CoreGraphics\nimport AppIntents\n\nenum DimensionsType: String, Equatable, CaseIterable {\n\tcase pixels\n\tcase percent"
  },
  {
    "path": "Gifski/Shared.swift",
    "chars": 100,
    "preview": "import Foundation\n\nenum Shared {\n\tstatic let appGroupIdentifier = \"group.com.sindresorhus.Gifski\"\n}\n"
  },
  {
    "path": "Gifski/StartScreen.swift",
    "chars": 603,
    "preview": "import SwiftUI\n\nstruct StartScreen: View {\n\t@Environment(AppState.self) private var appState\n\n\tvar body: some View {\n\t\tV"
  },
  {
    "path": "Gifski/Utilities.swift",
    "chars": 154769,
    "preview": "import SwiftUI\nimport AVKit\nimport Combine\nimport AVFoundation\nimport Accelerate.vImage\nimport AppIntents\nimport Default"
  },
  {
    "path": "Gifski/VideoValidator.swift",
    "chars": 6959,
    "preview": "import AVFoundation\n\nenum VideoValidator {\n\tstatic func validate(_ inputUrl: URL) async throws -> (asset: AVAsset, metad"
  },
  {
    "path": "Gifski.xcodeproj/project.pbxproj",
    "chars": 38346,
    "preview": "// !$*UTF8*$!\n{\n\tarchiveVersion = 1;\n\tclasses = {\n\t};\n\tobjectVersion = 77;\n\tobjects = {\n\n/* Begin PBXBuildFile section *"
  },
  {
    "path": "Gifski.xcodeproj/project.xcworkspace/contents.xcworkspacedata",
    "chars": 135,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Workspace\n   version = \"1.0\">\n   <FileRef\n      location = \"self:\">\n   </FileRef"
  },
  {
    "path": "Gifski.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist",
    "chars": 238,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
  },
  {
    "path": "Gifski.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved",
    "chars": 1483,
    "preview": "{\n  \"originHash\" : \"0c90c366404e200efeaefbb832e9ea0e7faea9e1f96c7c74f6e39e86d8f25618\",\n  \"pins\" : [\n    {\n      \"identit"
  },
  {
    "path": "Gifski.xcodeproj/xcshareddata/xcschemes/Gifski.xcscheme",
    "chars": 3677,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n   LastUpgradeVersion = \"2600\"\n   version = \"1.8\">\n   <BuildAction\n      "
  },
  {
    "path": "Gifski.xcodeproj/xcshareddata/xcschemes/Share Extension.xcscheme",
    "chars": 3607,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n   LastUpgradeVersion = \"2600\"\n   wasCreatedForAppExtension = \"YES\"\n   ve"
  },
  {
    "path": "Share Extension/Info.plist",
    "chars": 1051,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
  },
  {
    "path": "Share Extension/ShareController.swift",
    "chars": 1147,
    "preview": "import SwiftUI\n\nfinal class ShareController: ExtensionController {\n\toverride func run(_ context: NSExtensionContext) asy"
  },
  {
    "path": "Share Extension/Share_Extension.entitlements",
    "chars": 358,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
  },
  {
    "path": "Share Extension/Utilities.swift",
    "chars": 1899,
    "preview": "import SwiftUI\nimport UniformTypeIdentifiers\n\n\nextension Sequence where Element: Sequence {\n\tfunc flatten() -> [Element."
  },
  {
    "path": "app-store-description.txt",
    "chars": 2677,
    "preview": "Convert videos to high-quality GIFs.\n\nGifski converts videos to animated GIFs that use thousands of colors per frame. Th"
  },
  {
    "path": "app-store-keywords.txt",
    "chars": 100,
    "preview": "gif,video,movie,image,convert,converter,mp4,mov,photo,picture,photography,resize,design,bounce,yoyo\n"
  },
  {
    "path": "contributing.md",
    "chars": 80,
    "preview": "## Contributing\n\n### New features\n\nNew features should use SwiftUI and Combine.\n"
  },
  {
    "path": "gifski-api/.github/dependabot.yml",
    "chars": 194,
    "preview": "version: 2\nupdates:\n- package-ecosystem: cargo\n  directory: \"/\"\n  schedule:\n    interval: monthly\n  open-pull-requests-l"
  },
  {
    "path": "gifski-api/.gitignore",
    "chars": 122,
    "preview": "/target/\n**/*.rs.bk\n\n# Snap packaging specific rules\n/snap/.snapcraft/\n/parts/\n/stage/\n/prime/\n\n/*.snap\n/*_source.tar.bz"
  },
  {
    "path": "gifski-api/Cargo.toml",
    "chars": 3426,
    "preview": "[package]\nauthors = [\"Kornel <kornel@geekhood.net>\"]\ncategories = [\"multimedia::video\", \"command-line-utilities\"]\ndescri"
  },
  {
    "path": "gifski-api/LICENSE",
    "chars": 32239,
    "preview": "\nLet [me](https://kornel.ski/contact) know if you'd like to use it in a product incompatible with this license. I can of"
  },
  {
    "path": "gifski-api/README.md",
    "chars": 7328,
    "preview": "# [<img width=\"100%\" src=\"https://gif.ski/gifski.svg\" alt=\"gif.ski\">](https://gif.ski)\n\nHighest-quality GIF encoder base"
  },
  {
    "path": "gifski-api/gifski.h",
    "chars": 12256,
    "preview": "#include <stdarg.h>\n#include <stdint.h>\n#include <stdlib.h>\n#include <stdbool.h>\n\n\n#ifdef __cplusplus\nextern \"C\" {\n#endi"
  },
  {
    "path": "gifski-api/gifski.xcodeproj/project.pbxproj",
    "chars": 19589,
    "preview": "// !$*UTF8*$!\n{\n\tarchiveVersion = 1;\n\tclasses = {\n\t};\n\tobjectVersion = 90;\n\tobjects = {\n\n/* Begin PBXBuildFile section *"
  },
  {
    "path": "gifski-api/gifski.xcodeproj/project.xcworkspace/contents.xcworkspacedata",
    "chars": 83,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Workspace\n   version = \"1.0\">\n</Workspace>\n"
  },
  {
    "path": "gifski-api/gifski.xcodeproj/xcshareddata/xcschemes/gifski.a (static library).xcscheme",
    "chars": 2458,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n   LastUpgradeVersion = \"2600\"\n   version = \"1.7\">\n   <BuildAction\n      "
  },
  {
    "path": "gifski-api/snapcraft.yaml",
    "chars": 803,
    "preview": "name: gifski\nsummary: gifski\ndescription: |\n   GIF encoder based on libimagequant (pngquant).\n   Squeezes maximum possib"
  },
  {
    "path": "gifski-api/src/bin/ffmpeg_source.rs",
    "chars": 5791,
    "preview": "use crate::source::{Fps, Source};\nuse crate::{BinResult, SrcPath};\nuse gifski::{Collector, Settings};\nuse imgref::*;\nuse"
  },
  {
    "path": "gifski-api/src/bin/gif_source.rs",
    "chars": 1600,
    "preview": "//! This is for reading GIFs as an input for re-encoding as another GIF\n\nuse crate::source::{Fps, Source};\nuse crate::{B"
  },
  {
    "path": "gifski-api/src/bin/gifski.rs",
    "chars": 26621,
    "preview": "#![allow(clippy::bool_to_int_with_if)]\n#![allow(clippy::cast_possible_truncation)]\n#![allow(clippy::enum_glob_use)]\n#![a"
  },
  {
    "path": "gifski-api/src/bin/png.rs",
    "chars": 806,
    "preview": "use crate::source::{Fps, Source};\nuse crate::BinResult;\nuse gifski::Collector;\nuse std::path::PathBuf;\n\npub struct Lodec"
  },
  {
    "path": "gifski-api/src/bin/source.rs",
    "chars": 304,
    "preview": "use crate::BinResult;\nuse gifski::Collector;\n\npub trait Source {\n    fn total_frames(&self) -> Option<u64>;\n    fn colle"
  },
  {
    "path": "gifski-api/src/bin/y4m_source.rs",
    "chars": 10373,
    "preview": "use std::io::BufReader;\nuse std::io::Read;\nuse imgref::ImgVec;\nuse gifski::Collector;\nuse y4m::{Colorspace, Decoder, Par"
  },
  {
    "path": "gifski-api/src/c_api/c_api_error.rs",
    "chars": 3747,
    "preview": "use crate::GifResult;\nuse std::fmt;\nuse std::io;\nuse std::os::raw::c_int;\n\n#[repr(C)]\n#[derive(Copy, Clone, Debug, Eq, P"
  },
  {
    "path": "gifski-api/src/c_api.rs",
    "chars": 30088,
    "preview": "#![allow(clippy::missing_safety_doc)]\n//! How to use from C\n//!\n//! ```c\n//! gifski *g = gifski_new(&(GifskiSettings){\n/"
  },
  {
    "path": "gifski-api/src/collector.rs",
    "chars": 4279,
    "preview": "//! For adding frames to the encoder\n//!\n//! [`gifski::new()`][crate::new] returns the [`Collector`] that collects anima"
  },
  {
    "path": "gifski-api/src/denoise.rs",
    "chars": 20197,
    "preview": "use std::collections::VecDeque;\nuse crate::PushInCapacity;\npub use imgref::ImgRef;\nuse imgref::ImgVec;\nuse loop9::loop9_"
  },
  {
    "path": "gifski-api/src/encoderust.rs",
    "chars": 4152,
    "preview": "use crate::error::CatResult;\nuse crate::{GIFFrame, Settings, SettingsExt};\nuse rgb::RGB8;\nuse std::cell::Cell;\nuse std::"
  },
  {
    "path": "gifski-api/src/error.rs",
    "chars": 2206,
    "preview": "use crate::WrongSizeError;\nuse quick_error::quick_error;\nuse std::io;\nuse std::num::TryFromIntError;\n\nquick_error! {\n   "
  },
  {
    "path": "gifski-api/src/gifsicle.rs",
    "chars": 11634,
    "preview": "pub struct GiflossyImage<'data> {\n    img: &'data [u8],\n    width: u16,\n    height: u16,\n    interlace: bool,\n    transp"
  },
  {
    "path": "gifski-api/src/lib.rs",
    "chars": 40179,
    "preview": "/*\n gifski pngquant-based GIF encoder\n © 2017 Kornel Lesiński\n\n This program is free software: you can redistribute it a"
  },
  {
    "path": "gifski-api/src/minipool.rs",
    "chars": 2005,
    "preview": "use crate::Error;\nuse crossbeam_channel::Sender;\nuse std::num::NonZeroU8;\nuse std::panic::catch_unwind;\nuse std::sync::a"
  },
  {
    "path": "gifski-api/src/progress.rs",
    "chars": 1825,
    "preview": "//! For tracking conversion progress and aborting early\n\n#[cfg(feature = \"pbr\")]\n#[doc(hidden)]\n#[deprecated(note = \"The"
  },
  {
    "path": "license",
    "chars": 1187,
    "preview": "MIT License\n\nCopyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)\nCopyright (c) Kornel Lesińs"
  },
  {
    "path": "maintaining.md",
    "chars": 2035,
    "preview": "## Maintaining\n\n### Testing system services\n\nFirst, we need to install the app:\n1. Archive the app.\n2. Once you have you"
  },
  {
    "path": "readme.md",
    "chars": 7667,
    "preview": "<div align=\"center\">\n\t<img src=\"Stuff/AppIcon-readme.png\" width=\"200\" height=\"200\">\n\t<h1>Gifski</h1>\n\t<p>\n\t\t<b>Convert v"
  }
]

// ... and 2 more files (download for full content)

About this extraction

This page contains the full source code of the sindresorhus/Gifski GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 95 files (674.0 KB), approximately 186.4k tokens, and a symbol index with 236 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!