[
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\nindent_style = tab\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\n\n[*.yml]\nindent_style = space\nindent_size = 2\n"
  },
  {
    "path": ".gitattributes",
    "content": "* text=auto eol=lf\n*.pdf binary\n*.ai binary\n*.psd binary\ngifski-api/* linguist-vendored\n"
  },
  {
    "path": ".gitignore",
    "content": "xcuserdata/\n/gifski-api/tests/\n"
  },
  {
    "path": ".swiftlint.yml",
    "content": "only_rules:\n  - accessibility_trait_for_button\n  - array_init\n  - blanket_disable_command\n  - block_based_kvo\n  - class_delegate_protocol\n  - closing_brace\n  - closure_end_indentation\n  - closure_parameter_position\n  - closure_spacing\n  - collection_alignment\n  - colon\n  - comma\n  - comma_inheritance\n  - compiler_protocol_init\n  - computed_accessors_order\n  - conditional_returns_on_newline\n  - contains_over_filter_count\n  - contains_over_filter_is_empty\n  - contains_over_first_not_nil\n  - contains_over_range_nil_comparison\n  - control_statement\n  - custom_rules\n  - deployment_target\n  - direct_return\n  - discarded_notification_center_observer\n  - discouraged_assert\n  - discouraged_direct_init\n  - discouraged_none_name\n  - discouraged_object_literal\n  - discouraged_optional_boolean\n  - discouraged_optional_collection\n  - duplicate_conditions\n  - duplicate_enum_cases\n  - duplicate_imports\n  - duplicated_key_in_dictionary_literal\n  - dynamic_inline\n  - empty_collection_literal\n  - empty_count\n  - empty_enum_arguments\n  - empty_parameters\n  - empty_parentheses_with_trailing_closure\n  - empty_string\n  - empty_xctest_method\n  - enum_case_associated_values_count\n  - explicit_init\n  - fallthrough\n  - fatal_error_message\n  - final_test_case\n  - first_where\n  - flatmap_over_map_reduce\n  - for_where\n  - function_name_whitespace\n  - generic_type_name\n  - ibinspectable_in_extension\n  - identical_operands\n  - identifier_name\n  - implicit_getter\n  - implicit_return\n  - implicit_optional_initialization\n  - inclusive_language\n  - invalid_swiftlint_command\n  - is_disjoint\n  - joined_default_parameter\n  - last_where\n  - leading_whitespace\n  - legacy_cggeometry_functions\n  - legacy_constant\n  - legacy_constructor\n  - legacy_hashing\n  - legacy_multiple\n  - legacy_nsgeometry_functions\n  - legacy_random\n  - literal_expression_end_indentation\n  - lower_acl_than_parent\n  - mark\n  - modifier_order\n  - multiline_arguments\n  - multiline_arguments_brackets\n  - multiline_function_chains\n  - multiline_literal_brackets\n  - multiline_parameters\n  - multiline_parameters_brackets\n  - nimble_operator\n  - no_extension_access_modifier\n  - no_fallthrough_only\n  - no_space_in_method_call\n  - non_optional_string_data_conversion\n  - non_overridable_class_declaration\n  - notification_center_detachment\n  - ns_number_init_as_function_reference\n  - nsobject_prefer_isequal\n  - number_separator\n  - operator_usage_whitespace\n  - optional_data_string_conversion\n  - overridden_super_call\n  - prefer_condition_list\n  - prefer_key_path\n  - prefer_self_in_static_references\n  - prefer_self_type_over_type_of_self\n  - prefer_zero_over_explicit_init\n  - private_action\n  - private_outlet\n  - private_subject\n  - private_swiftui_state\n  - private_unit_test\n  - prohibited_super_call\n  - protocol_property_accessors_order\n  - reduce_boolean\n  - reduce_into\n  - redundant_discardable_let\n  - redundant_nil_coalescing\n  - redundant_objc_attribute\n  - redundant_sendable\n  - redundant_set_access_control\n  - redundant_string_enum_value\n  - redundant_type_annotation\n  - redundant_void_return\n  - required_enum_case\n  - return_arrow_whitespace\n  - return_value_from_void_function\n  - self_binding\n  - self_in_property_initialization\n  - shorthand_operator\n  - shorthand_optional_binding\n  - sorted_first_last\n  - statement_position\n  - static_operator\n  - static_over_final_class\n  - strong_iboutlet\n  - superfluous_disable_command\n  - superfluous_else\n  - switch_case_alignment\n  - switch_case_on_newline\n  - syntactic_sugar\n  - test_case_accessibility\n  - toggle_bool\n  - trailing_closure\n  - trailing_comma\n  - trailing_newline\n  - trailing_semicolon\n  - trailing_whitespace\n  - unavailable_condition\n  - unavailable_function\n  - unneeded_break_in_switch\n  - unneeded_override\n  - unneeded_parentheses_in_closure_argument\n  - unowned_variable_capture\n  - untyped_error_in_catch\n  - unused_closure_parameter\n  - unused_control_flow_label\n  - unused_enumerated\n  - unused_optional_binding\n  - unused_setter_value\n  - valid_ibinspectable\n  - vertical_parameter_alignment\n  - vertical_parameter_alignment_on_call\n  - vertical_whitespace_closing_braces\n  - vertical_whitespace_opening_braces\n  - void_function_in_ternary\n  - void_return\n  - xct_specific_matcher\n  - xctfail_message\n  - yoda_condition\nanalyzer_rules:\n  - capture_variable\n  - typesafe_array_init\n  - unneeded_synthesized_initializer\n  - unused_declaration\n  - unused_import\nredundant_discardable_let:\n  ignore_swiftui_view_bodies: true\nfor_where:\n  allow_for_as_filter: true\nnumber_separator:\n  minimum_length: 5\nidentifier_name:\n  max_length:\n    warning: 100\n    error: 100\n  min_length:\n    warning: 2\n    error: 2\n  allowed_symbols:\n    - '_'\n  excluded:\n    - 'x'\n    - 'y'\n    - 'z'\n    - 'a'\n    - 'b'\n    - 'x1'\n    - 'x2'\n    - 'y1'\n    - 'y2'\n    - 'z2'\nredundant_type_annotation:\n  consider_default_literal_types_redundant: true\nunneeded_override:\n  affect_initializers: true\ndeployment_target:\n  macOS_deployment_target: '14'\ncustom_rules:\n  no_nsrect:\n    regex: '\\bNSRect\\b'\n    match_kinds: typeidentifier\n    message: 'Use CGRect instead of NSRect'\n  no_nssize:\n    regex: '\\bNSSize\\b'\n    match_kinds: typeidentifier\n    message: 'Use CGSize instead of NSSize'\n  no_nspoint:\n    regex: '\\bNSPoint\\b'\n    match_kinds: typeidentifier\n    message: 'Use CGPoint instead of NSPoint'\n  no_cgfloat:\n    regex: '\\bCGFloat\\b'\n    match_kinds: typeidentifier\n    message: 'Use Double instead of CGFloat'\n  no_cgfloat2:\n    regex: '\\bCGFloat\\('\n    message: 'Use Double instead of CGFloat'\n  swiftui_state_private:\n    regex: '@(ObservedObject|EnvironmentObject)\\s+var'\n    message: 'SwiftUI @ObservedObject and @EnvironmentObject properties should be private'\n  swiftui_environment_private:\n    regex: '@Environment\\(\\\\\\.\\w+\\)\\s+var'\n    message: 'SwiftUI @Environment properties should be private'\n  swiftui_scaledtofit:\n    regex: 'aspectRatio\\(contentMode: \\.fit\\)'\n    message: 'Prefer `scaledToFit()`'\n  swiftui_scaledtofill:\n      regex: 'aspectRatio\\(contentMode: \\.fill\\)'\n      message: 'Prefer `scaledToFill()`'\n  final_class:\n    regex: '^class [a-zA-Z\\d]+[^{]+\\{'\n    message: 'Classes should be marked as final whenever possible. If you actually need it to be subclassable, just add `// swiftlint:disable:next final_class`.'\n  no_alignment_center:\n    regex: '\\b\\(alignment: .center\\b'\n    message: 'This alignment is the default.'\n"
  },
  {
    "path": "Config.xcconfig",
    "content": "MARKETING_VERSION = 2.23.1\nCURRENT_PROJECT_VERSION = 73\n"
  },
  {
    "path": "Gifski/App.swift",
    "content": "import SwiftUI\n\n@main\nstruct AppMain: App {\n\tprivate let appState = AppState.shared\n\t@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate\n\n\tinit() {\n\t\tsetUpConfig()\n\t}\n\n\tvar body: some Scene {\n\t\tWindow(SSApp.name, id: \"main\") {\n\t\t\tMainScreen()\n\t\t\t\t.environment(appState)\n\t\t}\n\t\t.windowResizability(.contentSize)\n\t\t.windowToolbarStyle(.unifiedCompact)\n//\t\t.windowBackgroundDragBehavior(.enabled) // Does not work. (macOS 15.2)\n\t\t.defaultPosition(.center)\n\t\t.restorationBehavior(.disabled)\n\t\t.handlesExternalEvents(matching: []) // Makes sure it does not open a new window when dragging files onto the Dock icon.\n\t\t.commands {\n\t\t\tCommandGroup(replacing: .newItem) {\n\t\t\t\tButton(\"Open…\", systemImage: \"arrow.up.forward.square\") {\n\t\t\t\t\tappState.isFileImporterPresented = true\n\t\t\t\t}\n\t\t\t\t.keyboardShortcut(\"o\")\n\t\t\t\t.disabled(appState.isConverting)\n\t\t\t}\n\t\t\tCommandGroup(replacing: .importExport) {\n\t\t\t\tButton(\"Export as Video…\", systemImage: \"square.and.arrow.up\") {\n\t\t\t\t\tappState.onExportAsVideo?()\n\t\t\t\t}\n\t\t\t\t.keyboardShortcut(\"e\")\n\t\t\t\t.disabled(appState.onExportAsVideo == nil)\n\t\t\t}\n\t\t\tCommandGroup(replacing: .textEditing) {\n\t\t\t\tToggle(\n\t\t\t\t\t\"Preview\",\n\t\t\t\t\tsystemImage: \"eye\",\n\t\t\t\t\tisOn: appState.toggleMode(mode: .preview)\n\t\t\t\t)\n\t\t\t\t.keyboardShortcut(\"p\", modifiers: [.command, .shift])\n\t\t\t\t.disabled(!appState.isOnEditScreen)\n\t\t\t\t.help(\"Preview is only available when editing a video\")\n\t\t\t\tToggle(\n\t\t\t\t\t\"Crop\",\n\t\t\t\t\tsystemImage: \"crop\",\n\t\t\t\t\tisOn: appState.toggleMode(mode: .editCrop)\n\t\t\t\t)\n\t\t\t\t.keyboardShortcut(\"c\", modifiers: [.command, .shift])\n\t\t\t\t.disabled(!appState.isOnEditScreen)\n\t\t\t}\n\t\t\tCommandGroup(replacing: .help) {\n\t\t\t\tLink(\n\t\t\t\t\t\"Website\",\n\t\t\t\t\tsystemImage: \"safari\",\n\t\t\t\t\tdestination: \"https://sindresorhus.com/Gifski\"\n\t\t\t\t)\n\t\t\t\tLink(\n\t\t\t\t\t\"Source Code\",\n\t\t\t\t\tsystemImage: \"chevron.left.forwardslash.chevron.right\",\n\t\t\t\t\tdestination: \"https://github.com/sindresorhus/Gifski\"\n\t\t\t\t)\n\t\t\t\tLink(\n\t\t\t\t\t\"Gifski Library\",\n\t\t\t\t\tsystemImage: \"shippingbox\",\n\t\t\t\t\tdestination: \"https://github.com/ImageOptim/gifski\"\n\t\t\t\t)\n\t\t\t\tDivider()\n\t\t\t\tRateOnAppStoreButton(appStoreID: \"1351639930\")\n\t\t\t\tShareAppButton(appStoreID: \"1351639930\")\n\t\t\t\tDivider()\n\t\t\t\tSendFeedbackButton()\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate func setUpConfig() {\n\t\tUserDefaults.standard.register(defaults: [\n\t\t\t\"NSApplicationCrashOnExceptions\": true\n\t\t])\n\n\t\tSSApp.initSentry(\"https://0ab0665326c54956f3caa10fc2f525d1@o844094.ingest.sentry.io/4505991507738624\")\n\n\t\tSSApp.setUpExternalEventListeners()\n\t}\n}\n"
  },
  {
    "path": "Gifski/AppIcon.icon/icon.json",
    "content": "{\n  \"fill-specializations\" : [\n    {\n      \"value\" : {\n        \"solid\" : \"gray:0.20000,1.00000\"\n      }\n    },\n    {\n      \"appearance\" : \"dark\",\n      \"value\" : \"system-dark\"\n    }\n  ],\n  \"groups\" : [\n    {\n      \"blend-mode\" : \"normal\",\n      \"blur-material\" : null,\n      \"layers\" : [\n        {\n          \"blend-mode\" : \"normal\",\n          \"image-name\" : \"Rainbow.png\",\n          \"name\" : \"Rainbow\",\n          \"position\" : {\n            \"scale\" : 1,\n            \"translation-in-points\" : [\n              0,\n              0\n            ]\n          }\n        }\n      ],\n      \"shadow\" : {\n        \"kind\" : \"layer-color\",\n        \"opacity\" : 0.4\n      },\n      \"translucency\" : {\n        \"enabled\" : true,\n        \"value\" : 0.4\n      }\n    },\n    {\n      \"layers\" : [\n        {\n          \"glass\" : false,\n          \"hidden\" : false,\n          \"image-name\" : \"Background.png\",\n          \"name\" : \"Background\",\n          \"opacity\" : 0.4,\n          \"position\" : {\n            \"scale\" : 1.01,\n            \"translation-in-points\" : [\n              0,\n              0\n            ]\n          }\n        }\n      ],\n      \"shadow\" : {\n        \"kind\" : \"none\",\n        \"opacity\" : 0.5\n      },\n      \"translucency\" : {\n        \"enabled\" : true,\n        \"value\" : 0.5\n      }\n    }\n  ],\n  \"supported-platforms\" : {\n    \"circles\" : [\n      \"watchOS\"\n    ],\n    \"squares\" : \"shared\"\n  }\n}"
  },
  {
    "path": "Gifski/AppState.swift",
    "content": "import SwiftUI\nimport UserNotifications\nimport DockProgress\n\n@MainActor\n@Observable\nfinal class AppState {\n\tstatic let shared = AppState()\n\n\tvar isOnEditScreen: Bool {\n\t\tguard case .edit = navigationPath.last else {\n\t\t\treturn false\n\t\t}\n\n\t\treturn true\n\t}\n\n\tvar isConverting: Bool {\n\t\tguard case .conversion = navigationPath.last else {\n\t\t\treturn false\n\t\t}\n\n\t\treturn true\n\t}\n\n\tvar navigationPath = [Route]()\n\tvar isFileImporterPresented = false\n\n\tenum Mode {\n\t\tcase normal\n\t\tcase editCrop\n\t\tcase preview\n\t}\n\n\tvar mode = Mode.normal\n\n\tvar shouldShowPreview: Bool {\n\t\tmode == .preview\n\t}\n\n\tvar isCropActive: Bool {\n\t\tmode == .editCrop\n\t}\n\n\tvar onExportAsVideo: (() -> Void)?\n\n\t/**\n\tProvides a binding for a toggle button to access a certain mode.\n\n\tThe 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).\n\t*/\n\tfunc toggleMode(mode: Mode) -> Binding<Bool> {\n\t\t.init(\n\t\t\tget: {\n\t\t\t\tself.mode == mode\n\t\t\t},\n\t\t\tset: { newValue in\n\t\t\t\tif newValue {\n\t\t\t\t\tself.mode = mode\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tguard self.mode == mode else {\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tself.mode = .normal\n\t\t\t}\n\t\t)\n\t}\n\n\tvar error: Error?\n\n\tinit() {\n\t\tDockProgress.style = .squircle(color: .white.withAlphaComponent(0.7))\n\n\t\tDispatchQueue.main.async { [self] in\n\t\t\tdidLaunch()\n\t\t}\n\t}\n\n\tprivate func didLaunch() {\n\t\tNSApp.servicesProvider = self\n\n\t\t// We have to include `.badge` otherwise system settings does not show the checkbox to turn off sounds. (macOS 12.4)\n\t\tUNUserNotificationCenter.current().requestAuthorization(options: [.sound, .badge]) { _, _ in }\n\t}\n\n\tfunc start(_ url: URL) {\n\t\t_ = url.startAccessingSecurityScopedResource()\n\n\t\t// We have to nil it out first and dispatch, otherwise it shows the old video. (macOS 14.3)\n\t\tnavigationPath = []\n\n\t\tTask { [self] in\n\t\t\tdo {\n\t\t\t\t// TODO: Simplify the validator.\n\t\t\t\tlet (asset, metadata) = try await VideoValidator.validate(url)\n\t\t\t\tnavigationPath = [.edit(url, asset, metadata)]\n\t\t\t} catch {\n\t\t\t\tself.error = error\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\tReturns `nil` if it should not continue.\n\t*/\n\tfileprivate func extractSharedVideoUrlIfAny(from url: URL) -> URL? {\n\t\tguard url.host == \"shareExtension\" else {\n\t\t\treturn url\n\t\t}\n\n\t\tguard\n\t\t\tlet path = url.queryDictionary[\"path\"],\n\t\t\tlet appGroupShareVideoUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Shared.appGroupIdentifier)?.appendingPathComponent(path, isDirectory: false)\n\t\telse {\n\t\t\tNSAlert.showModal(\n\t\t\t\tfor: SSApp.swiftUIMainWindow,\n\t\t\t\ttitle: \"Could not retrieve the shared video.\"\n\t\t\t)\n\t\t\treturn nil\n\t\t}\n\n\t\treturn appGroupShareVideoUrl\n\t}\n}\n\nfinal class AppDelegate: NSObject, NSApplicationDelegate {\n\tfunc applicationDidFinishLaunching(_ notification: Notification) {\n\t\t// Set launch completions option if the notification center could not be set up already.\n\t\tLaunchCompletions.applicationDidLaunch()\n\t}\n\n\t// TODO: Try to migrate to `.onOpenURL` when targeting macOS 15.\n\tfunc application(_ application: NSApplication, open urls: [URL]) {\n\t\tguard\n\t\t\turls.count == 1,\n\t\t\tlet videoUrl = urls.first\n\t\telse {\n\t\t\tNSAlert.showModal(\n\t\t\t\tfor: SSApp.swiftUIMainWindow,\n\t\t\t\ttitle: \"Gifski can only convert a single file at the time.\"\n\t\t\t)\n\n\t\t\treturn\n\t\t}\n\n\t\tguard let videoUrl2 = AppState.shared.extractSharedVideoUrlIfAny(from: videoUrl) else {\n\t\t\treturn\n\t\t}\n\n\t\t// Start video conversion on launch\n\t\tLaunchCompletions.add {\n\t\t\tAppState.shared.start(videoUrl2)\n\t\t}\n\t}\n\n\tfunc applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {\n\t\tif AppState.shared.isConverting {\n\t\t\tlet response = NSAlert.showModal(\n\t\t\t\tfor: SSApp.swiftUIMainWindow,\n\t\t\t\ttitle: \"Do you want to continue converting?\",\n\t\t\t\tmessage: \"Gifski is currently converting a video. If you quit, the conversion will be cancelled.\",\n\t\t\t\tbuttonTitles: [\n\t\t\t\t\t\"Continue\",\n\t\t\t\t\t\"Quit\"\n\t\t\t\t]\n\t\t\t)\n\n\t\t\tif response == .alertFirstButtonReturn {\n\t\t\t\treturn .terminateCancel\n\t\t\t}\n\t\t}\n\n\t\treturn .terminateNow\n\t}\n\n\tfunc applicationWillTerminate(_ notification: Notification) {\n\t\tUNUserNotificationCenter.current().removeAllDeliveredNotifications()\n\t}\n}\n\nextension AppState {\n\t/**\n\tThis is called from NSApp as a service resolver.\n\t*/\n\t@objc\n\tfunc convertToGIF(_ pasteboard: NSPasteboard, userData: String, error: NSErrorPointer) {\n\t\tguard let url = pasteboard.fileURLs().first else {\n\t\t\treturn\n\t\t}\n\n\t\tTask {\n\t\t\tstart(url)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "Gifski/Assets.xcassets/Contents.json",
    "content": "{\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "Gifski/CompletedScreen.swift",
    "content": "import SwiftUI\nimport UserNotifications\nimport StoreKit\n\nstruct CompletedScreen: View {\n\t@Environment(AppState.self) private var appState\n\t@Environment(\\.requestReview) private var requestReview\n\t@AppStorage(\"conversionCount\") private var conversionCount = 0\n\t@State private var isFileExporterPresented = false\n\t@State private var isShowingContent = false\n\t@State private var isCopyWarning1Presented = false\n\t@State private var isCopyWarning2Presented = false\n\t@State private var isDragTipPresented = false\n\n\tlet data: Data\n\tlet url: URL\n\n\tvar body: some View {\n\t\tVStack {\n\t\t\tImageView(image: NSImage(data: data) ?? NSImage())\n\t\t\t\t.clipShape(.rect(cornerRadius: 8))\n\t\t\t\t.shadow(radius: 8)\n\t\t\t\t// TODO: This is probably fixed in macOS 15. Test.\n\t\t\t\t// TODO: `.draggable()` does not correctly add a file to the drag pasteboard. (macOS 14.0)\n//\t\t\t\t.draggable(ExportableGIF(url: url))\n\t\t\t\t.onDrag { .init(object: url as NSURL) }\n\t\t\t\t.popover(isPresented: $isDragTipPresented) {\n\t\t\t\t\tText(\"Go ahead and drag the thumbnail to an app like Finder or Safari\")\n\t\t\t\t\t\t.padding()\n\t\t\t\t\t\t.padding(.vertical, 4)\n\t\t\t\t\t\t.onTapGesture {\n\t\t\t\t\t\t\tisDragTipPresented = false\n\t\t\t\t\t\t}\n\t\t\t\t\t\t.accessibilityAddTraits(.isButton)\n\t\t\t\t}\n\t\t\t\t.opacity(isShowingContent ? 1 : -0.5)\n\t\t\t\t.scaleEffect(isShowingContent ? 1 : 4)\n\t\t}\n\t\t.fillFrame()\n\t\t.safeAreaInset(edge: .bottom) {\n\t\t\tcontrols\n\t\t}\n\t\t.scenePadding()\n\t\t.fileExporter(\n\t\t\tisPresented: $isFileExporterPresented,\n\t\t\titem: ExportableGIF(url: url),\n\t\t\tdefaultFilename: url.filename\n\t\t) {\n\t\t\tdo {\n\t\t\t\tlet url = try $0.get()\n\t\t\t\ttry? url.setAppAsItemCreator()\n\t\t\t} catch {\n\t\t\t\tappState.error = error\n\t\t\t}\n\t\t}\n\t\t.fileDialogCustomizationID(\"export\")\n\t\t.fileDialogMessage(\"Choose where to save the GIF\")\n\t\t.fileDialogConfirmationLabel(\"Save\")\n\t\t.alert2(\n\t\t\t\"The GIF was copied to the clipboard.\",\n\t\t\tmessage: \"However…\",\n\t\t\tisPresented: $isCopyWarning1Presented\n\t\t) {\n\t\t\tButton(\"Continue\") {\n\t\t\t\tisCopyWarning2Presented = true\n\t\t\t}\n\t\t}\n\t\t.alert2(\n\t\t\t\"Please read!\",\n\t\t\tmessage: \"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.\",\n\t\t\tisPresented: $isCopyWarning2Presented\n\t\t)\n\t\t.toolbar {\n\t\t\tToolbarItem(placement: .principal) {\n\t\t\t\tHStack(spacing: 8) {\n\t\t\t\t\tText(\"\\(url.filename)\")\n//\t\t\t\t\tText(\"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.gif\")\n\t\t\t\t\t\t.frame(maxWidth: 200)\n\t\t\t\t\t\t.truncationMode(.middle)\n\t\t\t\t\tText(\"·\")\n\t\t\t\t\tText(url.fileSizeFormatted)\n\t\t\t\t}\n\t\t\t\t.font(.system(weight: .medium, design: .rounded))\n\t\t\t\t.foregroundStyle(.secondary)\n\t\t\t}\n\t\t\t.ss_sharedBackgroundVisibility_hidden()\n\t\t\tToolbarItem {\n\t\t\t\tSpacer()\n\t\t\t}\n\t\t\tToolbarItem(placement: .primaryAction) {\n\t\t\t\tButton(\"New Conversion\", systemImage: \"plus\") {\n\t\t\t\t\tappState.isFileImporterPresented = true\n\t\t\t\t}\n\t\t\t\t.if(SSApp.isFirstLaunch) {\n\t\t\t\t\t$0.labelStyle(.titleAndIcon)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n//\t\t.navigationTitle(url.filename) // TODO\n//\t\t.navigationSubtitle(url.fileSizeFormatted)\n\t\t.navigationTitle(\"\")\n\t\t.task {\n\t\t\twithAnimationWhenNotReduced {\n\t\t\t\tisShowingContent = true\n\t\t\t}\n\t\t}\n\t\t.task {\n\t\t\tNSApp.requestUserAttention(.informationalRequest)\n\t\t\tshowNotificationIfNeeded()\n\t\t\tshowDragTipIfNeeded()\n\t\t\trequestReviewIfNeeded()\n\t\t}\n\t}\n\n\tprivate var controls: some View {\n\t\tHStack(spacing: 32) {\n\t\t\t// TODO: We cannot use controlgroup as the sharelink doesn't work then. (macOS 14.0)\n//\t\t\tControlGroup {\n\t\t\tButton(\"Save\") {\n\t\t\t\tisFileExporterPresented = true\n\t\t\t}\n\t\t\t\t.keyboardShortcut(\"s\")\n\t\t\tCopyButton {\n\t\t\t\tcopy(url)\n\t\t\t}\n\t\t\t\t.keyboardShortcut(\"c\")\n\t\t\tShareLink(\"Share\", item: url)\n\t\t\t\t// TODO: Document this shortcut.\n\t\t\t\t.keyboardShortcut(\"s\", modifiers: [.command, .shift])\n\t\t}\n\t\t.labelStyle(.titleOnly)\n\t\t.controlSize(.extraLarge)\n\t\t.buttonStyle(.equalWidth(.constant(0), minimumWidth: 80))\n//\t\t.background(.regularMaterial) // Enable if using controlgroup again.\n\t\t.frame(width: 300)\n\t\t.padding()\n\t\t.opacity(isShowingContent ? 1 : 0)\n\t}\n\n\tprivate func copy(_ url: URL) {\n\t\tNSPasteboard.general.with {\n\t\t\t// swiftlint:disable:next legacy_objc_type\n\t\t\t$0.writeObjects([url as NSURL])\n\t\t\t$0.setString(url.filenameWithoutExtension, forType: .urlName)\n\t\t}\n\n\t\tSSApp.runOnce(identifier: \"copyWarning\") {\n\t\t\tisCopyWarning1Presented = true\n\t\t}\n\t}\n\n\tprivate func showNotificationIfNeeded() {\n\t\tguard !NSApp.isActive || SSApp.swiftUIMainWindow?.isVisible == false else {\n\t\t\treturn\n\t\t}\n\n\t\tlet notification = UNMutableNotificationContent()\n\t\tnotification.title = \"Conversion Completed\"\n\t\tnotification.subtitle = url.filename\n\t\tnotification.sound = .default\n\t\tlet request = UNNotificationRequest(identifier: \"conversionCompleted\", content: notification, trigger: nil)\n\t\tUNUserNotificationCenter.current().add(request)\n\t}\n\n\tprivate func requestReviewIfNeeded() {\n\t\tconversionCount += 1\n\n\t\tguard conversionCount == 5 else {\n\t\t\treturn\n\t\t}\n\n\t\t#if !DEBUG\n\t\trequestReview()\n\t\t#endif\n\t}\n\n\tprivate func showDragTipIfNeeded() {\n\t\tSSApp.runOnce(identifier: \"CompletedScreen_dragTip\") {\n\t\t\tTask {\n\t\t\t\ttry? await Task.sleep(for: .seconds(1))\n\t\t\t\tisDragTipPresented = true\n\t\t\t\ttry? await Task.sleep(for: .seconds(10))\n\t\t\t\tisDragTipPresented = false\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "Gifski/Components/CheckerboardView.swift",
    "content": "import SwiftUI\n\nenum CheckerboardViewConstants {\n\tstatic let gridSize = 8\n\n\t/**\n\tI 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.\n\t*/\n\tstatic let firstColorLight = Color(white: 0.98)\n\tstatic let firstColorDark = Color(white: 0.46)\n\tstatic let secondColorLight = Color(white: 0.82)\n\tstatic let secondColorDark = Color(white: 0.26)\n\n\tstatic let firstColor = Color(light: firstColorLight, dark: firstColorDark)\n\tstatic let secondColor = Color(light: secondColorLight, dark: secondColorDark)\n}\n\nstruct CheckerboardView: View {\n\tvar gridSize = CGSize(width: CheckerboardViewConstants.gridSize, height: CheckerboardViewConstants.gridSize)\n\tvar clearRect: CGRect?\n\n\tvar body: some View {\n\t\tZStack {\n\t\t\tCanvas(opaque: true) { context, size in\n\t\t\t\tcontext.fill(Rectangle().path(in: size.cgRect), with: .color(CheckerboardViewConstants.secondColor))\n\n\t\t\t\tfor y in 0...Int(size.height / gridSize.height) {\n\t\t\t\t\tfor x in 0...Int(size.width / gridSize.width) where x.isEven == y.isEven {\n\t\t\t\t\t\tlet origin = CGPoint(x: x * Int(gridSize.width), y: y * Int(gridSize.height))\n\t\t\t\t\t\tlet rect = CGRect(origin: origin, size: gridSize)\n\t\t\t\t\t\tcontext.fill(Rectangle().path(in: rect), with: .color(CheckerboardViewConstants.firstColor))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t// TODO: Any way to do this directly in the `Canvas`?\n\t\t\tif let clearRect {\n\t\t\t\tRectangle()\n\t\t\t\t\t.fill(.black)\n\t\t\t\t\t.frame(width: clearRect.width, height: clearRect.height)\n\t\t\t\t\t.blendMode(.destinationOut)\n\t\t\t}\n\t\t}\n\t\t.compositingGroup()\n\t\t.drawingGroup()\n\t}\n}\n"
  },
  {
    "path": "Gifski/Components/IntTextField.swift",
    "content": "import SwiftUI\n\n// TODO: This does not correctly prevent larger numbers than `minMax`.\n\nstruct IntTextField: NSViewRepresentable {\n\ttypealias NSViewType = IntTextFieldCocoa\n\n\t@Binding var value: Int\n\tvar minMax: ClosedRange<Int>?\n\tvar delta = 1\n\tvar alternativeDelta = 10\n\tvar alignment: NSTextAlignment?\n\tvar font: NSFont?\n\tvar onValueChange: ((Int) -> Void)?\n\tvar onBlur: ((Int) -> Void)?\n\tvar onInvalid: ((Int) -> Void)?\n\n\tfunc makeNSView(context: Context) -> IntTextFieldCocoa {\n\t\tlet nsView = IntTextFieldCocoa()\n\n\t\tnsView.onValueChange = {\n\t\t\tvalue = $0\n\t\t\tonValueChange?($0)\n\t\t}\n\n\t\tnsView.onBlur = {\n\t\t\tvalue = $0\n\t\t\tonBlur?($0)\n\t\t}\n\n\t\tnsView.onInvalid = {\n\t\t\tonInvalid?($0)\n\t\t}\n\n\t\treturn nsView\n\t}\n\n\tfunc updateNSView(_ nsView: IntTextFieldCocoa, context: Context) {\n\t\tnsView.stringValue = \"\\(value)\" // We intentionally do not use `nsView.intValue` as it formats the number.\n\t\tnsView.minMax = minMax\n\t\tnsView.delta = delta\n\t\tnsView.alternativeDelta = alternativeDelta\n\n\t\tif let alignment {\n\t\t\tnsView.alignment = alignment\n\t\t}\n\n\t\tif let font {\n\t\t\tnsView.font = font\n\t\t}\n\t}\n}\n\nfinal class IntTextFieldCocoa: NSTextField, NSTextFieldDelegate, NSControlTextEditingDelegate {\n\toverride var canBecomeKeyView: Bool { true }\n\n\t/**\n\tDelta used for arrow navigation.\n\t*/\n\tvar delta = 1\n\n\t/**\n\tDelta used for option + arrow navigation.\n\t*/\n\tvar alternativeDelta = 10\n\n\tvar onValueChange: ((Int) -> Void)?\n\tvar onBlur: ((Int) -> Void)?\n\tvar onInvalid: ((Int) -> Void)?\n\tvar minMax: ClosedRange<Int>?\n\n\tvar isEmpty: Bool { stringValue.trimmingCharacters(in: .whitespaces).isEmpty }\n\n\trequired init?(coder: NSCoder) {\n\t\tsuper.init(coder: coder)\n\t\tsetup()\n\t}\n\n\toverride init(frame frameRect: CGRect) {\n\t\tsuper.init(frame: frameRect)\n\t\tsetup()\n\t}\n\n\tprivate func setup() {\n\t\tdelegate = self\n\t}\n\n\toverride func performKeyEquivalent(with event: NSEvent) -> Bool {\n\t\tguard window?.firstResponder == currentEditor() else {\n\t\t\treturn super.performKeyEquivalent(with: event)\n\t\t}\n\n\t\tlet key = event.specialKey\n\t\tlet isHoldingOption = event.modifierFlags.contains(.option)\n\t\tlet initialDelta = isHoldingOption ? alternativeDelta : delta\n\n\t\tlet delta: Int\n\t\tswitch key {\n\t\tcase .upArrow?:\n\t\t\tdelta = initialDelta\n\t\tcase .downArrow?:\n\t\t\tdelta = initialDelta * -1\n\t\tdefault:\n\t\t\treturn super.performKeyEquivalent(with: event)\n\t\t}\n\n\t\tlet currentValue = Int(stringValue) ?? 0\n\t\tlet tentativeNewValue = currentValue + delta\n\n\t\tfunc setValue() {\n\t\t\tstringValue = \"\\(tentativeNewValue)\"\n\t\t\thandleValueChange()\n\t\t}\n\n\t\tif let minMax {\n\t\t\tif minMax.contains(tentativeNewValue) {\n\t\t\t\tsetValue()\n\t\t\t} else {\n\t\t\t\tindicateValidationFailure(invalidValue: tentativeNewValue)\n\t\t\t}\n\t\t} else {\n\t\t\tsetValue()\n\t\t}\n\n\t\treturn true\n\t}\n\n\tfunc controlTextDidChange(_ object: Notification) {\n\t\tstringValue = stringValue\n\t\t\t.replacing(/\\D+/, with: \"\") // Make sure only digits can be entered.\n\t\t\t.replacing(/^0/, with: \"\") // Don't allow leading zero.\n\n\t\tif let minMax {\n\t\t\t// Ensure the user cannot input more digits than the max.\n\t\t\tstringValue = String(stringValue.prefix(\"\\(minMax.upperBound)\".count))\n\t\t}\n\n\t\tlet isInvalidButInBounds = !isValid(integerValue) && integerValue > 0 && integerValue <= (minMax?.upperBound ?? Int.max)\n\n\t\t// For entered text we want to give a little bit more room to breathe\n\t\tif isEmpty || isInvalidButInBounds {\n\t\t\treturn\n\t\t}\n\n\t\thandleValueChange()\n\t}\n\n\tprivate func handleValueChange() {\n\t\tif !isValid(integerValue) {\n\t\t\tindicateValidationFailure(invalidValue: integerValue)\n\t\t}\n\n\t\tonValueChange?(integerValue)\n\t}\n\n\tfunc controlTextDidEndEditing(_ object: Notification) {\n\t\tif !isValid(integerValue) {\n\t\t\tindicateValidationFailure(invalidValue: integerValue)\n\t\t}\n\n\t\tonBlur?(integerValue)\n\t}\n\n\tfunc indicateValidationFailure(invalidValue: Int) {\n\t\tshake(direction: .horizontal)\n\t\tonInvalid?(invalidValue)\n\t}\n\n\tprivate func isValid(_ value: Int) -> Bool {\n\t\tguard let minMax else {\n\t\t\treturn true\n\t\t}\n\n\t\treturn minMax.contains(value)\n\t}\n}\n"
  },
  {
    "path": "Gifski/Components/TrimmingAVPlayer.swift",
    "content": "import AVKit\nimport SwiftUI\n\nstruct TrimmingAVPlayer: NSViewControllerRepresentable {\n\ttypealias NSViewControllerType = TrimmingAVPlayerViewController\n\n\t@Environment(\\.colorScheme) private var colorScheme\n\n\tlet asset: AVAsset\n\tlet shouldShowPreview: Bool\n\tlet fullPreviewState: FullPreviewGenerationEvent\n\tvar controlsStyle = AVPlayerViewControlsStyle.inline\n\tvar loopPlayback = false\n\tvar bouncePlayback = false\n\tvar speed = 1.0\n\tvar overlay: NSView?\n\tvar isPlayPauseButtonEnabled = true\n\tvar isTrimmerDraggable = false\n\tvar timeRangeDidChange: ((ClosedRange<Double>) -> Void)?\n\n\tfunc makeNSViewController(context: Context) -> NSViewControllerType {\n\t\t.init(\n\t\t\tplayerItem: .init(asset: asset),\n\t\t\tcontrolsStyle: controlsStyle,\n\t\t\ttimeRangeDidChange: timeRangeDidChange\n\t\t)\n\t}\n\n\tfunc updateNSViewController(_ nsViewController: NSViewControllerType, context: Context) {\n\t\tif asset != nsViewController.currentItem.asset {\n\t\t\tlet item = AVPlayerItem(asset: asset)\n\t\t\tforceAVPlayerToRedraw(item: item)\n\t\t\titem.playbackRange = nsViewController.currentItem.playbackRange\n\t\t\tnsViewController.currentItem = item\n\t\t}\n\n\t\t// Always update video composition based on preview state.\n\t\t// When preview is ON, use custom compositor. When OFF, clear it so AVPlayer handles rotation.\n\t\tforceAVPlayerToRedraw(item: nsViewController.currentItem)\n\t\t_ = updatePreviewState(nsViewController)\n\n\t\tnsViewController.loopPlayback = loopPlayback\n\t\tnsViewController.bouncePlayback = bouncePlayback\n\t\tnsViewController.player.defaultRate = Float(speed)\n\t\tif nsViewController.player.rate != 0 {\n\t\t\tnsViewController.player.rate = nsViewController.player.rate > 0 ? Float(speed) : -Float(speed)\n\t\t}\n\t\tnsViewController.overlay = overlay\n\t\tnsViewController.isTrimmerDraggable = isTrimmerDraggable\n\t\tnsViewController.isPlayPauseButtonEnabled = isPlayPauseButtonEnabled\n\t}\n\n\t/**\n\tUpdate the preview state.\n\n\t- Returns: True if state was updated and needs a redraw, false otherwise.\n\t*/\n\tfunc updatePreviewState(_ controller: NSViewControllerType) -> Bool {\n\t\tguard\n\t\t\tlet previewVideoCompositor = controller.currentItem.customVideoCompositor as? PreviewVideoCompositor\n\t\telse {\n\t\t\treturn false\n\t\t}\n\n\t\tlet previewCheckerboardParams = CompositePreviewFragmentUniforms(\n\t\t\tisDarkMode: colorScheme.isDark,\n\t\t\tvideoBounds: controller.playerView.videoBounds\n\t\t)\n\n\t\treturn previewVideoCompositor.updateState(\n\t\t\tstate: .init(\n\t\t\t\tshouldShowPreview: shouldShowPreview,\n\t\t\t\tfullPreviewState: fullPreviewState,\n\t\t\t\tpreviewCheckerboardParams: previewCheckerboardParams\n\t\t\t)\n\t\t)\n\t}\n\n\t/**\n\tSets or clears the video composition based on preview state.\n\n\tWhen preview is OFF, we don't use the custom compositor so AVPlayer handles rotation via `preferredTransform` normally.\n\tWhen preview is ON, we use the custom compositor which renders the preview overlay.\n\t*/\n\tfunc forceAVPlayerToRedraw(item: AVPlayerItem) {\n\t\tguard let assetVideoComposition = (asset as? PreviewableComposition)?.videoComposition else {\n\t\t\treturn\n\t\t}\n\n\t\tif shouldShowPreview {\n\t\t\titem.videoComposition = assetVideoComposition.mutableCopy() as? AVMutableVideoComposition\n\t\t} else {\n\t\t\t// Clear video composition so AVPlayer handles rotation normally.\n\t\t\titem.videoComposition = nil\n\t\t}\n\t}\n}\n\n// TODO: Move more of the logic here over to the SwiftUI view.\n/**\nA view controller containing AVPlayerView and also extending possibilities for trimming (view) customization.\n*/\nfinal class TrimmingAVPlayerViewController: NSViewController {\n\tprivate(set) var timeRange: ClosedRange<Double>?\n\tprivate let playerItem: AVPlayerItem\n\tfileprivate let player: LoopingPlayer\n\tprivate let controlsStyle: AVPlayerViewControlsStyle\n\tprivate let timeRangeDidChange: ((ClosedRange<Double>) -> Void)?\n\tprivate var cancellables = Set<AnyCancellable>()\n\tprivate var currentItemDurationRange: ClosedRange<Double>?\n\n\tfileprivate var overlay: NSView? {\n\t\tdidSet {\n\t\t\tguard oldValue != overlay else {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif let oldValue {\n\t\t\t\toldValue.removeFromSuperview()\n\t\t\t}\n\n\t\t\tguard let overlay else {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tlet underTrimOverlayView = overlay\n\t\t\tunderTrimOverlayView.removeConstraints(underTrimOverlayView.constraints)\n\t\t\tplayerView.contentOverlayView?.addSubview(underTrimOverlayView)\n\t\t\tunderTrimOverlayView.translatesAutoresizingMaskIntoConstraints = false\n\n\t\t\tlet videoBounds = playerView.videoBounds\n\n\t\t\tguard let contentOverlayView = playerView.contentOverlayView else {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tNSLayoutConstraint.activate([\n\t\t\t\tunderTrimOverlayView.leadingAnchor.constraint(equalTo: contentOverlayView.leadingAnchor, constant: videoBounds.origin.x),\n\t\t\t\tunderTrimOverlayView.topAnchor.constraint(equalTo: contentOverlayView.topAnchor, constant: videoBounds.origin.y),\n\t\t\t\tunderTrimOverlayView.widthAnchor.constraint(equalToConstant: videoBounds.size.width),\n\t\t\t\tunderTrimOverlayView.heightAnchor.constraint(equalToConstant: videoBounds.size.height)\n\t\t\t])\n\t\t}\n\t}\n\n\tfileprivate var isTrimmerDraggable = false {\n\t\tdidSet {\n\t\t\ttrimmerDragViews?.isDraggable = isTrimmerDraggable\n\t\t}\n\t}\n\n\tfileprivate var isPlayPauseButtonEnabled = true {\n\t\tdidSet {\n\t\t\tguard isPlayPauseButtonEnabled != oldValue else {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tplayerView.setPlayPauseButton(isEnabled: isPlayPauseButtonEnabled)\n\t\t}\n\t}\n\n\tvar playerView: TrimmingAVPlayerView { view as! TrimmingAVPlayerView }\n\n\t// We cannot use lazy here because at start this will be `nil` before the player is initialized (there won't be an AVTrimView).\n\tprivate var _trimmerDragViews: TrimmerDragViews?\n\n\tprivate var trimmerDragViews: TrimmerDragViews? {\n\t\tif let _trimmerDragViews {\n\t\t\treturn _trimmerDragViews\n\t\t}\n\n\t\t// 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`.\n\t\tplayerView.layer?.masksToBounds = true\n\n\t\tguard\n\t\t\tlet avTrimView = (playerView.firstSubview(deep: true) { $0.simpleClassName == \"AVTrimView\" })?.superview,\n\t\t\tlet avTrimViewParent = avTrimView.superview?.superview\n\t\telse {\n\t\t\treturn nil\n\t\t}\n\n\t\t_trimmerDragViews = TrimmerDragViews(\n\t\t\tavTrimView: avTrimView,\n\t\t\tavTrimViewParent: avTrimViewParent,\n\t\t\tisDraggable: false\n\t\t)\n\n\t\treturn _trimmerDragViews\n\t}\n\n\t/**\n\tThe minimum duration the trimmer can be set to.\n\t*/\n\tvar minimumTrimDuration = 0.1 {\n\t\tdidSet {\n\t\t\tplayerView.minimumTrimDuration = minimumTrimDuration\n\t\t}\n\t}\n\n\tvar loopPlayback: Bool {\n\t\tget { player.loopPlayback }\n\t\tset {\n\t\t\tplayer.loopPlayback = newValue\n\t\t}\n\t}\n\n\tvar bouncePlayback: Bool {\n\t\tget { player.bouncePlayback }\n\t\tset {\n\t\t\tplayer.bouncePlayback = newValue\n\t\t}\n\t}\n\n\t/**\n\tGet or set the current player item.\n\n\tWhen setting an item, it preserves the current playback rate (which means pause state too), playback position, and trim range.\n\t*/\n\tvar currentItem: AVPlayerItem {\n\t\tget { player.currentItem! }\n\t\tset {\n\t\t\tlet rate = player.rate\n\t\t\tlet playbackPercentage = player.currentItem?.playbackProgress ?? 0\n\t\t\tlet playbackRangePercentage = player.currentItem?.playbackRangePercentage\n\n\t\t\tplayer.replaceCurrentItem(with: newValue)\n\n\t\t\tDispatchQueue.main.async { [self] in\n\t\t\t\tplayer.rate = rate\n\t\t\t\tplayer.currentItem?.seek(toPercentage: playbackPercentage)\n\t\t\t\tplayer.currentItem?.playbackRangePercentage = playbackRangePercentage\n\t\t\t}\n\t\t}\n\t}\n\n\tinit(\n\t\tplayerItem: AVPlayerItem,\n\t\tcontrolsStyle: AVPlayerViewControlsStyle = .inline,\n\t\ttimeRangeDidChange: ((ClosedRange<Double>) -> Void)? = nil\n\t) {\n\t\tself.playerItem = playerItem\n\t\tself.player = LoopingPlayer(playerItem: playerItem)\n\t\tself.controlsStyle = controlsStyle\n\t\tself.timeRangeDidChange = timeRangeDidChange\n\t\tsuper.init(nibName: nil, bundle: nil)\n\t}\n\n\tdeinit {\n\t\tprint(\"TrimmingAVPlayerViewController - DEINIT\")\n\t}\n\n\t@available(*, unavailable)\n\trequired init?(coder: NSCoder) {\n\t\tfatalError(\"init(coder:) has not been implemented\")\n\t}\n\n\toverride func loadView() {\n\t\tlet playerView = TrimmingAVPlayerView()\n\t\tplayerView.allowsVideoFrameAnalysis = false\n\t\tplayerView.controlsStyle = controlsStyle\n\t\tplayerView.player = player\n\t\tview = playerView\n\t}\n\n\toverride func viewDidLoad() {\n\t\tsuper.viewDidLoad()\n\n\t\t// Support replacing the item.\n\t\tplayer.publisher(for: \\.currentItem)\n\t\t\t.compactMap(\\.self)\n\t\t\t.flatMap { currentItem in\n\t\t\t\t// TODO: Make a `AVPlayerItem#waitForReady` async property when using Swift 6.\n\t\t\t\tcurrentItem.publisher(for: \\.status)\n\t\t\t\t\t.first { $0 == .readyToPlay }\n\t\t\t\t\t.map { _ in currentItem }\n\t\t\t}\n\t\t\t.receive(on: DispatchQueue.main)\n\t\t\t.sink { [weak self] in\n\t\t\t\tguard let self else {\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tplayerView.setupTrimmingObserver()\n\n\t\t\t\tonNewDurationRange(durationRange: $0.durationRange)\n\n\t\t\t\t// This is here as it needs to be refreshed when the current item changes.\n\t\t\t\tplayerView.observeTrimmedTimeRange { [weak self] timeRange in\n\t\t\t\t\tself?.timeRange = timeRange\n\t\t\t\t\tself?.timeRangeDidChange?(timeRange)\n\t\t\t\t}\n\t\t\t}\n\t\t\t.store(in: &cancellables)\n\t}\n\tfunc onNewDurationRange(durationRange newItemDurationRange: ClosedRange<Double>?) {\n\t\tguard let newItemDurationRange else {\n\t\t\tcurrentItemDurationRange = nil\n\t\t\treturn\n\t\t}\n\t\tdefer {\n\t\t\tcurrentItemDurationRange = newItemDurationRange\n\t\t}\n\t\tguard\n\t\t\tlet timeRange,\n\t\t\tlet currentItemDurationRange else {\n\t\t\tself.timeRange = newItemDurationRange\n\t\t\ttimeRangeDidChange?(newItemDurationRange)\n\t\t\treturn\n\t\t}\n\t\t// Convert `timeRange` from `oldItemDurationRange` to new duration range\n\t\t// necessary for when the video changes speed.\n\t\tlet speed: Double = {\n\t\t\tguard currentItemDurationRange.length != 0 else {\n\t\t\t\treturn 1.0\n\t\t\t}\n\t\t\treturn newItemDurationRange.length / currentItemDurationRange.length\n\t\t}()\n\t\tlet newTimeRange = (timeRange - currentItemDurationRange.lowerBound) * speed + newItemDurationRange.lowerBound\n\t\tself.timeRange = newTimeRange\n\t\ttimeRangeDidChange?(newTimeRange)\n\t}\n}\n\nfinal class TrimmingAVPlayerView: AVPlayerView {\n\tprivate var timeRangeCancellable: AnyCancellable?\n\tprivate var trimmingCancellable: AnyCancellable?\n\n\t/**\n\tThe minimum duration the trimmer can be set to.\n\t*/\n\tvar minimumTrimDuration = 0.1\n\n\tdeinit {\n\t\tprint(\"TrimmingAVPlayerView - DEINIT\")\n\t}\n\n\t// TODO: This should be an AsyncSequence.\n\tfileprivate func observeTrimmedTimeRange(_ updateClosure: @escaping (ClosedRange<Double>) -> Void) {\n\t\tvar skipNextUpdate = false\n\n\t\ttimeRangeCancellable = player?.currentItem?.publisher(for: \\.duration, options: .new)\n\t\t\t.sink { [weak self] _ in\n\t\t\t\tguard\n\t\t\t\t\tlet self,\n\t\t\t\t\tlet item = player?.currentItem,\n\t\t\t\t\tlet fullRange = item.durationRange,\n\t\t\t\t\tlet playbackRange = item.playbackRange\n\t\t\t\telse {\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\t// Prevent infinite recursion.\n\t\t\t\tguard !skipNextUpdate else {\n\t\t\t\t\tskipNextUpdate = false\n\t\t\t\t\tupdateClosure(playbackRange.minimumRangeLength(of: minimumTrimDuration, in: fullRange))\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tguard playbackRange.length > minimumTrimDuration else {\n\t\t\t\t\tskipNextUpdate = true\n\t\t\t\t\titem.playbackRange = playbackRange.minimumRangeLength(of: minimumTrimDuration, in: fullRange)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tupdateClosure(playbackRange)\n\t\t\t}\n\t}\n\n\tfileprivate func setupTrimmingObserver() {\n\t\ttrimmingCancellable = Task {\n\t\t\tdo {\n\t\t\t\ttry await activateTrimming()\n\t\t\t\taddCheckerboardView()\n\t\t\t\thideTrimButtons()\n\t\t\t\twindow?.makeFirstResponder(self)\n\t\t\t} catch {}\n\t\t}\n\t\t.toCancellable\n\t}\n\n\tfileprivate func setPlayPauseButton(isEnabled: Bool) {\n\t\tguard\n\t\t\tlet avTrimView = firstSubview(deep: true, where: { $0.simpleClassName == \"AVTrimView\" }),\n\t\t\tlet superview = avTrimView.superview\n\t\telse {\n\t\t\treturn\n\t\t}\n\n\t\tlet playPauseButton = superview\n\t\t\t.subviews\n\t\t\t.first { $0 != avTrimView }?\n\t\t\t.subviews\n\t\t\t.first {\n\t\t\t\tguard\n\t\t\t\t\tlet button = ($0 as? NSButton),\n\t\t\t\t\tbutton.action?.description == \"playPauseButtonPressed:\"\n\t\t\t\telse {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\n\t\t\t\treturn true\n\t\t\t} as? NSButton\n\n\t\tguard let playPauseButton else {\n\t\t\treturn\n\t\t}\n\n\t\tplayPauseButton.isEnabled = isEnabled\n\t}\n\n\tfileprivate func hideTrimButtons() {\n\t\t// This method is a collection of hacks, so it might be acting funky on different OS versions.\n\t\tguard\n\t\t\tlet avTrimView = firstSubview(deep: true, where: { $0.simpleClassName == \"AVTrimView\" }),\n\t\t\tlet superview = avTrimView.superview\n\t\telse {\n\t\t\treturn\n\t\t}\n\n\t\t// First find the constraints for `avTrimView` that pins to the left edge of the button.\n\t\t// Then replace the left edge of a button with the right edge - this will stretch the trim view.\n\t\tif let constraint = superview.constraints.first(where: {\n\t\t\t($0.firstItem as? NSView) == avTrimView && $0.firstAttribute == .right\n\t\t}) {\n\t\t\tsuperview.removeConstraint(constraint)\n\t\t\tconstraint.changing(secondAttribute: .right).isActive = true\n\t\t}\n\n\t\tif let constraint = superview.constraints.first(where: {\n\t\t\t($0.secondItem as? NSView) == avTrimView && $0.secondAttribute == .right\n\t\t}) {\n\t\t\tsuperview.removeConstraint(constraint)\n\t\t\tconstraint.changing(firstAttribute: .right).isActive = true\n\t\t}\n\n\t\t// Now find buttons that are not images (images are playing controls) and hide them.\n\t\tsuperview.subviews\n\t\t\t.first { $0 != avTrimView }?\n\t\t\t.subviews\n\t\t\t.filter { ($0 as? NSButton)?.image == nil }\n\t\t\t.forEach {\n\t\t\t\t$0.isHidden = true\n\t\t\t}\n\t}\n\n\tfileprivate func addCheckerboardView() {\n\t\tlet overlayView = NSHostingView(rootView: CheckerboardView(clearRect: videoBounds))\n\t\tcontentOverlayView?.addSubview(overlayView)\n\t\toverlayView.constrainEdgesToSuperview()\n\t}\n\n\t/**\n\tPrevent user from dismissing trimming view.\n\t*/\n\toverride func cancelOperation(_ sender: Any?) {}\n}\n\n@MainActor\nprivate class TrimmerDragViews {\n\tprivate var avTrimView: NSView\n\n\t/**\n\tThe view that holds the entire trimmer. The supermost view.\n\t*/\n\tprivate var fullTrimmerView: CustomCursorView\n\n\tprivate var avTrimViewParent: NSView\n\tprivate var drawHandleView: NSHostingView<DragHandleView>\n\n\tvar isDraggable = false {\n\t\tdidSet {\n\t\t\tif isDraggable {\n\t\t\t\tshowDrag()\n\t\t\t} else {\n\t\t\t\thideDrag()\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\tThe initial offset of the trimmer from the bottom before we drag it anywhere.\n\t*/\n\tstatic let dragBarHeight = 17.0\n\tstatic let newHeight = 87.0\n\tstatic let dragBarTopAnchor = 6.0\n\n\t/**\n\tThese offsets are computed before we swap the trimmer.\n\t*/\n\tprivate let trimmerConstraints: TrimmerConstraints\n\n\tinit(avTrimView: NSView, avTrimViewParent: NSView, isDraggable: Bool) {\n\t\tself.avTrimView = avTrimView\n\t\tself.avTrimViewParent = avTrimViewParent\n\t\tself.fullTrimmerView = CustomCursorView()\n\t\tself.drawHandleView = NSHostingView(rootView: DragHandleView())\n\n\t\tself.trimmerConstraints = TrimmerConstraints(avTrimViewParent: avTrimViewParent)\n\n\t\tswapTrimmerSuperviews()\n\t\tself.isDraggable = isDraggable\n\n\t\tlet panGesture = NSPanGestureRecognizer(target: self, action: #selector(handleDrag(_:)))\n\t\tpanGesture.delaysPrimaryMouseButtonEvents = false\n\t\tfullTrimmerView.addGestureRecognizer(panGesture)\n\t}\n\n\t/**\n\tRemove the `avTrimViewParent` from its old location in the view hierarchy and swap with our `fullTrimmerView`.\n\t*/\n\tprivate func swapTrimmerSuperviews() {\n\t\t// The view that previously held the full trimmer view.\n\t\tguard let oldSuperview = avTrimViewParent.superview else {\n\t\t\treturn\n\t\t}\n\n\t\tavTrimViewParent.removeFromSuperview()\n\n\t\tfullTrimmerView.translatesAutoresizingMaskIntoConstraints = false\n\t\tfullTrimmerView.addSubview(avTrimViewParent)\n\t\toldSuperview.addSubview(fullTrimmerView)\n\n\t\tavTrimViewParent.constrainEdgesToSuperview()\n\n\t\ttrimmerConstraints.apply(toNewView: fullTrimmerView, avTrimViewParentSuperView: oldSuperview)\n\t}\n\n\tprivate func showDrag() {\n\t\tfullTrimmerView.addSubview(drawHandleView)\n\t\tdrawHandleView.translatesAutoresizingMaskIntoConstraints = false\n\n\t\tNSLayoutConstraint.activate([\n\t\t\tdrawHandleView.leadingAnchor.constraint(equalTo: fullTrimmerView.leadingAnchor, constant: 0),\n\t\t\tdrawHandleView.trailingAnchor.constraint(equalTo: fullTrimmerView.trailingAnchor, constant: 0),\n\t\t\tdrawHandleView.topAnchor.constraint(equalTo: fullTrimmerView.topAnchor, constant: Self.dragBarTopAnchor),\n\t\t\tdrawHandleView.heightAnchor.constraint(equalToConstant: Self.dragBarHeight)\n\t\t])\n\n\t\tfullTrimmerHeightConstraint?.constant = Self.newHeight\n\t\ttrimmerWindowTopConstraint?.constant = Self.newHeight - trimmerConstraints.height\n\n\t\ttrimmerBottomConstraint?.animate(to: trimmerConstraints.height, duration: .seconds(0.3)) {\n\t\t\tguard self.isDraggable else {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tself.avTrimView.isHidden = true\n\t\t}\n\t}\n\n\tprivate func hideDrag() {\n\t\tTask {\n\t\t\t// Defer the NSHostingView removal to avoid reentrant layout, which happens if this code runs on the main actor\n\t\t\tdrawHandleView.removeFromSuperview()\n\t\t\tavTrimView.isHidden = false\n\t\t\ttrimmerBottomConstraint?.animate(to: trimmerConstraints.bottomOffset, duration: .seconds(0.3))\n\t\t\tfullTrimmerHeightConstraint?.constant = trimmerConstraints.height\n\t\t\ttrimmerWindowTopConstraint?.constant = 0\n\t\t}\n\t}\n\n\t/**\n\tBound 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.\n\t*/\n\t@objc private func handleDrag(_ gesture: NSPanGestureRecognizer) {\n\t\tguard\n\t\t\tisDraggable,\n\t\t\tlet view = gesture.view,\n\t\t\tlet superview = view.superview,\n\t\t\tlet trimmerBottomConstraint\n\t\telse {\n\t\t\treturn\n\t\t}\n\n\t\tlet endLocation = gesture.location(in: superview).y\n\t\tlet translation = gesture.translation(in: superview).y\n\t\tlet startLocation = endLocation - translation\n\n\t\tdefer {\n\t\t\tgesture.setTranslation(.zero, in: superview)\n\t\t}\n\n\t\tlet bounds = superview.bounds.minY...superview.bounds.maxY\n\n\t\tguard bounds.contains(startLocation) else {\n\t\t\treturn\n\t\t}\n\n\t\tlet boundedTranslation = endLocation.clamped(to: bounds) - startLocation\n\t\tlet newBottom = (trimmerBottomConstraint.constant - boundedTranslation).clamped(to: -superview.bounds.height + view.frame.height...trimmerConstraints.height)\n\n\t\ttrimmerBottomConstraint.constant = newBottom\n\t\tavTrimView.isHidden = newBottom > trimmerConstraints.height - 2\n\t}\n\n\tprivate lazy var fullTrimmerHeightConstraint: NSLayoutConstraint? = fullTrimmerView.constraints.first { $0.firstAttribute == .height && $0.firstItem as? NSView == fullTrimmerView }\n\n\tprivate lazy var trimmerBottomConstraint: NSLayoutConstraint? = fullTrimmerView.getConstraintFromSuperview(attribute: .bottom)\n\n\tprivate lazy var trimmerWindowTopConstraint: NSLayoutConstraint? = avTrimView.getConstraintFromSuperview(attribute: .top)\n\n\t/**\n\tGrab 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.\n\t*/\n\t@MainActor\n\tprivate struct TrimmerConstraints {\n\t\tlet bottomOffset: Double\n\t\tlet leadingOffset: Double\n\t\tlet trailingOffset: Double\n\t\tlet height: Double\n\n\t\tinit(avTrimViewParent: NSView) {\n\t\t\tself.bottomOffset = -(avTrimViewParent.getConstraintConstantFromSuperView(attribute: .bottom) ?? 6.0)\n\t\t\tself.leadingOffset = avTrimViewParent.getConstraintConstantFromSuperView(attribute: .leading) ?? 6.0\n\t\t\tself.trailingOffset = -(avTrimViewParent.getConstraintConstantFromSuperView(attribute: .trailing) ?? 6.0)\n\t\t\tself.height = avTrimViewParent.getConstraintConstantFromSuperView(attribute: .height) ?? 64.0\n\t\t}\n\n\t\t/**\n\t\tApply the saved constraints to a new container view, placing it in the same position as avTrimViewParent used to be.\n\t\t*/\n\t\tfunc apply(\n\t\t\ttoNewView newView: NSView,\n\t\t\tavTrimViewParentSuperView oldSuperview: NSView\n\t\t) {\n\t\t\tNSLayoutConstraint.activate([\n\t\t\t\tnewView.leadingAnchor.constraint(equalTo: oldSuperview.leadingAnchor, constant: leadingOffset),\n\t\t\t\tnewView.bottomAnchor.constraint(equalTo: oldSuperview.bottomAnchor, constant: bottomOffset),\n\t\t\t\tnewView.trailingAnchor.constraint(equalTo: oldSuperview.trailingAnchor, constant: trailingOffset),\n\t\t\t\tnewView.heightAnchor.constraint(equalToConstant: height)\n\t\t\t])\n\t\t}\n\t}\n\n\tprivate class CustomCursorView: NSView {\n\t\tvar cursor = NSCursor.arrow\n\n\t\toverride func resetCursorRects() {\n\t\t\tsuper.resetCursorRects()\n\t\t\taddCursorRect(bounds, cursor: cursor)\n\t\t}\n\t}\n\n\tprivate struct DragHandleView: View {\n\t\tvar body: some View {\n\t\t\tZStack {\n\t\t\t\tColor.clear\n\t\t\t\t\t.contentShape(.rect)\n\t\t\t\tRoundedRectangle(cornerRadius: 2)\n\t\t\t\t\t.fill(Color.white)\n\t\t\t\t\t.frame(width: 128, height: 4)\n\t\t\t\t\t.padding()\n\t\t\t}\n\t\t\t.pointerStyle(.rowResize)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "Gifski/Constants.swift",
    "content": "import SwiftUI\nimport CoreTransferable\nimport AVFoundation\n\nenum Constants {\n\tstatic let allowedFrameRate = 3.0...50.0\n\tstatic let loopCountRange = 0...100\n}\n\nextension Defaults.Keys {\n\tstatic let outputQuality = Key<Double>(\"outputQuality\", default: 1)\n\tstatic let outputSpeed = Key<Double>(\"outputSpeed\", default: 1)\n\tstatic let outputFPS = Key<Int>(\"outputFPS\", default: 10)\n\tstatic let loopGIF = Key<Bool>(\"loopGif\", default: true)\n\tstatic let bounceGIF = Key<Bool>(\"bounceGif\", default: false)\n\tstatic let suppressKeyframeWarning = Key<Bool>(\"suppressKeyframeWarning\", default: false)\n}\n\nenum Route: Hashable {\n\tcase edit(URL, AVAsset, AVAsset.VideoMetadata)\n\tcase conversion(GIFGenerator.Conversion)\n\tcase completed(Data, URL)\n}\n\nstruct ExportableGIF: Transferable {\n\tlet url: URL\n\n\tstatic var transferRepresentation: some TransferRepresentation {\n\t\tFileRepresentation(exportedContentType: .gif) { .init($0.url) }\n\t\t\t// TODO: Does not work when using `.fileExporter`. (macOS 14.3)\n\t\t\t.suggestedFileName { $0.url.filename }\n\t}\n}\n"
  },
  {
    "path": "Gifski/ConversionScreen.swift",
    "content": "import SwiftUI\nimport AVFoundation\nimport DockProgress\n\nstruct ConversionScreen: View {\n\t@Environment(\\.dismiss) private var dismiss\n\t@Environment(AppState.self) private var appState\n\t@State private var progress = 0.0\n\t@State private var timeRemaining: String?\n\t@State private var startTime: Date?\n\n\tlet conversion: GIFGenerator.Conversion\n\n\tvar body: some View {\n\t\tVStack {\n\t\t\tProgressView(value: progress)\n\t\t\t\t.progressViewStyle(\n\t\t\t\t\t.ssCircular(\n\t\t\t\t\t\tfill: LinearGradient(\n\t\t\t\t\t\t\tgradient: .init(\n\t\t\t\t\t\t\t\tcolors: [\n\t\t\t\t\t\t\t\t\t.purple,\n\t\t\t\t\t\t\t\t\t.pink,\n\t\t\t\t\t\t\t\t\t.orange\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\tstartPoint: .top,\n\t\t\t\t\t\t\tendPoint: .bottom\n\t\t\t\t\t\t),\n\t\t\t\t\t\tlineWidth: 30,\n\t\t\t\t\t\ttext: \"Converting\"\n\t\t\t\t\t)\n\t\t\t\t)\n\t\t\t\t.frame(width: 300, height: 300)\n\t\t\t\t.overlay {\n\t\t\t\t\tGroup {\n\t\t\t\t\t\tif let timeRemaining {\n\t\t\t\t\t\t\tText(timeRemaining)\n\t\t\t\t\t\t\t\t.font(.subheadline)\n\t\t\t\t\t\t\t\t.monospacedDigit()\n\t\t\t\t\t\t\t\t.offset(y: 24)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\t.animation(.default, value: timeRemaining == nil)\n\t\t\t\t}\n\t\t\t\t.offset(y: -16) // Makes it centered (needed because of toolbar).\n\t\t}\n\t\t.fillFrame()\n\t\t.onKeyboardShortcut(.escape, modifiers: []) {\n\t\t\tdismiss()\n\t\t}\n\t\t.navigationTitle(\"\")\n\t\t.task(priority: .utility) {\n\t\t\tdo {\n\t\t\t\ttry await convert()\n\t\t\t} catch {\n\t\t\t\tif !(error is CancellationError) {\n\t\t\t\t\tprint(\"Conversion error:\", error)\n\t\t\t\t\tappState.error = error\n\t\t\t\t}\n\n\t\t\t\t// So it doesn't get triggered when we press Escape to cancel.\n\t\t\t\tif !Task.isCancelled {\n\t\t\t\t\tdismiss()\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t.activity(options: .userInitiated, reason: \"Converting\")\n\t}\n\n\tfunc convert() async throws {\n\t\tstartTime = .now\n\n\t\tdefer {\n\t\t\ttimeRemaining = nil\n\t\t\tDockProgress.resetProgress()\n\t\t}\n\n\t\tlet data = try await GIFGenerator.run(conversion) { progress in\n\t\t\tself.progress = progress\n\t\t\tupdateEstimatedTimeRemaining(for: progress)\n\n\t\t\t// This should not be needed. It silences a thread sanitizer warning.\n\t\t\tTask { @MainActor in\n\t\t\t\tDockProgress.progress = progress\n\t\t\t}\n\t\t}\n\n\t\ttry Task.checkCancellation()\n\n\t\tlet filename = conversion.sourceURL.filenameWithoutExtension\n\t\tlet url = try data.writeToUniqueTemporaryFile(filename: filename, contentType: .gif)\n\t\ttry? url.setAppAsItemCreator()\n\n\t\ttry await Task.sleep(for: .seconds(1)) // Let the progress circle finish.\n\n\t\t// TODO: Support task cancellation.\n\t\t// TODO: Make sure it deinits too.\n\n//\t\tappState.navigationPath.removeLast()\n//\t\tappState.navigationPath.append(.completed(data))\n\n\t\t// This works around some race issue where it would sometimes end up with edit screen after conversion.\n\t\tvar path = appState.navigationPath\n\t\tpath.removeLast()\n\t\tpath.append(.completed(data, url))\n\t\tappState.navigationPath = path\n\t}\n\n\tprivate func updateEstimatedTimeRemaining(for progress: Double) {\n\t\tguard\n\t\t\tprogress > 0,\n\t\t\tlet startTime\n\t\telse {\n\t\t\ttimeRemaining = nil\n\t\t\treturn\n\t\t}\n\n\t\t/**\n\t\tThe delay before revealing the estimated time remaining, allowing the estimation to stabilize.\n\t\t*/\n\t\tlet bufferDuration = Duration.seconds(3)\n\n\t\t/**\n\t\tDon't show the estimate at all if the total time estimate (after it stabilizes) is less than this amount.\n\t\t*/\n\t\tlet skipThreshold = Duration.seconds(10)\n\n\t\t/**\n\t\tBegin fade out when remaining time reaches this amount.\n\t\t*/\n\t\tlet fadeOutThreshold = Duration.seconds(1)\n\n\t\tlet elapsed = Duration.seconds(Date.now.timeIntervalSince(startTime))\n\t\tlet remaining = (elapsed / progress) * (1 - progress)\n\t\tlet total = elapsed + remaining\n\n\t\tguard\n\t\t\telapsed > bufferDuration,\n\t\t\tremaining > fadeOutThreshold,\n\t\t\ttotal > skipThreshold\n\t\telse {\n\t\t\ttimeRemaining = nil\n\t\t\treturn\n\t\t}\n\n\t\tlet formatter = DateComponentsFormatter()\n\t\tformatter.unitsStyle = .full\n\t\tformatter.includesApproximationPhrase = true\n\t\tformatter.includesTimeRemainingPhrase = true\n\t\tformatter.allowedUnits = remaining < .seconds(60) ? .second : [.hour, .minute]\n\t\ttimeRemaining = formatter.string(from: remaining.toTimeInterval)\n\t}\n}\n"
  },
  {
    "path": "Gifski/Credits.rtf",
    "content": "{\\rtf1\\ansi\\ansicpg1252\\cocoartf2638\n\\cocoatextscaling0\\cocoaplatform0{\\fonttbl\\f0\\fswiss\\fcharset0 Helvetica;\\f1\\fswiss\\fcharset0 Helvetica-Bold;}\n{\\colortbl;\\red255\\green255\\blue255;\\red0\\green0\\blue0;}\n{\\*\\expandedcolortbl;;\\cssrgb\\c0\\c0\\c0\\c84706\\cname labelColor;}\n\\paperw11900\\paperh16840\\margl1440\\margr1440\\vieww8040\\viewh6780\\viewkind0\n\\pard\\tx566\\tx1133\\tx1700\\tx2267\\tx2834\\tx3401\\tx3968\\tx4535\\tx5102\\tx5669\\tx6236\\tx6803\\pardirnatural\\qc\\partightenfactor0\n\n\\f0\\fs24 \\cf2 \\\n\\pard\\tx566\\tx1133\\tx1700\\tx2267\\tx2834\\tx3401\\tx3968\\tx4535\\tx5102\\tx5669\\tx6236\\tx6803\\sl288\\slmult1\\pardirnatural\\qc\\partightenfactor0\n\n\\f1\\b \\cf2 Created by\n\\f0\\b0 \\\n{\\field{\\*\\fldinst{HYPERLINK \"https://github.com/sindresorhus\"}}{\\fldrslt Sindre Sorhus}}\\\n{\\field{\\*\\fldinst{HYPERLINK \"https://github.com/kornelski\"}}{\\fldrslt Kornel Lesi\\uc0\\u324 ski}}\\\n{\\field{\\*\\fldinst{HYPERLINK \"https://github.com/sindresorhus/Gifski/graphs/contributors\"}}{\\fldrslt awesome contributors}}}"
  },
  {
    "path": "Gifski/Crop/CropDragGestureModifier.swift",
    "content": "import SwiftUI\n\nextension View {\n\tfunc cropDragGesture(\n\t\tisDragging: Binding<Bool>,\n\t\tcropRect: Binding<CropRect>,\n\t\tframe: CGRect,\n\t\tdimensions: CGSize,\n\t\tposition: CropHandlePosition,\n\t\tdragMode: CropRect.DragMode\n\t) -> some View {\n\t\tmodifier(\n\t\t\tCropDragGestureModifier(\n\t\t\t\tisDragging: isDragging,\n\t\t\t\tcropRect: cropRect,\n\t\t\t\tframe: frame,\n\t\t\t\tdimensions: dimensions,\n\t\t\t\tposition: position,\n\t\t\t\tdragMode: dragMode\n\t\t\t)\n\t\t)\n\t}\n}\n\nstruct CropDragGestureModifier: ViewModifier {\n\t@GestureState private var initialCropRect: CropRect?\n\n\t@Binding var isDragging: Bool\n\t@Binding var cropRect: CropRect\n\tlet frame: CGRect\n\tlet dimensions: CGSize\n\tlet position: CropHandlePosition\n\tlet dragMode: CropRect.DragMode\n\n\tfunc body(content: Content) -> some View {\n\t\tlet dragGesture = DragGesture()\n\t\t\t.updating($initialCropRect) { _, state, _ in\n\t\t\t\tstate = state ?? cropRect\n\t\t\t}\n\t\t\t.onChanged { drag in\n\t\t\t\tguard let initial = initialCropRect else {\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tisDragging = true\n\n\t\t\t\tcropRect = initial.applyDragToCropRect(\n\t\t\t\t\tdrag: drag,\n\t\t\t\t\tframe: frame,\n\t\t\t\t\tdimensions: dimensions,\n\t\t\t\t\tposition: position,\n\t\t\t\t\tdragMode: dragMode\n\t\t\t\t)\n\t\t\t}\n\t\t\t.onEnded { _ in\n\t\t\t\tisDragging = false\n\t\t\t}\n\t\tcontent.highPriorityGesture(dragGesture)\n\t}\n}\n"
  },
  {
    "path": "Gifski/Crop/CropHandlePosition.swift",
    "content": "import SwiftUI\n\nenum CropHandlePosition: CaseIterable {\n\tcase top\n\tcase topRight\n\tcase right\n\tcase bottomRight\n\tcase bottom\n\tcase bottomLeft\n\tcase left\n\tcase topLeft\n\tcase center\n\n\tvar location: UnitPoint {\n\t\tsides.location\n\t}\n\n\tvar isVerticalOnlyHandle: Bool {\n\t\tsides.isVerticalOnlyHandle\n\t}\n\n\tvar isLeft: Bool {\n\t\tsides.isLeft\n\t}\n\n\tvar isRight: Bool {\n\t\tsides.isRight\n\t}\n\n\tvar isTop: Bool {\n\t\tsides.isTop\n\t}\n\n\tvar isBottom: Bool {\n\t\tsides.isBottom\n\t}\n\n\tvar isCorner: Bool {\n\t\tswitch self {\n\t\tcase .topLeft, .topRight, .bottomLeft, .bottomRight:\n\t\t\ttrue\n\t\tcase .bottom, .top, .left, .right, .center:\n\t\t\tfalse\n\t\t}\n\t}\n\n\tvar sides: RectSides {\n\t\tswitch self {\n\t\tcase .top:\n\t\t\t.init(horizontal: .center, vertical: .primary)\n\t\tcase .topRight:\n\t\t\t.init(horizontal: .secondary, vertical: .primary)\n\t\tcase .right:\n\t\t\t.init(horizontal: .secondary, vertical: .center)\n\t\tcase .bottomRight:\n\t\t\t.init(horizontal: .secondary, vertical: .secondary)\n\t\tcase .bottom:\n\t\t\t.init(horizontal: .center, vertical: .secondary)\n\t\tcase .bottomLeft:\n\t\t\t.init(horizontal: .primary, vertical: .secondary)\n\t\tcase .left:\n\t\t\t.init(horizontal: .primary, vertical: .center)\n\t\tcase .topLeft:\n\t\t\t.init(horizontal: .primary, vertical: .primary)\n\t\tcase .center:\n\t\t\t.init(horizontal: .center, vertical: .center)\n\t\t}\n\t}\n\n\tprivate var pointerPosition: FrameResizePosition {\n\t\tSelf.positionToPointer[self] ?? .top\n\t}\n\n\tprivate static let positionToPointer: [Self: FrameResizePosition] = [\n\t\t.top: .top,\n\t\t.topRight: .topTrailing,\n\t\t.right: .trailing,\n\t\t.bottomRight: .bottomTrailing,\n\t\t.bottom: .bottom,\n\t\t.bottomLeft: .bottomLeading,\n\t\t.left: .leading,\n\t\t.topLeft: .topLeading,\n\t\t.center: .top\n\t]\n\n\tvar pointerStyle: PointerStyle {\n\t\tif self == .center {\n\t\t\treturn .grabIdle\n\t\t}\n\n\t\treturn .frameResize(position: pointerPosition)\n\t}\n}\n\n\nstruct RectSides: Equatable, Hashable {\n\tlet horizontal: Side\n\tlet vertical: Side\n\n\tvar isVerticalOnlyHandle: Bool {\n\t\thorizontal == .center && vertical != .center\n\t}\n\n\tvar isLeft: Bool {\n\t\thorizontal == .primary\n\t}\n\n\tvar isRight: Bool {\n\t\thorizontal == .secondary\n\t}\n\n\tvar isTop: Bool {\n\t\tvertical == .primary\n\t}\n\n\tvar isBottom: Bool {\n\t\tvertical == .secondary\n\t}\n\n\tvar location: UnitPoint {\n\t\t.init(x: horizontal.location, y: vertical.location)\n\t}\n}\n\n\n/**\nA position on a rectangle.\n\nPrimary means left or top, secondary means right or bottom. Center is in the center.\n*/\nenum Side: Hashable {\n\tcase primary\n\tcase center\n\tcase secondary\n\n\t/**\n\tLocation in the crop, from 0-1.\n\t*/\n\tvar location: Double {\n\t\tswitch self {\n\t\tcase .primary:\n\t\t\t0\n\t\tcase .center:\n\t\t\t0.5\n\t\tcase .secondary:\n\t\t\t1\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "Gifski/Crop/CropOverlayView.swift",
    "content": "import SwiftUI\nimport AVFoundation\nimport AVKit\n\nstruct CropOverlayView: View {\n\t@State private var dragMode = CropRect.DragMode.normal\n\t@State private var isDragging = false\n\t// swiftlint:disable:next discouraged_optional_boolean\n\t@State private var windowIsMovable: Bool?\n\t@State private var window: NSWindow?\n\n\t@Binding var cropRect: CropRect\n\tlet dimensions: CGSize\n\tvar editable: Bool\n\n\tvar body: some View {\n\t\tGeometryReader { geometry in\n\t\t\tlet frame = geometry.frame(in: .local)\n\t\t\tlet cropFrame = cropRect.unnormalize(forDimensions: frame.size)\n\t\t\tZStack {\n\t\t\t\tCanvas { context, size in\n\t\t\t\t\t// 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\n\t\t\t\t\tlet entireCanvasPath = Path { path in\n\t\t\t\t\t\tpath.addRect(.init(origin: .zero, size: size))\n\t\t\t\t\t}\n\n\t\t\t\t\tcontext.fill(entireCanvasPath, with: .color(.black.opacity(0.5)))\n\n\t\t\t\t\tlet holePath = Path { path in\n\t\t\t\t\t\tpath.addRect(cropFrame)\n\t\t\t\t\t}\n\n\t\t\t\t\tcontext.blendMode = .clear\n\t\t\t\t\tcontext.fill(holePath, with: .color(.black))\n\n\t\t\t\t\tif editable {\n\t\t\t\t\t\tcontext.blendMode = .normal\n\t\t\t\t\t\tcontext.stroke(holePath, with: .color(.white), lineWidth: 1)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif editable {\n\t\t\t\t\tColor\n\t\t\t\t\t\t.clear\n\t\t\t\t\t\t.contentShape(\n\t\t\t\t\t\t\tPath { path in\n\t\t\t\t\t\t\t\tpath.addRect(cropFrame.insetBy(dx: 5, dy: 5))\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t)\n\t\t\t\t\t\t.pointerStyle(isDragging ? .grabActive : .grabIdle)\n\t\t\t\t\t\t.cropDragGesture(\n\t\t\t\t\t\t\tisDragging: $isDragging,\n\t\t\t\t\t\t\tcropRect: $cropRect,\n\t\t\t\t\t\t\tframe: frame,\n\t\t\t\t\t\t\tdimensions: dimensions,\n\t\t\t\t\t\t\tposition: .center,\n\t\t\t\t\t\t\tdragMode: dragMode\n\t\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tif isDragging {\n\t\t\t\t\tDraggingSections(cropFrame: cropFrame)\n\t\t\t\t\t\t.stroke(Color.white)\n\t\t\t\t\t\t.allowsHitTesting(false)\n\t\t\t\t}\n\t\t\t\tif editable {\n\t\t\t\t\tForEach(CropHandlePosition.allCases, id: \\.self) { position in\n\t\t\t\t\t\tif position != .center {\n\t\t\t\t\t\t\tHandleView(\n\t\t\t\t\t\t\t\tposition: position,\n\t\t\t\t\t\t\t\tcropRect: $cropRect,\n\t\t\t\t\t\t\t\tframe: frame,\n\t\t\t\t\t\t\t\tdimensions: dimensions,\n\t\t\t\t\t\t\t\tcropFrame: cropFrame,\n\t\t\t\t\t\t\t\tdragMode: dragMode,\n\t\t\t\t\t\t\t\tisDragging: $isDragging\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t/**\n\t\tThe setter is necessary because there are lifecycle changes not captured by the $binding.\n\n\t\tFor example consider this:\n\t\t```swift\n\t\tstruct SomeView: View {\n\t\t\t@State var window: NSWindow?\n\n\t\t\tvar body: some View {\n\t\t\t\tColor.clear()\n\t\t\t\t\t.bindHostingWindow($window)\n\t\t\t\t\t.onDisappear {\n\t\t\t\t\t\t/*\n\t\t\t\t\t\tBy the time this is called `window` is already nil\n\t\t\t\t\t\t*/\n\t\t\t\t\t\tassert(window == nil)\n\t\t\t\t\t}\n\t\t\t\t\t.accessHostingWindow { window in\n\t\t\t\t\t\t/**\n\t\t\t\t\t\tWhen view disappears this is never called.\n\t\t\t\t\t\t*/\n\t\t\t\t\t}\n\t\t\t\t\t.onChange(of: window) { old, new in\n\t\t\t\t\t\t/**\n\t\t\t\t\t\tWhen the view disappears this is never called.\n\t\t\t\t\t\t*/\n\t\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t```\n\n\t\tThis is because on view disappear the following events happen in order:\n\n\t\t1. `viewDidMoveToWindow` with `window` == nil\n\n\t\t2. Then `onDisappear` is called\n\n\t\t∞. ` accessHostingWindow` and `onChange` are never called because SwiftUI does not build the view again when disappearing\n\n\t\tI need a custom setter to capture all changes before the the view disappears, and I can't use `accessHostingWindow` or `onChange(of:)` or `onDisappear`\n\t\t*/\n\t\t.bindHostingWindow(\n\t\t\t.init(\n\t\t\t\tget: {\n\t\t\t\t\twindow\n\t\t\t\t},\n\t\t\t\tset: { newWindow in\n\t\t\t\t\tguard newWindow != window else {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\t// Defer window property modifications to avoid circular updates during view lifecycle\n\t\t\t\t\tDispatchQueue.main.async {\n\t\t\t\t\t\tif let windowIsMovable {\n\t\t\t\t\t\t\twindow?.isMovableByWindowBackground = windowIsMovable\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\twindowIsMovable = newWindow?.isMovableByWindowBackground\n\t\t\t\t\t\tnewWindow?.isMovableByWindowBackground = false\n\t\t\t\t\t}\n\n\t\t\t\t\twindow = newWindow\n\t\t\t\t}\n\t\t\t)\n\t\t)\n\t\t.onModifierKeysChanged(mask: [.option, .shift]) { _, new in\n\t\t\tdragMode = {\n\t\t\t\tif new.contains(.option) {\n\t\t\t\t\tif new.contains(.shift) {\n\t\t\t\t\t\treturn .aspectRatioLockScale\n\t\t\t\t\t}\n\n\t\t\t\t\treturn .symmetric\n\t\t\t\t}\n\n\t\t\t\tif new.contains(.shift) {\n\t\t\t\t\treturn .scale\n\t\t\t\t}\n\n\t\t\t\treturn .normal\n\t\t\t}()\n\t\t}\n\t}\n\n\t/**\n\tThe four lines that divide your crop into sections that appear when dragging.\n\t*/\n\tprivate struct DraggingSections: Shape {\n\t\tvar cropFrame: CGRect\n\n\t\tfunc path(in rect: CGRect) -> Path {\n\t\t\tvar path = Path()\n\n\t\t\tfor factor in [1.0 / 3.0, 2.0 / 3.0] {\n\t\t\t\tlet x = cropFrame.minX + cropFrame.width * factor\n\t\t\t\tpath.move(to: CGPoint(x: x, y: cropFrame.minY))\n\t\t\t\tpath.addLine(to: CGPoint(x: x, y: cropFrame.maxY))\n\n\t\t\t\tlet y = cropFrame.minY + cropFrame.height * factor\n\t\t\t\tpath.move(to: CGPoint(x: cropFrame.minX, y: y))\n\t\t\t\tpath.addLine(to: CGPoint(x: cropFrame.maxX, y: y))\n\t\t\t}\n\n\t\t\treturn path\n\t\t}\n\t}\n\n\tprivate struct HandleView: View {\n\t\tprivate static let cornerLineWidth = 3.0\n\t\tprivate static let cornerWidthHeight = 28.0\n\n\t\tlet position: CropHandlePosition\n\t\t@Binding var cropRect: CropRect\n\t\tlet frame: CGRect\n\t\tlet dimensions: CGSize\n\t\tvar cropFrame: CGRect\n\t\tvar dragMode: CropRect.DragMode\n\t\t@Binding var isDragging: Bool\n\n\t\tvar body: some View {\n\t\t\tGroup {\n\t\t\t\tif [.top, .left, .right, .bottom].contains(position) {\n\t\t\t\t\tSideHandleView(\n\t\t\t\t\t\tcropFrame: cropFrame,\n\t\t\t\t\t\tposition: position\n\t\t\t\t\t)\n\t\t\t\t} else {\n\t\t\t\t\tCornerLine(corner: position)\n\t\t\t\t}\n\t\t\t}\n\t\t\t.pointerStyle(position.pointerStyle)\n\t\t\t.position(canvasPosition)\n\t\t\t.cropDragGesture(\n\t\t\t\tisDragging: $isDragging,\n\t\t\t\tcropRect: $cropRect,\n\t\t\t\tframe: frame,\n\t\t\t\tdimensions: dimensions,\n\t\t\t\tposition: position,\n\t\t\t\tdragMode: dragMode\n\t\t\t)\n\t\t}\n\n\t\t/**\n\t\tWhere to place this handle in the canvas. Top is at the top, bottom is at the bottom, etc.\n\t\t*/\n\t\tprivate var canvasPosition: CGPoint {\n\t\t\tlet inset = (Self.cornerWidthHeight + Self.cornerLineWidth) / 2.0 - 3.0\n\t\t\tlet adjustedFrame = position.isCorner ? cropFrame.insetBy(dx: inset, dy: inset) : cropFrame\n\t\t\treturn CGPoint(\n\t\t\t\tx: adjustedFrame.minX + adjustedFrame.width * position.location.x,\n\t\t\t\ty: adjustedFrame.minY + adjustedFrame.height * position.location.y\n\t\t\t)\n\t\t}\n\n\t\t/**\n\t\tThe handles for top, bottom, left, and right. They are invisible and used only to change the pointer and handle drags.\n\t\t*/\n\t\tstruct SideHandleView: View {\n\t\t\tvar cropFrame: CGRect\n\t\t\tvar position: CropHandlePosition\n\n\t\t\tvar body: some View {\n\t\t\t\tZStack {\n\t\t\t\t\tColor.clear\n\t\t\t\t\t\t.frame(\n\t\t\t\t\t\t\twidth: sideViewSize.width,\n\t\t\t\t\t\t\theight: sideViewSize.height\n\t\t\t\t\t\t)\n\t\t\t\t\t\t.contentShape(\n\t\t\t\t\t\t\tPath { path in\n\t\t\t\t\t\t\t\t// A rectangle around the drag used to catch hits so we can drag.\n\t\t\t\t\t\t\t\tlet hitBoxSize = 20.0\n\t\t\t\t\t\t\t\tif position.isVerticalOnlyHandle {\n\t\t\t\t\t\t\t\t\tpath.addRect(.init(\n\t\t\t\t\t\t\t\t\t\torigin: .init(x: 0, y: -hitBoxSize / 2.0),\n\t\t\t\t\t\t\t\t\t\twidth: sideViewSize.width,\n\t\t\t\t\t\t\t\t\t\theight: hitBoxSize\n\t\t\t\t\t\t\t\t\t))\n\t\t\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tpath.addRect(.init(\n\t\t\t\t\t\t\t\t\torigin: .init(x: -hitBoxSize / 2.0, y: 0),\n\t\t\t\t\t\t\t\t\twidth: hitBoxSize,\n\t\t\t\t\t\t\t\t\theight: sideViewSize.height\n\t\t\t\t\t\t\t\t))\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tprivate var sideViewSize: CGSize {\n\t\t\t\tswitch position.isVerticalOnlyHandle {\n\t\t\t\tcase true:\n\t\t\t\t\tCGSize(width: max(0.0, cropFrame.width - HandleView.cornerWidthHeight * 2.0), height: 2.0)\n\t\t\t\tcase false:\n\t\t\t\t\tCGSize(width: 2.0, height: max(0.0, cropFrame.height - HandleView.cornerWidthHeight * 2.0))\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tprivate struct CornerLine: View {\n\t\t\t@Environment(\\.displayScale) private var displayScale\n\t\t\tprivate let hitboxExtensionSize = 5.0\n\n\t\t\tlet corner: CropHandlePosition\n\n\t\t\tvar body: some View {\n\t\t\t\tCornerLineShape(displayScale: displayScale, corner: corner)\n\t\t\t\t\t.stroke(Color.white, lineWidth: HandleView.cornerLineWidth)\n\t\t\t\t\t.contentShape(\n\t\t\t\t\t\tRectangle()\n\t\t\t\t\t\t\t.size(.init(widthHeight: HandleView.cornerWidthHeight + hitboxExtensionSize))\n\t\t\t\t\t\t\t.offset(offset)\n\t\t\t\t\t)\n\t\t\t\t\t.frame(width: HandleView.cornerWidthHeight, height: HandleView.cornerWidthHeight)\n\t\t\t}\n\n\t\t\tvar offset: CGSize {\n\t\t\t\tlet sx = corner.location.x * 2 - 1\n\t\t\t\tlet sy = corner.location.y * 2 - 1\n\t\t\t\treturn .init(width: sx * hitboxExtensionSize, height: sy * hitboxExtensionSize)\n\t\t\t}\n\n\t\t\t/**\n\t\t\tThe bent line at the corners.\n\t\t\t*/\n\t\t\tprivate struct CornerLineShape: Shape {\n\t\t\t\tlet displayScale: Double\n\t\t\t\tlet corner: CropHandlePosition\n\n\t\t\t\tfunc path(in rect: CGRect) -> Path {\n\t\t\t\t\tvar path = Path()\n\n\t\t\t\t\tguard\n\t\t\t\t\t\t!rect.width.isNaN,\n\t\t\t\t\t\t!rect.height.isNaN\n\t\t\t\t\telse {\n\t\t\t\t\t\treturn path\n\t\t\t\t\t}\n\n\t\t\t\t\tlet tab = displayScale == 1.0 ? 0.0 : -2.0\n\t\t\t\t\tlet insetRect = rect.insetBy(dx: 3, dy: 3)\n\t\t\t\t\tlet inset = -3.0\n\n\t\t\t\t\tlet base: [CGPoint] = [\n\t\t\t\t\t\t.init(x: -tab, y: insetRect.height),\n\t\t\t\t\t\t.init(x: 0 - inset, y: insetRect.height),\n\t\t\t\t\t\t.init(x: 0 - inset, y: 0 - inset),\n\t\t\t\t\t\t.init(x: insetRect.width, y: 0 - inset),\n\t\t\t\t\t\t.init(x: insetRect.width, y: -tab)\n\t\t\t\t\t]\n\n\t\t\t\t\tlet transforms: [CropHandlePosition: CGAffineTransform] = [\n\t\t\t\t\t\t.topLeft: .identity.translatedBy(x: rect.minX, y: rect.minY),\n\t\t\t\t\t\t.topRight: CGAffineTransform(scaleX: -1, y: 1)\n\t\t\t\t\t\t.translatedBy(x: -rect.minX - rect.width, y: rect.minY),\n\t\t\t\t\t\t.bottomRight: CGAffineTransform(scaleX: -1, y: -1)\n\t\t\t\t\t\t.translatedBy(x: -rect.minX - rect.width, y: -rect.minY - rect.height),\n\t\t\t\t\t\t.bottomLeft: CGAffineTransform(scaleX: 1, y: -1)\n\t\t\t\t\t\t.translatedBy(x: rect.minX, y: -rect.minY - rect.height)\n\t\t\t\t\t]\n\n\t\t\t\t\tguard let transform = transforms[corner] else {\n\t\t\t\t\t\treturn path\n\t\t\t\t\t}\n\n\t\t\t\t\tpath.move(to: base[0].applying(transform))\n\n\t\t\t\t\tfor point in base.dropFirst() {\n\t\t\t\t\t\tpath.addLine(to: point.applying(transform))\n\t\t\t\t\t}\n\n\t\t\t\t\treturn path\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "Gifski/Crop/CropRect.swift",
    "content": "import SwiftUI\n\n/**\nRepresents a crop rect.\n\nBoth size and origin are unit points, so it does not matter what the aspect of the source is.\n*/\nstruct CropRect: Equatable {\n\tvar origin: UnitPoint\n\tvar size: UnitSize\n}\n\nextension CropRect {\n\tstatic let initialCropRect = Self(x: 0, y: 0, width: 1, height: 1)\n\n\tinit(\n\t\tx: Double,\n\t\ty: Double,\n\t\twidth: Double,\n\t\theight: Double\n\t) {\n\t\tself.origin = .init(x: x, y: y)\n\t\tself.size = .init(width: width, height: height)\n\t}\n\n\tvar width: Double {\n\t\tsize.width\n\t}\n\n\tvar height: Double {\n\t\tsize.height\n\t}\n\n\tvar x: Double {\n\t\torigin.x\n\t}\n\n\tvar y: Double {\n\t\torigin.y\n\t}\n\n\tvar midX: Double {\n\t\torigin.x + (size.width / 2)\n\t}\n\n\tvar midY: Double {\n\t\torigin.y + (size.height / 2)\n\t}\n\n\tenum Axis {\n\t\tcase horizontal\n\t\tcase vertical\n\n\t\t// swiftlint:disable:next no_cgfloat\n\t\tvar origin: WritableKeyPath<UnitPoint, CGFloat> {\n\t\t\tswitch self {\n\t\t\tcase .horizontal:\n\t\t\t\t\\.x\n\t\t\tcase .vertical:\n\t\t\t\t\\.y\n\t\t\t}\n\t\t}\n\n\t\tvar size: WritableKeyPath<UnitSize, Double> {\n\t\t\tswitch self {\n\t\t\tcase .horizontal:\n\t\t\t\t\\.width\n\t\t\tcase .vertical:\n\t\t\t\t\\.height\n\t\t\t}\n\t\t}\n\t}\n\n\tfunc mid(axis: Axis) -> Double {\n\t\tswitch axis {\n\t\tcase .horizontal:\n\t\t\tmidX\n\t\tcase .vertical:\n\t\t\tmidY\n\t\t}\n\t}\n\n\tvar isReset: Bool {\n\t\torigin.x == 0 && origin.y == 0 && size.width == 1 && size.height == 1\n\t}\n\n\t/**\n\tProduce an unnormalized `CGRect` in pixels.\n\t*/\n\tfunc unnormalize(forDimensions dimensions: CGSize) -> CGRect {\n\t\t.init(\n\t\t\tx: dimensions.width * x,\n\t\t\ty: dimensions.height * y,\n\t\t\twidth: dimensions.width * width,\n\t\t\theight: dimensions.height * height\n\t\t)\n\t}\n\n\tfunc unnormalize(forDimensions dimensions: (Int, Int)) -> CGRect {\n\t\tunnormalize(forDimensions: .init(width: Double(dimensions.0), height: Double(dimensions.1)))\n\t}\n\n\t/**\n\tCreates a new `CropRect` with a given aspect ratio.\n\n\tIf 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).\n\t*/\n\tfunc withAspectRatio(\n\t\taspectWidth: Double,\n\t\taspectHeight: Double,\n\t\tforDimensions dimensions: CGSize\n\t) -> Self {\n\t\tif width == 1.0 || height == 1.0 {\n\t\t\treturn Self.centeredFrom(\n\t\t\t\taspectWidth: aspectWidth,\n\t\t\t\taspectHeight: aspectHeight,\n\t\t\t\tforDimensions: dimensions\n\t\t\t)\n\t\t}\n\n\t\treturn withAspectRatioInsideCurrentRect(\n\t\t\taspectWidth: aspectWidth,\n\t\t\taspectHeight: aspectHeight,\n\t\t\twithinVideoDimensions: dimensions\n\t\t)\n\t}\n\n\t/**\n\tThe range of valid numbers for the aspect ratio.\n\t*/\n\tstatic let defaultAspectRatioBounds = 1...99\n\n\t/**\n\tAdjusts 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.\n\n\t- Parameters:\n\t- aspectWidth: The width of the desired aspect ratio.\n\t- aspectHeight: The height of the desired aspect ratio.\n\t- dimensions: The dimensions of the video in pixels.\n\n\t- Returns: A new `CropRect` adjusted to the specified aspect ratio and constrained within the video dimensions.\n\t*/\n\tprivate func withAspectRatioInsideCurrentRect(\n\t\taspectWidth: Double,\n\t\taspectHeight: Double,\n\t\twithinVideoDimensions dimensions: CGSize\n\t) -> Self {\n\t\tlet cropRectInPixels = unnormalize(forDimensions: dimensions)\n\t\tlet aspectSize = CGSize(width: aspectWidth, height: aspectHeight)\n\n\t\tlet newLongestSide = withAspectRatioInsideCurrentRectLongestSide(\n\t\t\tcropRectInPixels: cropRectInPixels,\n\t\t\taspectSize: aspectSize,\n\t\t\twithinVideoDimensions: dimensions\n\t\t)\n\n\t\tlet newAspect = Self.clampAspect(\n\t\t\taspectRatio: aspectSize.aspectRatio,\n\t\t\tnewLongestSide: newLongestSide\n\t\t)\n\n\t\treturn cropRectInPixels\n\t\t\t.centeredRectWith(size: newAspect * newLongestSide)\n\t\t\t.toCropRect(forVideoDimensions: dimensions)\n\t}\n\n\tprivate func withAspectRatioInsideCurrentRectLongestSide(\n\t\tcropRectInPixels: CGRect,\n\t\taspectSize: CGSize,\n\t\twithinVideoDimensions dimensions: CGSize\n\t) -> Double {\n\t\tlet normalizedAspect = aspectSize.aspectRatio.normalizedAspectRatioSides\n\n\t\tlet scaleBounds = Self.scaleBounds(\n\t\t\tvideoDimensions: dimensions,\n\t\t\tcenter: cropRectInPixels.center,\n\t\t\tnormalizedAspect: normalizedAspect\n\t\t)\n\n\t\tlet desiredScale = aspectSize\n\t\t\t.aspectFittedSize(targetWidthHeight: cropRectInPixels.size.longestSide)[keyPath: Self.desiredSide(aspectRatio: normalizedAspect.aspectRatio)]\n\t\t\t.toDouble\n\n\t\treturn desiredScale.clamped(to: scaleBounds)\n\t}\n\n\t/**\n\tAdjusts the aspect ratio such that it is achievable (not too small).\n\t*/\n\tprivate static func clampAspect(aspectRatio: Double, newLongestSide: Double) -> CGSize {\n\t\tif aspectRatio >= 1.0 {\n\t\t\treturn min(aspectRatio, newLongestSide / minRectWidthHeight).normalizedAspectRatioSides\n\t\t}\n\t\treturn max(aspectRatio, minRectWidthHeight / newLongestSide).normalizedAspectRatioSides\n\t}\n\n\t// swiftlint:disable:next no_cgfloat\n\tprivate static func desiredSide(aspectRatio: Double) -> KeyPath<CGSize, CGFloat> {\n\t\taspectRatio >= 1.0 ? \\.width : \\.height\n\t}\n\n\tprivate static func scaleBounds(\n\t\tvideoDimensions dimensions: CGSize,\n\t\tcenter: CGPoint,\n\t\tnormalizedAspect: CGSize\n\t) -> ClosedRange<Double> {\n\t\tlet maxScale = min(\n\t\t\tmaxScaleForSide(\n\t\t\t\tin: 0...dimensions.width,\n\t\t\t\tcenter: center.x,\n\t\t\t\tnormalizedAspectOfSide: normalizedAspect.width\n\t\t\t),\n\t\t\tmaxScaleForSide(\n\t\t\t\tin: 0...dimensions.height,\n\t\t\t\tcenter: center.y,\n\t\t\t\tnormalizedAspectOfSide: normalizedAspect.height\n\t\t\t)\n\t\t)\n\n\t\treturn 0...maxScale\n\t}\n\n\tprivate static func maxScaleForSide(\n\t\tin range: ClosedRange<Double>,\n\t\tcenter: Double,\n\t\tnormalizedAspectOfSide: Double = 1.0\n\t) -> Double {\n\t\tmin(center - range.lowerBound, range.upperBound - center) * 2.0 / normalizedAspectOfSide\n\t}\n\n\t/**\n\tReturns 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)\n\t*/\n\tfunc changeSize(size: UnitSize, minSize: UnitSize) -> Self {\n\t\tvar out = Self(x: 0.0, y: 0.0, width: 0.0, height: 0.0)\n\t\tchangeSizeFor(axis: .horizontal, size: size, minSize: minSize, out: &out)\n\t\tchangeSizeFor(axis: .vertical, size: size, minSize: minSize, out: &out)\n\t\treturn out\n\t}\n\n\tprivate func changeSizeFor(\n\t\taxis: Axis,\n\t\tsize: UnitSize,\n\t\tminSize: UnitSize,\n\t\tout: inout Self\n\t) {\n\t\tlet newSideLength = size[keyPath: axis.size].clamped(\n\t\t\tfrom: minSize[keyPath: axis.size],\n\t\t\tto: 1.0\n\t\t)\n\n\t\tvar newOrigin = mid(axis: axis) - newSideLength / 2.0\n\n\t\tif newOrigin < 0 {\n\t\t\tnewOrigin = 0\n\t\t} else if newOrigin + newSideLength > 1.0 {\n\t\t\tnewOrigin = 1.0 - newSideLength\n\t\t}\n\n\t\tout.origin[keyPath: axis.origin] = newOrigin\n\t\tout.size[keyPath: axis.size] = newSideLength\n\t}\n}\n\nextension CropRect {\n\tenum DragMode {\n\t\tcase normal\n\t\tcase symmetric\n\t\tcase scale\n\t\tcase aspectRatioLockScale\n\t}\n\n\t/**\n\tThe minimum crop rect width/height in pixels.\n\n\tAs crop rectangles use unit points for size, you need a frame to convert a crop rect to pixels and use `CropRect.minSize`.\n\t*/\n\tstatic let minRectWidthHeight = 100.0\n\n\tstatic func minSize(videoSize: CGSize) -> UnitSize {\n\t\t.init(width: minRectWidthHeight / videoSize.width, height: minRectWidthHeight / videoSize.height)\n\t}\n\n\t/**\n\tReturns a crop rect centered in a video from an aspect and dimensions.\n\t*/\n\tstatic func centeredFrom(\n\t\taspectWidth: Double,\n\t\taspectHeight: Double,\n\t\tforDimensions dimensions: CGSize\n\t) -> CropRect {\n\t\tlet aspectSize = CGSize(width: aspectWidth, height: aspectHeight)\n\n\t\tlet fittedSize = aspectSize.aspectFittedSize(\n\t\t\ttargetWidth: dimensions.width,\n\t\t\ttargetHeight: dimensions.height\n\t\t)\n\n\t\tlet newAspect = clampAspect(\n\t\t\taspectRatio: aspectSize.aspectRatio,\n\t\t\tnewLongestSide: fittedSize.longestSide\n\t\t)\n\n\t\tlet newSize = newAspect * fittedSize.longestSide\n\t\tlet cropWidth = newSize.width / dimensions.width\n\t\tlet cropHeight = newSize.height / dimensions.height\n\n\t\treturn .init(\n\t\t\torigin: .init(\n\t\t\t\tx: 0.5 - cropWidth / 2.0,\n\t\t\t\ty: 0.5 - cropHeight / 2.0\n\t\t\t),\n\t\t\tsize: .init(\n\t\t\t\twidth: cropWidth,\n\t\t\t\theight: cropHeight\n\t\t\t)\n\t\t)\n\t}\n\n\tfunc applyDragToCropRect(\n\t\tdrag: DragGesture.Value,\n\t\tframe: CGRect,\n\t\tdimensions: CGSize,\n\t\tposition: CropHandlePosition,\n\t\tdragMode: DragMode\n\t) -> CropRect {\n\t\tlet delta = getRelativeDragDelta(drag: drag, position: position, frame: frame)\n\n\t\tif position == .center {\n\t\t\treturn applyCenterDrag(delta: delta)\n\t\t}\n\n\t\tlet minSize = CropRect.minSize(videoSize: dimensions)\n\n\t\treturn switch dragMode {\n\t\tcase .normal:\n\t\t\tapplyNormal(\n\t\t\t\tposition: position,\n\t\t\t\tminSize: minSize,\n\t\t\t\tdelta: delta\n\t\t\t)\n\t\tcase .symmetric:\n\t\t\tapplySymmetric(\n\t\t\t\tposition: position,\n\t\t\t\tminSize: minSize,\n\t\t\t\tdelta: delta\n\t\t\t)\n\t\tcase .scale:\n\t\t\tapplyScale(\n\t\t\t\tposition: position,\n\t\t\t\tminSize: minSize,\n\t\t\t\tdelta: delta\n\t\t\t)\n\t\tcase .aspectRatioLockScale:\n\t\t\tapplyAspectRatioLock(\n\t\t\t\tminSize: minSize,\n\t\t\t\tdragLocation: drag.locationInside(frame: frame)\n\t\t\t)\n\t\t}\n\t}\n\n\tfunc getRelativeDragDelta(\n\t\tdrag: DragGesture.Value,\n\t\tposition: CropHandlePosition,\n\t\tframe: CGRect\n\t) -> UnitPoint {\n\t\tlet dragStartAnchor: UnitPoint = switch position {\n\t\tcase .bottom, .right, .center, .left, .top:\n\t\t\t.init(x: drag.startLocation.x / frame.width, y: drag.startLocation.y / frame.height)\n\t\tcase .topLeft, .topRight, .bottomLeft, .bottomRight:\n\t\t\t.init(\n\t\t\t\tx: x + width * position.location.x,\n\t\t\t\ty: y + height * position.location.y\n\t\t\t)\n\t\t}\n\n\t\tlet dragLocation = drag.locationInside(frame: frame)\n\n\t\treturn .init(x: dragLocation.x - dragStartAnchor.x, y: dragLocation.y - dragStartAnchor.y)\n\t}\n\n\t/**\n\tDrag the crop rect without scaling.\n\n\tAlso prevents the crop rect from leaving the rect.\n\t*/\n\tfunc applyCenterDrag(\n\t\tdelta: UnitPoint\n\t) -> CropRect {\n\t\t.init(\n\t\t\tx: x + delta.x.clamped(from: -x, to: 1.0 - x - width),\n\t\t\ty: y + delta.y.clamped(from: -y, to: 1.0 - y - height),\n\t\t\twidth: width,\n\t\t\theight: height\n\t\t)\n\t}\n\n\t/**\n\tApply normal dragging.\n\n\tIf you grab the top-left corner, the bottom location and right-hand side location remains the same while the top and left sides move.\n\n\tAlso prevents the crop rect from leaving the rect, and it has a minimum size.\n\t*/\n\tfunc applyNormal(\n\t\tposition: CropHandlePosition,\n\t\tminSize: UnitSize,\n\t\tdelta: UnitPoint\n\t) -> CropRect {\n\t\tlet (dx, dWidth) = Self.helpNormal(\n\t\t\tisPrimary: position.isLeft,\n\t\t\tisSecondary: position.isRight,\n\t\t\torigin: x,\n\t\t\tsize: width,\n\t\t\tminSize: minSize.width,\n\t\t\traw: delta.x\n\t\t)\n\n\t\tlet (dy, dHeight) = Self.helpNormal(\n\t\t\tisPrimary: position.isTop,\n\t\t\tisSecondary: position.isBottom,\n\t\t\torigin: y,\n\t\t\tsize: height,\n\t\t\tminSize: minSize.height,\n\t\t\traw: delta.y\n\t\t)\n\n\t\treturn .init(\n\t\t\tx: x + dx,\n\t\t\ty: y + dy,\n\t\t\twidth: width + dWidth,\n\t\t\theight: height + dHeight\n\t\t)\n\t}\n\n\tprivate static func helpNormal(\n\t\tisPrimary: Bool,\n\t\tisSecondary: Bool,\n\t\torigin: Double,\n\t\tsize: Double,\n\t\tminSize: Double,\n\t\traw: Double\n\t) -> (Double, Double) {\n\t\tswitch (isPrimary, isSecondary) {\n\t\tcase (true, _):\n\t\t\tlet dx = raw.clamped(from: -origin, to: size - minSize)\n\t\t\treturn (dx, -dx)\n\t\tcase (_, true):\n\t\t\treturn (0.0, raw.clamped(from: minSize - size, to: (1.0 - origin) - size))\n\t\tdefault:\n\t\t\treturn (0.0, 0.0)\n\t\t}\n\t}\n\n\t/**\n\tApply a scaling such that it is symmetric depending on drag direction.\n\n\tFor 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.\n\n\tAlso prevents the crop rect from leaving the rect, and it has minimum size.\n\t*/\n\tfunc applySymmetric(\n\t\tposition: CropHandlePosition,\n\t\tminSize: UnitSize,\n\t\tdelta: UnitPoint\n\t) -> CropRect {\n\t\tlet dx = Double(delta.x).clamped(\n\t\t\tto: Self.symmetricDeltaRange(\n\t\t\t\tprimary: position.isLeft,\n\t\t\t\tsecondary: position.isRight,\n\t\t\t\torigin: x,\n\t\t\t\tsize: width,\n\t\t\t\tminSize: minSize.width\n\t\t\t)\n\t\t)\n\n\t\tlet dy = Double(delta.y).clamped(\n\t\t\tto: Self.symmetricDeltaRange(\n\t\t\t\tprimary: position.isTop,\n\t\t\t\tsecondary: position.isBottom,\n\t\t\t\torigin: y,\n\t\t\t\tsize: height,\n\t\t\t\tminSize: minSize.height\n\t\t\t)\n\t\t)\n\n\t\tlet xSign = position.isLeft ? 1.0 : position.isRight ? -1.0 : 0.0\n\t\tlet ySign = position.isTop ? 1.0 : position.isBottom ? -1.0 : 0.0\n\n\t\treturn .init(\n\t\t\tx: x + xSign * dx,\n\t\t\ty: y + ySign * dy,\n\t\t\twidth: width - 2 * xSign * dx,\n\t\t\theight: height - 2 * ySign * dy\n\t\t)\n\t}\n\n\t/**\n\tFor `applySymmetric`.\n\n\t- `primary` is left/top.\n\t- `secondary` is right/bottom.\n\t*/\n\tstatic func symmetricDeltaRange(\n\t\tprimary: Bool,\n\t\tsecondary: Bool,\n\t\torigin: Double,\n\t\tsize: Double,\n\t\tminSize: Double\n\t) -> ClosedRange<Double> {\n\t\tif primary {\n\t\t\tlet lower = max(-origin, origin + size - 1)\n\t\t\tlet upper = (size - minSize) / 2\n\t\t\treturn lower...upper\n\t\t}\n\n\t\tguard secondary else {\n\t\t\treturn 0...0\n\t\t}\n\n\t\tlet lower = (minSize - size) / 2\n\t\tlet upper = min(origin, 1 - (origin + size))\n\n\t\treturn lower...upper\n\t}\n\n\t/**\n\tScale 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.\n\n\tAlso prevents the crop rect from leaving the rect, and it has a minimum size.\n\t*/\n\tfunc applyScale(\n\t\tposition: CropHandlePosition,\n\t\tminSize: UnitSize,\n\t\tdelta: UnitPoint\n\t) -> CropRect {\n\t\tlet scaleX = (position.location.x * 2) - 1\n\t\tlet scaleY = (position.location.y * 2) - 1\n\n\t\tlet handleCount = max((abs(scaleX) > 0 ? 1 : 0) + (abs(scaleY) > 0 ? 1 : 0), 1)\n\n\t\tlet (tempScale, anchorX) = Self.scaleAnchorPoint(\n\t\t\torigin: x,\n\t\t\tsize: width,\n\t\t\tlocation: position.location.x,\n\t\t\tscale: 1 + (scaleX * delta.x / width + scaleY * delta.y / height) / Double(handleCount)\n\t\t)\n\n\t\tvar (scale, anchorY) = Self.scaleAnchorPoint(\n\t\t\torigin: y,\n\t\t\tsize: height,\n\t\t\tlocation: position.location.y,\n\t\t\tscale: tempScale\n\t\t)\n\n\t\tscale = max(scale, minSize.width / width, minSize.height / height)\n\n\t\treturn .init(\n\t\t\tx: anchorX - (anchorX - x) * scale,\n\t\t\ty: anchorY - (anchorY - y) * scale,\n\t\t\twidth: width * scale,\n\t\t\theight: height * scale\n\t\t)\n\t}\n\n\t/**\n\tFor  `applyScale`.\n\t*/\n\tstatic func scaleAnchorPoint(\n\t\torigin: Double,\n\t\tsize: Double,\n\t\tlocation: Double,\n\t\tscale inScale: Double\n\t) -> (scale: Double, anchor: Double) {\n\t\tlet anchor = origin + size * (1 - location)\n\t\tvar scale = inScale\n\n\t\tif anchor > 0 {\n\t\t\tscale = min(anchor / (anchor - origin), scale)\n\t\t}\n\n\t\tif anchor < 1 {\n\t\t\tscale = min((1 - anchor) / (origin + size - anchor), scale)\n\t\t}\n\n\t\treturn (scale: scale, anchor: anchor)\n\t}\n\n\t/**\n\tScale the crop rect while maintaining aspect ratio.\n\n\tAlso prevents the crop rect from leaving the rect, and it has minimum size.\n\t*/\n\tfunc applyAspectRatioLock(\n\t\tminSize: UnitSize,\n\t\tdragLocation: UnitPoint\n\t) -> CropRect {\n\t\tlet dx = abs(dragLocation.x - midX)\n\t\tlet dy = abs(dragLocation.y - midY)\n\n\t\tlet rawScale = max(\n\t\t\tdx / (width / 2),\n\t\t\tdy / (height / 2)\n\t\t)\n\n\t\tlet scaleRange = max(\n\t\t\tminSize.width / width,\n\t\t\tminSize.height / height\n\t\t)...[\n\t\t\t2 * midX / width,\n\t\t\t2 * (1 - midX) / width,\n\t\t\t2 * midY / height,\n\t\t\t2 * (1 - midY) / height\n\t\t].min()!\n\n\t\tlet scale = rawScale.clamped(to: scaleRange)\n\n\t\tlet newWidth = width * scale\n\t\tlet newHeight = height * scale\n\n\t\treturn CropRect(\n\t\t\tx: midX - newWidth / 2,\n\t\t\ty: midY - newHeight / 2,\n\t\t\twidth: newWidth,\n\t\t\theight: newHeight\n\t\t)\n\t}\n}\n\n/**\nA normalized 2D size in a view’s coordinate space.\n\n`UnitSize` is for sizes as `UnitPoint` is for points.\n*/\nstruct UnitSize: Hashable {\n\tvar width: Double\n\tvar height: Double\n}\n\nextension DragGesture.Value {\n\t/**\n\tIf you drag outside of the view's frame, this will clamp it back to an edge.\n\t*/\n\tfunc locationInside(frame: CGRect) -> UnitPoint {\n\t\t.init(\n\t\t\tx: location.x.clamped(from: frame.minX, to: frame.maxX) / frame.width,\n\t\t\ty: location.y.clamped(from: frame.minY, to: frame.maxY) / frame.height\n\t\t)\n\t}\n}\n"
  },
  {
    "path": "Gifski/Crop/CropSettings.swift",
    "content": "import Foundation\nimport AVKit\n\nprotocol CropSettings {\n\tvar dimensions: (width: Int, height: Int)? { get }\n\tvar trackPreferredTransform: CGAffineTransform? { get }\n\tvar crop: CropRect? { get }\n}\n\nextension GIFGenerator.Conversion: CropSettings {}\n\nextension CropSettings {\n\t/**\n\tWe don't use `croppedOutputDimensions` here because the `CGImage` source may have a different size. We use the size directly from the image.\n\n\tIf the rect parameter defines an area that is not in the image, it returns nil: https://developer.apple.com/documentation/coregraphics/cgimage/1454683-cropping\n\t*/\n\tfunc croppedImage(image: CGImage) -> CGImage? {\n\t\tguard crop != nil else {\n\t\t\treturn image\n\t\t}\n\t\tlet transformedCrop = unnormalizedCropRect(sizeInPreferredTransformationSpace: .init(width: image.width, height: image.height))\n\t\treturn image.cropping(to: transformedCrop)\n\t}\n\n\t/**\n\tReturns the unnormalized crop rect for an image that is already in the preferred transform space (i.e., already rotated).\n\n\tSince `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.\n\t*/\n\tfunc unnormalizedCropRect(sizeInPreferredTransformationSpace preferredSize: CGSize) -> CGRect {\n\t\tguard let cropRect = crop else {\n\t\t\treturn .init(origin: .zero, size: preferredSize)\n\t\t}\n\t\treturn cropRect.unnormalize(forDimensions: preferredSize)\n\t}\n\n\tvar croppedOutputDimensions: (width: Int, height: Int)? {\n\t\tguard crop != nil else {\n\t\t\treturn dimensions\n\t\t}\n\n\t\tguard let dimensions else {\n\t\t\treturn nil\n\t\t}\n\n\t\tlet outputDimensions = unnormalizedCropRect(sizeInPreferredTransformationSpace: .init(width: dimensions.width, height: dimensions.height))\n\t\treturn (outputDimensions.width.toIntAndClampingIfNeeded,\n\t\t\t\toutputDimensions.height.toIntAndClampingIfNeeded)\n\t}\n}\n"
  },
  {
    "path": "Gifski/Crop/CropToolBarItems.swift",
    "content": "import SwiftUI\nimport AVFoundation\n\nstruct CropToolbarItems: View {\n\t@State private var showCropTooltip = false\n\n\t@Binding var isCropActive: Bool\n\tlet metadata: AVAsset.VideoMetadata\n\t@Binding var outputCropRect: CropRect\n\t@FocusState private var isCropToggleFocused: Bool\n\n\tvar body: some View {\n\t\tHStack {\n\t\t\tif isCropActive {\n\t\t\t\tAspectRatioPicker(\n\t\t\t\t\tmetadata: metadata,\n\t\t\t\t\toutputCropRect: $outputCropRect\n\t\t\t\t)\n\t\t\t}\n\t\t\tToggle(\"Crop\", systemImage: \"crop\", isOn: $isCropActive)\n\t\t\t\t.focused($isCropToggleFocused)\n\t\t\t\t.onChange(of: isCropActive) {\n\t\t\t\t\tisCropToggleFocused = true\n\t\t\t\t\tguard isCropActive else {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tSSApp.runOnce(identifier: \"showCropTooltip\") {\n\t\t\t\t\t\tshowCropTooltip = true\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t.popover(isPresented: $showCropTooltip) {\n\t\t\t\t\tTipsView(title: \"Crop Tips\", tips: Self.tips)\n\t\t\t\t}\n\t\t}\n\t}\n\n\tprivate static let tips = [\n\t\t\"• Hold Shift to scale both sides.\",\n\t\t\"• Hold Option to resize from the center.\",\n\t\t\"• Hold both to keep aspect ratio and resize from center.\"\n\t]\n}\n\nprivate enum CustomFieldType {\n\tcase pixel\n\tcase aspect\n}\n\nprivate struct AspectRatioPicker: View {\n\t@State private var showEnterCustomAspectRatio = false\n\t@State private var customAspectRatio: PickerAspectRatio?\n\t@State private var customPixelSize = CGSize.zero\n\t@State private var modifiedCustomField: CustomFieldType?\n\n\tlet metadata: AVAsset.VideoMetadata\n\t@Binding var outputCropRect: CropRect\n\n\tvar body: some View {\n\t\tMenu(selectionText) {\n\t\t\tpresetSection\n\t\t\tcustomSection\n\t\t\totherSections\n\t\t}\n\t\t.onChange(of: customAspectRatio) {\n\t\t\tguard let customAspectRatio else {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\toutputCropRect = outputCropRect.withAspectRatio(\n\t\t\t\tfor: customAspectRatio,\n\t\t\t\tforDimensions: metadata.dimensions\n\t\t\t)\n\n\t\t\t// 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.\n\t\t\tDebouncer.debounce(delay: .seconds(2)) {\n\t\t\t\tlet cropSizeRightNow = outputCropRect.unnormalize(forDimensions: metadata.dimensions).size\n\n\t\t\t\tlet newRatio = PickerAspectRatio.closestAspectRatio(\n\t\t\t\t\tfor: cropSizeRightNow,\n\t\t\t\t\twithin: CropRect.defaultAspectRatioBounds\n\t\t\t\t)\n\n\t\t\t\tguard newRatio.aspectRatio != self.customAspectRatio?.aspectRatio else {\n\t\t\t\t\t// Prevent simplification (like `25:5` -> `5:1`), only assign if the aspect ratio is new.\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tself.customAspectRatio = newRatio\n\t\t\t}\n\t\t}\n\t\t.staticPopover(isPresented: $showEnterCustomAspectRatio) {\n\t\t\tCustomAspectRatioView(\n\t\t\t\tcropRect: $outputCropRect,\n\t\t\t\tcustomAspectRatio: $customAspectRatio,\n\t\t\t\tcustomPixelSize: $customPixelSize,\n\t\t\t\tmodifiedCustomField: $modifiedCustomField,\n\t\t\t\tdimensions: metadata.dimensions\n\t\t\t)\n\t\t}\n\t}\n\n\tprivate var selectionText: String {\n\t\tPickerAspectRatio.selectionText(for: aspect, customAspectRatio: customAspectRatio, videoDimensions: metadata.dimensions, cropRect: outputCropRect)\n\t}\n\n\tprivate var presetSection: some View {\n\t\tSection(\"Presets\") {\n\t\t\tForEach(PickerAspectRatio.presets, id: \\.self) { aspectRatio in\n\t\t\t\tAspectToggle(\n\t\t\t\t\taspectRatio: aspectRatio,\n\t\t\t\t\toutputCropRect: $outputCropRect,\n\t\t\t\t\tcustomAspectRatio: $customAspectRatio,\n\t\t\t\t\tcurrentAspect: aspect,\n\t\t\t\t\tdimensions: metadata.dimensions\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\t}\n\n\t@ViewBuilder\n\tprivate var customSection: some View {\n\t\tif\n\t\t\tlet customAspectRatio,\n\t\t\t!customAspectRatio.matchesPreset()\n\t\t{\n\t\t\tSection(\"Custom\") {\n\t\t\t\tAspectToggle(\n\t\t\t\t\taspectRatio: customAspectRatio,\n\t\t\t\t\toutputCropRect: $outputCropRect,\n\t\t\t\t\tcustomAspectRatio: $customAspectRatio,\n\t\t\t\t\tcurrentAspect: aspect,\n\t\t\t\t\tdimensions: metadata.dimensions\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\t}\n\n\t@ViewBuilder\n\tprivate var otherSections: some View {\n\t\tSection {\n\t\t\tButton(\"Custom\") {\n\t\t\t\thandleCustomAspectButton()\n\t\t\t}\n\t\t}\n\t\tSection {\n\t\t\tButton(\"Reset\") {\n\t\t\t\tresetAspectRatio()\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate var aspect: Double {\n\t\tlet cropRectInPixels = outputCropRect.unnormalize(forDimensions: metadata.dimensions)\n\t\treturn cropRectInPixels.width / cropRectInPixels.height\n\t}\n\n\tprivate func handleCustomAspectButton() {\n\t\tlet cropSizeRightNow = outputCropRect.unnormalize(forDimensions: metadata.dimensions).size\n\n\t\tcustomAspectRatio = PickerAspectRatio.closestAspectRatio(\n\t\t\tfor: cropSizeRightNow,\n\t\t\twithin: CropRect.defaultAspectRatioBounds\n\t\t)\n\n\t\tcustomPixelSize = cropSizeRightNow\n\t\tmodifiedCustomField = nil\n\t\tshowEnterCustomAspectRatio = true\n\t}\n\n\tprivate func resetAspectRatio() {\n\t\tcustomAspectRatio = nil\n\t\toutputCropRect = .initialCropRect\n\t}\n}\n\nprivate struct AspectToggle: View {\n\tvar aspectRatio: PickerAspectRatio\n\t@Binding var outputCropRect: CropRect\n\t@Binding var customAspectRatio: PickerAspectRatio?\n\tvar currentAspect: Double\n\tvar dimensions: CGSize\n\n\tvar body: some View {\n\t\tToggle(\n\t\t\taspectRatio.description,\n\t\t\tisOn: .init(\n\t\t\t\tget: {\n\t\t\t\t\taspectRatio.aspectRatio.isAlmostEqual(to: currentAspect)\n\t\t\t\t},\n\t\t\t\tset: { _ in\n\t\t\t\t\toutputCropRect = outputCropRect.withAspectRatio(for: aspectRatio, forDimensions: dimensions)\n\t\t\t\t}\n\t\t\t)\n\t\t)\n\t}\n}\n\nprivate struct CustomAspectRatioView: View {\n\t@Binding var cropRect: CropRect\n\t@Binding var customAspectRatio: PickerAspectRatio?\n\t@Binding var customPixelSize: CGSize\n\t@Binding var modifiedCustomField: CustomFieldType?\n\tvar dimensions: CGSize\n\n\tvar body: some View {\n\t\tVStack(spacing: 10) {\n\t\t\tHStack(spacing: 4) {\n\t\t\t\tCustomAspectField(\n\t\t\t\t\tcustomAspectRatio: $customAspectRatio,\n\t\t\t\t\tmodifiedCustomField: $modifiedCustomField,\n\t\t\t\t\tside: \\.width\n\t\t\t\t)\n\t\t\t\tText(\":\")\n\t\t\t\t\t.foregroundStyle(.secondary)\n\t\t\t\tCustomAspectField(\n\t\t\t\t\tcustomAspectRatio: $customAspectRatio,\n\t\t\t\t\tmodifiedCustomField: $modifiedCustomField,\n\t\t\t\t\tside: \\.height\n\t\t\t\t)\n\t\t\t}\n\t\t\t.frame(width: 90)\n\t\t\t.opacity(modifiedCustomField == .pixel ? 0.7 : 1)\n\t\t\tHStack(spacing: 4) {\n\t\t\t\tCustomPixelField(\n\t\t\t\t\tcustomPixelSize: $customPixelSize,\n\t\t\t\t\tcropRect: $cropRect,\n\t\t\t\t\tmodifiedCustomField: $modifiedCustomField,\n\t\t\t\t\tdimensions: dimensions,\n\t\t\t\t\tside: \\.width\n\t\t\t\t)\n\t\t\t\tText(\"x\")\n\t\t\t\t\t.foregroundStyle(.secondary)\n\t\t\t\tCustomPixelField(\n\t\t\t\t\tcustomPixelSize: $customPixelSize,\n\t\t\t\t\tcropRect: $cropRect,\n\t\t\t\t\tmodifiedCustomField: $modifiedCustomField,\n\t\t\t\t\tdimensions: dimensions,\n\t\t\t\t\tside: \\.height\n\t\t\t\t)\n\t\t\t}\n\t\t\t.opacity(modifiedCustomField == .aspect ? 0.7 : 1)\n\t\t}\n\t\t.padding()\n\t\t.frame(width: 135)\n\t}\n}\n\nprivate struct CustomPixelField: View {\n\t@Binding var customPixelSize: CGSize\n\t@Binding var cropRect: CropRect\n\t@Binding var modifiedCustomField: CustomFieldType?\n\n\tvar dimensions: CGSize\n\t// swiftlint:disable:next no_cgfloat\n\tlet side: WritableKeyPath<CGSize, CGFloat>\n\t@State private var showWarning = false\n\t@State private var warningCount = 0\n\n\tvar body: some View {\n\t\tIntTextField(\n\t\t\tvalue: .init(\n\t\t\t\tget: {\n\t\t\t\t\tvalue\n\t\t\t\t},\n\t\t\t\tset: {\n\t\t\t\t\tguard minMax.contains($0) else {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\tvar newSize = cropRect.size\n\t\t\t\t\tnewSize[keyPath: unitSizeSide] = Double($0) / dimensions[keyPath: side]\n\t\t\t\t\tcropRect = cropRect.changeSize(size: newSize, minSize: CropRect.minSize(videoSize: dimensions))\n\n\t\t\t\t\tif value != $0 {\n\t\t\t\t\t\tmodifiedCustomField = .pixel\n\t\t\t\t\t}\n\n\t\t\t\t\tcustomPixelSize[keyPath: side] = Double($0)\n\t\t\t\t\tshowWarning = false\n\t\t\t\t}\n\t\t\t),\n\t\t\tminMax: minMax,\n\t\t\talignment: isWidth ? .right : .left,\n\t\t\tfont: .fieldFont,\n\t\t\t//swiftlint:disable:next trailing_closure\n\t\t\tonInvalid: { invalidValue in\n\t\t\t\tcustomPixelSize[keyPath: side] = Double(invalidValue.clamped(to: minMax))\n\t\t\t\twarningCount += 1\n\t\t\t\tshowWarning = true\n\t\t\t}\n\t\t)\n\t\t.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.\n\t\t.frame(width: OS.isMacOS26OrLater ? 46 : 42.0)\n\t\t.popover2(isPresented: $showWarning) {\n\t\t\tVStack {\n\t\t\t\tText(\"Value must be in the range \\(minMax.lowerBound) to \\(minMax.upperBound)\")\n\t\t\t}\n\t\t\t.padding()\n\t\t}\n\t}\n\n\tvar value: Int {\n\t\tInt(customPixelSize[keyPath: side].rounded())\n\t}\n\n\tvar isWidth: Bool {\n\t\tside == \\.width\n\t}\n\n\tvar minMax: ClosedRange<Int> {\n\t\tInt(CropRect.minRectWidthHeight)...Int(dimensions[keyPath: side])\n\t}\n\n\tvar unitSizeSide: WritableKeyPath<UnitSize, Double> {\n\t\tisWidth ? \\.width : \\.height\n\t}\n}\n\nprivate struct CustomAspectField: View {\n\t@Binding var customAspectRatio: PickerAspectRatio?\n\t@Binding var modifiedCustomField: CustomFieldType?\n\tlet side: WritableKeyPath<PickerAspectRatio, Int>\n\n\tvar body: some View {\n\t\tIntTextField(\n\t\t\tvalue: .init(\n\t\t\t\tget: {\n\t\t\t\t\tcustomAspectRatio?[keyPath: side] ?? 1\n\t\t\t\t},\n\t\t\t\tset: {\n\t\t\t\t\tguard\n\t\t\t\t\t\tvar customAspectRatioCopy = customAspectRatio,\n\t\t\t\t\t\t$0 > 0\n\t\t\t\t\telse {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\tif customAspectRatioCopy[keyPath: side] != $0 {\n\t\t\t\t\t\tmodifiedCustomField = .aspect\n\t\t\t\t\t}\n\n\t\t\t\t\tcustomAspectRatioCopy[keyPath: side] = $0.clamped(to: minMax)\n\t\t\t\t\tcustomAspectRatio = customAspectRatioCopy\n\t\t\t\t}\n\t\t\t),\n\t\t\tminMax: minMax,\n\t\t\talignment: side == \\.width ? .right : .left,\n\t\t\tfont: .fieldFont\n\t\t)\n\t\t.frame(width: OS.isMacOS26OrLater ? 30 : 26)\n\t}\n\n\tvar minMax: ClosedRange<Int> {\n\t\tCropRect.defaultAspectRatioBounds\n\t}\n\n\tvar isWidth: Bool {\n\t\tside == \\.width\n\t}\n\n\tvar unitSizeSide: WritableKeyPath<UnitSize, Double> {\n\t\tisWidth ? \\.width : \\.height\n\t}\n}\n\nprivate struct TipsView: View {\n\tlet title: String\n\tlet tips: [String]\n\n\tvar body: some View {\n\t\tVStack(alignment: .leading, spacing: 10) {\n\t\t\tText(title)\n\t\t\t\t.font(.headline)\n\t\t\tForEach(tips, id: \\.self) { tip in\n\t\t\t\tText(tip)\n\t\t\t}\n\t\t}\n\t\t.padding()\n\t\t.fixedSize()\n\t}\n}\n\nextension NSFont {\n\tfileprivate static var fieldFont: NSFont {\n\t\tmonospacedDigitSystemFont(ofSize: 12, weight: .regular)\n\t}\n}\n"
  },
  {
    "path": "Gifski/Crop/PickerAspectRatio.swift",
    "content": "import Foundation\n\nstruct PickerAspectRatio: Hashable {\n\tvar width: Int\n\tvar height: Int\n\n\tinit(_ width: Int, _ height: Int) {\n\t\tself.width = width\n\t\tself.height = height\n\t}\n}\n\nextension PickerAspectRatio: CustomStringConvertible {\n\tvar description: String {\n\t\t\"\\(width):\\(height)\"\n\t}\n}\n\nextension PickerAspectRatio {\n\tstatic let presets: [Self] = [\n\t\t.init(16, 9),\n\t\t.init(4, 3),\n\t\t.init(1, 1),\n\t\t.init(9, 16),\n\t\t.init(3, 4)\n\t]\n\n\t/**\n\tThe description is the aspect ratio and the size in pixels for the given crop rect if were to switch to using this aspect ratio.\n\t*/\n\tfunc description(\n\t\tforVideoDimensions dimensions: CGSize,\n\t\tcropRect: CropRect\n\t) -> String {\n\t\t\"\\(description) - \\(cropRect.withAspectRatio(for: self, forDimensions: dimensions).unnormalize(forDimensions: dimensions).size.videoSizeDescription)\"\n\t}\n\n\tvar aspectRatio: Double {\n\t\tDouble(width) / Double(height)\n\t}\n}\n\nextension PickerAspectRatio {\n\tfunc matchesPreset() -> Bool {\n\t\tSelf.presets.contains { $0.isCloseTo(self.aspectRatio) }\n\t}\n\n\tfunc isCloseTo(_ aspect: Double, tolerance: Double = 0.01) -> Bool {\n\t\tabs(aspectRatio - aspect) < tolerance\n\t}\n\n\tstatic func selectionText(\n\t\tfor aspect: Double,\n\t\tcustomAspectRatio: PickerAspectRatio?,\n\t\tvideoDimensions: CGSize,\n\t\tcropRect: CropRect\n\t) -> String {\n\t\tlet allRatios = presets + (customAspectRatio.map { [$0] } ?? [])\n\n\t\tif let matchingRatio = allRatios.first(where: { $0.aspectRatio.isAlmostEqual(to: aspect) }) {\n\t\t\treturn matchingRatio.description(\n\t\t\t\tforVideoDimensions: videoDimensions,\n\t\t\t\tcropRect: cropRect\n\t\t\t)\n\t\t}\n\n\t\tlet customSizeDescription = cropRect.unnormalize(forDimensions: videoDimensions).size.videoSizeDescription\n\n\t\treturn \"Custom - \\(customSizeDescription)\"\n\t}\n\n\n\t/**\n\tCalculates the closest current aspect ratio of the crop rec with width and height within the given range.\n\n\tFirst, 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.\n\t*/\n\tstatic func closestAspectRatio(\n\t\tfor size: CGSize,\n\t\twithin range: ClosedRange<Int>\n\t) -> Self {\n\t\tlet (intWidth, intHeight) = size.integerAspectRatio()\n\n\t\tif\n\t\t\trange.contains(intWidth),\n\t\t\trange.contains(intHeight)\n\t\t{\n\t\t\treturn .init(intWidth, intHeight)\n\t\t}\n\n\t\treturn approximateAspectRatio(for: size, within: range)\n\t}\n\n\tprivate static func approximateAspectRatio(\n\t\tfor size: CGSize,\n\t\twithin range: ClosedRange<Int>\n\t) -> Self {\n\t\t// Calculate the aspect ratio as a floating-point value\n\t\tlet aspect = size.width / size.height\n\n\t\t// Generate all possible numerator-denominator pairs within the range\n\t\tlet bestPairMap = range.flatMap { denominator in\n\t\t\tlet numerator = Int(round(aspect * Double(denominator)))\n\t\t\treturn range.contains(numerator) ? [(numerator, denominator)] : []\n\t\t}\n\n\t\t// Find the pair that most closely matches the aspect ratio\n\t\tlet bestPair = bestPairMap.min {\n\t\t\tabs(Double($0.0) / Double($0.1) - aspect) < abs(Double($1.0) / Double($1.1) - aspect)\n\t\t} ?? (1, 1)\n\n\t\treturn .init(bestPair.0, bestPair.1)\n\t}\n}\n\nextension CropRect {\n\tfunc withAspectRatio(\n\t\tfor newRatio: PickerAspectRatio,\n\t\tforDimensions dimensions: CGSize\n\t) -> CropRect {\n\t\twithAspectRatio(\n\t\t\taspectWidth: Double(newRatio.width),\n\t\t\taspectHeight: Double(newRatio.height),\n\t\t\tforDimensions: dimensions\n\t\t)\n\t}\n}\n"
  },
  {
    "path": "Gifski/EditScreen.swift",
    "content": "import SwiftUI\nimport AVFoundation\n\nstruct EditScreen: View {\n\t@Environment(AppState.self) private var appState\n\t@State private var outputCropRect = CropRect.initialCropRect\n\t@State private var fullPreviewStream = FullPreviewStream()\n\n\tvar url: URL\n\tvar asset: AVAsset\n\tvar metadata: AVAsset.VideoMetadata\n\n\tinit(url: URL, asset: AVAsset, metadata: AVAsset.VideoMetadata) {\n\t\tself.url = url\n\t\tself.asset = asset\n\t\tself.metadata = metadata\n\t}\n\n\tvar body: some View {\n\t\t_EditScreen(\n\t\t\turl: url,\n\t\t\tasset: asset,\n\t\t\tmetadata: metadata,\n\t\t\toutputCropRect: $outputCropRect,\n\t\t\toverlay: NSHostingView(rootView: CropOverlayView(\n\t\t\t\tcropRect: $outputCropRect,\n\t\t\t\tdimensions: metadata.dimensions,\n\t\t\t\teditable: appState.isCropActive\n\t\t\t)),\n\t\t\tfullPreviewStream: fullPreviewStream\n\t\t)\n\t}\n}\n\nprivate struct _EditScreen: View {\n\t@Environment(AppState.self) private var appState\n\t@Default(.outputQuality) private var outputQuality\n\t@Default(.bounceGIF) private var bounceGIF\n\t@Default(.outputFPS) private var frameRate\n\t@Default(.loopGIF) private var loopGIF\n\t@Default(.suppressKeyframeWarning) private var suppressKeyframeWarning\n\t@State private var url: URL\n\t@State private var asset: AVAsset\n\t@State private var modifiedAsset: AVAsset\n\t@State private var modifiedAssetTimeRange: CMTimeRange?\n\t@State private var metadata: AVAsset.VideoMetadata\n\t@State private var estimatedFileSizeModel = EstimatedFileSizeModel()\n\t@State private var timeRange: ClosedRange<Double>?\n\t@State private var loopCount = 0\n\t@State private var isKeyframeRateChecked = false\n\t@State private var isReversePlaybackWarningPresented = false\n\t@State private var resizableDimensions = Dimensions.percent(1, originalSize: .init(widthHeight: 100))\n\t@State private var shouldShow = false\n\t@State private var fullPreviewState = FullPreviewGenerationEvent.initialState\n\t@State private var fullPreviewDebouncer = Debouncer(delay: .milliseconds(200))\n\n\t@Binding private var outputCropRect: CropRect\n\t@State private var exportModifiedVideoState = ExportModifiedVideoState.idle\n\t@State private var isExportModifiedVideoAudioWarningPresented = false\n\tprivate var overlay: NSView\n\tprivate let fullPreviewStream: FullPreviewStream\n\t@State private var lastSpeed: Double?\n\n\n\tinit(\n\t\turl: URL,\n\t\tasset: AVAsset,\n\t\tmetadata: AVAsset.VideoMetadata,\n\t\toutputCropRect: Binding<CropRect>,\n\t\toverlay: NSView,\n\t\tfullPreviewStream: FullPreviewStream\n\t) {\n\t\tself._url = .init(wrappedValue: url)\n\t\tself._asset = .init(wrappedValue: asset)\n\t\tself._modifiedAsset = .init(wrappedValue: asset)\n\t\tself._metadata = .init(wrappedValue: metadata)\n\t\tself._outputCropRect = outputCropRect\n\t\tself.overlay = overlay\n\t\tself.fullPreviewStream = fullPreviewStream\n\t}\n\n\tvar body: some View {\n\t\tVStack {\n\t\t\ttrimmingAVPlayer\n\t\t\tcontrols\n\t\t\tbottomBar\n\t\t\tExportModifiedVideoView(\n\t\t\t\tstate: $exportModifiedVideoState,\n\t\t\t\tsourceURL: url,\n\t\t\t\tisAudioWarningPresented: $isExportModifiedVideoAudioWarningPresented\n\t\t\t)\n\t\t}\n\t\t.background(.ultraThickMaterial)\n\t\t.navigationTitle(url.lastPathComponent)\n\t\t.navigationDocument(url)\n\t\t.toolbar {\n\t\t\tToolbarItemGroup {\n\t\t\t\tif fullPreviewState.isGenerating {\n\t\t\t\t\tProgressView(value: fullPreviewState.progress)\n\t\t\t\t\t\t.progressViewStyle(.circular)\n\t\t\t\t\t\t.controlSize(.mini)\n\t\t\t\t\t\t.scaleEffect(0.8)\n\t\t\t\t\t\t.overlay {\n\t\t\t\t\t\t\tif let fullPreviewStateErrorMessage = fullPreviewState.errorMessage {\n\t\t\t\t\t\t\t\tColor.clear\n\t\t\t\t\t\t\t\t\t.popover(isPresented: .constant(true)) {\n\t\t\t\t\t\t\t\t\t\tText(fullPreviewStateErrorMessage)\n\t\t\t\t\t\t\t\t\t\t\t.padding()\n\t\t\t\t\t\t\t\t\t\t\t.frame(maxWidth: 300)\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tToggle(\n\t\t\t\t\t\"Preview\",\n\t\t\t\t\tsystemImage: appState.shouldShowPreview && fullPreviewState.canShowPreview ? \"eye\" : \"eye.slash\",\n\t\t\t\t\tisOn: appState.toggleMode(mode: .preview)\n\t\t\t\t)\n\t\t\t}\n\t\t\t// We have to use this as the glass background is buggy.\n\t\t\t.ss_sharedBackgroundVisibility_hidden()\n\t\t\tif #available(macOS 26, *) {\n\t\t\t\tToolbarSpacer(.fixed)\n\t\t\t}\n\t\t\tToolbarItemGroup {\n\t\t\t\tCropToolbarItems(\n\t\t\t\t\tisCropActive: appState.toggleMode(mode: .editCrop),\n\t\t\t\t\tmetadata: metadata,\n\t\t\t\t\toutputCropRect: $outputCropRect\n\t\t\t\t)\n\t\t\t\t.focusSection()\n\t\t\t}\n\t\t\t.ss_sharedBackgroundVisibility_hidden()\n\t\t}\n\t\t.onReceive(Defaults.publisher(.outputSpeed, options: [])) { _ in\n\t\t\tDebouncer.debounce(delay: .seconds(0.4)) {\n\t\t\t\tTask {\n\t\t\t\t\tawait setSpeed()\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t// We cannot use `Defaults.publisher(.outputSpeed, options: [])` without the `options` as it causes some weird glitches.\n\t\t.task {\n\t\t\tawait setSpeed()\n\t\t}\n\t\t.onChange(of: outputQuality, initial: true) {\n\t\t\testimatedFileSizeModel.duration = metadata.duration\n\t\t\testimatedFileSizeModel.updateEstimate()\n\t\t\tupdatePreviewOnSettingsChange()\n\t\t}\n\t\t// TODO: Make these a single call when tuples are equatable.\n\t\t.onChange(of: resizableDimensions) {\n\t\t\testimatedFileSizeModel.updateEstimate()\n\t\t\tupdatePreviewOnSettingsChange()\n\t\t}\n\t\t.onChange(of: timeRange) {\n\t\t\testimatedFileSizeModel.updateEstimate()\n\t\t\tupdatePreviewOnSettingsChange()\n\t\t}\n\t\t.onChange(of: bounceGIF) {\n\t\t\testimatedFileSizeModel.updateEstimate()\n\t\t}\n\t\t.onChange(of: frameRate) {\n\t\t\testimatedFileSizeModel.updateEstimate()\n\t\t\tupdatePreviewOnSettingsChange()\n\t\t}\n\t\t.onChange(of: bounceGIF) {\n\t\t\tguard bounceGIF else {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tshowKeyframeRateWarningIfNeeded()\n\t\t}\n\t\t.alert2(\n\t\t\t\"Reverse Playback Preview Limitation\",\n\t\t\tmessage: \"Reverse playback may stutter when the video has a low keyframe rate. The GIF will not have the same stutter.\",\n\t\t\tisPresented: $isReversePlaybackWarningPresented\n\t\t)\n\t\t.dialogSuppressionToggle(isSuppressed: $suppressKeyframeWarning)\n\t\t.opacity(shouldShow ? 1 : 0)\n\t\t.onAppear {\n\t\t\tsetUp()\n\t\t\tappState.onExportAsVideo = onExportAsVideo\n\t\t}\n\t\t.onDisappear {\n\t\t\tappState.onExportAsVideo = nil\n\n\t\t\tswitch exportModifiedVideoState {\n\t\t\tcase .idle:\n\t\t\t\tbreak\n\t\t\tcase .exporting(let task, _):\n\t\t\t\ttask.cancel()\n\t\t\tcase .finished(let url):\n\t\t\t\ttry? FileManager.default.removeItem(at: url)\n\t\t\t}\n\t\t}\n\t\t.task {\n\t\t\ttry? await Task.sleep(for: .seconds(0.3))\n\n\t\t\twithAnimation {\n\t\t\t\tshouldShow = true\n\t\t\t}\n\t\t}\n\t\t.task {\n\t\t\tfor await event in fullPreviewStream.eventStream {\n\t\t\t\tfullPreviewState = event\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate func onExportAsVideo() {\n\t\tswitch exportModifiedVideoState {\n\t\tcase .idle:\n\t\t\tbreak\n\t\tcase .exporting, .finished:\n\t\t\t// 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.\n\t\t\texportModifiedVideoState = exportModifiedVideoState\n\t\t\treturn\n\t\t}\n\n\t\tif metadata.hasAudio {\n\t\t\tSSApp.runOnce(identifier: \"audioTrackExportWarning\") {\n\t\t\t\tisExportModifiedVideoAudioWarningPresented = true\n\t\t\t}\n\t\t}\n\n\t\texportModifiedVideoState = .exporting(\n\t\t\tTask {\n\t\t\t\tdo {\n\t\t\t\t\tlet outputURL = try await exportModifiedVideo(conversion: conversionSettings)\n\t\t\t\t\ttry await MainActor.run {\n\t\t\t\t\t\ttry Task.checkCancellation()\n\t\t\t\t\t\texportModifiedVideoState = .finished(outputURL)\n\t\t\t\t\t}\n\t\t\t\t} catch {\n\t\t\t\t\tif Task.isCancelled || error.isCancelled {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tawait MainActor.run {\n\t\t\t\t\t\texportModifiedVideoState = .idle\n\t\t\t\t\t\tappState.error = error\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t},\n\t\t\tvideoIsOverTwentySeconds: conversionSettings.gifDuration(assetTimeRange: modifiedAssetTimeRange, withBounce: false) > .seconds(20)\n\t\t)\n\t}\n\n\tprivate func updatePreviewOnSettingsChange() {\n\t\tguard appState.mode != .editCrop else {\n\t\t\treturn\n\t\t}\n\n\t\tfullPreviewDebouncer {\n\t\t\tTask {\n\t\t\t\tlet conversion = conversionSettings\n\n\t\t\t\tawait fullPreviewStream.requestNewFullPreview(\n\t\t\t\t\tasset: conversion.asset,\n\t\t\t\t\tsettingsEvent: .init(\n\t\t\t\t\t\tconversion: conversion,\n\t\t\t\t\t\tspeed: Defaults[.outputSpeed],\n\t\t\t\t\t\tframesPerSecondsWithoutSpeedAdjustment: Defaults[.outputFPS],\n\t\t\t\t\t\tduration: metadata.duration.toTimeInterval\n\t\t\t\t\t)\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate func setSpeed() async {\n\t\tdo {\n\t\t\tif Defaults[.outputSpeed] == lastSpeed {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tlastSpeed = Defaults[.outputSpeed]\n\t\t\t// 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.\n\n\t\t\tlet changedSpeedAsset = try await asset.firstVideoTrack?.extractToNewAssetAndChangeSpeed(to: Defaults[.outputSpeed]) ?? modifiedAsset\n\t\t\tmodifiedAsset = try await PreviewableComposition(extractPreviewableCompositionFrom: changedSpeedAsset)\n\t\t\tmodifiedAssetTimeRange = try await changedSpeedAsset.firstVideoTrack?.load(.timeRange)\n\n\t\t\testimatedFileSizeModel.updateEstimate()\n\t\t\tupdatePreviewOnSettingsChange()\n\t\t} catch {\n\t\t\tappState.error = error\n\t\t}\n\t}\n\n\tprivate func setUp() {\n\t\testimatedFileSizeModel.getConversionSettings = { conversionSettings }\n\t\tupdatePreviewOnSettingsChange()\n\t}\n\n\t/**\n\tPaused because the preview is generating the new preview.\n\t*/\n\tvar previewPaused: Bool {\n\t\tappState.shouldShowPreview && fullPreviewState.isGenerating\n\t}\n\n\tprivate var trimmingAVPlayer: some View {\n\t\t// TODO: Move the trimmer outside the video view.\n\t\tTrimmingAVPlayer(\n\t\t\tasset: modifiedAsset,\n\t\t\tshouldShowPreview: appState.shouldShowPreview,\n\t\t\tfullPreviewState: fullPreviewState,\n\t\t\tloopPlayback: loopGIF,\n\t\t\tbouncePlayback: bounceGIF,\n\t\t\tspeed: previewPaused ? 0.0 : 1.0,\n\t\t\toverlay: appState.shouldShowPreview ? nil : overlay,\n\t\t\tisPlayPauseButtonEnabled: !previewPaused,\n\t\t\tisTrimmerDraggable: appState.isCropActive\n\t\t) { timeRange in\n\t\t\tDispatchQueue.main.async {\n\t\t\t\tself.timeRange = timeRange\n\t\t\t\testimatedFileSizeModel.updateEstimate()\n\t\t\t\tupdatePreviewOnSettingsChange()\n\t\t\t}\n\t\t}\n\t\t.onChange(of: appState.mode) {\n\t\t\tif appState.mode == .editCrop {\n\t\t\t\tTask {\n\t\t\t\t\tawait fullPreviewStream.cancelFullPreviewGeneration()\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Because we don't update the preview during editCrop, the preview may be stale.\n\t\t\tupdatePreviewOnSettingsChange()\n\t\t}\n\t}\n\n\tprivate var controls: some View {\n\t\tHStack(spacing: 0) {\n\t\t\tForm {\n\t\t\t\tDimensionsSetting(\n\t\t\t\t\tvideoDimensions: metadata.dimensions,\n\t\t\t\t\tresizableDimensions: $resizableDimensions\n\t\t\t\t)\n\t\t\t\tSpeedSetting()\n\t\t\t\t\t.padding(.bottom, 6) // Makes the forms have equal height.\n\t\t\t}\n\t\t\t.padding(.horizontal, -8) // Form comes with some default padding, which we don't want.\n\t\t\t.fillFrame()\n\t\t\t.containerRelativeFrame(.horizontal, count: 2, span: 1, spacing: 0)\n\t\t\t.padding(.trailing, -8)\n\t\t\tForm {\n\t\t\t\tFrameRateSetting(videoFrameRate: metadata.frameRate)\n\t\t\t\tQualitySetting()\n\t\t\t\tLoopSetting(loopCount: $loopCount)\n\t\t\t}\n\t\t\t.padding(.horizontal, -8)\n\t\t\t.fillFrame()\n\t\t\t.containerRelativeFrame(.horizontal, count: 2, span: 1, spacing: 0)\n\t\t}\n\t\t.padding(-12)\n\t\t.formStyle(.grouped)\n\t\t.scrollContentBackground(.hidden)\n\t\t.scrollDisabled(true)\n\t\t.fixedSize()\n\t}\n\n\tprivate var bottomBar: some View {\n\t\tHStack {\n\t\t\tSpacer()\n\t\t\tButton(\"Convert\") {\n\t\t\t\tappState.navigationPath.append(.conversion(conversionSettings))\n\t\t\t}\n\t\t\t.keyboardShortcut(.defaultAction)\n\t\t\t.padding(.top, -1) // Makes the bar have equal spacing on top and bottom.\n\t\t}\n\t\t.overlay {\n\t\t\tEstimatedFileSizeView(model: estimatedFileSizeModel)\n\t\t}\n\t\t.padding()\n\t\t.padding(.top, -16)\n\t}\n\n\tprivate var conversionSettings: GIFGenerator.Conversion {\n\t\t.init(\n\t\t\tasset: modifiedAsset,\n\t\t\tsourceURL: url,\n\t\t\ttimeRange: timeRange,\n\t\t\tquality: outputQuality,\n\t\t\tdimensions: resizableDimensions.pixels.toInt,\n\t\t\tframeRate: frameRate,\n\t\t\tloop: {\n\t\t\t\tguard loopGIF else {\n\t\t\t\t\treturn loopCount == 0 ? .never : .count(loopCount)\n\t\t\t\t}\n\n\t\t\t\treturn .forever\n\t\t\t}(),\n\t\t\tbounce: bounceGIF,\n\t\t\tcrop: outputCropRect,\n\t\t\ttrackPreferredTransform: metadata.trackPreferredTransform\n\t\t)\n\t}\n\n\tprivate func showKeyframeRateWarningIfNeeded(maximumKeyframeInterval: Double = 30) {\n\t\tguard\n\t\t\t!isKeyframeRateChecked,\n\t\t\t!Defaults[.suppressKeyframeWarning]\n\t\telse {\n\t\t\treturn\n\t\t}\n\n\t\tisKeyframeRateChecked = true\n\n\t\tTask.detached(priority: .utility) {\n\t\t\tdo {\n\t\t\t\tguard\n\t\t\t\t\tlet keyframeInfo = try await modifiedAsset.firstVideoTrack?.getKeyframeInfo(),\n\t\t\t\t\tkeyframeInfo.keyframeInterval > maximumKeyframeInterval\n\t\t\t\telse {\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tprint(\"Low keyframe interval \\(keyframeInfo.keyframeInterval)\")\n\n\t\t\t\tawait MainActor.run {\n\t\t\t\t\tisReversePlaybackWarningPresented = true\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\tawait MainActor.run {\n\t\t\t\t\tappState.error = error\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nenum PredefinedSizeItem: Hashable {\n\tcase custom\n\tcase spacer\n\tcase dimensions(Dimensions)\n\n\tvar resizableDimensions: Dimensions? {\n\t\tswitch self {\n\t\tcase .dimensions(let dimensions):\n\t\t\tdimensions\n\t\tdefault:\n\t\t\tnil\n\t\t}\n\t}\n}\n\nprivate struct DimensionsSetting: View {\n\t@State private var predefinedSizes = [PredefinedSizeItem]()\n\t@State private var selectedPredefinedSize: PredefinedSizeItem?\n\t@State private var dimensionsType = DimensionsType.pixels\n\t@State private var width = 0\n\t@State private var height = 0\n\t@State private var percent = 0\n\t@State private var isArrowKeyTipPresented = false\n\n\tlet videoDimensions: CGSize\n\t@Binding var resizableDimensions: Dimensions // TODO: Rename.\n\n\tvar body: some View {\n\t\tVStack(spacing: 16) {\n\t\t\tPicker(\"Dimensions\", selection: $selectedPredefinedSize) {\n\t\t\t\tForEach(predefinedSizes, id: \\.self) { size in\n\t\t\t\t\tswitch size {\n\t\t\t\t\tcase .custom:\n\t\t\t\t\t\tif selectedPredefinedSize == .custom {\n\t\t\t\t\t\t\tlet string = switch dimensionsType {\n\t\t\t\t\t\t\tcase .pixels:\n\t\t\t\t\t\t\t\t// TODO: Make this a property on `resizableDimensions`.\n\t\t\t\t\t\t\t\tString(format: \"%.0f%%\", resizableDimensions.percent * 100)\n\t\t\t\t\t\t\tcase .percent:\n\t\t\t\t\t\t\t\tresizableDimensions.pixels.formatted\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tText(\"Custom — \\(string)\")\n\t\t\t\t\t\t\t\t.tag(size as PredefinedSizeItem?)\n\t\t\t\t\t\t}\n\t\t\t\t\tcase .spacer:\n\t\t\t\t\t\tDivider()\n\t\t\t\t\t\t\t.tag(UUID())\n\t\t\t\t\tcase .dimensions(let dimensions):\n\t\t\t\t\t\tText(\"\\(dimensions.description)\")\n\t\t\t\t\t\t\t.tag(size as PredefinedSizeItem?)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t.onChange(of: selectedPredefinedSize) {\n\t\t\t\tupdateDimensionsBasedOnSelection(selectedPredefinedSize)\n\t\t\t}\n\t\t\tHStack {\n\t\t\t\tSpacer()\n\t\t\t\tHStack {\n\t\t\t\t\tswitch dimensionsType {\n\t\t\t\t\tcase .pixels:\n\t\t\t\t\t\tlet textFieldWidth = OS.isMacOS26OrLater ? 44 : 42.0\n\t\t\t\t\t\tHStack(spacing: 4) {\n\t\t\t\t\t\t\tLabeledContent(\"Width\") {\n\t\t\t\t\t\t\t\tIntTextField(\n\t\t\t\t\t\t\t\t\tvalue: $width,\n\t\t\t\t\t\t\t\t\tminMax: resizableDimensions.widthMinMax.toInt,\n\t\t\t\t\t\t\t\t\tonBlur: { _ in // swiftlint:disable:this trailing_closure\n\t\t\t\t\t\t\t\t\t\tDispatchQueue.main.async {\n\t\t\t\t\t\t\t\t\t\t\tapplyWidth()\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t.frame(width: textFieldWidth)\n\t\t\t\t\t\t\t\t.onChange(of: width) {\n\t\t\t\t\t\t\t\t\tapplyWidth()\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t// TODO: Use TipKit when targeting macOS 15.\n\t\t\t\t\t\t\t.popover(isPresented: $isArrowKeyTipPresented) {\n\t\t\t\t\t\t\t\tText(\"Press the arrow up/down keys to change the value by 1.\\nHold the Option key meanwhile to change it by 10.\")\n\t\t\t\t\t\t\t\t\t.padding()\n\t\t\t\t\t\t\t\t\t.padding(.vertical, 4)\n\t\t\t\t\t\t\t\t\t.onTapGesture {\n\t\t\t\t\t\t\t\t\t\tisArrowKeyTipPresented = false\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t.accessibilityAddTraits(.isButton)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tText(\"×\")\n\t\t\t\t\t\t\tLabeledContent(\"Height\") {\n\t\t\t\t\t\t\t\tIntTextField(\n\t\t\t\t\t\t\t\t\tvalue: $height,\n\t\t\t\t\t\t\t\t\tminMax: resizableDimensions.heightMinMax.toInt,\n\t\t\t\t\t\t\t\t\tonBlur: { _ in // swiftlint:disable:this trailing_closure\n\t\t\t\t\t\t\t\t\t\tDispatchQueue.main.async {\n\t\t\t\t\t\t\t\t\t\t\tapplyHeight()\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t.frame(width: textFieldWidth)\n\t\t\t\t\t\t\t\t.onChange(of: height) {\n\t\t\t\t\t\t\t\t\tapplyHeight()\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\tcase .percent:\n\t\t\t\t\t\tLabeledContent(\"Percent\") {\n\t\t\t\t\t\t\tIntTextField(\n\t\t\t\t\t\t\t\tvalue: $percent,\n\t\t\t\t\t\t\t\tminMax: resizableDimensions.percentMinMax.toInt,\n\t\t\t\t\t\t\t\tonBlur: { _ in // swiftlint:disable:this trailing_closure\n\t\t\t\t\t\t\t\t\tDispatchQueue.main.async { // Ensures it uses updated values.\n\t\t\t\t\t\t\t\t\t\tapplyPercent()\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t.frame(width: OS.isMacOS26OrLater ? 36 : 32)\n\t\t\t\t\t\t\t.onChange(of: percent) {\n\t\t\t\t\t\t\t\tapplyPercent()\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t.padding(.trailing, -8)\n\t\t\t\tPicker(\"Dimension type\", selection: $dimensionsType) {\n\t\t\t\t\tForEach(DimensionsType.allCases, id: \\.self) {\n\t\t\t\t\t\tText($0.rawValue)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t.onChange(of: dimensionsType) {\n\t\t\t\t\tDispatchQueue.main.async { // Fixes an issue where if you do 100%, then 99%, and then try to switch to \"pixel\" type, it doesn't switch.\n\t\t\t\t\t\tupdateTextFieldsForCurrentDimensions()\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t.fixedSize()\n\t\t\t.fillFrame(.horizontal, alignment: .trailing)\n\t\t\t.labelsHidden()\n\t\t}\n\t\t.onAppear {\n\t\t\tsetUpDimensions()\n\t\t\tupdateTextFieldsForCurrentDimensions()\n\t\t\tshowArrowKeyTipIfNeeded()\n\t\t}\n\t}\n\n\tprivate func setUpDimensions() {\n\t\tlet dimensions = Dimensions.pixels(videoDimensions, originalSize: videoDimensions)\n\n\t\tresizableDimensions = dimensions\n\n\t\tvar pixelCommonSizes: [Double] = [\n\t\t\t960,\n\t\t\t800,\n\t\t\t640,\n\t\t\t500,\n\t\t\t480,\n\t\t\t320,\n\t\t\t256,\n\t\t\t200,\n\t\t\t160,\n\t\t\t128,\n\t\t\t80,\n\t\t\t64\n\t\t]\n\n\t\tif !pixelCommonSizes.contains(dimensions.pixels.width) {\n\t\t\tpixelCommonSizes.append(dimensions.pixels.width)\n\t\t\tpixelCommonSizes.sort(by: >)\n\t\t}\n\n\t\tlet pixelDimensions = pixelCommonSizes.map { width in\n\t\t\tlet ratio = width / dimensions.pixels.width\n\t\t\tlet height = dimensions.pixels.height * ratio\n\t\t\treturn CGSize(width: width, height: height).rounded()\n\t\t}\n\t\t.filter { $0.width <= videoDimensions.width && $0.height <= videoDimensions.height }\n\n\t\tlet predefinedPixelDimensions = pixelDimensions\n\t\t\t// TODO\n//\t\t\t.filter { resizableDimensions.validate(newSize: $0) }\n\t\t\t.map { Dimensions.pixels($0, originalSize: videoDimensions) }\n\n\t\tlet percentCommonSizes: [Double] = [\n\t\t\t100,\n\t\t\t50,\n\t\t\t33,\n\t\t\t25,\n\t\t\t20\n\t\t]\n\n\t\tlet predefinedPercentDimensions = percentCommonSizes.map {\n\t\t\tDimensions.percent($0 / 100, originalSize: videoDimensions)\n\t\t}\n\n\t\tpredefinedSizes = [.custom]\n\t\tpredefinedSizes.append(.spacer)\n\t\tpredefinedSizes.append(contentsOf: predefinedPixelDimensions.map { .dimensions($0) })\n\t\tpredefinedSizes.append(.spacer)\n\t\tpredefinedSizes.append(contentsOf: predefinedPercentDimensions.map { .dimensions($0) })\n\n\t\tselectPredefinedSizeBasedOnCurrentDimensions()\n\t}\n\n\tprivate func updateDimensionsBasedOnSelection(_ selectedSize: PredefinedSizeItem?) {\n\t\tguard let selectedSize else {\n\t\t\treturn\n\t\t}\n\n\t\tswitch selectedSize {\n\t\tcase .custom, .spacer:\n\t\t\tbreak\n\t\tcase .dimensions(let dimensions):\n\t\t\tdimensionsType = dimensions.isPercent ? .percent : .pixels\n\t\t\tresizableDimensions = dimensions\n\t\t}\n\n\t\tupdateTextFieldsForCurrentDimensions()\n\t}\n\n\tprivate func applyWidth() {\n\t\tresizableDimensions = resizableDimensions.aspectResized(usingWidth: width.toDouble)\n\t\theight = resizableDimensions.pixels.height.toDouble.clamped(to: resizableDimensions.heightMinMax).toIntAndClampingIfNeeded\n\t}\n\n\tprivate func applyHeight() {\n\t\tresizableDimensions = resizableDimensions.aspectResized(usingHeight: height.toDouble)\n\t\twidth = resizableDimensions.pixels.width.toDouble.clamped(to: resizableDimensions.widthMinMax).toIntAndClampingIfNeeded\n\t\tselectPredefinedSizeBasedOnCurrentDimensions(forceCustom: true)\n\t}\n\n\tprivate func applyPercent() {\n\t\tresizableDimensions = .percent(percent.toDouble / 100, originalSize: videoDimensions)\n\t\twidth = resizableDimensions.pixels.width.toDouble.clamped(to: resizableDimensions.widthMinMax).toIntAndClampingIfNeeded\n\t\theight = resizableDimensions.pixels.height.toDouble.clamped(to: resizableDimensions.heightMinMax).toIntAndClampingIfNeeded\n\t\tselectPredefinedSizeBasedOnCurrentDimensions(forceCustom: true)\n\t}\n\n\tprivate func updateTextFieldsForCurrentDimensions() {\n\t\twidth = resizableDimensions.pixels.width.toDouble.clamped(to: resizableDimensions.widthMinMax).toIntAndClampingIfNeeded\n\t\theight = resizableDimensions.pixels.height.toDouble.clamped(to: resizableDimensions.heightMinMax).toIntAndClampingIfNeeded\n\t\tpercent = (resizableDimensions.percent * 100).rounded().toIntAndClampingIfNeeded\n\t\tselectPredefinedSizeBasedOnCurrentDimensions()\n\t}\n\n\tprivate func selectPredefinedSizeBasedOnCurrentDimensions(forceCustom: Bool = false) {\n\t\tif forceCustom {\n\t\t\tselectedPredefinedSize = .custom\n\t\t\treturn\n\t\t}\n\n\t\tguard let index = (predefinedSizes.first { size in\n\t\t\tguard case .dimensions(let dimensions) = size else {\n\t\t\t\treturn false\n\t\t\t}\n\n\t\t\treturn dimensions == resizableDimensions\n\t\t}) else {\n\t\t\tselectedPredefinedSize = .custom\n\t\t\treturn\n\t\t}\n\n\t\tselectedPredefinedSize = index\n\t}\n\n\tprivate func showArrowKeyTipIfNeeded() {\n\t\tSSApp.runOnce(identifier: \"DimensionsSetting_arrowKeyTip\") {\n\t\t\tTask {\n\t\t\t\ttry? await Task.sleep(for: .seconds(1))\n\t\t\t\tisArrowKeyTipPresented = true\n\t\t\t\ttry? await Task.sleep(for: .seconds(10))\n\t\t\t\tisArrowKeyTipPresented = false\n\t\t\t}\n\t\t}\n\t}\n}\n\nprivate struct SpeedSetting: View {\n\t@Default(.outputSpeed) private var outputSpeed\n\n\tvar body: some View {\n\t\tLabeledContent(\"Speed\") {\n\t\t\tSlider(value: $outputSpeed, in: 0.5...5, step: 0.25)\n\t\t\tText(\"\\(outputSpeed.formatted(.number.precision(.fractionLength(2))))×\")\n\t\t\t\t.monospacedDigit()\n\t\t\t\t.frame(width: 40, alignment: .leading)\n\t\t}\n\t}\n}\n\nprivate struct FrameRateSetting: View {\n\t@Default(.outputFPS) private var frameRate\n\t@Default(.outputSpeed) private var speed\n\t@State private var isHighFrameRateWarningPresented = false\n\n\tvar videoFrameRate: Double\n\n\tvar body: some View {\n\t\tLabeledContent(\"FPS\") {\n\t\t\tSlider(\n\t\t\t\tvalue: $frameRate.intToDouble,\n\t\t\t\tin: range\n\t\t\t)\n\t\t\tText(\"\\(frameRate.formatted())\")\n\t\t\t\t.monospacedDigit()\n\t\t\t\t.frame(width: 38, alignment: .leading)\n\t\t}\n\t\t.alert2(\n\t\t\t\"Animated GIF Limitation\",\n\t\t\tmessage: \"Exporting GIFs with a frame rate higher than 50 is not supported as browsers will throttle and play them at 10 FPS.\",\n\t\t\tisPresented: $isHighFrameRateWarningPresented\n\t\t)\n\t\t.onChange(of: frameRate) {\n\t\t\tif frameRate > 50 {\n\t\t\t\tSSApp.runOnce(identifier: \"fpsWarning\") {\n\t\t\t\t\tisHighFrameRateWarningPresented = true\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t.onAppear {\n\t\t\tframeRate = frameRate.clamped(to: intRange)\n\t\t}\n\t}\n\n\tprivate var maxFrameRate: Double {\n\t\t// We round it so that `29.970` becomes `30` for practical reasons.\n\t\t(videoFrameRate * speed).rounded().clamped(to: Constants.allowedFrameRate)\n\t}\n\n\tprivate var range: ClosedRange<Double> {\n\t\t.fromGraceful(\n\t\t\tConstants.allowedFrameRate.lowerBound,\n\t\t\tmaxFrameRate\n\t\t)\n\t}\n\n\t// TODO: Make extension for this conversion.\n\tprivate var intRange: ClosedRange<Int> {\n\t\t.fromGraceful(\n\t\t\tInt(Constants.allowedFrameRate.lowerBound.rounded()),\n\t\t\tInt(maxFrameRate.rounded())\n\t\t)\n\t}\n}\n\nprivate struct QualitySetting: View {\n\t@Default(.outputQuality) private var quality\n\n\tvar body: some View {\n\t\tLabeledContent(\"Quality\") {\n\t\t\tSlider(value: $quality, in: 0.01...1)\n\t\t\t// We replace the non-breaking space with a word-joiner to save space.\n\t\t\tText(\"\\(quality.formatted(.percent.noFraction).replacing(\"\\u{00A0}\", with: \"\\u{2060}\"))\")\n\t\t\t\t.monospacedDigit()\n\t\t\t\t.frame(width: 38, alignment: .leading)\n\t\t}\n\t}\n}\n\nprivate struct LoopSetting: View {\n\t@Default(.loopGIF) private var loop\n\t@Default(.bounceGIF) private var bounce\n\t@State private var isGifLoopCountWarningPresented = false\n\n\t@Binding var loopCount: Int\n\n\tvar body: some View {\n\t\tLabeledContent(\"Loops\") {\n\t\t\tStepper(\n\t\t\t\t\"Loop count\",\n\t\t\t\tvalue: $loopCount.intToDouble,\n\t\t\t\tin: 0...100,\n\t\t\t\tstep: 1,\n\t\t\t\tformat: .number\n\t\t\t)\n\t\t\t.labelsHidden()\n\t\t\t.disabled(loop)\n\t\t\tToggle(\"Forever\", isOn: $loop)\n\t\t\tToggle(\"Bounce\", isOn: $bounce)\n\t\t}\n\t\t.alert2(\n\t\t\t\"Animated GIF Preview Limitation\",\n\t\t\tmessage: \"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.\",\n\t\t\tisPresented: $isGifLoopCountWarningPresented\n\t\t)\n\t\t.onChange(of: loop) {\n\t\t\tif loop {\n\t\t\t\tloopCount = 0\n\t\t\t} else {\n\t\t\t\tshowConversionCompletedAnimationWarningIfNeeded()\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate func showConversionCompletedAnimationWarningIfNeeded() {\n\t\t// NOTE: This function eventually will become an OS version check when Apple fixes their GIF animation implementation.\n\t\t// 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.\n\t\t// FB8947153: https://github.com/feedback-assistant/reports/issues/187\n\t\tSSApp.runOnce(identifier: \"gifLoopCountWarning\") {\n\t\t\tisGifLoopCountWarningPresented = true\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "Gifski/EstimatedFileSize.swift",
    "content": "import SwiftUI\n\n// TODO: Rewrite the whole estimation thing.\n\n@MainActor\n@Observable\nfinal class EstimatedFileSizeModel {\n\tvar estimatedFileSize: String?\n\tvar estimatedFileSizeNaive: String?\n\tvar error: Error?\n\n\t// 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.\n\tvar duration = Duration.zero\n\n\tvar getConversionSettings: (() -> GIFGenerator.Conversion)?\n\tprivate var gifski: GIFGenerator?\n\n\tprivate func getEstimatedFileSizeNaive() async -> String {\n\t\tawait Int(getNaiveEstimate()).formatted(.byteCount(style: .file))\n\t}\n\n\tprivate func _estimateFileSize() {\n\t\tself.gifski = nil\n\t\tlet gifski = GIFGenerator()\n\t\tself.gifski = gifski\n\t\terror = nil\n\t\testimatedFileSize = nil\n\n\t\tTask {\n\t\t\t// TODO: Improve.\n\t\t\tduration = (try? await getConversionSettings?().gifDuration) ?? .zero\n\t\t}\n\n\t\tTask {\n\t\t\testimatedFileSizeNaive = await getEstimatedFileSizeNaive()\n\n\t\t\tguard let settings = getConversionSettings?() else {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tdo {\n\t\t\t\tlet data = try await gifski.run(settings, isEstimation: true) { _ in }\n\n\t\t\t\t// We add 10% extra because it's better to estimate slightly too much than too little.\n\t\t\t\tlet fileSize = await (Double(data.count) * gifski.sizeMultiplierForEstimation) * 1.1\n\n\t\t\t\testimatedFileSize = Int(fileSize).formatted(.byteCount(style: .file))\n\t\t\t} catch {\n\t\t\t\tguard !(error is CancellationError) else {\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tif case .notEnoughFrames = error as? GIFGenerator.Error {\n\t\t\t\t\testimatedFileSize = await getEstimatedFileSizeNaive()\n\t\t\t\t} else {\n\t\t\t\t\tself.error = error\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tfunc updateEstimate() {\n\t\tDebouncer.debounce(delay: .seconds(0.5), action: _estimateFileSize)\n\t}\n\n\tprivate func getNaiveEstimate() async -> Double {\n\t\tguard\n\t\t\tlet conversionSettings = getConversionSettings?(),\n\t\t\tlet duration = try? await conversionSettings.gifDuration\n\t\telse {\n\t\t\treturn 0\n\t\t}\n\n\t\tlet frameCount = duration.toTimeInterval * Defaults[.outputFPS].toDouble // TODO: Needs to be live.\n\t\tlet dimensions = conversionSettings.dimensions ?? (0, 0) // TODO: Get asset dimensions.\n\t\tvar fileSize = (dimensions.width.toDouble * dimensions.height.toDouble * frameCount) / 3\n\t\tfileSize = fileSize * (Defaults[.outputQuality] + 1.5) / 2.5\n\n\t\treturn fileSize\n\t}\n}\n\nstruct EstimatedFileSizeView: View {\n\t@State private var model: EstimatedFileSizeModel\n\n\tinit(model: EstimatedFileSizeModel) {\n\t\t_model = .init(wrappedValue: model)\n\t}\n\n\tvar body: some View {\n\t\tHStack {\n\t\t\tif let error = model.error {\n\t\t\t\tText(\"Failed to get estimate: \\(error.localizedDescription)\")\n\t\t\t\t\t.help(error.localizedDescription)\n\t\t\t} else {\n\t\t\t\tHStack(spacing: 0) {\n\t\t\t\t\tText(\"Estimated size: \")\n\t\t\t\t\tText(model.estimatedFileSize ?? model.estimatedFileSizeNaive ?? \"…\")\n\t\t\t\t\t\t.monospacedDigit()\n\t\t\t\t\t\t.foregroundStyle(model.estimatedFileSize == nil ? .secondary : .primary)\n\t\t\t\t}\n\t\t\t\t\t.foregroundStyle(.secondary)\n\t\t\t\tif model.estimatedFileSize == nil {\n\t\t\t\t\tProgressView()\n\t\t\t\t\t\t.controlSize(.mini)\n\t\t\t\t\t\t.padding(.leading, -4)\n\t\t\t\t\t\t.help(\"Calculating file size estimate\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t.fillFrame(.horizontal, alignment: .leading)\n\t\t.overlay {\n\t\t\tif model.error == nil {\n\t\t\t\tHStack {\n\t\t\t\t\tlet formattedDuration = model.duration.formatted(.time(pattern: .minuteSecond(padMinuteToLength: 2, fractionalSecondsLength: 2)))\n\t\t\t\t\tText(formattedDuration)\n\t\t\t\t\t\t.monospacedDigit()\n\t\t\t\t\t\t.padding(.horizontal, 6)\n\t\t\t\t\t\t.padding(.vertical, 3)\n\t\t\t\t\t\t.background(Color.primary.opacity(0.04))\n\t\t\t\t\t\t.clipShape(.rect(cornerRadius: 4))\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t.task {\n\t\t\tif model.estimatedFileSize == nil {\n\t\t\t\tmodel.updateEstimate()\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "Gifski/ExportModifiedVideo.swift",
    "content": "import Foundation\nimport AVKit\nimport SwiftUI\n\nstruct ExportModifiedVideoView: View {\n\t@Environment(AppState.self) private var appState\n\t@Binding var state: ExportModifiedVideoState\n\tlet sourceURL: URL\n\n\t@Binding var isAudioWarningPresented: Bool\n\n\tvar body: some View {\n\t\tZStack{}\n\t\t\t.sheet(isPresented: isProgressSheetPresented) {\n\t\t\t\tProgressView()\n\t\t\t}\n\t\t\t.fileExporter(\n\t\t\t\tisPresented: isFileExporterPresented,\n\t\t\t\titem: exportableMP4,\n\t\t\t\tdefaultFilename: defaultExportModifiedFileName\n\t\t\t) {\n\t\t\t\tdo {\n\t\t\t\t\tlet url = try $0.get()\n\t\t\t\t\ttry? url.setAppAsItemCreator()\n\t\t\t\t} catch {\n\t\t\t\t\tappState.error = error\n\t\t\t\t}\n\t\t\t}\n\t\t\t.fileDialogCustomizationID(\"export\")\n\t\t\t.fileDialogMessage(\"Choose where to save the video\")\n\t\t\t.fileDialogConfirmationLabel(\"Save\")\n\t\t\t.alert2(\n\t\t\t\t\"Export Video Limitation\",\n\t\t\t\tmessage: \"Exporting a video with audio is not supported. The audio track will be ignored.\",\n\t\t\t\tisPresented: $isAudioWarningPresented\n\t\t\t)\n\t}\n\n\tprivate var exportableMP4: ExportableMP4? {\n\t\tguard case let .finished(url) = state else {\n\t\t\treturn nil\n\t\t}\n\t\treturn ExportableMP4(url: url)\n\t}\n\n\tprivate var defaultExportModifiedFileName: String {\n\t\t\"\\(sourceURL.filenameWithoutExtension) modified.mp4\"\n\t}\n\n\tprivate var isProgressSheetPresented: Binding<Bool> {\n\t\t.init(\n\t\t\tget: {\n\t\t\t\tguard\n\t\t\t\t\t!isAudioWarningPresented,\n\t\t\t\t\tcase let .exporting(_, videoIsOverTwentySeconds) = state else {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t\treturn videoIsOverTwentySeconds\n\t\t\t},\n\t\t\tset: {\n\t\t\t\tguard\n\t\t\t\t\t!$0,\n\t\t\t\t\tcase let .exporting(task, _) = state else {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\ttask.cancel()\n\t\t\t\tstate = .idle\n\t\t\t}\n\t\t)\n\t}\n\n\tprivate var isFileExporterPresented: Binding<Bool> {\n\t\t.init(\n\t\t\tget: { state.isFinished && !isAudioWarningPresented },\n\t\t\tset: {\n\t\t\t\tguard\n\t\t\t\t\t!$0,\n\t\t\t\t\tcase let .finished(url) = state else {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\ttry? FileManager.default.removeItem(at: url)\n\t\t\t\tstate = .idle\n\t\t\t}\n\t\t)\n\t}\n\n\n\tenum Error: Swift.Error {\n\t\tcase unableToExportAsset\n\t\tcase unableToCreateExportSession\n\t\tcase unableToAddCompositionTrack\n\n\t\tvar errorDescription: String? {\n\t\t\tswitch self {\n\t\t\tcase .unableToExportAsset:\n\t\t\t\t\"Unable to export the asset because it is not compatible with the current device.\"\n\t\t\tcase .unableToCreateExportSession:\n\t\t\t\t\"Unable to create an export session for the video.\"\n\t\t\tcase .unableToAddCompositionTrack:\n\t\t\t\t\"Failed to add a composition track to the video.\"\n\t\t\t}\n\t\t}\n\t}\n}\n\n\nenum ExportModifiedVideoState: Equatable {\n\tcase idle\n\tcase exporting(Task<Void, Never>, videoIsOverTwentySeconds: Bool)\n\tcase finished(URL)\n\n\tvar shouldShowProgress: Bool {\n\t\tswitch self {\n\t\tcase .idle, .finished:\n\t\t\tfalse\n\t\tcase .exporting(_, videoIsOverTwentySeconds: let videoIsOverTwentySeconds):\n\t\t\tvideoIsOverTwentySeconds\n\t\t}\n\t}\n\tvar shouldShowFileExporter: Bool {\n\t\tswitch self {\n\t\tcase .idle, .exporting:\n\t\t\tfalse\n\t\tcase .finished:\n\t\t\ttrue\n\t\t}\n\t}\n\n\tvar isExporting: Bool {\n\t\tswitch self {\n\t\tcase .exporting:\n\t\t\ttrue\n\t\tdefault:\n\t\t\tfalse\n\t\t}\n\t}\n\n\tvar isFinished: Bool {\n\t\tswitch self {\n\t\tcase .finished:\n\t\t\ttrue\n\t\tdefault:\n\t\t\tfalse\n\t\t}\n\t}\n}\n\n\n/**\nConvert a source video to an `.mp4` using the same scale, speed, and crop as the exported `.gif`.\n- Returns: Temporary URL of the exported video.\n*/\nfunc exportModifiedVideo(conversion: GIFGenerator.Conversion) async throws -> URL {\n\tlet (composition, compositionVideoTrack) = try await createComposition(\n\t\tconversion: conversion\n\t)\n\tlet videoComposition = try await createVideoComposition(\n\t\tcompositionVideoTrack: compositionVideoTrack,\n\t\tconversion: conversion\n\t)\n\tlet outputURL = FileManager.default.temporaryDirectory.appendingPathComponent( \"\\(UUID().uuidString).mp4\")\n\n\tlet presets = AVAssetExportSession.allExportPresets()\n\tguard presets.contains(AVAssetExportPresetHighestQuality) else {\n\t\tthrow ExportModifiedVideoView.Error.unableToCreateExportSession\n\t}\n\tguard await AVAssetExportSession.compatibility(ofExportPreset: AVAssetExportPresetHighestQuality, with: composition, outputFileType: .mp4) else {\n\t\tthrow ExportModifiedVideoView.Error.unableToCreateExportSession\n\t}\n\n\tguard let exportSession = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetHighestQuality) else {\n\t\tthrow ExportModifiedVideoView.Error.unableToCreateExportSession\n\t}\n\texportSession.shouldOptimizeForNetworkUse = true\n\texportSession.videoComposition = videoComposition\n\ttry await exportSession.export(to: outputURL, as: .mp4)\n\treturn outputURL\n}\n\n/**\nCreates the mutable composition along with the video track inserted.\n*/\nprivate func createComposition(\n\tconversion: GIFGenerator.Conversion,\n) async throws -> (AVMutableComposition, AVMutableCompositionTrack) {\n\tlet composition = AVMutableComposition()\n\n\tguard let compositionTrack = composition.addMutableTrack(\n\t\twithMediaType: .video,\n\t\tpreferredTrackID: kCMPersistentTrackID_Invalid\n\t) else {\n\t\tthrow ExportModifiedVideoView.Error.unableToAddCompositionTrack\n\t}\n\tlet videoTrack = try await conversion.firstVideoTrack\n\ttry compositionTrack.insertTimeRange(\n\t\ttry await conversion.exportModifiedVideoTimeRange,\n\t\tof: videoTrack,\n\t\tat: .zero\n\t)\n\tif let preferredTransform = conversion.trackPreferredTransform {\n\t\tcompositionTrack.preferredTransform = preferredTransform\n\t}\n\treturn (composition, compositionTrack)\n}\n\n/**\nCreate an `AVMutableVideoComposition` that will scale, translate, and crop the `compositionVideoTrack`.\n*/\nprivate func createVideoComposition(\n\tcompositionVideoTrack: AVMutableCompositionTrack,\n\tconversion: GIFGenerator.Conversion\n) async throws -> AVMutableVideoComposition {\n\tlet videoComposition = AVMutableVideoComposition()\n\n\tvideoComposition.renderSize = try await conversion.exportModifiedRenderRect.size\n\tvideoComposition.frameDuration = try await compositionVideoTrack.load(.minFrameDuration)\n\n\tlet instruction = AVMutableVideoCompositionInstruction()\n\t// 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\n\tinstruction.timeRange = CMTimeRange(start: .zero, duration: .init(seconds: try await conversion.videoWithoutBounceDuration.toTimeInterval + 1.0, preferredTimescale: .video))\n\n\tlet layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: compositionVideoTrack)\n\n\t// Layer instructions operate in natural space (unrotated). The crop rect from UI is in\n\t// preferred space, so `cropRectAppliedToNaturalSize` transforms it back to natural space.\n\tlet cropRectAppliedToNaturalSize = try await conversion.cropRectAppliedToNaturalSize\n\tlet preferredTransform = conversion.trackPreferredTransform ?? .identity\n\tlet scaleTransform = CGAffineTransform(scaledBy: try await conversion.scale)\n\tlet scaledCropRect = cropRectAppliedToNaturalSize.applying(scaleTransform)\n\tlet cropRectAfterPreferred = scaledCropRect.applying(preferredTransform)\n\n\t// Place the crop rect in the top left corner.\n\tlet translateTransform = CGAffineTransform(translationX: -cropRectAfterPreferred.minX, y: -cropRectAfterPreferred.minY)\n\tlayerInstruction.setCropRectangle(cropRectAppliedToNaturalSize, at: .zero)\n\tlayerInstruction.setTransform(scaleTransform.concatenating(preferredTransform).concatenating(translateTransform), at: .zero)\n\tinstruction.layerInstructions = [layerInstruction]\n\n\n\tvideoComposition.instructions = [instruction]\n\treturn videoComposition\n}\n\nprivate struct ExportableMP4: Transferable {\n\tlet url: URL\n\tstatic var transferRepresentation: some TransferRepresentation {\n\t\tFileRepresentation(exportedContentType: .mpeg4Movie) { .init($0.url) }\n\t\t\t.suggestedFileName { $0.url.filename }\n\t}\n}\n"
  },
  {
    "path": "Gifski/GIFGenerator.swift",
    "content": "import Foundation\nimport AVFoundation\n\nactor GIFGenerator {\n\tprivate var gifski: Gifski?\n\tprivate(set) var sizeMultiplierForEstimation = 1.0\n\n\tstatic func run(\n\t\t_ conversion: Conversion,\n\t\tisEstimation: Bool = false,\n\t\tonProgress: @escaping (Double) -> Void\n\t) async throws -> Data {\n\t\tlet converter = Self()\n\n\t\treturn try await converter.run(\n\t\t\tconversion,\n\t\t\tisEstimation: isEstimation,\n\t\t\tonProgress: onProgress\n\t\t)\n\t}\n\n\t/**\n\tConverts a single frame to GIF data.\n\t*/\n\tstatic func convertOneFrame(\n\t\tframe: CGImage,\n\t\tdimensions: (width: Int, height: Int)?,\n\t\tquality: Double,\n\t\tfast: Bool = false\n\t) async throws -> Data {\n\t\tlet gifski = try Gifski(\n\t\t\tdimensions: dimensions,\n\t\t\tquality: quality,\n\t\t\tloop: .never,\n\t\t\tfast: fast\n\t\t)\n\n\t\ttry gifski.addFrame(frame, presentationTimestamp: 0.0)\n\n\t\treturn try gifski.finish()\n\t}\n\n\tdeinit {\n\t\tprint(\"GIFGenerator DEINIT\")\n\t}\n\n\t// TODO: Make private.\n\t/**\n\tConverts a movie to GIF.\n\t*/\n\tfunc run(\n\t\t_ conversion: Conversion,\n\t\tisEstimation: Bool = false,\n\t\tonProgress: @escaping (Double) -> Void\n\t) async throws -> Data {\n\t\tgifski = try Gifski(\n\t\t\tdimensions: conversion.croppedOutputDimensions,\n\t\t\tquality: conversion.quality.clamped(to: 0.1...1),\n\t\t\tloop: conversion.loop\n\t\t)\n\n\t\tdefer {\n\t\t\t// Ensure Gifski finishes no matter what.\n\t\t\tgifski = nil\n\t\t}\n\n\t\tlet result = try await generateData(\n\t\t\tfor: conversion,\n\t\t\tisEstimation: isEstimation,\n\t\t\tonProgress: onProgress\n\t\t)\n\n\t\ttry Task.checkCancellation()\n\n\t\treturn result\n\t}\n\n\t/**\n\tGenerates GIF data for the provided conversion.\n\n\t- Parameters:\n\t\t- conversion: The source information of the conversion.\n\t\t- isEstimation: Whether the frame is part of a size estimation job.\n\t\t- jobKey: The string used to identify the current conversion job.\n\t\t- completionHandler: Closure called when the data conversion completes or an error is encountered.\n\t*/\n\tprivate func generateData(\n\t\tfor conversion: Conversion,\n\t\tisEstimation: Bool,\n\t\tonProgress: @escaping (Double) -> Void\n\t) async throws -> Data {\n\t\tvar (generator, times, frameRate) = try await imageGenerator(for: conversion)\n\n\t\t// TODO: The whole estimation thing should be split out into a separate method and the things that are shared should also be split out.\n\t\tif isEstimation {\n\t\t\tlet originalCount = times.count\n\n\t\t\tif originalCount > 25 {\n\t\t\t\ttimes = times\n\t\t\t\t\t.chunked(by: 5)\n\t\t\t\t\t.sample(length: 5)\n\t\t\t\t\t.flatten()\n\t\t\t}\n\n\t\t\tsizeMultiplierForEstimation = Double(originalCount) / Double(times.count)\n\t\t}\n\n\t\tlet totalFrameCount = totalFrameCount(for: conversion, sourceFrameCount: times.count)\n\n\t\tvar completedFrameCount = 0\n\t\tgifski?.onProgress = {\n\t\t\tlet progress = Double(completedFrameCount.increment()) / Double(totalFrameCount)\n\t\t\tonProgress(progress.clamped(to: 0...1)) // TODO: For some reason, when we use `bounce`, `totalFrameCount` can be 1 less than `completedFrameCount` on completion.\n\t\t}\n\n\t\t// TODO: Use `Duration`.\n\t\tlet startTime = times.first?.seconds ?? 0\n\n\t\t// TODO: Does it handle cancellation?\n\n\t\tvar index = 0\n\t\tvar previousTime = -100.0 // Just to make sure it doesn't match any timestamp.\n\n\t\tprint(\"Total frame count:\", totalFrameCount)\n\n\t\tfor await imageResult in generator.images(for: times) {\n\t\t\ttry Task.checkCancellation()\n\n\t\t\tlet requestedTime = imageResult.requestedTime\n\n\t\t\t// `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)\n\t\t\tguard requestedTime.seconds > previousTime else {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tpreviousTime = requestedTime.seconds\n\n\t\t\tguard let image = conversion.croppedImage(image: try imageResult.image) else {\n\t\t\t\tthrow GIFGenerator.Error.cropNotInBounds\n\t\t\t}\n\n\t\t\tlet actualTime = try imageResult.actualTime\n\n\t\t\tdo {\n\t\t\t\tlet frameNumber = index\n\n\t\t\t\tif index > 0 {\n\t\t\t\t\tassert(actualTime.seconds > 0)\n\t\t\t\t}\n\n\t\t\t\t// TODO: Use a custom executer for this when using Swift 6.\n\t\t\t\ttry gifski?.addFrame(\n\t\t\t\t\timage,\n\t\t\t\t\tframeNumber: frameNumber,\n\t\t\t\t\tpresentationTimestamp: max(0, actualTime.seconds - startTime)\n\t\t\t\t)\n\n\t\t\t\tif conversion.bounce {\n\t\t\t\t\t/*\n\t\t\t\t\tInserts the frame again at the reverse index of the natural order.\n\n\t\t\t\t\tFor example, if this frame is at index 2 of 5 in its natural order:\n\n\t\t\t\t\t```\n\t\t\t\t\t\t  ↓\n\t\t\t\t\t0, 1, 2, 3, 4\n\t\t\t\t\t```\n\n\t\t\t\t\tThen the frame should be inserted at 6 of 9 in the reverse order:\n\n\t\t\t\t\t```\n\t\t\t\t\t\t\t\t\t  ↓\n\t\t\t\t\t0, 1, 2, 3, 4, 3, 2, 1, 0\n\t\t\t\t\t```\n\t\t\t\t\t*/\n\t\t\t\t\tlet reverseFrameNumber = totalFrameCount - frameNumber - 1\n\n\t\t\t\t\t// Determine the reverse timestamp by finding the expected timestamp (frame number / frame rate) and adjusting for the image generator's slippage (actualTime - requestedTime)\n\t\t\t\t\tlet expectedReverseTimestamp = TimeInterval(reverseFrameNumber) / TimeInterval(frameRate)\n\t\t\t\t\tlet timestampSlippage = actualTime - requestedTime\n\t\t\t\t\tlet actualReverseTimestamp = max(0, expectedReverseTimestamp + timestampSlippage.seconds)\n\n\t\t\t\t\t// Prevent duplicate frame with the same frame number causing an unwanted frame at the end of the GIF.\n\t\t\t\t\tif frameNumber != reverseFrameNumber {\n\t\t\t\t\t\ttry gifski?.addFrame(\n\t\t\t\t\t\t\timage,\n\t\t\t\t\t\t\tframeNumber: reverseFrameNumber,\n\t\t\t\t\t\t\tpresentationTimestamp: actualReverseTimestamp\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tindex += 1\n\t\t\t} catch {\n\t\t\t\tthrow Error.addFrameFailed(error)\n\t\t\t}\n\n\t\t\tawait Task.yield() // Give `addFrame` room to start.\n\t\t}\n\n\t\tguard let gifski else {\n\t\t\tthrow CancellationError()\n\t\t}\n\n\t\treturn try gifski.finish()\n\t}\n\n\t/**\n\tCreates an image generator for the provided conversion.\n\n\t- Parameters:\n\t\t- conversion: The conversion source of the image generator.\n\t- Returns: An `AVAssetImageGenerator` along with the times of the frames requested by the conversion.\n\t*/\n\tprivate func imageGenerator(for conversion: Conversion) async throws -> (generator: AVAssetImageGenerator, times: [CMTime], frameRate: Int) {\n\t\tlet asset = conversion.asset\n//\n//\t\trecord(\n//\t\t\tjobKey: jobKey,\n//\t\t\tkey: \"Is readable?\",\n//\t\t\tvalue: asset.isReadable\n//\t\t)\n//\t\trecord(\n//\t\t\tjobKey: jobKey,\n//\t\t\tkey: \"First video track\",\n//\t\t\tvalue: asset.firstVideoTrack\n//\t\t)\n//\t\trecord(\n//\t\t\tjobKey: jobKey,\n//\t\t\tkey: \"First video track time range\",\n//\t\t\tvalue: asset.firstVideoTrack?.timeRange\n//\t\t)\n//\t\trecord(\n//\t\t\tjobKey: jobKey,\n//\t\t\tkey: \"Duration\",\n//\t\t\tvalue: asset.duration.seconds\n//\t\t)\n//\t\trecord(\n//\t\t\tjobKey: jobKey,\n//\t\t\tkey: \"AVAsset debug info\",\n//\t\t\tvalue: asset.debugInfo\n//\t\t)\n\n\t\t// TODO: Parallelize using `async let`.\n\t\tguard\n\t\t\ttry await asset.load(.isReadable),\n\t\t\tlet assetFrameRate = try await asset.frameRate,\n\t\t\tlet firstVideoTrack = try await asset.firstVideoTrack,\n\n\t\t\t// 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).\n\t\t\t// 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.\n\t\t\tlet videoTrackRange = try await firstVideoTrack.load(.timeRange).range\n\t\telse {\n\t\t\t// This can happen if the user selects a file, and then the file becomes\n\t\t\t// unavailable or deleted before the \"Convert\" button is clicked.\n\t\t\tthrow Error.unreadableFile\n\t\t}\n//\n//\t\trecord(\n//\t\t\tjobKey: jobKey,\n//\t\t\tkey: \"AVAsset debug info2\",\n//\t\t\tvalue: asset.debugInfo\n//\t\t)\n\n\t\tlet generator = AVAssetImageGenerator(asset: asset)\n\n\t\t// Images are returned already rotated to match how the user sees the video.\n\t\t// This means crop coordinates (defined in rotated space) can be applied directly.\n\t\tgenerator.appliesPreferredTrackTransform = true\n\n\t\tgenerator.requestedTimeToleranceBefore = .zero\n\t\tgenerator.requestedTimeToleranceAfter = .zero\n\n\t\t// We are intentionally not setting a `generator.maximumSize` as it's buggy: https://github.com/sindresorhus/Gifski/pull/278\n\n\t\t// Even though we enforce a minimum of 3 FPS in the GUI, a source video could have lower FPS, and we should allow that.\n\t\tvar frameRate = (conversion.frameRate.map(Double.init) ?? assetFrameRate).clamped(to: 0.1...Constants.allowedFrameRate.upperBound)\n\t\tframeRate = min(frameRate, assetFrameRate)\n\n\t\tprint(\"Video FPS:\", frameRate)\n\n\t\t// TODO: Instead of calculating what part of the video to get, we could just trim the actual `AVAssetTrack`.\n\t\tlet videoRange = conversion.timeRange?.clamped(to: videoTrackRange) ?? videoTrackRange\n\t\tlet startTime = videoRange.lowerBound\n\t\tlet duration = videoRange.length\n\t\tlet frameCount = Int(duration * frameRate)\n\t\tlet timescale = try await firstVideoTrack.load(.naturalTimeScale) // TODO: Move this to the other `load` call.\n\n\t\tprint(\"Video frame count:\", frameCount)\n\n\t\tguard frameCount >= 2 else {\n\t\t\tthrow Error.notEnoughFrames(frameCount)\n\t\t}\n\n\t\tlet frameStep = 1 / frameRate\n\t\tvar frameForTimes: [CMTime] = (0..<frameCount).map { index in\n\t\t\tlet presentationTimestamp = startTime + (frameStep * Double(index))\n\t\t\treturn CMTime(\n\t\t\t\tseconds: presentationTimestamp,\n\t\t\t\tpreferredTimescale: timescale\n\t\t\t)\n\t\t}\n\n\t\t// We don't do this when \"bounce\" is enabled as the bounce calculations are not able to handle this.\n\t\tif !conversion.bounce {\n\t\t\t// 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.\n\t\t\tframeForTimes.append(CMTime(seconds: duration, preferredTimescale: timescale))\n\t\t}\n//\n//\t\trecord(\n//\t\t\tjobKey: jobKey,\n//\t\t\tkey: \"frameRate\",\n//\t\t\tvalue: frameRate\n//\t\t)\n//\t\trecord(\n//\t\t\tjobKey: jobKey,\n//\t\t\tkey: \"videoRange\",\n//\t\t\tvalue: videoRange\n//\t\t)\n//\t\trecord(\n//\t\t\tjobKey: jobKey,\n//\t\t\tkey: \"frameCount\",\n//\t\t\tvalue: frameCount\n//\t\t)\n//\t\trecord(\n//\t\t\tjobKey: jobKey,\n//\t\t\tkey: \"frameForTimes\",\n//\t\t\tvalue: frameForTimes.map(\\.seconds)\n//\t\t)\n\n\t\treturn (generator, frameForTimes, Int(frameRate))\n\t}\n\n\tprivate func totalFrameCount(for conversion: Conversion, sourceFrameCount: Int) -> Int {\n\t\t/*\n\t\tBouncing doubles the frame count except for the frame at the apex (middle) of the bounce.\n\n\t\tFor example, a sequence of 5 frames becomes a sequence of 9 frames when bounced:\n\n\t\t```\n\t\t0, 1, 2, 3, 4\n\t\t            ↓\n\t\t0, 1, 2, 3, 4, 3, 2, 1, 0\n\t\t```\n\t\t*/\n\t\tconversion.bounce ? (sourceFrameCount * 2 - 1) : sourceFrameCount\n\t}\n}\n\nextension GIFGenerator {\n\t/**\n\t- Parameter frameRate: Clamped to `5...30`. Uses the frame rate of `input` if not specified.\n\t- Parameter loopGif: Whether output should loop infinitely or not.\n\t- Parameter bounce: Whether output should bounce or not.\n\t*/\n\tstruct Conversion: ReflectiveHashable { // TODO\n\t\tlet asset: AVAsset\n\t\tlet sourceURL: URL\n\t\tvar timeRange: ClosedRange<Double>?\n\t\tvar quality: Double = 1\n\t\tvar dimensions: (width: Int, height: Int)?\n\t\tvar frameRate: Int?\n\t\tvar loop: Gifski.Loop\n\t\tvar bounce: Bool\n\t\tvar crop: CropRect?\n\t\tvar trackPreferredTransform: CGAffineTransform?\n\t}\n}\n\nextension GIFGenerator.Conversion {\n\tvar gifDuration: Duration {\n\t\tget async throws {\n\t\t\t// TODO: Make this lazy so it's only used for fallback.\n\t\t\tlet fallbackRange = try await asset.firstVideoTrack?.load(.timeRange)\n\t\t\treturn gifDuration(assetTimeRange: fallbackRange)\n\t\t}\n\t}\n\n\tfunc gifDuration(assetTimeRange fallbackRange: CMTimeRange?, withBounce: Bool = true) -> Duration {\n\t\tguard let duration = (timeRange ?? fallbackRange?.range)?.length else {\n\t\t\treturn .zero\n\t\t}\n\n\t\t// TODO: Do this when Swift supports async in `??`.\n\t\t//\t\t\t\tguard let duration = (timeRange ?? asset.firstVideoTrack?.timeRange.range)?.length else {\n\t\t//\t\t\t\t\treturn .zero\n\t\t//\t\t\t\t}\n\t\treturn .seconds(withBounce && bounce ? (duration * 2) : duration)\n\t}\n\n\tvar videoWithoutBounceDuration: Duration {\n\t\tget async throws {\n\t\t\t.seconds(try await gifDuration.toTimeInterval / (bounce ? 2 : 1))\n\t\t}\n\t}\n\n\t/**\n\t- Returns: The current scale of the `dimensions` compared to the dimensions of the video track.\n\t*/\n\tvar scale: CGSize {\n\t\tget async throws {\n\t\t\tguard let trackDimensions = try await trackDimensions else {\n\t\t\t\treturn .one\n\t\t\t}\n\t\t\tguard trackDimensions > 0 else {\n\t\t\t\tthrow Error.invalidDimensions\n\t\t\t}\n\t\t\tguard let dimensions = dimensionsAsCGSize else {\n\t\t\t\treturn .one\n\t\t\t}\n\t\t\tlet scale = dimensions / trackDimensions\n\t\t\tguard scale > 0 else {\n\t\t\t\tthrow Error.invalidScale\n\t\t\t}\n\t\t\treturn scale\n\t\t}\n\t}\n\n\t/**\n\t- Returns: Dimensions of the first video track after applying preferredTransform\n\t*/\n\tvar trackDimensions: CGSize? {\n\t\tget async throws {\n\t\t\ttry await asset.firstVideoTrack?.dimensions\n\t\t}\n\t}\n\n\tvar dimensionsAsCGSize: CGSize? {\n\t\tdimensions.map {\n\t\t\t.init(width: Double($0.0), height: Double($0.1))\n\t\t}\n\t}\n\n\t/**\n\tThe size of the output render without taking crop into account.\n\t*/\n\tvar renderSize: CGSize {\n\t\tget async throws {\n\t\t\tif let dimensionsAsCGSize {\n\t\t\t\treturn dimensionsAsCGSize\n\t\t\t}\n\t\t\tguard let trackSize = try await trackDimensions else {\n\t\t\t\tthrow Error.invalidDimensions\n\t\t\t}\n\t\t\treturn trackSize\n\t\t}\n\t}\n\n\t/**\n\t- Returns: Crop rect in pixels, if there is no crop rect then it returns the full render size.\n\t*/\n\tvar cropRectInPixels: CGRect {\n\t\tget async throws {\n\t\t\t(crop ?? .initialCropRect).unnormalize(forDimensions: try await renderSize)\n\t\t}\n\t}\n\n\t/**\n\tThe crop rect applied to the natural (unrotated) size of the video track.\n\n\tThe 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.\n\t*/\n\tvar cropRectAppliedToNaturalSize: CGRect {\n\t\tget async throws {\n\t\t\tguard let videoTrack = try await asset.firstVideoTrack else {\n\t\t\t\treturn .zero\n\t\t\t}\n\n\t\t\tlet (naturalSize, preferredTransform) = try await videoTrack.load(.naturalSize, .preferredTransform)\n\n\t\t\t// Get the rotated dimensions (how the user sees the video)\n\t\t\tlet rotatedSize = CGRect(origin: .zero, size: naturalSize).applying(preferredTransform).size\n\t\t\tlet rotatedDimensions = CGSize(width: abs(rotatedSize.width), height: abs(rotatedSize.height))\n\n\t\t\t// The crop rect is defined in rotated space, so unnormalize it using rotated dimensions\n\t\t\tlet cropRectInRotatedSpace = (crop ?? .initialCropRect).unnormalize(forDimensions: rotatedDimensions)\n\n\t\t\t// Transform the crop rect from rotated space back to natural space\n\t\t\treturn cropRectInRotatedSpace.applying(preferredTransform.inverted())\n\t\t}\n\t}\n\n\tvar exportModifiedRenderRect: CGRect {\n\t\tget async throws {\n\t\t\tunnormalizedCropRect(sizeInPreferredTransformationSpace: try await renderSize)\n\t\t}\n\t}\n\n\t/**\n\t- Returns: The time range used to export the modified video (i.e. not the `.gif` export).\n\t*/\n\tvar exportModifiedVideoTimeRange: CMTimeRange {\n\t\tget async throws {\n\t\t\tif let timeRange {\n\t\t\t\treturn timeRange.cmTimeRange\n\t\t\t}\n\t\t\treturn (0...(try await videoWithoutBounceDuration.toTimeInterval)).cmTimeRange\n\t\t}\n\t}\n\n\tvar firstVideoTrack: AVAssetTrack {\n\t\tget async throws {\n\t\t\tguard let videoTrack = try await asset.firstVideoTrack else {\n\t\t\t\tthrow Error.noVideoTrack\n\t\t\t}\n\t\t\treturn videoTrack\n\t\t}\n\t}\n\n\tenum Error: Swift.Error {\n\t\tcase invalidDimensions\n\t\tcase invalidScale\n\t\tcase noVideoTrack\n\t}\n}\n\nextension GIFGenerator {\n\tenum Error: LocalizedError {\n\t\tcase invalidSettings\n\t\tcase unreadableFile\n\t\tcase notEnoughFrames(Int)\n\t\tcase generateFrameFailed(Swift.Error)\n\t\tcase addFrameFailed(Swift.Error)\n\t\tcase writeFailed(Swift.Error)\n\t\tcase cropNotInBounds\n\t\tcase cancelled\n\n\t\tvar errorDescription: String? {\n\t\t\tswitch self {\n\t\t\tcase .invalidSettings:\n\t\t\t\t\"Invalid settings.\"\n\t\t\tcase .unreadableFile:\n\t\t\t\t\"The selected file is no longer readable.\"\n\t\t\tcase .notEnoughFrames(let frameCount):\n\t\t\t\t\"An animated GIF requires a minimum of 2 frames. Your video contains \\(frameCount) frame\\(frameCount == 1 ? \"\" : \"s\").\"\n\t\t\tcase .generateFrameFailed(let error):\n\t\t\t\t\"Failed to generate frame: \\(error.localizedDescription)\"\n\t\t\tcase .addFrameFailed(let error):\n\t\t\t\t\"Failed to add frame, with underlying error: \\(error.localizedDescription)\"\n\t\t\tcase .writeFailed(let error):\n\t\t\t\t\"Failed to write, with underlying error: \\(error.localizedDescription)\"\n\t\t\tcase .cropNotInBounds:\n\t\t\t\t\"The crop is not in bounds of the video.\"\n\t\t\tcase .cancelled:\n\t\t\t\t\"The conversion was cancelled.\"\n\t\t\t}\n\t\t}\n\t}\n}\n\nextension GIFGenerator {\n\tstatic func runProgressable(_ conversion: GIFGenerator.Conversion) -> ProgressableTask<Double, Data> {\n\t\tProgressableTask { progressContinuation in\n\t\t\ttry await GIFGenerator.run(conversion) {\n\t\t\t\tprogressContinuation.yield($0)\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "Gifski/Gifski-Bridging-Header.h",
    "content": "#import \"gifski.h\"\n#include \"CompositePreviewShared.h\"\n"
  },
  {
    "path": "Gifski/Gifski.entitlements",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>com.apple.security.application-groups</key>\n\t<array>\n\t\t<string>group.com.sindresorhus.Gifski</string>\n\t</array>\n</dict>\n</plist>\n"
  },
  {
    "path": "Gifski/Gifski.swift",
    "content": "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\tprivate var wrapper: GifskiWrapper?\n\tprivate var frameNumber = 0\n\tprivate var data = Data()\n\tvar onProgress: (() -> Void)?\n\n\t// TODO: Make this when the rest of the app uses more async\n//\tvar progress: AsyncStream<Data> {}\n\n\tinit(\n\t\tdimensions: (width: Int, height: Int)? = nil,\n\t\tquality: Double,\n\t\tloop: Loop,\n\t\tfast: Bool = false\n\t) throws {\n\t\tlet loopCount = {\n\t\t\tswitch loop {\n\t\t\tcase .forever:\n\t\t\t\treturn 0\n\t\t\tcase .never:\n\t\t\t\treturn -1\n\t\t\tcase .count(let the_count):\n\t\t\t\tassert(the_count > 0)\n\t\t\t\treturn the_count\n\t\t\t}\n\t\t}()\n\n\t\tassert(quality >= 0.1)\n\t\tassert(quality <= 1)\n\n\t\tlet settings = GifskiSettings(\n\t\t\twidth: UInt32(clamping: dimensions?.width ?? 0),\n\t\t\theight: UInt32(clamping: dimensions?.height ?? 0),\n\t\t\tquality: UInt8(clamping: Int((quality * 100).rounded()).clamped(to: 1...100)),\n\t\t\tfast: fast,\n\t\t\trepeat: Int16(clamping: loopCount)\n\t\t)\n\n\t\tguard let wrapper = GifskiWrapper(settings) else {\n\t\t\tthrow GifskiWrapper.Error.invalidInput\n\t\t}\n\n\t\tself.wrapper = wrapper\n\n\t\twrapper.setErrorMessageCallback {\n\t\t\tSSApp.reportError($0)\n\t\t}\n\n\t\twrapper.setProgressCallback { [weak self] in\n\t\t\tguard let self else {\n\t\t\t\treturn 0\n\t\t\t}\n\n\t\t\tonProgress?()\n\n\t\t\treturn self.wrapper == nil ? 0 : 1\n\t\t}\n\n\t\twrapper.setWriteCallback { [weak self] bufferLength, bufferPointer in\n\t\t\tguard let self else {\n\t\t\t\treturn 0\n\t\t\t}\n\n\t\t\tdata.append(bufferPointer, count: bufferLength)\n\n\t\t\treturn 0\n\t\t}\n\t}\n\n\tdeinit {\n\t\t_ = try? wrapper?.finish()\n\t}\n\n\tfunc addFrame(\n\t\t_ image: CGImage,\n\t\tframeNumber: Int,\n\t\tpresentationTimestamp: Double\n\t) throws {\n\t\tguard let wrapper else {\n\t\t\tassertionFailure(\"Called “addFrame” after it finished.\")\n\t\t\tthrow GifskiWrapper.Error.invalidState\n\t\t}\n\n\t\tlet pixels = try image.pixels(as: .rgba, premultiplyAlpha: false)\n\n\t\ttry wrapper.addFrame(\n\t\t\tpixelFormat: .rgba,\n\t\t\tframeNumber: frameNumber,\n\t\t\twidth: pixels.width,\n\t\t\theight: pixels.height,\n\t\t\tbytesPerRow: pixels.bytesPerRow,\n\t\t\tpixels: pixels.bytes,\n\t\t\tpresentationTimestamp: presentationTimestamp\n\t\t)\n\t}\n\n\tfunc addFrame(\n\t\t_ image: CGImage,\n\t\tpresentationTimestamp: Double\n\t) throws {\n\t\ttry addFrame(\n\t\t\timage,\n\t\t\tframeNumber: frameNumber,\n\t\t\tpresentationTimestamp: presentationTimestamp\n\t\t)\n\n\t\tframeNumber += 1\n\t}\n\n\tfunc finish() throws -> Data {\n\t\tguard let wrapper else {\n\t\t\tassertionFailure(\"Called “finish” more than once.\")\n\t\t\tthrow GifskiWrapper.Error.invalidState\n\t\t}\n\n\t\ttry wrapper.finish()\n\t\tself.wrapper = nil\n\t\treturn data\n\t}\n}\n"
  },
  {
    "path": "Gifski/GifskiWrapper.swift",
    "content": "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\tcase rgb\n\t}\n\n\ttypealias ErrorMessageCallback = (String) -> Void\n\ttypealias ProgressCallback = () -> Int\n\ttypealias WriteCallback = (Int, UnsafePointer<UInt8>) -> Int\n\n\tprivate let pointer: OpaquePointer\n\tprivate var unmanagedSelf: Unmanaged<GifskiWrapper>!\n\tprivate var hasFinished = false\n\tprivate var errorMessageCallback: ErrorMessageCallback!\n\tprivate var progressCallback: ProgressCallback!\n\tprivate var writeCallback: WriteCallback!\n\n\tinit?(_ settings: GifskiSettings) {\n\t\tvar settings = settings\n\n\t\tguard let pointer = gifski_new(&settings) else {\n\t\t\treturn nil\n\t\t}\n\n\t\tself.pointer = pointer\n\n\t\t// We need to keep a strong reference to self so we can ensure it's not deallocated before libgifski finishes writing.\n\t\tself.unmanagedSelf = Unmanaged.passRetained(self)\n\t}\n\n\tprivate func wrap(_ fn: () -> GifskiError) throws {\n\t\tlet result = fn()\n\n\t\tguard result == GIFSKI_OK else {\n\t\t\tthrow Error(rawValue: result.rawValue) ?? .other\n\t\t}\n\t}\n\n\tfunc setErrorMessageCallback(_ callback: @escaping ErrorMessageCallback) {\n\t\tguard !hasFinished else {\n\t\t\treturn\n\t\t}\n\n\t\terrorMessageCallback = callback\n\n\t\tgifski_set_error_message_callback(\n\t\t\tpointer,\n\t\t\t{ message, context in // swiftlint:disable:this opening_brace\n\t\t\t\tguard\n\t\t\t\t\tlet message,\n\t\t\t\t\tlet context\n\t\t\t\telse {\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tlet this = Unmanaged<GifskiWrapper>.fromOpaque(context).takeUnretainedValue()\n\t\t\t\tthis.errorMessageCallback(String(cString: message))\n\t\t\t},\n\t\t\tunmanagedSelf.toOpaque()\n\t\t)\n\t}\n\n\tfunc setProgressCallback(_ callback: @escaping ProgressCallback) {\n\t\tguard !hasFinished else {\n\t\t\treturn\n\t\t}\n\n\t\tprogressCallback = callback\n\n\t\tgifski_set_progress_callback(\n\t\t\tpointer,\n\t\t\t{ context in // swiftlint:disable:this opening_brace\n\t\t\t\tguard let context else {\n\t\t\t\t\treturn 0\n\t\t\t\t}\n\n\t\t\t\tlet this = Unmanaged<GifskiWrapper>.fromOpaque(context).takeUnretainedValue()\n\t\t\t\treturn Int32(this.progressCallback())\n\t\t\t},\n\t\t\tunmanagedSelf.toOpaque()\n\t\t)\n\t}\n\n\tfunc setWriteCallback(_ callback: @escaping WriteCallback) {\n\t\tguard !hasFinished else {\n\t\t\treturn\n\t\t}\n\n\t\twriteCallback = callback\n\n\t\tgifski_set_write_callback(\n\t\t\tpointer,\n\t\t\t{ bufferLength, bufferPointer, context in // swiftlint:disable:this opening_brace\n\t\t\t\tguard\n\t\t\t\t\tbufferLength > 0,\n\t\t\t\t\tlet bufferPointer,\n\t\t\t\t\tlet context\n\t\t\t\telse {\n\t\t\t\t\treturn 0\n\t\t\t\t}\n\n\t\t\t\tlet this = Unmanaged<GifskiWrapper>.fromOpaque(context).takeUnretainedValue()\n\t\t\t\treturn Int32(this.writeCallback(bufferLength, bufferPointer))\n\t\t\t},\n\t\t\tunmanagedSelf.toOpaque()\n\t\t)\n\t}\n\n\t// swiftlint:disable:next function_parameter_count\n\tfunc addFrame(\n\t\tpixelFormat: PixelFormat,\n\t\tframeNumber: Int,\n\t\twidth: Int,\n\t\theight: Int,\n\t\tbytesPerRow: Int,\n\t\tpixels: [UInt8],\n\t\tpresentationTimestamp: Double\n\t) throws {\n\t\tguard !hasFinished else {\n\t\t\tthrow Error.invalidState\n\t\t}\n\n\t\ttry wrap {\n\t\t\tvar pixels = pixels\n\n\t\t\tswitch pixelFormat {\n\t\t\tcase .rgba:\n\t\t\t\treturn gifski_add_frame_rgba_stride(\n\t\t\t\t\tpointer,\n\t\t\t\t\tUInt32(frameNumber),\n\t\t\t\t\tUInt32(width),\n\t\t\t\t\tUInt32(height),\n\t\t\t\t\tUInt32(bytesPerRow),\n\t\t\t\t\t&pixels,\n\t\t\t\t\tpresentationTimestamp\n\t\t\t\t)\n\t\t\tcase .argb:\n\t\t\t\treturn gifski_add_frame_argb(\n\t\t\t\t\tpointer,\n\t\t\t\t\tUInt32(frameNumber),\n\t\t\t\t\tUInt32(width),\n\t\t\t\t\tUInt32(bytesPerRow),\n\t\t\t\t\tUInt32(height),\n\t\t\t\t\t&pixels,\n\t\t\t\t\tpresentationTimestamp\n\t\t\t\t)\n\t\t\tcase .rgb:\n\t\t\t\treturn gifski_add_frame_rgb(\n\t\t\t\t\tpointer,\n\t\t\t\t\tUInt32(frameNumber),\n\t\t\t\t\tUInt32(width),\n\t\t\t\t\tUInt32(bytesPerRow),\n\t\t\t\t\tUInt32(height),\n\t\t\t\t\t&pixels,\n\t\t\t\t\tpresentationTimestamp\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\t}\n\n\tfunc finish() throws {\n\t\tguard !hasFinished else {\n\t\t\tthrow Error.invalidState\n\t\t}\n\n\t\thasFinished = true\n\n\t\tdefer {\n\t\t\tunmanagedSelf.release()\n\t\t}\n\n\t\ttry wrap {\n\t\t\tgifski_finish(pointer)\n\t\t}\n\t}\n}\n\nextension GifskiWrapper {\n\tenum Error: UInt32, LocalizedError {\n\t\tcase nullArg = 1\n\t\tcase invalidState\n\t\tcase quant\n\t\tcase gif\n\t\tcase threadLost\n\t\tcase notFound\n\t\tcase permissionDenied\n\t\tcase alreadyExists\n\t\tcase invalidInput\n\t\tcase timedOut\n\t\tcase writeZero\n\t\tcase interrupted\n\t\tcase unexpectedEof\n\t\tcase aborted\n\t\tcase other\n\n\t\tvar errorDescription: String? {\n\t\t\tswitch self {\n\t\t\tcase .nullArg:\n\t\t\t\t\"One of input arguments was NULL\"\n\t\t\tcase .invalidState:\n\t\t\t\t\"A one-time function was called twice, or functions were called in wrong order\"\n\t\t\tcase .quant:\n\t\t\t\t\"Internal error related to palette quantization\"\n\t\t\tcase .gif:\n\t\t\t\t\"Internal error related to GIF composing\"\n\t\t\tcase .threadLost:\n\t\t\t\t\"Internal error related (panic)\"\n\t\t\tcase .notFound:\n\t\t\t\t\"I/O error: File or directory not found\"\n\t\t\tcase .permissionDenied:\n\t\t\t\t\"I/O error: Permission denied\"\n\t\t\tcase .alreadyExists:\n\t\t\t\t\"I/O error: File already exists\"\n\t\t\tcase .invalidInput:\n\t\t\t\t\"Invalid arguments passed to function\"\n\t\t\tcase .timedOut, .writeZero, .interrupted, .unexpectedEof:\n\t\t\t\t\"Misc I/O error\"\n\t\t\tcase .aborted:\n\t\t\t\t\"Progress callback returned 0, writing aborted\"\n\t\t\tcase .other:\n\t\t\t\t\"Should not happen, file a bug: https://github.com/ImageOptim/gifski\"\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "Gifski/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>CFBundleDocumentTypes</key>\n\t<array>\n\t\t<dict>\n\t\t\t<key>CFBundleTypeName</key>\n\t\t\t<string>Video</string>\n\t\t\t<key>CFBundleTypeRole</key>\n\t\t\t<string>Viewer</string>\n\t\t\t<key>LSHandlerRank</key>\n\t\t\t<string>Alternate</string>\n\t\t\t<key>LSItemContentTypes</key>\n\t\t\t<array>\n\t\t\t\t<string>public.mpeg-4</string>\n\t\t\t\t<string>com.apple.m4v-video</string>\n\t\t\t\t<string>com.apple.quicktime-movie</string>\n\t\t\t</array>\n\t\t\t<key>NSExportableTypes</key>\n\t\t\t<array>\n\t\t\t\t<string>com.compuserve.gif</string>\n\t\t\t\t<string>public.mpeg-4</string>\n\t\t\t</array>\n\t\t</dict>\n\t</array>\n\t<key>CFBundleURLTypes</key>\n\t<array>\n\t\t<dict>\n\t\t\t<key>CFBundleTypeRole</key>\n\t\t\t<string>Viewer</string>\n\t\t\t<key>CFBundleURLName</key>\n\t\t\t<string>com.sindresorhus.Gifski</string>\n\t\t\t<key>CFBundleURLSchemes</key>\n\t\t\t<array>\n\t\t\t\t<string>gifski</string>\n\t\t\t</array>\n\t\t</dict>\n\t</array>\n\t<key>ITSAppUsesNonExemptEncryption</key>\n\t<false/>\n\t<key>MDItemKeywords</key>\n\t<string>gif,convert,video</string>\n\t<key>NSServices</key>\n\t<array>\n\t\t<dict>\n\t\t\t<key>NSMenuItem</key>\n\t\t\t<dict>\n\t\t\t\t<key>default</key>\n\t\t\t\t<string>Convert to GIF with Gifski</string>\n\t\t\t</dict>\n\t\t\t<key>NSMessage</key>\n\t\t\t<string>convertToGIF</string>\n\t\t\t<key>NSPortName</key>\n\t\t\t<string>${EXECUTABLE_NAME}</string>\n\t\t\t<key>NSRequiredContext</key>\n\t\t\t<dict/>\n\t\t\t<key>NSSendFileTypes</key>\n\t\t\t<array>\n\t\t\t\t<string>public.mpeg-4</string>\n\t\t\t\t<string>com.apple.m4v-video</string>\n\t\t\t\t<string>com.apple.quicktime-movie</string>\n\t\t\t</array>\n\t\t</dict>\n\t</array>\n</dict>\n</plist>\n"
  },
  {
    "path": "Gifski/Intents.swift",
    "content": "import AppIntents\nimport AVFoundation\n\nstruct Crop_AppEntity: Hashable, Codable, AppEntity {\n\tvar mode: CropMode_AppEnum\n\tvar x: Int?\n\tvar bottomLeftY: Int?\n\tvar width: Int\n\tvar height: Int\n\n\tinit() {\n\t\tself.mode = .aspectRatio\n\t\tself.width = 1\n\t\tself.height = 1\n\t}\n\n\tstatic let defaultQuery = CropEntityQuery()\n\tstatic let typeDisplayRepresentation: TypeDisplayRepresentation = \"Crop\"\n\n\tvar displayRepresentation: DisplayRepresentation {\n\t\t.init(title: \"\\(description)\")\n\t}\n}\n\nextension Crop_AppEntity: Identifiable {\n\tvar id: String {\n\t\tlet encoder = JSONEncoder()\n\t\tencoder.outputFormatting = .sortedKeys\n\t\treturn (try? encoder.encode(self).base64EncodedString()) ?? Self.errorID\n\t}\n}\n\nextension Crop_AppEntity {\n\tprivate static let errorID = \"0\"\n\n\tvar description: String {\n\t\tswitch mode {\n\t\tcase .exact:\n\t\t\t\"\\(width)x\\(height) at (\\(x ?? 0),\\(bottomLeftY ?? 0))\"\n\t\tcase .aspectRatio:\n\t\t\t\"\\(width):\\(height)\"\n\t\t}\n\t}\n\n\tfunc cropRect(forDimensions dimensions: (Int, Int)) throws -> CropRect? {\n\t\tlet (dimensionsWidth, dimensionsHeight) = dimensions\n\n\t\tswitch mode {\n\t\tcase .exact:\n\t\t\tguard\n\t\t\t\tlet bottomLeftY,\n\t\t\t\tlet x\n\t\t\telse {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tlet cropWidth = width > 1 ? width : 1\n\t\t\tlet cropHeight = height > 1 ? height : 1\n\n\t\t\tlet topLeftY = dimensionsHeight - bottomLeftY - cropHeight\n\t\t\tlet entityRect = CGRect(x: x, y: topLeftY, width: cropWidth, height: cropHeight)\n\t\t\tlet videoRect = CGRect(origin: .zero, size: .init(width: dimensionsWidth, height: dimensionsHeight))\n\t\t\tlet intersectionRect = videoRect.intersection(entityRect)\n\n\t\t\tguard\n\t\t\t\tintersectionRect.width >= 1,\n\t\t\t\tintersectionRect.height >= 1\n\t\t\telse {\n\t\t\t\tthrow CropOutOfBoundsError(enteredRect: CGRect(x: x, y: bottomLeftY, width: cropWidth, height: cropHeight), videoRect: videoRect)\n\t\t\t}\n\n\t\t\treturn CropRect(\n\t\t\t\tx: Double(intersectionRect.x) / Double(dimensionsWidth),\n\t\t\t\ty: Double(intersectionRect.y) / Double(dimensionsHeight),\n\t\t\t\twidth: Double(intersectionRect.width) / Double(dimensionsWidth),\n\t\t\t\theight: Double(intersectionRect.height) / Double(dimensionsHeight)\n\t\t\t)\n\t\tcase .aspectRatio:\n\t\t\tlet aspectWidth = width > 1 ? width : 1\n\t\t\tlet aspectHeight = height > 1 ? height : 1\n\n\t\t\treturn CropRect.centeredFrom(\n\t\t\t\taspectWidth: Double(aspectWidth),\n\t\t\t\taspectHeight: Double(aspectHeight),\n\t\t\t\tforDimensions: .init(width: dimensionsWidth, height: dimensionsHeight)\n\t\t\t)\n\t\t}\n\t}\n\n\tstatic func from(id: String) throws -> Self {\n\t\tguard id != errorID else {\n\t\t\treturn Self()\n\t\t}\n\n\t\tguard let data = Data(base64Encoded: id) else {\n\t\t\tthrow DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: \"Invalid ID format\"))\n\t\t}\n\n\t\treturn try JSONDecoder().decode(Self.self, from: data)\n\t}\n}\n\nstruct CropEntityQuery: EntityQuery {\n\tfunc entities(for identifiers: [Crop_AppEntity.ID]) async throws -> [Crop_AppEntity] {\n\t\ttry identifiers.map {\n\t\t\ttry Crop_AppEntity.from(id: $0)\n\t\t}\n\t}\n\n\tfunc suggestedEntities() async throws -> [Crop_AppEntity] {\n\t\tPickerAspectRatio.presets.map {\n\t\t\tvar crop = Crop_AppEntity()\n\t\t\tcrop.mode = .aspectRatio\n\t\t\tcrop.width = $0.width\n\t\t\tcrop.height = $0.height\n\t\t\treturn crop\n\t\t}\n\t}\n}\n\nenum CropMode_AppEnum: String, AppEnum, CaseIterable, Codable, Hashable {\n\tcase aspectRatio\n\tcase exact\n\n\tstatic let typeDisplayRepresentation: TypeDisplayRepresentation = \"Crop Mode\"\n\n\tstatic let caseDisplayRepresentations: [Self: DisplayRepresentation] = [\n\t\t.aspectRatio: \"a fixed aspect ratio\",\n\t\t.exact: \"exact dimensions\"\n\t]\n}\n\nstruct CropOutOfBoundsError: Error, CustomLocalizedStringResourceConvertible {\n\tlet localizedStringResource: LocalizedStringResource\n\n\tinit(enteredRect: CGRect, videoRect: CGRect) {\n\t\tself.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.\")\n\t}\n}\n\nstruct CreateCropIntent: AppIntent {\n\tstatic let title: LocalizedStringResource = \"Create Crop for Gifski\"\n\n\tstatic let description = IntentDescription(\n\t\t\"\"\"\n\t\tCreates a crop to pass into the “Convert Video to Animated GIF” action.\n\t\t\"\"\",\n\t\tsearchKeywords: [\n\t\t\t\"video\",\n\t\t\t\"conversion\",\n\t\t\t\"converter\",\n\t\t\t\"crop\",\n\t\t\t\"mp4\",\n\t\t\t\"mov\"\n\t\t],\n\t\tresultValueName: \"Crop\"\n\t)\n\n\tstatic var parameterSummary: some ParameterSummary {\n\t\tSwitch(\\.$mode) {\n\t\t\tCase(.exact) {\n\t\t\t\tSummary(\"Create a crop with \\(\\.$mode):  \\(\\.$width)x\\(\\.$height) at (\\(\\.$x), \\(\\.$y))\")\n\t\t\t}\n\t\t\tDefaultCase {\n\t\t\t\tSummary(\"Create a crop with \\(\\.$mode):  \\(\\.$aspectWidth):\\(\\.$aspectHeight)\")\n\t\t\t}\n\t\t}\n\t}\n\n\t@Parameter(\n\t\tdescription: \"Crop by aspect ratio or exact dimensions.\",\n\t\tdefault: .aspectRatio\n\t)\n\tvar mode: CropMode_AppEnum\n\n\t@Parameter(\n\t\ttitle: \"X Position\",\n\t\tdescription: \"The position of the left side of the crop in pixels. 0 is the left edge of the image.\",\n\t\tdefault: 0,\n\t\tinclusiveRange: (0, 100_000)\n\t)\n\tvar x: Int\n\n\t@Parameter(\n\t\ttitle: \"Y Position\",\n\t\tdescription: \"The position of the bottom side of the crop in pixels. 0 is the bottom edge of the image.\",\n\t\tdefault: 0,\n\t\tinclusiveRange: (0, 100_000)\n\t)\n\tvar y: Int\n\n\t@Parameter(\n\t\tdescription: \"The width of the crop in pixels.\",\n\t\tdefault: 1,\n\t\tinclusiveRange: (1, 100_000)\n\t)\n\tvar width: Int\n\n\t@Parameter(\n\t\tdescription: \"The height of the crop in pixels.\",\n\t\tdefault: 1,\n\t\tinclusiveRange: (1, 100_000)\n\t)\n\tvar height: Int\n\n\t@Parameter(\n\t\tdescription: \"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.\",\n\t\tdefault: 16,\n\t\tinclusiveRange: (1, 99)\n\t)\n\tvar aspectWidth: Int\n\n\t@Parameter(\n\t\tdescription: \"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.\",\n\t\tdefault: 9,\n\t\tinclusiveRange: (1, 99)\n\t)\n\tvar aspectHeight: Int\n\n\tfunc perform() async throws -> some IntentResult & ReturnsValue<Crop_AppEntity> {\n\t\t.result(value: entity)\n\t}\n\n\tprivate var entity: Crop_AppEntity {\n\t\tvar entity = Crop_AppEntity()\n\n\t\tswitch mode {\n\t\tcase .exact:\n\t\t\tentity.mode = .exact\n\t\t\tentity.x = x\n\t\t\tentity.bottomLeftY = y\n\t\t\tentity.width = width\n\t\t\tentity.height = height\n\t\tcase .aspectRatio:\n\t\t\tentity.mode = .aspectRatio\n\t\t\tentity.width = aspectWidth\n\t\t\tentity.height = aspectHeight\n\t\t}\n\n\t\treturn entity\n\t}\n}\n\nstruct ConvertIntent: AppIntent, ProgressReportingIntent {\n\tstatic let title: LocalizedStringResource = \"Convert Video to Animated GIF\"\n\n\tstatic let description = IntentDescription(\n\t\t\"\"\"\n\t\tConverts a video to a high-quality animated GIF.\n\t\t\"\"\",\n\t\tsearchKeywords: [\n\t\t\t\"video\",\n\t\t\t\"conversion\",\n\t\t\t\"converter\",\n\t\t\t\"mp4\",\n\t\t\t\"mov\"\n\t\t],\n\t\tresultValueName: \"Animated GIF\"\n\t)\n\n\t@Parameter(\n\t\tdescription: \"Accepts MP4 and MOV video files.\",\n\t\tsupportedContentTypes: [\n\t\t\t.mpeg4Movie,\n\t\t\t.quickTimeMovie\n\t\t]\n\t)\n\tvar video: IntentFile\n\n\t@Parameter(\n\t\tdefault: 1,\n\t\tcontrolStyle: .slider,\n\t\tinclusiveRange: (0, 1)\n\t)\n\tvar quality: Double\n\n\t@Parameter(\n\t\tdescription: \"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.\",\n\t\tinclusiveRange: (3, 50)\n\t)\n\tvar frameRate: Int?\n\n\t@Parameter(\n\t\tdescription: \"Makes the GIF loop forever.\",\n\t\tdefault: true\n\t)\n\tvar loop: Bool\n\n\t@Parameter(\n\t\tdescription: \"Makes the GIF play forward and then backwards.\",\n\t\tdefault: false\n\t)\n\tvar bounce: Bool\n\n\t@Parameter(\n\t\tdescription: \"Choose how to specify the dimensions.\",\n\t\tdefault: DimensionsType.percent\n\t)\n\tvar dimensionsType: DimensionsType\n\n\t@Parameter(\n\t\tdescription: \"The resize percentage of the original dimensions (1-100%).\",\n\t\tdefault: 100,\n\t\tinclusiveRange: (1, 100)\n\t)\n\tvar dimensionsPercent: Double?\n\n\t@Parameter(\n\t\ttitle: \"Max Width\",\n\t\tdescription: \"You can specify both width and height or either.\",\n\t\tinclusiveRange: (10, 10_000)\n\t)\n\tvar dimensionsWidth: Int?\n\n\t@Parameter(\n\t\ttitle: \"Max Height\",\n\t\tdescription: \"You can specify both width and height or either.\",\n\t\tinclusiveRange: (10, 10_000)\n\t)\n\tvar dimensionsHeight: Int?\n\n\t@Parameter(\n\t\tdescription: \"Optionally crop the video.\",\n\t)\n\tvar crop: Crop_AppEntity?\n\n\t@Parameter(\n\t\tdescription: \"Whether it should generate only a single frame preview of the GIF.\",\n\t\tdefault: false\n\t)\n\tvar isPreview: Bool\n\n\t// TODO: Dimensions setting. Percentage or width/height.\n\n\tstatic var parameterSummary: some ParameterSummary {\n\t\tSwitch(\\.$dimensionsType) {\n\t\t\tCase(.pixels) {\n\t\t\t\tSummary(\"Convert \\(\\.$video) to animated GIF\") {\n\t\t\t\t\t\\.$quality\n\t\t\t\t\t\\.$frameRate\n\t\t\t\t\t\\.$loop\n\t\t\t\t\t\\.$bounce\n\t\t\t\t\t\\.$dimensionsType\n\t\t\t\t\t\\.$dimensionsWidth\n\t\t\t\t\t\\.$dimensionsHeight\n\t\t\t\t\t\\.$crop\n\t\t\t\t\t\\.$isPreview\n\t\t\t\t}\n\t\t\t}\n\t\t\tDefaultCase {\n\t\t\t\tSummary(\"Convert \\(\\.$video) to animated GIF\") {\n\t\t\t\t\t\\.$quality\n\t\t\t\t\t\\.$frameRate\n\t\t\t\t\t\\.$loop\n\t\t\t\t\t\\.$bounce\n\t\t\t\t\t\\.$dimensionsType\n\t\t\t\t\t\\.$dimensionsPercent\n\t\t\t\t\t\\.$crop\n\t\t\t\t\t\\.$isPreview\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t@MainActor\n\tfunc perform() async throws -> some IntentResult & ReturnsValue<IntentFile> {\n\t\tlet videoURL = try video.writeToUniqueTemporaryFile()\n\n\t\tdefer {\n\t\t\ttry? FileManager.default.removeItem(at: videoURL)\n\t\t}\n\n\t\tlet data = try await generateGIF(videoURL: videoURL)\n\n\t\tlet file = data.toIntentFile(\n\t\t\tcontentType: .gif,\n\t\t\tfilename: videoURL.filenameWithoutExtension\n\t\t)\n\n\t\treturn .result(value: file)\n\t}\n\n\tprivate func generateGIF(videoURL: URL) async throws -> Data {\n\t\tlet (videoAsset, metadata) = try await VideoValidator.validate(videoURL)\n\n\t\tguard !isPreview else {\n\t\t\tguard let frame = try await videoAsset.image(at: .init(seconds: metadata.duration.toTimeInterval / 3.0, preferredTimescale: .video)) else {\n\t\t\t\tthrow \"Could not generate a preview image from the source video.\".toError\n\t\t\t}\n\t\t\treturn try await GIFGenerator.convertOneFrame(\n\t\t\t\tframe: frame,\n\t\t\t\tdimensions: dimensions(metadataDimensions: metadata.dimensions),\n\t\t\t\tquality: quality\n\t\t\t)\n\t\t}\n\n\t\t// TODO: Progress does not seem to show in the Shortcuts app.\n\t\tprogress.totalUnitCount = 100\n\n\t\treturn try await GIFGenerator.run(\n\t\t\ttry conversionSettings(\n\t\t\t\tvideoAsset: videoAsset,\n\t\t\t\tvideoURL: videoURL,\n\t\t\t\tmetaDatDimensions: metadata.dimensions\n\t\t\t)\n\t\t) { fractionCompleted in\n\t\t\tprogress.completedUnitCount = .init(fractionCompleted * 100)\n\t\t}\n\t}\n\n\tprivate func conversionSettings(\n\t\tvideoAsset: AVAsset,\n\t\tvideoURL: URL,\n\t\tmetaDatDimensions: CGSize\n\t) async throws -> GIFGenerator.Conversion {\n\t\tlet dimensions = dimensions(metadataDimensions: metaDatDimensions)\n\n\t\treturn GIFGenerator.Conversion(\n\t\t\tasset: videoAsset,\n\t\t\tsourceURL: videoURL,\n\t\t\ttimeRange: nil,\n\t\t\tquality: quality,\n\t\t\tdimensions: dimensions,\n\t\t\tframeRate: frameRate,\n\t\t\tloop: loop ? .forever : .never,\n\t\t\tbounce: bounce,\n\t\t\tcrop: try crop?.cropRect(forDimensions: dimensions ?? metaDatDimensions.toInt),\n\t\t\ttrackPreferredTransform: try? await videoAsset.firstVideoTrack?.load(.preferredTransform)\n\t\t)\n\t}\n\n\tprivate func dimensions(metadataDimensions dimensions: CGSize) -> (Int, Int)? {\n\t\tswitch dimensionsType {\n\t\tcase .pixels:\n\t\t\tguard dimensionsWidth != nil || dimensionsHeight != nil else {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tlet size = dimensions.aspectFittedSize(\n\t\t\t\ttargetWidth: dimensionsWidth,\n\t\t\t\ttargetHeight: dimensionsHeight\n\t\t\t)\n\n\t\t\treturn (\n\t\t\t\tInt(size.width.rounded()),\n\t\t\t\tInt(size.height.rounded())\n\t\t\t)\n\t\tcase .percent:\n\t\t\tguard let dimensionsPercent else {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tlet factor = dimensionsPercent / 100\n\n\t\t\treturn (\n\t\t\t\tInt((dimensions.width * factor).rounded()),\n\t\t\t\tInt((dimensions.height * factor).rounded())\n\t\t\t)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "Gifski/InternetAccessPolicy.json",
    "content": "{\n\t\"ApplicationDescription\": \"Gifski converts videos to high-quality GIFs.\",\n\t\"DeveloperName\": \"Sindre Sorhus\",\n\t\"Website\": \"https://sindresorhus.com/gifski\",\n\t\"Connections\" : [\n\t\t{\n\t\t\t\"Host\" : \"*.sentry.io\",\n\t\t\t\"NetworkProtocol\" : \"TCP\",\n\t\t\t\"Port\" : \"443\",\n\t\t\t\"Purpose\" : \"Gifski sends crash reports to this server.\",\n\t\t\t\"DenyConsequences\" : \"If you deny this connection, crash reports will not be sent and bugs might stay unfixed.\"\n\t\t}\n\t]\n}\n"
  },
  {
    "path": "Gifski/MainScreen.swift",
    "content": "import SwiftUI\n\nstruct MainScreen: View {\n\t@Environment(AppState.self) private var appState\n\t@State private var isDropTargeted = false\n\t@State private var isWelcomeScreenPresented = false\n\n\tvar body: some View {\n\t\t@Bindable var appState = appState\n\t\tNavigationStack(path: $appState.navigationPath) {\n\t\t\tStartScreen()\n\t\t\t\t.navigationDestination(for: Route.self) {\n\t\t\t\t\tswitch $0 {\n\t\t\t\t\tcase .edit(let url, let asset, let metadata): // TODO: Make a `Job` struct for this?\n\t\t\t\t\t\tEditScreen(url: url, asset: asset, metadata: metadata)\n\t\t\t\t\tcase .conversion(let conversion):\n\t\t\t\t\t\tConversionScreen(conversion: conversion)\n\t\t\t\t\tcase .completed(let data, let url):\n\t\t\t\t\t\tCompletedScreen(data: data, url: url)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t}\n\t\t.frame(width: 760, height: 640)\n\t\t.fileImporter(\n\t\t\tisPresented: $appState.isFileImporterPresented,\n\t\t\tallowedContentTypes: Device.supportedVideoTypes\n\t\t) {\n\t\t\tdo {\n\t\t\t\tappState.start(try $0.get())\n\t\t\t} catch {\n\t\t\t\tappState.error = error\n\t\t\t}\n\t\t}\n\t\t.fileDialogCustomizationID(\"import\")\n\t\t.fileDialogMessage(\"Choose a MP4 or MOV video to convert to an animated GIF\")\n\t\t.fileDialogDefaultDirectory(.downloadsDirectory)\n//\t\t.backgroundWithMaterial(.underWindowBackground, blendingMode: .behindWindow)\n\t\t.alert(error: $appState.error)\n\t\t.border(isDropTargeted ? Color.accentColor : .clear, width: 5, cornerRadius: 10)\n\t\t// TODO: use `.dropDestination` here when targeting macOS 15. It's stil buggy in macOS 14 (from experience with Aiko)\n\t\t.onDrop(\n\t\t\tof: appState.isConverting ? [] : [.fileURL],\n\t\t\tdelegate: AnyDropDelegate(\n\t\t\t\tisTargeted: $isDropTargeted.animation(.easeInOut(duration: 0.2)),\n\t\t\t\tonValidate: {\n\t\t\t\t\t$0.hasFileURLsConforming(to: Device.supportedVideoTypes)\n\t\t\t\t},\n\t\t\t\tonPerform: {\n\t\t\t\t\tguard let itemProvider = $0.itemProviders(for: [.fileURL]).first else {\n\t\t\t\t\t\treturn false\n\t\t\t\t\t}\n\n\t\t\t\t\tTask {\n\t\t\t\t\t\tguard let url = await itemProvider.getURL() else {\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tappState.start(url)\n\t\t\t\t\t}\n\n\t\t\t\t\treturn true\n\t\t\t\t}\n\t\t\t)\n\t\t)\n\t\t.alert2(\n\t\t\t\"Welcome to Gifski!\",\n\t\t\tmessage:\n\t\t\t\t\"\"\"\n\t\t\t\tKeep in mind that the GIF image format is very space inefficient. Only convert short video clips unless you want huge files.\n\n\t\t\t\tIf you have any feedback, bug reports, or feature requests, use the feedback button in the “Help” menu. We quickly respond to all submissions.\n\t\t\t\t\"\"\",\n\t\t\tisPresented: $isWelcomeScreenPresented\n\t\t) {\n\t\t\tButton(\"Get Started\") {}\n\t\t}\n\t\t.task {\n\t\t\tif SSApp.isFirstLaunch {\n\t\t\t\tisWelcomeScreenPresented = true\n\t\t\t}\n\t\t}\n\t\t.task {\n\t\t\t#if DEBUG\n//\t\t\tappState.isFileImporterPresented = true\n\t\t\t#endif\n\t\t}\n\t\t.toolbar {\n\t\t\tColor.clear\n\t\t\t\t.frame(width: 0, height: 0)\n\t\t}\n\t\t// `.materialActiveAppearance` does not currently work here. Remove `.windowIsVibrant` when it does.\n//\t\t.containerBackground(.thinMaterial.materialActiveAppearance(.active), for: .window)\n\t\t.toolbarBackgroundVisibility(.hidden, for: .windowToolbar)\n\t\t.windowResizeBehavior(.disabled)\n\t\t.windowTabbingMode(.disallowed)\n\t\t.windowCollectionBehavior(.fullScreenNone)\n\t\t.windowIsMovableByWindowBackground()\n\t\t.windowIsVibrant()\n\t}\n}\n\n#Preview {\n\tMainScreen()\n}\n"
  },
  {
    "path": "Gifski/Preview/CVPixelBuffer+convertToGIF.swift",
    "content": "import Foundation\nimport AVKit\n\nextension CVPixelBuffer {\n\tenum ConvertToGIFError: Error {\n\t\tcase failedToCreateCGContext\n\t}\n\n\tfunc convertToGIF(\n\t\tsettings: SettingsForFullPreview\n\t) async throws -> Data {\n\t\t// 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.\n\t\tvar ciImage = CIImage(cvPixelBuffer: self)\n\n\t\t// Raw pixel buffers are in natural space (unrotated). Apply the transform to rotate\n\t\t// the image to preferred space so crop coordinates (defined in preferred space) work correctly.\n\t\tif let trackPreferredTransform = settings.conversion.trackPreferredTransform {\n\t\t\t// Convert AVFoundation (top-left origin) transform to Core Image (bottom-left origin).\n\t\t\tlet imageHeight = ciImage.extent.height\n\t\t\tlet flip = CGAffineTransform(translationX: 0, y: imageHeight).scaledBy(x: 1, y: -1)\n\t\t\tlet ciTransform = flip.concatenating(trackPreferredTransform).concatenating(flip)\n\t\t\tciImage = ciImage.transformed(by: ciTransform)\n\t\t}\n\t\tlet ciContext = CIContext()\n\n\t\tguard\n\t\t\tlet cgImage = ciContext.createCGImage(ciImage, from: ciImage.extent)\n\t\telse {\n\t\t\tthrow ConvertToGIFError.failedToCreateCGContext\n\t\t}\n\n\t\tguard\n\t\t\tlet croppedImage = settings.conversion.croppedImage(image: cgImage)\n\t\telse {\n\t\t\tthrow GIFGenerator.Error.cropNotInBounds\n\t\t}\n\n\t\treturn try await GIFGenerator.convertOneFrame(\n\t\t\tframe: croppedImage,\n\t\t\tdimensions: settings.conversion.croppedOutputDimensions,\n\t\t\tquality: max(0.1, settings.conversion.settings.quality),\n\t\t\tfast: true\n\t\t)\n\t}\n}\n"
  },
  {
    "path": "Gifski/Preview/CompositePreviewShared.h",
    "content": "#pragma once\n#ifdef __METAL_VERSION__\n// Metal types\n#include <metal_stdlib>\nusing namespace metal;\ntypedef float2 shared_float2;\ntypedef float3 shared_float3;\ntypedef float4 shared_float4;\ntypedef uint shared_uint;\n\n#define SHARED_CONSTANT constant\n#else\n// Swift/C types\n#include <simd/simd.h>\ntypedef simd_float2 shared_float2;\ntypedef simd_float3 shared_float3;\ntypedef simd_float4 shared_float4;\ntypedef uint32_t shared_uint;\n\n#define SHARED_CONSTANT\n#endif\n\nSHARED_CONSTANT const shared_uint VERTICES_PER_QUAD = 6;\ntypedef struct {\n\t/**\n\tMust be >= 0.\n\t*/\n\tshared_float2 videoOrigin;\n\n\t/**\n\tMust be >= 0\n\t*/\n\tshared_float2 videoSize;\n\tshared_float4 firstColor;\n\tshared_float4 secondColor;\n\n\t/**\n\tMust be >= 1;\n\t*/\n\tint gridSize;\n} CompositePreviewFragmentUniforms;\n\ntypedef struct {\n\tshared_float2 scale;\n} CompositePreviewVertexUniforms;\n"
  },
  {
    "path": "Gifski/Preview/FullPreviewGenerationEvent.swift",
    "content": "import Foundation\nimport AVFoundation\nimport Metal\n\n/**\nEvents that will be emitted by `PreviewStream`, which represent the state or a generation request.\n*/\nstruct FullPreviewGenerationEvent: Equatable, Sendable {\n\tlet requestID: Int\n\tprivate let state: State\n}\n\nextension FullPreviewGenerationEvent {\n\tvar canShowPreview: Bool {\n\t\tswitch state {\n\t\tcase .empty, .cancelled:\n\t\t\tfalse\n\t\tcase .generating, .ready:\n\t\t\ttrue\n\t\t}\n\t}\n\n\tvar errorMessage: String? {\n\t\tswitch state {\n\t\tcase .empty(error: let error):\n\t\t\terror\n\t\tcase .generating, .ready, .cancelled:\n\t\t\tnil\n\t\t}\n\t}\n\n\tvar progress: Double {\n\t\tswitch state {\n\t\tcase .empty, .cancelled:\n\t\t\t0.0\n\t\tcase .generating(let generating):\n\t\t\tgenerating.progress\n\t\tcase .ready:\n\t\t\t1.0\n\t\t}\n\t}\n\n\tvar isGenerating: Bool {\n\t\tswitch state {\n\t\tcase .generating:\n\t\t\ttrue\n\t\tcase .empty, .ready, .cancelled:\n\t\t\tfalse\n\t\t}\n\t}\n\n\t/**\n\t- Returns: The texture that represents the current preview frame, or `nil` if there is no preview for this frame.\n\t*/\n\tfunc getPreviewFrame(\n\t\toriginalFrame: CVPixelBuffer,\n\t\tcompositionTime: CMTime\n\t) async throws -> SendableTexture? {\n\t\tswitch state {\n\t\tcase .empty, .cancelled:\n\t\t\tnil\n\t\tcase .generating(let generating):\n\t\t\ttry await originalFrame.convertToGIF(settings: generating.settings).convertToTexture()\n\t\tcase .ready(let fullPreview):\n\t\t\ttry fullPreview.getGIF(at: compositionTime)\n\t\t}\n\t}\n\n\t/**\n\tCheck if we can skip generating a full preview based on the last state.\n\n\t- Returns: `true` if a new generation is required, `false` otherwise.\n\t*/\n\tfunc isNecessaryToCreateNewFullPreview(\n\t\tnewSettings: SettingsForFullPreview,\n\t\tnewRequestID: Int\n\t) -> Bool {\n\t\tsettings?.areSettingsDifferentEnoughForANewFullPreview(\n\t\t\tnewSettings: newSettings,\n\t\t\tareCurrentlyGenerating: isGenerating,\n\t\t\toldRequestID: requestID,\n\t\t\tnewRequestID: newRequestID\n\t\t) ?? true\n\t}\n\n\tprivate var settings: SettingsForFullPreview? {\n\t\tswitch state {\n\t\tcase .empty, .cancelled:\n\t\t\tnil\n\t\tcase .generating(let generating):\n\t\t\tgenerating.settings\n\t\tcase .ready(let fullPreview):\n\t\t\tfullPreview.settings\n\t\t}\n\t}\n}\n\nextension FullPreviewGenerationEvent {\n\tstatic let initialState = Self(requestID: -1, state: .initialState)\n\n\tstatic func empty(error: String? = nil, requestID: Int) -> Self {\n\t\t.init(requestID: requestID, state: .empty(error: error))\n\t}\n\n\tstatic func cancelled(requestID: Int) -> Self {\n\t\t.init(requestID: requestID, state: .cancelled)\n\t}\n\n\tstatic func generating(\n\t\tsettings: SettingsForFullPreview,\n\t\tprogress: Double,\n\t\trequestID: Int\n\t) -> Self {\n\t\t.init(\n\t\t\trequestID: requestID,\n\t\t\tstate: .generating(.init(settings: settings, progress: progress))\n\t\t)\n\t}\n\n\tstatic func ready(\n\t\tsettings: SettingsForFullPreview,\n\t\tgifData: [SendableTexture?],\n\t\trequestID: Int\n\t) -> Self {\n\t\t.init(\n\t\t\trequestID: requestID,\n\t\t\tstate: .ready(.init(settings: settings, gifData: gifData))\n\t\t)\n\t}\n}\n\nextension FullPreviewGenerationEvent {\n\tprivate enum State: Equatable {\n\t\tcase empty(error: String?)\n\t\tcase cancelled\n\t\tcase generating(Generating)\n\t\tcase ready(FullPreview)\n\n\t\tstatic let initialState = Self.empty(error: nil)\n\n\t\tfunc sameCase(as other: Self) -> Bool {\n\t\t\tswitch (self, other) {\n\t\t\tcase (.empty, .empty),\n\t\t\t\t(.cancelled, .cancelled),\n\t\t\t\t(.generating, .generating),\n\t\t\t\t(.ready, .ready):\n\t\t\t\ttrue\n\t\t\tdefault:\n\t\t\t\tfalse\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate struct Generating: Equatable {\n\t\tlet settings: SettingsForFullPreview\n\t\tlet progress: Double\n\t}\n}\n\nextension FullPreviewGenerationEvent {\n\tfileprivate struct FullPreview {\n\t\tlet settings: SettingsForFullPreview\n\t\tlet gifData: [SendableTexture?]\n\t}\n}\n\nextension FullPreviewGenerationEvent.FullPreview: Equatable {\n\t/**\n\tThey are equal if the settings that led to the creation of a full preview are equal.\n\t*/\n\tstatic func == (lhs: Self, rhs: Self) -> Bool {\n\t\tlhs.settings == rhs.settings\n\t}\n}\n\nextension FullPreviewGenerationEvent.FullPreview {\n\tenum Error: Swift.Error {\n\t\tcase failedToGetGIFFrame\n\t}\n\n\tfunc getGIF(at compositionTime: CMTime) throws(Error) -> SendableTexture {\n\t\tguard let image = gifData[getCurrentGIFIndex(at: compositionTime)] else {\n\t\t\tthrow .failedToGetGIFFrame\n\t\t}\n\n\t\treturn image\n\t}\n\n\tprivate func getCurrentGIFIndex(at compositionTime: CMTime) -> Int {\n\t\tlet timeRangeInOriginalSpeed = settings.conversion.timeRange ?? (0...settings.assetDuration)\n\n\t\tlet gifTimeInOriginalSpeed = originalCompositionTime(from: compositionTime) - timeRangeInOriginalSpeed.lowerBound\n\t\tlet adjustedFramesPerSecond = Double(settings.framesPerSecondsWithoutSpeedAdjustment) / settings.speed\n\n\t\treturn Int(floor(gifTimeInOriginalSpeed * adjustedFramesPerSecond))\n\t\t\t.clamped(from: 0, to: gifData.count - 1)\n\t}\n\n\t/**\n\tTime that has been scaled.\n\n\tThe 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.\n\t*/\n\tprivate func originalCompositionTime(from reportedCompositionTime: CMTime) -> Double {\n\t\treportedCompositionTime.seconds * settings.speed\n\t}\n}\n\nextension FullPreviewGenerationEvent: PreviewComparable {\n\t/**\n\t`PreviewComparable` compares if the image on the screen is visually different between the two states.\n\t*/\n\tstatic func ~= (lhs: Self, rhs: Self) -> Bool {\n\t\t// If we have two settings, compare if the settings are the same.\n\t\tif let lhsSettings = lhs.settings {\n\t\t\tif let rhsSettings = rhs.settings {\n\t\t\t\treturn lhs.state.sameCase(as: rhs.state) && lhsSettings == rhsSettings\n\t\t\t}\n\n\t\t\treturn false\n\t\t}\n\n\t\t// lhs is `no preview`, so if rhs has `settings` we know we are different.\n\t\tif rhs.settings != nil {\n\t\t\treturn false\n\t\t}\n\n\t\t// lhs has `no preview` and rhs has `no preview` so they are the same.\n\t\treturn true\n\t}\n}\n"
  },
  {
    "path": "Gifski/Preview/FullPreviewStream.swift",
    "content": "import Foundation\nimport AVFoundation\nimport Compression\n\nactor FullPreviewStream {\n\tprivate let stateStreamContinuation: AsyncStream<FullPreviewGenerationEvent>.Continuation\n\tprivate var state = FullPreviewGenerationEvent.initialState\n\n\t/**\n\tThe 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.\n\t*/\n\tprivate var generationTask: Task<Void, Never>?\n\n\t/**\n\tIncremented on every new request.\n\t*/\n\tprivate var automaticRequestID = 0\n\n\tprivate func newID() -> Int {\n\t\tautomaticRequestID += 1\n\t\treturn automaticRequestID\n\t}\n\n\tlet eventStream: AsyncStream<FullPreviewGenerationEvent>\n\n\tinit() {\n\t\t// The output stream. This is a stream of `FullPreviewGenerationEvents`.\n\t\t(self.eventStream, self.stateStreamContinuation) = AsyncStream<FullPreviewGenerationEvent>.makeStream(bufferingPolicy: .bufferingNewest(100))\n\n\t\tstateStreamContinuation.onTermination = { [weak self] _ in\n\t\t\tguard let self else {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tTask { [weak self] in\n\t\t\t\tawait self?.generationTask?.cancel()\n\t\t\t}\n\t\t}\n\t}\n\n\tdeinit {\n\t\tgenerationTask?.cancel()\n\t\tstateStreamContinuation.finish()\n\t}\n\n\t/**\n\tRequest a new full preview.\n\n\tReturns when the generation has *started* not when it finishes. Monitor the `eventStream` for the status of the generation.\n\t*/\n\tfunc requestNewFullPreview(\n\t\tasset: sending AVAsset,\n\t\tsettingsEvent newSettings: SettingsForFullPreview\n\t) async {\n\t\tlet requestID = newID()\n\n\t\trequestID.p(\"starting new settings\")\n\n\t\tguard state.isNecessaryToCreateNewFullPreview(newSettings: newSettings, newRequestID: requestID) else {\n\t\t\t// Not necessary to create a new full preview since there is no state change.\n\t\t\treturn\n\t\t}\n\n\t\trequestID.p(\"Generating\")\n\n\t\tif\n\t\t\tlet generationTask,\n\t\t\t!generationTask.isCancelled\n\t\t{\n\t\t\trequestID.p(\"canceling\")\n\t\t\tgenerationTask.cancel()\n\t\t\t_ = await generationTask.result\n\t\t\trequestID.p(\"canceled old\")\n\t\t}\n\n\t\tgenerationTask = .detached(priority: .medium) {\n\t\t\tdo {\n\t\t\t\tawait self.updatePreview(\n\t\t\t\t\tnewPreviewState: .generating(\n\t\t\t\t\t\tsettings: newSettings,\n\t\t\t\t\t\tprogress: 0,\n\t\t\t\t\t\trequestID: requestID\n\t\t\t\t\t)\n\t\t\t\t)\n\n\t\t\t\tlet fullPreviewTask = Self.convertToFullPreview(asset: asset, newSettings: newSettings)\n\n\t\t\t\tawait withTaskCancellationHandler {\n\t\t\t\t\tfor await progress in fullPreviewTask.progress {\n\t\t\t\t\t\tawait self.updatePreview(\n\t\t\t\t\t\t\tnewPreviewState: .generating(\n\t\t\t\t\t\t\t\tsettings: newSettings,\n\t\t\t\t\t\t\t\tprogress: progress,\n\t\t\t\t\t\t\t\trequestID: requestID\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t} onCancel: {\n\t\t\t\t\tfullPreviewTask.cancel()\n\t\t\t\t}\n\n\t\t\t\ttry Task.checkCancellation()\n\t\t\t\tlet textures = try await fullPreviewTask.value\n\n\t\t\t\ttry Task.checkCancellation()\n\t\t\t\trequestID.p(\"success\")\n\n\t\t\t\tawait self.updatePreview(newPreviewState: .ready(settings: newSettings, gifData: textures, requestID: requestID))\n\t\t\t} catch {\n\t\t\t\tif Task.isCancelled || error.isCancelled {\n\t\t\t\t\trequestID.p(\"I was cancelled\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tawait self.updatePreview(newPreviewState: .empty(error: error.localizedDescription, requestID: requestID))\n\t\t\t}\n\t\t}\n\t}\n\n\tstatic func convertToFullPreview(\n\t\tasset: AVAsset,\n\t\tnewSettings: SettingsForFullPreview\n\t) -> ProgressableTask<Double, [SendableTexture?]> {\n\t\tGIFGenerator.runProgressable(newSettings.conversion.toConversion(asset: asset))\n\t\t\t.then(progressWeight: 0.67) {\n\t\t\t\ttry await PreviewRenderer.shared.convertAnimatedGIFToTextures(gifData: $0)\n\t\t\t}\n\t}\n\n\t/**\n\tRequest cancellation of the current generation.\n\n\tMonitor `eventStream` for `.cancelled` events.\n\t*/\n\tfunc cancelFullPreviewGeneration() {\n\t\tgenerationTask?.cancel()\n\n\t\tguard state.isGenerating else {\n\t\t\treturn\n\t\t}\n\n\t\tupdatePreview(newPreviewState: .cancelled(requestID: newID()))\n\t}\n\n\tprivate func updatePreview(newPreviewState: FullPreviewGenerationEvent) {\n\t\tguard newPreviewState.requestID >= state.requestID else {\n\t\t\treturn\n\t\t}\n\n\t\tstate = newPreviewState\n\t\tstateStreamContinuation.yield(newPreviewState)\n\t}\n}\n\nextension Int {\n\t/**\n\tFor debugging `createPreviewStream`.\n\t*/\n\tfunc p(_ message: String) {\n\t\t#if DEBUG\n//\t\tprint(\"\\n\\n\\(self): \\(message)\\n\\n\")\n\t\t#endif\n\t}\n}\n"
  },
  {
    "path": "Gifski/Preview/PreviewRenderer.swift",
    "content": "import Foundation\nimport Metal\nimport MetalKit\n\nactor PreviewRenderer {\n\tprivate static var sharedRenderer: PreviewRenderer?\n\n\tstatic var shared: PreviewRenderer {\n\t\tget throws {\n\t\t\tif let sharedRenderer {\n\t\t\t\treturn sharedRenderer\n\t\t\t}\n\n\t\t\tlet renderer = try PreviewRenderer()\n\t\t\tsharedRenderer = renderer\n\n\t\t\treturn renderer\n\t\t}\n\t}\n\n\tstatic let colorAttachmentPixelFormat = MTLPixelFormat.bgra8Unorm\n\tstatic let depthAttachmentPixelFormat = MTLPixelFormat.depth32Float\n\n\tprivate let context: PreviewRendererContext\n\n\tlet metalDevice: MTLDevice\n\tlet textureLoader: MTKTextureLoader\n\tvar depthTextureCache = [DepthTextureSize: MTLTexture]()\n\n\tprivate init() throws {\n\t\tguard let metalDevice = MTLCreateSystemDefaultDevice() else {\n\t\t\tthrow Error.noDevice\n\t\t}\n\n\t\tself.metalDevice = metalDevice\n\n\t\tguard metalDevice.supportsFamily(.common1) else {\n\t\t\tthrow Error.unsupportedDevice\n\t\t}\n\n\t\tself.textureLoader = MTKTextureLoader(device: metalDevice)\n\t\tself.context = try PreviewRendererContext(metalDevice)\n\t}\n\n\tfunc renderOriginal(\n\t\tfrom videoFrame: SendableCVPixelBuffer,\n\t\tto outputFrame: SendableCVPixelBuffer,\n\t) throws {\n\t\tvideoFrame.pixelBuffer.propagateAttachments(to: outputFrame.pixelBuffer)\n\t\ttry videoFrame.pixelBuffer.copy(to: outputFrame.pixelBuffer)\n\t}\n\n\tfunc renderPreview(\n\t\tpreviewFrame: SendableTexture,\n\t\toutputFrame: SendableCVPixelBuffer,\n\t\tfragmentUniforms: CompositePreviewFragmentUniforms\n\t) async throws {\n\t\toutputFrame.pixelBuffer.setSRGBColorSpace()\n\n\t\t// Get a command buffer which will let us submit commands to the GPU.\n\t\ttry await context.commandQueue.withCommandBuffer(isolated: self) { commandBuffer in\n\t\t\t// Convert our pixel buffer to a texture.\n\t\t\tlet outputTexture = try context.textureCache.createTexture(\n\t\t\t\tfrom: outputFrame.pixelBuffer,\n\t\t\t\tpixelFormat: Self.colorAttachmentPixelFormat\n\t\t\t)\n\n\t\t\t// Remove isolation.\n\t\t\tlet previewTexture = previewFrame.getTexture(isolated: self)\n\n\t\t\t// Setup the scale of our preview frame.\n\t\t\tlet scale = SIMD2<Float>(\n\t\t\t\tx: outputTexture.texture.width > 0 ? Float(previewTexture.width.toDouble / outputTexture.texture.width.toDouble) : 1.0,\n\t\t\t\ty: outputTexture.texture.height > 0 ? Float(previewTexture.height.toDouble / outputTexture.texture.height.toDouble) : 1.0\n\t\t\t)\n\n\t\t\t// 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)).\n\t\t\ttry commandBuffer.withRenderCommandEncoder(\n\t\t\t\trenderPassDescriptor: PreviewRendererContext.makeRenderPassDescriptor(\n\t\t\t\t\toutputTexture: outputTexture,\n\t\t\t\t\tdepthTexture: try getDepthTexture(\n\t\t\t\t\t\twidth: outputTexture.texture.width,\n\t\t\t\t\t\theight: outputTexture.texture.height\n\t\t\t\t\t)\n\t\t\t\t)\n\t\t\t) { renderEncoder in\n\t\t\t\tcontext.applyContext(to: renderEncoder)\n\n\t\t\t\t// Turn off back culling (this means we don't care what order triangles are wound, we can list the vertices in any order).\n\t\t\t\trenderEncoder.setCullMode(.none)\n\n\t\t\t\t// Send the texture to the fragment shader (which chooses the color of each pixel).\n\t\t\t\trenderEncoder.setFragmentTexture(previewTexture, index: 0)\n\n\t\t\t\tdo {\n\t\t\t\t\t// Send data to the vertex shader. In this case, what scale the preview image is.\n\t\t\t\t\tvar vertexUniforms = CompositePreviewVertexUniforms(scale: scale)\n\n\t\t\t\t\trenderEncoder.setVertexBytes(\n\t\t\t\t\t\t&vertexUniforms,\n\t\t\t\t\t\tlength: MemoryLayout<CompositePreviewVertexUniforms>.stride,\n\t\t\t\t\t\tindex: 0\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\tdo {\n\t\t\t\t\t// Send our data to the fragment shader. Mostly about the checkerboard pattern.\n\t\t\t\t\tvar fragmentUniforms = fragmentUniforms\n\n\t\t\t\t\trenderEncoder.setFragmentBytes(\n\t\t\t\t\t\t&fragmentUniforms,\n\t\t\t\t\t\tlength: MemoryLayout<CompositePreviewFragmentUniforms>.stride,\n\t\t\t\t\t\tindex: 0\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\t// 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`.\n\t\t\t\trenderEncoder.drawPrimitives(\n\t\t\t\t\ttype: .triangle,\n\t\t\t\t\tvertexStart: 0,\n\t\t\t\t\tvertexCount: Int(VERTICES_PER_QUAD) * 2\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\t}\n}\n\nextension PreviewRenderer {\n\tenum Error: Swift.Error {\n\t\tcase noDevice\n\t\tcase unsupportedDevice\n\t\tcase noCommandQueue\n\t\tcase failedToMakeSampler\n\t\tcase failedToMakeTextureCache\n\t\tcase libraryFailure\n\t\tcase failedToMakeDepthStencilState\n\t\tcase failedToMakeSendableTexture\n\t}\n}\n\nextension PreviewRenderer {\n\t/**\n\tAfter it is sent to `SendableCVPixelBuffer`, the `CVPixelBuffer` is only accessible to `PreviewRenderer`.\n\t*/\n\tfinal class SendableCVPixelBuffer: @unchecked Sendable {\n\t\tfileprivate let pixelBuffer: CVPixelBuffer\n\n\t\tinit(pixelBuffer: CVPixelBuffer) {\n\t\t\tself.pixelBuffer = pixelBuffer\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "Gifski/Preview/PreviewRendererContext.swift",
    "content": "import Foundation\nimport MetalKit\n\n/**\nThe static state context we setup at runtime and use later.\n*/\nstruct PreviewRendererContext {\n\tprivate let pipelineState: MTLRenderPipelineState\n\tprivate let depthStencilState: MTLDepthStencilState\n\tprivate let samplerState: MTLSamplerState\n\n\tlet commandQueue: MTLCommandQueue\n\tlet textureCache: CVMetalTextureCache\n\n\tinit(_ metalDevice: MTLDevice) throws {\n\t\tguard let commandQueue = metalDevice.makeCommandQueue() else {\n\t\t\tthrow PreviewRenderer.Error.noCommandQueue\n\t\t}\n\n\t\tself.pipelineState = try Self.setupPipelineState(metalDevice)\n\t\tself.samplerState = try Self.setupSamplerState(metalDevice)\n\t\tself.depthStencilState = try Self.setupDepthStencilState(metalDevice)\n\t\tself.commandQueue = commandQueue\n\t\tself.textureCache = try Self.setupTextureCache(metalDevice)\n\t}\n\n\t/**\n\tSet the render command encoder to use the context we have created.\n\t*/\n\tfunc applyContext(to renderCommandEncoder: MTLRenderCommandEncoder) {\n\t\t// Set up the depth buffer.\n\t\trenderCommandEncoder.setDepthStencilState(depthStencilState)\n\n\t\t// Set up the actual render.\n\t\trenderCommandEncoder.setRenderPipelineState(pipelineState)\n\n\t\t// Set up the sampler (allow us to read from the texture).\n\t\trenderCommandEncoder.setFragmentSamplerState(samplerState, index: 0)\n\t}\n\n\t/**\n\tThe render pipeline sets up our shaders in `compositePreview.metal` and sets up to write to a color attachment with a depth buffer.\n\t*/\n\tprivate static func setupPipelineState(_ metalDevice: MTLDevice) throws -> MTLRenderPipelineState {\n\t\tguard\n\t\t\tlet library = metalDevice.makeDefaultLibrary(),\n\t\t\tlet vertexFunction = library.makeFunction(name: \"previewVertexShader\"),\n\t\t\tlet fragmentFunction = library.makeFunction(name: \"previewFragment\")\n\t\telse {\n\t\t\tthrow PreviewRenderer.Error.libraryFailure\n\t\t}\n\n\t\tlet pipelineDescriptor = MTLRenderPipelineDescriptor()\n\t\tpipelineDescriptor.vertexFunction = vertexFunction\n\t\tpipelineDescriptor.fragmentFunction = fragmentFunction\n\n\t\t// This is the output of the render pass.\n\t\tpipelineDescriptor.colorAttachments[0].pixelFormat = PreviewRenderer.colorAttachmentPixelFormat\n\n\t\t// This is a texture which stores the \"depth\" of each pixel. It is used to decide whether a pixel will occlude another pixel.\n\t\tpipelineDescriptor.depthAttachmentPixelFormat = PreviewRenderer.depthAttachmentPixelFormat\n\n\t\treturn try metalDevice.makeRenderPipelineState(descriptor: pipelineDescriptor)\n\t}\n\n\t/**\n\tCreate a render pass descriptor to match our pipeline. Here we pass in the actual data (i.e. the textures).\n\t*/\n\tstatic func makeRenderPassDescriptor(\n\t\toutputTexture: CVMetalTextureReference,\n\t\tdepthTexture: MTLTexture\n\t) -> MTLRenderPassDescriptor {\n\t\tlet renderPassDescriptor = MTLRenderPassDescriptor()\n\n\t\trenderPassDescriptor.colorAttachments[0].texture = outputTexture.texture\n\n\t\t// before the render pass clear the output to the clear color\n\t\trenderPassDescriptor.colorAttachments[0].loadAction = .clear\n\t\t// which in this case is black\n\t\trenderPassDescriptor.colorAttachments[0].clearColor = MTLClearColorMake(0, 0, 0, 1)\n\t\t// after the render pass write to the output texture\n\t\trenderPassDescriptor.colorAttachments[0].storeAction = .store\n\n\t\trenderPassDescriptor.depthAttachment.texture = depthTexture\n\t\t// before render pass clear the depth texture to the clear depth\n\t\trenderPassDescriptor.depthAttachment.loadAction = .clear\n\t\t// which is 1.0, since our `depthCompareFunction` is `.less` anything less than `1.0` will be drawn\n\t\trenderPassDescriptor.depthAttachment.clearDepth = 1.0\n\t\t// after render pass we don't care what happens to the depth texture (it has served its purpose)\n\t\trenderPassDescriptor.depthAttachment.storeAction = .dontCare\n\n\t\treturn renderPassDescriptor\n\t}\n\n\t/**\n\tThe sampler is how we retrieve texture data inside the shader. We set it up such that we will linearly interpret all pixel data.\n\t*/\n\tprivate static func setupSamplerState(_ metalDevice: MTLDevice) throws(PreviewRenderer.Error) -> MTLSamplerState {\n\t\tlet samplerDescriptor = MTLSamplerDescriptor()\n\n\t\t// Linearly interpolate colors between texels.\n\t\tsamplerDescriptor.minFilter = .linear\n\t\tsamplerDescriptor.magFilter = .linear\n\n\t\t// If we sample outside of our texture (0-1) use the same color as the edge.\n\t\tsamplerDescriptor.sAddressMode = .clampToEdge\n\t\tsamplerDescriptor.tAddressMode = .clampToEdge\n\n\t\tguard let samplerState = metalDevice.makeSamplerState(descriptor: samplerDescriptor) else {\n\t\t\tthrow .failedToMakeSampler\n\t\t}\n\n\t\treturn samplerState\n\t}\n\n\t/**\n\tSet up a depth buffer so that the preview will appear above the checkerboard pattern on all devices.\n\t*/\n\tprivate static func setupDepthStencilState(\n\t\t_ metalDevice: MTLDevice\n\t) throws(PreviewRenderer.Error) -> MTLDepthStencilState {\n\t\tlet depthStencilDescriptor = MTLDepthStencilDescriptor()\n\n\t\t// For each pixel, if the depth is less than the current depth buffer, then draw, other wise don't draw.\n\t\tdepthStencilDescriptor.depthCompareFunction = .less\n\n\t\t// Each time you do draw (it is less than current depth buffer), store the current depth in the depth buffer.\n\t\tdepthStencilDescriptor.isDepthWriteEnabled = true\n\n\t\tguard let depthStencilState = metalDevice.makeDepthStencilState(descriptor: depthStencilDescriptor) else {\n\t\t\tthrow .failedToMakeDepthStencilState\n\t\t}\n\n\t\treturn depthStencilState\n\t}\n\n\t/**\n\tSet up a texture cache to write out output pixel buffer to.\n\t*/\n\tprivate static func setupTextureCache(\n\t\t_ metalDevice: MTLDevice\n\t) throws(PreviewRenderer.Error) -> CVMetalTextureCache {\n\t\tvar textureCache: CVMetalTextureCache?\n\t\tCVMetalTextureCacheCreate(nil, nil, metalDevice, nil, &textureCache)\n\n\t\tguard let textureCache else {\n\t\t\tthrow .failedToMakeTextureCache\n\t\t}\n\n\t\treturn textureCache\n\t}\n}\n"
  },
  {
    "path": "Gifski/Preview/PreviewVideoCompositor.swift",
    "content": "import Foundation\nimport AVFoundation\nimport CoreImage\n\n/**\nA video compositor to composite the preview over the original video. This is called by the `AVPlayer` on redraws. What it draws depends on the state: if we are generating or don't have a full preview, we will generate a GIF of the current frame on the fly. If we have a full preview then it will just composite the full preview with the frame in most cases.\n*/\nfinal class PreviewVideoCompositor: NSObject, AVVideoCompositing {\n\tenum Error: Swift.Error {\n\t\tcase failedToGetVideoFrame\n\t}\n\n\t@MainActor\n\tprivate var state = State()\n\n\t/**\n\t- Returns: True if the state needed an update and you should redraw, false if there is no change.\n\t*/\n\t@MainActor\n\tfunc updateState(\n\t\tstate: State\n\t) -> Bool {\n\t\tif self.state ~= state {\n\t\t\treturn false\n\t\t}\n\n\t\tself.state = state\n\n\t\treturn true\n\t}\n\n\tfunc startRequest(_ unwrappedRequest: AVAsynchronousVideoCompositionRequest) {\n\t\t// Safe to wrap it like this because we never ever use the wrapped value in this thread anymore.\n\t\tstruct WrappedRequest: @unchecked Sendable {\n\t\t\tlet value: AVAsynchronousVideoCompositionRequest\n\t\t}\n\n\t\tlet wrapped = WrappedRequest(value: unwrappedRequest)\n\n\t\tTask.detached(priority: .userInitiated) {\n\t\t\tlet asyncVideoCompositionRequest = wrapped.value\n\t\t\tlet compositionTime = asyncVideoCompositionRequest.compositionTime\n\n\t\t\tguard\n\t\t\t\tlet outputFrame = asyncVideoCompositionRequest.renderContext.newPixelBuffer(),\n\t\t\t\tlet sourceTrackID = asyncVideoCompositionRequest.sourceTrackIDs.first,\n\t\t\t\tlet originalFrame = asyncVideoCompositionRequest.sourceFrame(byTrackID: sourceTrackID.int32Value)\n\t\t\telse {\n\t\t\t\tasyncVideoCompositionRequest.finish(with: Error.failedToGetVideoFrame)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tdo {\n\t\t\t\ttry await self.state.render(\n\t\t\t\t\toriginalFrame: originalFrame,\n\t\t\t\t\toutputFrame: outputFrame,\n\t\t\t\t\tcompositionTime: compositionTime\n\t\t\t\t)\n\n\t\t\t\tasyncVideoCompositionRequest.finish(withComposedVideoFrame: outputFrame)\n\t\t\t} catch {\n\t\t\t\tassertionFailure()\n\n\t\t\t\ttry? await PreviewRenderer.shared.renderOriginal(\n\t\t\t\t\tfrom: originalFrame.previewSendable,\n\t\t\t\t\tto: outputFrame.previewSendable\n\t\t\t\t)\n\n\t\t\t\tasyncVideoCompositionRequest.finish(withComposedVideoFrame: outputFrame)\n\t\t\t}\n\t\t}\n\t}\n\n\t// swiftlint:disable:next discouraged_optional_collection\n\tlet sourcePixelBufferAttributes: [String: any Sendable]? = [\n\t\tkCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA\n\t]\n\n\tlet requiredPixelBufferAttributesForRenderContext: [String: any Sendable] = [\n\t\tkCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA\n\t]\n\n\tfunc renderContextChanged(_ newRenderContext: AVVideoCompositionRenderContext) {\n\t\t// no-op\n\t}\n}\n\nextension PreviewVideoCompositor {\n\tstruct State: Equatable {\n\t\tprivate let shouldShowPreview: Bool\n\t\tprivate let fullPreviewState: FullPreviewGenerationEvent\n\t\tprivate let previewCheckerboardParams: CompositePreviewFragmentUniforms\n\n\t\tinit() {\n\t\t\tself.shouldShowPreview = false\n\t\t\tself.fullPreviewState = .initialState\n\t\t\tself.previewCheckerboardParams = .init()\n\t\t}\n\n\t\tinit(\n\t\t\tshouldShowPreview: Bool,\n\t\t\tfullPreviewState: FullPreviewGenerationEvent,\n\t\t\tpreviewCheckerboardParams: CompositePreviewFragmentUniforms\n\t\t) {\n\t\t\tself.shouldShowPreview = shouldShowPreview\n\t\t\tself.fullPreviewState = fullPreviewState\n\t\t\tself.previewCheckerboardParams = previewCheckerboardParams\n\t\t}\n\n\t\tfunc render(\n\t\t\toriginalFrame: CVPixelBuffer,\n\t\t\toutputFrame: CVPixelBuffer,\n\t\t\tcompositionTime: CMTime\n\t\t) async throws {\n\t\t\tguard\n\t\t\t\tshouldShowPreview,\n\t\t\t\tlet previewFrame = try await fullPreviewState.getPreviewFrame(\n\t\t\t\t\toriginalFrame: originalFrame,\n\t\t\t\t\tcompositionTime: compositionTime\n\t\t\t\t)\n\t\t\telse {\n\t\t\t\ttry await PreviewRenderer.shared.renderOriginal(\n\t\t\t\t\tfrom: originalFrame.previewSendable,\n\t\t\t\t\tto: outputFrame.previewSendable\n\t\t\t\t)\n\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ttry await PreviewRenderer.shared.renderPreview(\n\t\t\t\tpreviewFrame: previewFrame,\n\t\t\t\toutputFrame: outputFrame.previewSendable,\n\t\t\t\tfragmentUniforms: previewCheckerboardParams\n\t\t\t)\n\t\t}\n\t}\n}\n\nextension PreviewVideoCompositor.State: PreviewComparable {\n\tstatic func ~= (lhs: Self, rhs: Self) -> Bool {\n\t\tguard\n\t\t\tlhs.shouldShowPreview == rhs.shouldShowPreview,\n\t\t\tlhs.previewCheckerboardParams == rhs.previewCheckerboardParams\n\t\telse {\n\t\t\treturn false\n\t\t}\n\n\t\treturn lhs.fullPreviewState ~= rhs.fullPreviewState\n\t}\n}\n"
  },
  {
    "path": "Gifski/Preview/PreviewableComposition.swift",
    "content": "import Foundation\nimport AVFoundation\n\n/**\nAdds `PreviewVideoCompositor` to a `AVComposition`, setting up the instructions and tracks.\n*/\nfinal class PreviewableComposition: AVMutableComposition {\n\tenum Error: Swift.Error {\n\t\tcase assetHasNoTracks\n\t\tcase couldNotCreateTracks\n\t}\n\n\tlet videoComposition = AVMutableVideoComposition()\n\n\tinit(extractPreviewableCompositionFrom asset: AVAsset) async throws {\n\t\tsuper.init()\n\n\t\tlet (assetTracks, duration) = try await asset.load(.tracks, .duration)\n\n\t\tguard let assetTrack = assetTracks.first else {\n\t\t\tthrow Error.assetHasNoTracks\n\t\t}\n\n\t\tlet (trackSize, frameDuration, preferredTransform) = try await assetTrack.load(.naturalSize, .minFrameDuration, .preferredTransform)\n\n\t\tguard\n\t\t\tlet compositionOriginalTrack = addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid)\n\t\telse {\n\t\t\tthrow Error.couldNotCreateTracks\n\t\t}\n\t\tcompositionOriginalTrack.preferredTransform = preferredTransform\n\n\t\ttry compositionOriginalTrack.insertTimeRange(\n\t\t\tCMTimeRange(start: .videoZero, duration: duration),\n\t\t\tof: assetTrack,\n\t\t\tat: .videoZero\n\t\t)\n\n\t\tlet instruction = AVMutableVideoCompositionInstruction()\n\t\tinstruction.timeRange = CMTimeRange(start: .videoZero, duration: duration)\n\t\tinstruction.layerInstructions = [AVMutableVideoCompositionLayerInstruction(assetTrack: compositionOriginalTrack)]\n\n\t\t// Render size in preferred space (rotated) so preview displays correctly.\n\t\tlet rotatedRect = CGRect(origin: .zero, size: trackSize).applying(preferredTransform)\n\n\t\tvideoComposition.frameDuration = frameDuration\n\t\tvideoComposition.renderSize = CGSize(width: abs(rotatedRect.width), height: abs(rotatedRect.height))\n\t\tvideoComposition.instructions = [instruction]\n\t\tvideoComposition.customVideoCompositorClass = PreviewVideoCompositor.self\n\t}\n}\n"
  },
  {
    "path": "Gifski/Preview/SendableTexture.swift",
    "content": "import Foundation\nimport Metal\nimport MetalKit\n\n/**\nTextures that can only be accessed on the `PreviewRenderer` actor.\n*/\nstruct SendableTexture: @unchecked Sendable {\n\tprivate let texture: MTLTexture\n\n\t/*\n\tKept fileprivate, because in this file we can ensure that the `SendableTexture` is isolated to the `PreviewRenderer`.\n\t*/\n\tfileprivate init(texture: MTLTexture) {\n\t\tself.texture = texture\n\t}\n\n\tfunc getTexture(isolated: isolated PreviewRenderer) -> MTLTexture {\n\t\ttexture\n\t}\n}\n\nextension PreviewRenderer {\n\tfunc convertToTexture(data: Data) async throws -> SendableTexture {\n\t\ttry await newSendableTexture(source: .data(data), options: textureOptions)\n\t}\n\n\tfunc convertToTexture(cgImage: CGImage) async throws -> SendableTexture {\n\t\ttry await newSendableTexture(source: .image(cgImage), options: textureOptions)\n\t}\n\n\tprivate var textureOptions: [MTKTextureLoader.Option: Any] {\n\t\t[\n\t\t\t.SRGB: false,\n\t\t\t.origin: MTKTextureLoader.Origin.flippedVertically\n\t\t]\n\t}\n\n\tfunc newSendableTexture(\n\t\tsource: SendableTextureSource,\n\t\toptions: [MTKTextureLoader.Option: Any]? = nil // swiftlint:disable:this discouraged_optional_collection\n\t) async throws -> SendableTexture {\n\t\tlet texture = switch source {\n\t\tcase .data(let data):\n\t\t\ttry await textureLoader.newTexture(\n\t\t\t\tdata: data,\n\t\t\t\toptions: options\n\t\t\t)\n\t\tcase .image(let image):\n\t\t\ttry await textureLoader.newTexture(\n\t\t\t\tcgImage: image,\n\t\t\t\toptions: options\n\t\t\t)\n\t\t}\n\n\t\treturn .init(texture: texture)\n\t}\n\n\t/**\n\t[Metal Feature Set Tables ](https://developer.apple.com/metal/Metal-Feature-Set-Tables.pdf)\n\t*/\n\tvar supportsASTCCompressedTextures: Bool {\n\t\tmetalDevice.supportsFamily(.apple2)\n\t}\n\n\t/**\n\tCompress to pixel format `astc_8x8_ldr`.\n\n\tSee `convertToASTCTexture` for more info.\n\t*/\n\tfunc convertToASTCTexture(cgImage: CGImage) throws -> SendableTexture {\n\t\tlet astcData = try cgImage.convertToData(\n\t\t\twithNewType: \"org.khronos.astc\",\n\t\t\t// TODO: Use https://developer.apple.com/documentation/imageio/kcgimagepropertyastcblocksize8x8?changes=lat_7_3 when targeting macOS 26.\n\t\t\taddOptions: [\"kCGImagePropertyASTCBlockSize\": 0x88]\n\t\t)\n\n\t\treturn try metalDevice.convertToASTCTexture(isolated: self, astcData: astcData)\n\t}\n\n\tfunc convertAnimatedGIFToTextures(gifData: Data) -> ProgressableTask<Double, [SendableTexture?]> {\n\t\tProgressableTask { progressContinuation in\n\t\t\tlet imageSource = try CGImageSource.from(gifData)\n\t\t\tlet supportsCompressedTextures = self.supportsASTCCompressedTextures\n\n\t\t\tvar out = [SendableTexture?]()\n\t\t\tout.reserveCapacity(imageSource.count)\n\n\t\t\tfor index in 0..<imageSource.count {\n\t\t\t\ttry Task.checkCancellation()\n\t\t\t\tprogressContinuation.yield(Double(index) / Double(imageSource.count))\n\n\t\t\t\tlet image = try imageSource.createImage(atIndex: index)\n\t\t\t\tlet newImage = supportsCompressedTextures ? try self.convertToASTCTexture(cgImage: image) : try await self.convertToTexture(cgImage: image)\n\t\t\t\tout.append(newImage)\n\t\t\t}\n\n\t\t\treturn out\n\t\t}\n\t}\n\n\tstruct DepthTextureSize: Hashable {\n\t\tlet width: Int\n\t\tlet height: Int\n\t}\n\n\tfunc getDepthTexture(width: Int, height: Int) throws -> MTLTexture {\n\t\tlet size = DepthTextureSize(width: width, height: height)\n\n\t\tif let existingTexture = depthTextureCache[size] {\n\t\t\treturn existingTexture\n\t\t}\n\n\t\t// Clean cache if it gets too large.\n\t\tif depthTextureCache.count >= 10 {\n\t\t\tdepthTextureCache.removeAll()\n\t\t}\n\n\t\tlet descriptor = MTLTextureDescriptor.texture2DDescriptor(\n\t\t\tpixelFormat: Self.depthAttachmentPixelFormat,\n\t\t\twidth: width,\n\t\t\theight: height,\n\t\t\tmipmapped: false\n\t\t)\n\t\tdescriptor.usage = .renderTarget\n\t\tdescriptor.storageMode = .private\n\n\t\tguard let depthTexture = metalDevice.makeTexture(descriptor: descriptor) else {\n\t\t\tthrow \"Failed to create depth texture.\".toError\n\t\t}\n\n\t\tdepthTextureCache[size] = depthTexture\n\n\t\treturn depthTexture\n\t}\n}\n\nextension MTLDevice {\n\t/**\n\tUse the compressed texture pixel Format [ASTC](https://www.khronos.org/opengl/wiki/ASTC_Texture_Compression)\n\n\tAccording to the [documentation](/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/AppleTextureEncoder.h), `astc_8x8` is the smallest we can encode to with the built-in encoder, which is 2 bits per pixel.\n\t*/\n\tfunc convertToASTCTexture(\n\t\tisolated: isolated PreviewRenderer,\n\t\tastcData: Data\n\t) throws -> SendableTexture {\n\t\tlet astcImage = try ASTCImage(data: astcData)\n\t\tlet descriptor = try astcImage.descriptor()\n\t\tdescriptor.storageMode = .managed\n\t\tdescriptor.usage = [.shaderRead]\n\n\t\tguard let texture = makeTexture(descriptor: descriptor) else {\n\t\t\tthrow ConvertToASTCTextureError.failedToCreateTextures\n\t\t}\n\n\t\ttry astcImage.write(to: texture)\n\t\treturn SendableTexture(texture: texture)\n\t}\n}\n\nenum ConvertToASTCTextureError: Error {\n\tcase failedToCreateTextures\n}\n\nenum SendableTextureSource {\n\tcase data(Data)\n\tcase image(CGImage)\n}\n\nextension Data {\n\t/**\n\tIf data holds `imageData`, this will convert to a texture suitable for `PreviewRenderer`.\n\t*/\n\tfunc convertToTexture() async throws -> SendableTexture {\n\t\ttry await PreviewRenderer.shared.convertToTexture(data: self)\n\t}\n}\n"
  },
  {
    "path": "Gifski/Preview/SettingsForFullPreview.swift",
    "content": "import Foundation\nimport CoreMedia\nimport AVFoundation\n\n/**\nWhen creating a full preview, you don't need the some setting such as loop or bounce, plus it has additional info like asset duration and speed.\n*/\nstruct SettingsForFullPreview: Equatable, Sendable {\n\tlet conversion: SendableConversion\n\tlet speed: Double\n\tlet assetDuration: TimeInterval\n\tlet framesPerSecondsWithoutSpeedAdjustment: Int\n\n\tinit(\n\t\tconversion: GIFGenerator.Conversion,\n\t\tspeed: Double,\n\t\tframesPerSecondsWithoutSpeedAdjustment: Int,\n\t\tduration assetDuration: TimeInterval\n\t) {\n\t\tself.speed = speed\n\t\tself.framesPerSecondsWithoutSpeedAdjustment = framesPerSecondsWithoutSpeedAdjustment\n\t\tself.assetDuration = assetDuration\n\t\tself.conversion = SendableConversion(conversion: conversion)\n\t}\n\n\tfunc areSettingsDifferentEnoughForANewFullPreview(\n\t\tnewSettings: Self,\n\t\tareCurrentlyGenerating: Bool,\n\t\toldRequestID: Int,\n\t\tnewRequestID: Int\n\t) -> Bool {\n\t\tguard speed == newSettings.speed else {\n\t\t\treturn true\n\t\t}\n\n\t\tif self == newSettings {\n\t\t\tnewRequestID.p(\"Skipping - Same as \\(oldRequestID)\")\n\t\t\treturn false\n\t\t}\n\n\t\tif\n\t\t\t!areCurrentlyGenerating,\n\t\t\tareTheSameBesidesTimeRange(newSettings),\n\t\t\ttimeRangeContainsTimeRange(of: newSettings)\n\t\t{\n\t\t\tnewRequestID.p(\"Skipping - Same as ready \\(oldRequestID)\")\n\t\t\treturn false\n\t\t}\n\n\t\tnewRequestID.p(\"Different than \\(oldRequestID)\")\n\n\t\treturn true\n\t}\n\n\t/**\n\tCheck if the settings for full preview are the same, ignoring settings that do not affect full preview.\n\t*/\n\tprivate func areTheSameBesidesTimeRange(_ settings: Self) -> Bool {\n\t\tconversion.settings == settings.conversion.settings\n\t}\n\n\t/**\n\tCheck if the time range of the new settings is a subset of the old settings.\n\t*/\n\tprivate func timeRangeContainsTimeRange(of newSettings: Self) -> Bool {\n\t\tguard let oldTimeRange = conversion.timeRange else {\n\t\t\t/**\n\t\t\t`nil` means the entire duration, so all sets are subset of the range.\n\t\t\t*/\n\t\t\treturn true\n\t\t}\n\n\t\tguard let newTimeRange = newSettings.conversion.timeRange else {\n\t\t\t/**\n\t\t\tOld is not full, but new is full, thus it is not a subset.\n\t\t\t*/\n\t\t\treturn false\n\t\t}\n\n\t\treturn oldTimeRange.contains(newTimeRange)\n\t}\n\n\tstruct SendableConversion: ReflectiveHashable, Sendable, CropSettings {\n\t\tlet timeRange: ClosedRange<Double>?\n\t\tlet settings: ConversionSettings\n\n\t\tvar dimensions: (width: Int, height: Int)? {\n\t\t\tsettings.dimensions\n\t\t}\n\t\tvar trackPreferredTransform: CGAffineTransform? {\n\t\t\tsettings.trackPreferredTransform\n\t\t}\n\n\t\tvar crop: CropRect? {\n\t\t\tsettings.crop\n\t\t}\n\n\t\tstruct ConversionSettings: ReflectiveHashable, Sendable {\n\t\t\tlet sourceURL: URL\n\t\t\tlet quality: Double\n\t\t\tlet dimensions: (width: Int, height: Int)?\n\t\t\tlet frameRate: Int?\n\t\t\tlet crop: CropRect?\n\t\t\tlet trackPreferredTransform: CGAffineTransform?\n\n\t\t\tvar loop: Gifski.Loop {\n\t\t\t\t.never\n\t\t\t}\n\n\t\t\tvar bounce: Bool {\n\t\t\t\tfalse\n\t\t\t}\n\t\t}\n\n\t\tinit(conversion: GIFGenerator.Conversion) {\n\t\t\tself.timeRange = conversion.timeRange\n\n\t\t\tself.settings = .init(\n\t\t\t\tsourceURL: conversion.sourceURL,\n\t\t\t\tquality: conversion.quality,\n\t\t\t\tdimensions: conversion.dimensions,\n\t\t\t\tframeRate: conversion.frameRate,\n\t\t\t\tcrop: conversion.crop,\n\t\t\t\ttrackPreferredTransform: conversion.trackPreferredTransform\n\t\t\t)\n\t\t}\n\n\t\tfunc toConversion(asset: AVAsset) -> GIFGenerator.Conversion {\n\t\t\t.init(\n\t\t\t\tasset: asset,\n\t\t\t\tsourceURL: settings.sourceURL,\n\t\t\t\ttimeRange: timeRange,\n\t\t\t\tquality: settings.quality,\n\t\t\t\tdimensions: settings.dimensions,\n\t\t\t\tframeRate: settings.frameRate,\n\t\t\t\tloop: settings.loop,\n\t\t\t\tbounce: settings.bounce,\n\t\t\t\tcrop: settings.crop,\n\t\t\t\ttrackPreferredTransform: settings.trackPreferredTransform\n\t\t\t)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "Gifski/Preview/compositePreview.metal",
    "content": "#include <metal_stdlib>\n#include <metal_graphics>\n#include \"CompositePreviewShared.h\"\n\nusing namespace metal;\n\nstruct Vertex {\n\tfloat2 position;\n\tfloat2 textureCoordinates;\n\n\tVertex(float2 position, float2 textureCoordinates): position(position), textureCoordinates(textureCoordinates) {}\n};\n\nstruct VertexOut {\n\t// The position of the vertex in homogeneous clip space. In our case, the clip space goes from -1...1 in both the x and y directions. (-1,-1) is the bottom-left of the screen, while (1,1) is the top right. The position goes from  0...1 in the z direction, and it is used by the depth buffer to decide what pixels will occlude other pixels. In our case, pixels \"closer\" to 0 will be \"on-top\" of pixels \"farther\" away (1.0 being the maximum depth). `w` will be kept 1.0 and can be ignored for now.\n\tfloat4 position [[position]];\n\n\t// Pass the texture coordinates on to the fragment shader. Texture coordinates range from 0...1 in `s` and `t` (i.e., horizontal and vertical).\n\tfloat2 textureCoordinates;\n\n\t// Pass whether or not the triangle is checkerboard to the fragment shader.\n\tuint isCheckerboard;\n\n\tVertexOut(Vertex vert, float2 scale, float z, bool isCheckerboard):\n\tposition(float4(vert.position * scale, z, 1.0)),\n\ttextureCoordinates(vert.textureCoordinates),\n\tisCheckerboard(isCheckerboard) {}\n};\n\n\nconstant int vertexIndices[VERTICES_PER_QUAD] = {0, 1, 2, 2, 1, 3};\nconstant Vertex vertices[4] = {\n\tVertex(float2(-1.0, -1.0), float2(0.0, 0.0)),\n\tVertex(float2( 1.0, -1.0), float2(1.0, 0.0)),\n\tVertex(float2(-1.0,  1.0), float2(0.0, 1.0)),\n\tVertex(float2( 1.0,  1.0), float2(1.0, 1.0))\n};\n\n\n/*\nThe vertex shader computes the position of each vertex. This function gets called once per vertex (which in our case is `VERTICES_PER_QUAD * 2` vertices). This shader simply looks up the vertex position, and texture coordinates from some precomputed vertex data we include in the shader. After the vertex shader stage completes, the GPU will [rasterize](https://jtsorlinis.github.io/rendering-tutorial/)  each triangle, computing the position of pixels on the screen. Then it will move on to the fragment shader `previewFragment`.\n*/\nvertex VertexOut previewVertexShader(\n\tuint vertexID [[vertex_id]],\n\tconstant CompositePreviewVertexUniforms &uniforms [[buffer(0)]]\n) {\n\tbool isCheckerboard = vertexID >= VERTICES_PER_QUAD;\n\n\treturn VertexOut(\n\t\tvertices[vertexIndices[vertexID % VERTICES_PER_QUAD]],\n\t\tisCheckerboard ? float2(1.0, 1.0) : uniforms.scale,\n\t\tisCheckerboard ? 0.5 : 0.1,\n\t\tisCheckerboard\n\t);\n}\n\n/*\nThe preview fragment shader runs for each rasterized pixel. The data from each vertex (`VertexOut`) for each triangle is interpolated (at one of the vertices the data is exactly the same as the input vertex; in the exact middle of the triangle is a blend of each vertex). Returns a color for the pixel.\n*/\nfragment float4 previewFragment(\n\tVertexOut in [[stage_in]],\n\ttexture2d<float> inputTexture [[texture(0)]],\n\tsampler inputSampler [[sampler(0)]],\n\tconstant CompositePreviewFragmentUniforms &uniforms [[buffer(0)]]\n) {\n\tif (!in.isCheckerboard) {\n\t\t// Grab the color given by the texture at the coordinates given by `textureCoordinates`.\n\t\treturn inputTexture.sample(inputSampler, in.textureCoordinates);\n\t}\n\n\tfloat2 topLeftOriginTexCoords = float2(in.textureCoordinates.x, 1.0 - in.textureCoordinates.y);\n\tfloat2 texCoordsInPixels = topLeftOriginTexCoords * uniforms.videoSize + uniforms.videoOrigin;\n\tint gridSize = uniforms.gridSize;\n\n\tint checkerX = (int(texCoordsInPixels.x) % (gridSize * 2)) >= gridSize ? 1 : 0;\n\tint checkerY = (int(texCoordsInPixels.y) % (gridSize * 2)) >= gridSize ? 1 : 0;\n\treturn (checkerX + checkerY) % 2 == 0 ? uniforms.firstColor : uniforms.secondColor;\n}\n"
  },
  {
    "path": "Gifski/ResizableDimensions.swift",
    "content": "import CoreGraphics\nimport AppIntents\n\nenum DimensionsType: String, Equatable, CaseIterable {\n\tcase pixels\n\tcase percent\n}\n\nextension DimensionsType: AppEnum {\n\tstatic let typeDisplayRepresentation: TypeDisplayRepresentation = \"Dimension Type\"\n\n\tstatic let caseDisplayRepresentations: [Self: DisplayRepresentation] = [\n\t\t.pixels: \"Pixels\",\n\t\t.percent: \"Percent\"\n\t]\n}\n\nenum Dimensions: Hashable {\n\tcase pixels(_ value: CGSize, originalSize: CGSize)\n\tcase percent(_ value: Double, originalSize: CGSize)\n}\n\nextension Dimensions {\n\tvar pixels: CGSize {\n\t\tswitch self {\n\t\tcase .pixels(let value, _):\n\t\t\treturn value.rounded()\n\t\tcase .percent(let percent, let originalSize):\n\t\t\tguard originalSize != .zero else {\n\t\t\t\treturn .zero\n\t\t\t}\n\n\t\t\treturn (originalSize * percent).rounded()\n\t\t}\n\t}\n\n\tvar percent: Double {\n\t\tswitch self {\n\t\tcase .pixels(let value, let originalSize):\n\t\t\tguard originalSize.width > 0 else {\n\t\t\t\treturn 0\n\t\t\t}\n\n\t\t\treturn value.width / originalSize.width\n\t\tcase .percent(let value, _):\n\t\t\treturn value\n\t\t}\n\t}\n\n\tvar isPercent: Bool {\n\t\tswitch self {\n\t\tcase .pixels:\n\t\t\tfalse\n\t\tcase .percent:\n\t\t\ttrue\n\t\t}\n\t}\n\n\tvar originalSize: CGSize {\n\t\tswitch self {\n\t\tcase .pixels(_, let originalSize):\n\t\t\toriginalSize\n\t\tcase .percent(_, let originalSize):\n\t\t\toriginalSize\n\t\t}\n\t}\n\n\tvar widthMinMax: ClosedRange<Double> {\n\t\tlet minimumSize = originalSize.aspectFill(to: 5)\n\t\treturn minimumSize.width.clamped(to: ...originalSize.width).rounded()...originalSize.width\n\t}\n\n\tvar heightMinMax: ClosedRange<Double> {\n\t\tlet minimumSize = originalSize.aspectFill(to: 5)\n\t\treturn minimumSize.height.clamped(to: ...originalSize.height).rounded()...originalSize.height\n\t}\n\n\tvar percentMinMax: ClosedRange<Double> { 1...100 }\n\n\tfunc rounded(_ rule: FloatingPointRoundingRule = .toNearestOrAwayFromZero) -> Self {\n\t\tswitch self {\n\t\tcase .pixels(let value, let originalSize):\n\t\t\tlet roundedValue = CGSize(width: value.width.rounded(rule), height: value.height.rounded(rule))\n\t\t\treturn .pixels(roundedValue, originalSize: originalSize)\n\t\tcase .percent(let value, let originalSize):\n\t\t\tlet roundedValue = value.rounded(rule)\n\t\t\treturn .percent(roundedValue, originalSize: originalSize)\n\t\t}\n\t}\n\n\tfunc resized(to newSize: CGSize) -> Self {\n\t\tswitch self {\n\t\tcase .pixels(_, let originalSize):\n\t\t\treturn .pixels(newSize, originalSize: originalSize)\n\t\tcase .percent(_, let originalSize):\n\t\t\tlet newWidthPercent = (newSize.width / originalSize.width) * 100\n\t\t\tlet newHeightPercent = (newSize.height / originalSize.height) * 100\n\t\t\tlet averagePercent = (newWidthPercent + newHeightPercent) / 2\n\t\t\treturn .percent(averagePercent, originalSize: originalSize)\n\t\t}\n\t}\n}\n\nextension Dimensions: CustomStringConvertible {\n\tvar description: String {\n\t\tswitch self {\n\t\tcase .pixels(let value, _):\n\t\t\tlet percent = percent * 100\n\t\t\tlet percentString = percent == 100 ? \"Original\" : String(format: \"~%.0f%%\", percent)\n\t\t\treturn \"\\(value.formatted) (\\(percentString))\"\n\t\tcase .percent(let value, _):\n\t\t\tlet pixels = pixels\n\t\t\tlet percentValue = value * 100\n\t\t\tlet pixelString = percentValue == 100 ? \"Original\" : \"\\(pixels.formatted)\"\n\t\t\treturn String(format: \"%.0f%% (\\(pixelString))\", percentValue)\n\t\t}\n\t}\n}\n\nextension Dimensions {\n\tfunc aspectResized(usingWidth width: Double) -> Self {\n\t\tswitch self {\n\t\tcase .pixels(let originalValue, let originalSize):\n\t\t\tprint(\"ORIGINAL\", originalSize, originalValue)\n\t\t\tguard originalSize.width != .zero else {\n\t\t\t\treturn self\n\t\t\t}\n\n\t\t\tlet newHeight = originalSize.height * (width / originalSize.width)\n\t\t\treturn .pixels(CGSize(width: width, height: newHeight).rounded(), originalSize: originalSize)\n\t\tcase .percent(_, let originalSize):\n\t\t\tprint(\"ORIGINAL2\", originalSize)\n\t\t\tlet newPercent = width / originalSize.width\n\t\t\treturn .percent(newPercent, originalSize: originalSize)\n\t\t}\n\t}\n\n\tfunc aspectResized(usingHeight height: Double) -> Self {\n\t\tswitch self {\n\t\tcase .pixels(_, let originalSize):\n\t\t\tguard originalSize.height != .zero else {\n\t\t\t\treturn self\n\t\t\t}\n\n\t\t\tlet newWidth = originalSize.width * (height / originalSize.height)\n\t\t\treturn .pixels(CGSize(width: newWidth, height: height).rounded(), originalSize: originalSize)\n\t\tcase .percent(_, let originalSize):\n\t\t\tlet newPercent = height / originalSize.height\n\t\t\treturn .percent(newPercent, originalSize: originalSize)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "Gifski/Shared.swift",
    "content": "import Foundation\n\nenum Shared {\n\tstatic let appGroupIdentifier = \"group.com.sindresorhus.Gifski\"\n}\n"
  },
  {
    "path": "Gifski/StartScreen.swift",
    "content": "import SwiftUI\n\nstruct StartScreen: View {\n\t@Environment(AppState.self) private var appState\n\n\tvar body: some View {\n\t\tVStack(spacing: 8) {\n\t\t\tText(\"Drop Video\")\n\t\t\tText(\"or\")\n\t\t\t\t.font(.system(size: 10))\n\t\t\t\t.italic()\n\t\t\tButton(\"Open\") {\n\t\t\t\tappState.isFileImporterPresented = true\n\t\t\t}\n\t\t}\n\t\t.font(.title3)\n\t\t.controlSize(.extraLarge)\n\t\t.foregroundStyle(.secondary)\n\t\t.padding()\n\t\t.fillFrame()\n\t\t.navigationTitle(\"\")\n\t\t// TODO: When targeting macOS 15, set `.containerShape()` at the top-level and then use `ContainerRelativeShape()` for the border.\n\t\t// TODO: Or do a `.windowBorder()` utility.\n\t}\n}\n"
  },
  {
    "path": "Gifski/Utilities.swift",
    "content": "import SwiftUI\nimport AVKit\nimport Combine\nimport AVFoundation\nimport Accelerate.vImage\nimport AppIntents\nimport Defaults\nimport Sentry\nimport ExtendedAttributes\n\ntypealias Defaults = _Defaults\ntypealias Default = _Default\ntypealias AnyCancellable = Combine.AnyCancellable\n\n\n// TODO: Check if any of these can be removed when targeting macOS 15.\nextension NSItemProvider: @retroactive @unchecked Sendable {}\n\n\n@discardableResult\nfunc with<T, E>(_ item: T, update: (inout T) throws(E) -> Void) throws(E) -> T {\n\tvar this = item\n\ttry update(&this)\n\treturn this\n}\n\n\nfunc delay(@_implicitSelfCapture _ duration: Duration, closure: @escaping () -> Void) {\n\tDispatchQueue.main.asyncAfter(duration, execute: closure)\n}\n\n\nextension DispatchQueue {\n\tfunc asyncAfter(_ duration: Duration, execute: @escaping () -> Void) {\n\t\tasyncAfter(deadline: .now() + duration.toTimeInterval, execute: execute)\n\t}\n\n\tfunc asyncAfter(_ duration: Duration, execute: DispatchWorkItem) {\n\t\tasyncAfter(deadline: .now() + duration.toTimeInterval, execute: execute)\n\t}\n}\n\n\nfunc asyncNilCoalescing<T>(\n\t_ optional: T?,\n\tdefault defaultValue: @escaping @autoclosure () async throws -> T\n) async rethrows -> T {\n\tguard let optional else {\n\t\treturn try await defaultValue()\n\t}\n\n\treturn optional\n}\n\nfunc asyncNilCoalescing<T>(\n\t_ optional: T?,\n\tdefault defaultValue: @escaping @autoclosure () async throws -> T?\n) async rethrows -> T? {\n\tguard let optional else {\n\t\treturn try await defaultValue()\n\t}\n\n\treturn optional\n}\n\n\n// swiftlint:disable:next no_cgfloat\nextension CGFloat {\n\t/**\n\tGet a Double from a CGFloat. This makes it easier to work with optionals.\n\t*/\n\tvar toDouble: Double { Double(self) }\n}\n\nextension Double {\n\t/**\n\tDiscouraged but sometimes needed when implicit coercion doesn't work.\n\t*/\n\tvar toCGFloat: CGFloat { CGFloat(self) } // swiftlint:disable:this no_cgfloat no_cgfloat2\n\n\t/**\n\tIf this represents an aspect ratio, return the normalized aspect ratio for each side as a `CGSize`.\n\t*/\n\tvar normalizedAspectRatioSides: CGSize {\n\t\tself > 1.0 ? .init(width: 1.0, height: 1.0 / self) : .init(width: self, height: 1.0)\n\t}\n}\n\nextension BinaryInteger {\n\tvar toDouble: Double { Double(Int(self)) }\n}\n\nextension BinaryFloatingPoint {\n\tvar toInt: Int? { self >= Self(Int.min) && self <= Self(Int.max) ? Int(self) : nil }\n\n\tvar toIntAndClampingIfNeeded: Int { Int(clamped(to: Self(Int.min)...Self(Int.max))) }\n}\n\n\nextension Link<Label<Text, Image>> {\n\tinit(\n\t\t_ title: String,\n\t\tsystemImage: String,\n\t\tdestination: URL\n\t) {\n\t\tself.init(destination: destination) {\n\t\t\tLabel(title, systemImage: systemImage)\n\t\t}\n\t}\n}\n\n\nextension NSView {\n\tfunc shake(duration: Duration = .seconds(0.3), direction: NSUserInterfaceLayoutOrientation) {\n\t\tlet translation = direction == .horizontal ? \"x\" : \"y\"\n\t\tlet animation = CAKeyframeAnimation(keyPath: \"transform.translation.\\(translation)\")\n\t\tanimation.timingFunction = .linear\n\t\tanimation.duration = duration.toTimeInterval\n\t\tanimation.values = [-5, 5, -2.5, 2.5, 0]\n\t\tlayer?.add(animation, forKey: nil)\n\t}\n}\n\n\nstruct SendFeedbackButton: View {\n\tvar body: some View {\n\t\tLink(\n\t\t\t\"Support & Feedback\",\n\t\t\tsystemImage: \"exclamationmark.bubble\",\n\t\t\tdestination: SSApp.appFeedbackUrl()\n\t\t)\n\t}\n}\n\n\nstruct ShareAppButton: View {\n\tlet appStoreID: String\n\n\tvar body: some View {\n\t\tShareLink(\"Share App\", item: \"https://apps.apple.com/app/id\\(appStoreID)\")\n\t}\n}\n\n\nstruct RateOnAppStoreButton: View {\n\tlet appStoreID: String\n\n\tvar body: some View {\n\t\tLink(\n\t\t\t\"Rate App\",\n\t\t\tsystemImage: \"star\",\n\t\t\tdestination: URL(string: \"itms-apps://apps.apple.com/app/id\\(appStoreID)?action=write-review\")!\n\t\t)\n\t}\n}\n\n\n// NOTE: This is moot with macOS 12, but `.values` property provided is super buggy and crashes a lot.\nextension Publisher where Failure == Never {\n\tvar toAsyncSequence: some AsyncSequence<Output, Failure> {\n\t\tAsyncStream(Output.self) { continuation in\n\t\t\tlet cancellable = sink { completion in\n\t\t\t\tswitch completion {\n\t\t\t\tcase .finished:\n\t\t\t\t\tcontinuation.finish()\n\t\t\t\t}\n\t\t\t} receiveValue: { output in\n\t\t\t\tcontinuation.yield(output)\n\t\t\t}\n\n\t\t\tcontinuation.onTermination = { [cancellable] _ in\n\t\t\t\tcancellable.cancel()\n\t\t\t}\n\t\t}\n\t}\n}\n\n\nextension Task {\n\t/**\n\tMake a task cancellable.\n\n\t- Important: You need to assign it to a cancellable property for it to be cancelled. It's not weak by default like Combine.\n\t*/\n\tvar toCancellable: AnyCancellable { .init(cancel) }\n}\n\n\nextension Sequence {\n\tfunc asyncMap<T, E>(\n\t\t_ transform: (Element) async throws(E) -> T\n\t) async throws(E) -> [T] {\n\t\tvar values = [T]()\n\n\t\tfor element in self {\n\t\t\ttry await values.append(transform(element))\n\t\t}\n\n\t\treturn values\n\t}\n}\n\n\nextension NSView {\n\t@discardableResult\n\tfunc insertVibrancyView(\n\t\tmaterial: NSVisualEffectView.Material,\n\t\tblendingMode: NSVisualEffectView.BlendingMode = .behindWindow,\n\t\tappearanceName: NSAppearance.Name? = nil\n\t) -> NSVisualEffectView {\n\t\tlet view = NSVisualEffectView(frame: bounds)\n\t\tview.autoresizingMask = [.width, .height]\n\t\tview.material = material\n\t\tview.blendingMode = blendingMode\n\n\t\tif let appearanceName {\n\t\t\tview.appearance = NSAppearance(named: appearanceName)\n\t\t}\n\n\t\taddSubview(view, positioned: .below, relativeTo: nil)\n\n\t\treturn view\n\t}\n}\n\n\nextension NSWindow {\n\tprivate enum AssociatedKeys {\n\t\tstatic let cancellable = ObjectAssociation<AnyCancellable?>()\n\t}\n\n\tfunc makeVibrant() {\n\t\t// So there seems to be a visual effect view already created by NSWindow.\n\t\t// If we can attach ourselves to it and make it a vibrant one - awesome.\n\t\t// If not, let's just add our view as a first one so it is vibrant anyways.\n\t\tguard let visualEffectView = contentView?.superview?.subviews.lazy.compactMap({ $0 as? NSVisualEffectView }).first else {\n\t\t\tcontentView?.superview?.insertVibrancyView(material: .underWindowBackground)\n\t\t\treturn\n\t\t}\n\n\t\tvisualEffectView.blendingMode = .behindWindow\n\t\tvisualEffectView.material = .underWindowBackground\n\n\t\tAssociatedKeys.cancellable[self] = visualEffectView.publisher(for: \\.effectiveAppearance)\n\t\t\t.sink { _ in\n\t\t\t\tvisualEffectView.blendingMode = .behindWindow\n\t\t\t\tvisualEffectView.material = .underWindowBackground\n\t\t\t}\n\t}\n}\n\n\nextension Binding<Double> {\n\tvar doubleToInt: Binding<Int> {\n\t\tmap(\n\t\t\tget: { Int($0) },\n\t\t\tset: { Double($0) }\n\t\t)\n\t}\n}\n\nextension Binding<Int> {\n\tvar intToDouble: Binding<Double> {\n\t\tmap(\n\t\t\tget: { Double($0) },\n\t\t\tset: { Int($0) }\n\t\t)\n\t}\n}\n\n\nextension NSView {\n\tprivate final class AddedToSuperviewObserverView: NSView {\n\t\tvar onAdded: (() -> Void)?\n\n\t\toverride var acceptsFirstResponder: Bool { false }\n\n\t\tconvenience init() {\n\t\t\tself.init(frame: .zero)\n\t\t}\n\n\t\toverride func viewDidMoveToWindow() {\n\t\t\tguard window != nil else {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tonAdded?()\n\t\t\tremoveFromSuperview()\n\t\t}\n\t}\n\n\tfunc onAddedToSuperview(_ closure: @escaping () -> Void) {\n\t\tlet view = AddedToSuperviewObserverView()\n\t\tview.onAdded = closure\n\t\taddSubview(view)\n\t}\n}\n\n\nextension NSAlert {\n\t/**\n\tShow an alert as a window-modal sheet, or as an app-modal (window-indepedendent) alert if the window is `nil` or not given.\n\t*/\n\t@discardableResult\n\tstatic func showModal(\n\t\tfor window: NSWindow? = nil,\n\t\ttitle: String,\n\t\tmessage: String? = nil,\n\t\tdetailText: String? = nil,\n\t\tstyle: Style = .warning,\n\t\tbuttonTitles: [String] = [],\n\t\tdefaultButtonIndex: Int? = nil,\n\t\tminimumWidth: Double? = nil\n\t) -> NSApplication.ModalResponse {\n\t\tNSAlert(\n\t\t\ttitle: title,\n\t\t\tmessage: message,\n\t\t\tdetailText: detailText,\n\t\t\tstyle: style,\n\t\t\tbuttonTitles: buttonTitles,\n\t\t\tdefaultButtonIndex: defaultButtonIndex,\n\t\t\tminimumWidth: minimumWidth\n\t\t).runModal(for: window)\n\t}\n\n\t/**\n\tThe index in the `buttonTitles` array for the button to use as default.\n\n\tSet `-1` to not have any default. Useful for really destructive actions.\n\t*/\n\tvar defaultButtonIndex: Int {\n\t\tget {\n\t\t\tbuttons.firstIndex { $0.keyEquivalent == \"\\r\" } ?? -1\n\t\t}\n\t\tset {\n\t\t\t// Clear the default button indicator from other buttons.\n\t\t\tfor button in buttons where button.keyEquivalent == \"\\r\" {\n\t\t\t\tbutton.keyEquivalent = \"\"\n\t\t\t}\n\n\t\t\tif newValue != -1 {\n\t\t\t\tbuttons[newValue].keyEquivalent = \"\\r\"\n\t\t\t}\n\t\t}\n\t}\n\n\tconvenience init(\n\t\ttitle: String,\n\t\tmessage: String? = nil,\n\t\tdetailText: String? = nil,\n\t\tstyle: Style = .warning,\n\t\tbuttonTitles: [String] = [],\n\t\tdefaultButtonIndex: Int? = nil,\n\t\tminimumWidth: Double? = nil\n\t) {\n\t\tself.init()\n\t\tself.messageText = title\n\t\tself.alertStyle = style\n\n\t\tif let message {\n\t\t\tself.informativeText = message\n\t\t}\n\n\t\tif let detailText {\n\t\t\tlet scrollView = NSTextView.scrollableTextView()\n\n\t\t\t// We're setting the frame manually here as it's impossible to use auto-layout,\n\t\t\t// since it has nothing to constrain to. This will eventually be rewritten in SwiftUI anyway.\n\t\t\tscrollView.frame = CGRect(width: minimumWidth ?? 300, height: 120)\n\n\t\t\tif minimumWidth == nil {\n\t\t\t\tscrollView.onAddedToSuperview {\n\t\t\t\t\tif let messageTextField = (scrollView.superview?.superview?.subviews.first { $0 is NSTextField }) {\n\t\t\t\t\t\tscrollView.frame.width = messageTextField.frame.width\n\t\t\t\t\t} else {\n\t\t\t\t\t\tassertionFailure(\"Couldn't detect the message textfield view of the NSAlert panel\")\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlet textView = scrollView.documentView as! NSTextView\n\t\t\ttextView.drawsBackground = false\n\t\t\ttextView.isEditable = false\n\t\t\ttextView.font = .systemFont(ofSize: NSFont.systemFontSize(for: .small))\n\t\t\ttextView.textColor = .secondaryLabelColor\n\t\t\ttextView.string = detailText\n\n\t\t\tself.accessoryView = scrollView\n\t\t} else if let minimumWidth {\n\t\t\tself.accessoryView = NSView(frame: CGRect(width: minimumWidth, height: 0))\n\t\t}\n\n\t\taddButtons(withTitles: buttonTitles)\n\n\t\tif let defaultButtonIndex {\n\t\t\tself.defaultButtonIndex = defaultButtonIndex\n\t\t}\n\t}\n\n\t/**\n\tRuns the alert as a window-modal sheet, or as an app-modal (window-indepedendent) alert if the window is `nil` or not given.\n\t*/\n\t@discardableResult\n\tfunc runModal(for window: NSWindow? = nil) -> NSApplication.ModalResponse {\n\t\tguard let window else {\n\t\t\treturn runModal()\n\t\t}\n\n\t\tbeginSheetModal(for: window) { returnCode in\n\t\t\tNSApp.stopModal(withCode: returnCode)\n\t\t}\n\n\t\treturn NSApp.runModal(for: window)\n\t}\n\n\t/**\n\tAdds buttons with the given titles to the alert.\n\t*/\n\tfunc addButtons(withTitles buttonTitles: [String]) {\n\t\tfor buttonTitle in buttonTitles {\n\t\t\taddButton(withTitle: buttonTitle)\n\t\t}\n\t}\n}\n\n\nextension CMTimeScale {\n\t/**\n\tApple-recommended scale for video.\n\n\t```\n\tCMTime(seconds: (1 / fps), preferredTimescale: .video)\n\t```\n\t*/\n\tstatic let video: Self = 600\n}\n\n\nextension CMTime {\n\t/**\n\tZero in the video timescale.\n\t*/\n\tstatic var videoZero: Self {\n\t\t.init(seconds: 0, preferredTimescale: .video)\n\t}\n}\n\n\nextension Comparable {\n\tfunc clamped(from lowerBound: Self, to upperBound: Self) -> Self {\n\t\tmin(max(self, lowerBound), upperBound)\n\t}\n\n\tfunc clamped(to range: ClosedRange<Self>) -> Self {\n\t\tclamped(from: range.lowerBound, to: range.upperBound)\n\t}\n\n\tfunc clamped(to range: PartialRangeThrough<Self>) -> Self {\n\t\tmin(self, range.upperBound)\n\t}\n\n\tfunc clamped(to range: PartialRangeFrom<Self>) -> Self {\n\t\tmax(self, range.lowerBound)\n\t}\n}\n\nextension Strideable where Stride: SignedInteger {\n\tfunc clamped(to range: CountableRange<Self>) -> Self {\n\t\tclamped(from: range.lowerBound, to: range.upperBound.advanced(by: -1))\n\t}\n\n\tfunc clamped(to range: CountableClosedRange<Self>) -> Self {\n\t\tclamped(from: range.lowerBound, to: range.upperBound)\n\t}\n\n\tfunc clamped(to range: PartialRangeUpTo<Self>) -> Self {\n\t\tmin(self, range.upperBound.advanced(by: -1))\n\t}\n}\n\n\nextension String.StringInterpolation {\n\t/**\n\tInterpolate the value by unwrapping it, and if `nil`, use the given default string.\n\n\t```\n\t// This doesn't work as you can only use nil coalescing in interpolation with the same type as the optional\n\t\"foo \\(optionalDouble ?? \"none\")\n\n\t// Now you can do this\n\t\"foo \\(optionalDouble, default: \"none\")\n\t```\n\t*/\n\tpublic mutating func appendInterpolation(_ value: Any?, default defaultValue: String) {\n\t\tif let value {\n\t\t\tappendInterpolation(value)\n\t\t} else {\n\t\t\tappendLiteral(defaultValue)\n\t\t}\n\t}\n\n\t/**\n\tInterpolate the value by unwrapping it, and if `nil`, use `\"nil\"`.\n\n\t```\n\t// This doesn't work as you can only use nil coalescing in interpolation with the same type as the optional\n\t\"foo \\(optionalDouble ?? \"nil\")\n\n\t// Now you can do this\n\t\"foo \\(describing: optionalDouble)\n\t```\n\t*/\n\tpublic mutating func appendInterpolation(describing value: Any?) {\n\t\tif let value {\n\t\t\tappendInterpolation(value)\n\t\t} else {\n\t\t\tappendLiteral(\"nil\")\n\t\t}\n\t}\n}\n\nextension CGSize {\n\t/**\n\tExample: `140×100`\n\t*/\n\tvar formatted: String { \"\\(Double(width).formatted(.number.grouping(.never)))\\u{2009}×\\u{2009}\\(Double(height).formatted(.number.grouping(.never)))\" }\n}\n\n\nextension NSImage {\n\t/**\n\t`UIImage` polyfill.\n\t*/\n\tconvenience init(cgImage: CGImage) {\n\t\tself.init(cgImage: cgImage, size: .zero)\n\t}\n}\n\n\nextension CGImage {\n\tvar toNSImage: NSImage { NSImage(cgImage: self) }\n}\n\n\nextension AVAsset {\n\tfunc image(at time: CMTime) async throws -> CGImage? {\n\t\tlet imageGenerator = AVAssetImageGenerator(asset: self)\n\t\timageGenerator.appliesPreferredTrackTransform = true\n\t\timageGenerator.requestedTimeToleranceAfter = .zero\n\t\timageGenerator.requestedTimeToleranceBefore = .zero\n\t\treturn try await imageGenerator.image(at: time).image\n\t}\n}\n\n\nextension AVAssetTrack {\n\tenum VideoTrimmingError: Error {\n\t\tcase unknownAssetReaderFailure\n\t\tcase videoTrackIsEmpty\n\t\tcase assetIsMissingVideoTrack\n\t\tcase compositionCouldNotBeCreated\n\t\tcase codecNotSupported\n\t}\n\n\t/**\n\tRemoves blank frames from the beginning of the track.\n\n\tThis can be useful to trim blank frames from files produced by tools like the iOS simulator screen recorder.\n\t*/\n\tfunc trimmingBlankFrames() async throws -> AVAssetTrack {\n\t\t// See https://github.com/sindresorhus/Gifski/issues/254 for context.\n\t\t// In short: Some codecs seem to always report a sample buffer size of 0 when reading, breaking this function. (macOS 11.6)\n\t\tlet buggyCodecs = [\"v210\", \"BGRA\"]\n\t\tif\n\t\t\tlet codecIdentifier = try await codecIdentifier,\n\t\t\tbuggyCodecs.contains(codecIdentifier)\n\t\t{\n\t\t\tthrow VideoTrimmingError.codecNotSupported\n\t\t}\n\n\t\t// Create new composition\n\t\tlet composition = AVMutableComposition()\n\t\tguard\n\t\t\tlet wrappedTrack = composition.addMutableTrack(withMediaType: mediaType, preferredTrackID: .zero)\n\t\telse {\n\t\t\tthrow VideoTrimmingError.compositionCouldNotBeCreated\n\t\t}\n\n\t\tlet (preferredTransform, timeRange) = try await load(.preferredTransform, .timeRange)\n\n\t\twrappedTrack.preferredTransform = preferredTransform\n\n\t\ttry wrappedTrack.insertTimeRange(timeRange, of: self, at: .zero)\n\n\t\tlet reader = try AVAssetReader(asset: composition)\n\n\t\t// Create reader for wrapped track.\n\t\tlet readerOutput = AVAssetReaderTrackOutput(track: wrappedTrack, outputSettings: nil)\n\t\treaderOutput.alwaysCopiesSampleData = false\n\n\t\treader.add(readerOutput)\n\t\treader.startReading()\n\n\t\tdefer {\n\t\t\treader.cancelReading()\n\t\t}\n\n\t\t// TODO: When targeting macOS 13, use this instead: https://developer.apple.com/documentation/avfoundation/avsamplebuffergenerator/3950878-makebatch?changes=latest_minor\n\n\t\t// Iterate through samples until we reach one with a non-zero size.\n\t\twhile let sampleBuffer = readerOutput.copyNextSampleBuffer() {\n\t\t\tguard [.completed, .reading].contains(reader.status) else {\n\t\t\t\tthrow reader.error ?? VideoTrimmingError.unknownAssetReaderFailure\n\t\t\t}\n\n\t\t\t// On first non-empty frame.\n\t\t\tguard sampleBuffer.totalSampleSize == 0 else {\n\t\t\t\tlet currentTimestamp = sampleBuffer.outputPresentationTimeStamp\n\t\t\t\twrappedTrack.removeTimeRange(.init(start: .zero, end: currentTimestamp))\n\t\t\t\treturn wrappedTrack\n\t\t\t}\n\t\t}\n\n\t\tthrow VideoTrimmingError.videoTrackIsEmpty\n\t}\n}\n\n\nextension AVAssetTrack.VideoTrimmingError: LocalizedError {\n\tpublic var errorDescription: String? {\n\t\tswitch self {\n\t\tcase .unknownAssetReaderFailure:\n\t\t\t\"Asset could not be read.\"\n\t\tcase .videoTrackIsEmpty:\n\t\t\t\"Video track is empty.\"\n\t\tcase .assetIsMissingVideoTrack:\n\t\t\t\"Asset is missing video track.\"\n\t\tcase .compositionCouldNotBeCreated:\n\t\t\t\"Composition could not be created.\"\n\t\tcase .codecNotSupported:\n\t\t\t\"Video codec is not supported.\"\n\t\t}\n\t}\n}\n\n\nextension AVAsset {\n\ttypealias VideoTrimmingError = AVAssetTrack.VideoTrimmingError\n\n\t/**\n\tRemoves blank frames from the beginning of the first video track of the asset. The returned asset only includes the first video track.\n\n\tThis can be useful to trim blank frames from files produced by tools like the iOS simulator screen recorder.\n\t*/\n\tfunc trimmingBlankFramesFromFirstVideoTrack() async throws -> AVAsset {\n\t\tguard let firstVideoTrack = try await firstVideoTrack else {\n\t\t\tthrow VideoTrimmingError.assetIsMissingVideoTrack\n\t\t}\n\n\t\tlet trimmedTrack = try await firstVideoTrack.trimmingBlankFrames()\n\n\t\tguard let trimmedAsset = trimmedTrack.asset else {\n\t\t\tassertionFailure(\"Track is somehow missing asset\")\n\t\t\treturn AVMutableComposition()\n\t\t}\n\n\t\treturn trimmedAsset\n\t}\n}\n\n\nextension AVAssetTrack {\n\t/**\n\tReturns the dimensions of the track if it's a video.\n\t*/\n\tvar dimensions: CGSize? {\n\t\tget async throws {\n\t\t\tlet (naturalSize, preferredTransform) = try await load(.naturalSize, .preferredTransform)\n\n\t\t\tguard naturalSize != .zero else {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tlet size = naturalSize.applying(preferredTransform)\n\t\t\tlet preferredSize = CGSize(width: abs(size.width), height: abs(size.height))\n\n\t\t\t// Workaround for https://github.com/sindresorhus/gifski-app/issues/76\n\t\t\tguard preferredSize != .zero else {\n\t\t\t\t// SInce this is just a fallback, we don't want to throw the error here.\n\t\t\t\treturn try? await asset?.image(at: CMTime(seconds: 0, preferredTimescale: .video))?.size\n\t\t\t}\n\n\t\t\treturn preferredSize\n\t\t}\n\t}\n\n\t/**\n\tReturns the frame rate of the track if it's a video.\n\t*/\n\tvar frameRate: Double? {\n\t\tget async throws {\n\t\t\tDouble(try await load(.nominalFrameRate))\n\t\t}\n\t}\n\n\t/**\n\tReturns the aspect ratio of the track if it's a video.\n\t*/\n\tvar aspectRatio: Double? {\n\t\tget async throws {\n\t\t\ttry await dimensions?.aspectRatio\n\t\t}\n\t}\n\n\t// TODO: Deprecate this. The system now provides strongly-typed identifiers.\n\t/**\n\tExample:\n\t`avc1` (video)\n\t`aac` (audio)\n\t*/\n\tvar codecIdentifier: String? {\n\t\tget async throws {\n\t\t\ttry await load(.formatDescriptions).first?.mediaSubType.rawValue.fourCharCodeToString().nilIfEmpty\n\t\t}\n\t}\n\n\t// TODO: Rename to `format`?\n\tvar codec: AVFormat? {\n\t\tget async throws {\n\t\t\tguard let codecString = try await codecIdentifier else {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\treturn AVFormat(fourCC: codecString)\n\t\t}\n\t}\n\n\t/**\n\tUse this for presenting the codec to the user. This is either the codec name, if known, or the codec identifier. You can just default to `\"Unknown\"` if this is `nil`.\n\t*/\n\tvar codecTitle: String? {\n\t\tget async throws {\n\t\t\t// TODO: Doesn't work because of missing `reasync`.\n\t\t\t// try await codec?.description ?? codecIdentifier\n\n\t\t\tguard let codec = try await codec else {\n\t\t\t\treturn try await codecIdentifier\n\t\t\t}\n\n\t\t\treturn codec.description\n\t\t}\n\t}\n\n\t/**\n\tReturns a debug string with the media format.\n\n\tExample: `vide/avc1`\n\t*/\n\tvar mediaFormat: String {\n\t\tget async throws {\n\t\t\ttry await load(.formatDescriptions).map {\n\t\t\t\t// Get string representation of media type (vide, soun, sbtl, etc.)\n\t\t\t\tlet type = $0.mediaType.description\n\n\t\t\t\t// Get string representation media subtype (avc1, aac, tx3g, etc.)\n\t\t\t\tlet subType = $0.mediaSubType.description\n\n\t\t\t\treturn \"\\(type)/\\(subType)\"\n\t\t\t}\n\t\t\t\t.joined(separator: \",\")\n\t\t}\n\t}\n\n\t/**\n\tEstimated file size of the track, in bytes\n\t*/\n\tvar estimatedFileSize: Int {\n\t\tget async throws {\n\t\t\tlet (estimatedDataRate, timeRange) = try await load(.estimatedDataRate, .timeRange)\n\t\t\tlet dataRateInBytes = Double(estimatedDataRate / 8)\n\t\t\tlet bytes = timeRange.duration.seconds * dataRateInBytes\n\t\t\treturn Int(bytes)\n\t\t}\n\t}\n}\n\n\nextension AVAssetTrack {\n\t/**\n\tWhether the track's duration is the same as the total asset duration.\n\t*/\n\tvar isFullDuration: Bool {\n\t\tget async throws {\n\t\t\tguard let asset else {\n\t\t\t\treturn false\n\t\t\t}\n\n\t\t\tasync let timeRange = load(.timeRange)\n\t\t\tasync let assetDuration = asset.load(.duration)\n\n\t\t\treturn try await (timeRange.duration == assetDuration)\n\t\t}\n\t}\n\n\t/**\n\tExtract the track into a new AVAsset.\n\n\tOptionally, mutate the track.\n\n\tThis can be useful if you only want the video or audio of an asset. For example, sometimes the video track duration is shorter than the total asset duration. Extracting the track into a new asset ensures the asset duration is only as long as the video track duration.\n\t*/\n\tfunc extractToNewAsset(\n\t\t_ modify: ((AVMutableCompositionTrack) -> Void)? = nil\n\t) async throws -> AVAsset? {\n\t\tlet composition = AVMutableComposition()\n\t\tlet (timeRange, preferredTransform) = try await load(.timeRange, .preferredTransform)\n\n\t\tguard\n\t\t\tlet track = composition.addMutableTrack(withMediaType: mediaType, preferredTrackID: kCMPersistentTrackID_Invalid),\n\t\t\t(try? track.insertTimeRange(CMTimeRange(start: .zero, duration: timeRange.duration), of: self, at: .zero)) != nil\n\t\telse {\n\t\t\treturn nil\n\t\t}\n\n\t\ttrack.preferredTransform = preferredTransform\n\n\t\tmodify?(track)\n\n\t\treturn composition\n\t}\n}\n\nextension AVAssetTrack {\n\tstruct VideoKeyframeInfo {\n\t\tlet frameCount: Int\n\t\tlet keyframeCount: Int\n\n\t\tvar keyframeInterval: Double {\n\t\t\tDouble(frameCount) / Double(keyframeCount)\n\t\t}\n\n\t\tvar keyframeRate: Double {\n\t\t\tDouble(keyframeCount) / Double(frameCount)\n\t\t}\n\t}\n\n\tfunc getKeyframeInfo() -> VideoKeyframeInfo? {\n\t\tguard\n\t\t\tlet asset,\n\t\t\tlet reader = try? AVAssetReader(asset: asset)\n\t\telse {\n\t\t\treturn nil\n\t\t}\n\n\t\tlet trackReaderOutput = AVAssetReaderTrackOutput(track: self, outputSettings: nil)\n\t\treader.add(trackReaderOutput)\n\n\t\tguard reader.startReading() else {\n\t\t\treturn nil\n\t\t}\n\n\t\tvar frameCount = 0\n\t\tvar keyframeCount = 0\n\n\t\twhile true {\n\t\t\tguard let sampleBuffer = trackReaderOutput.copyNextSampleBuffer() else {\n\t\t\t\treader.cancelReading()\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tif sampleBuffer.numSamples > 0 {\n\t\t\t\tframeCount += 1\n\n\t\t\t\tif sampleBuffer.sampleAttachments.first?[.notSync] == nil {\n\t\t\t\t\tkeyframeCount += 1\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn VideoKeyframeInfo(frameCount: frameCount, keyframeCount: keyframeCount)\n\t}\n}\n\n\n/*\n> FOURCC is short for \"four character code\" - an identifier for a video codec, compression format, color or pixel format used in media files.\n*/\nextension FourCharCode {\n\t/**\n\tCreate a String representation of a FourCC.\n\t*/\n\tfunc fourCharCodeToString() -> String {\n\t\tlet a_ = self >> 24\n\t\tlet b_ = self >> 16\n\t\tlet c_ = self >> 8\n\t\tlet d_ = self\n\n\t\tlet bytes: [CChar] = [\n\t\t\tCChar(a_ & 0xFF),\n\t\t\tCChar(b_ & 0xFF),\n\t\t\tCChar(c_ & 0xFF),\n\t\t\tCChar(d_ & 0xFF),\n\t\t\t0\n\t\t]\n\n\t\t// Swift type-checking is too slow for this...\n\t\t//\t\tlet bytes: [CChar] = [\n\t\t//\t\t\tCChar((self >> 24) & 0xff),\n\t\t//\t\t\tCChar((self >> 16) & 0xff),\n\t\t//\t\t\tCChar((self >> 8) & 0xff),\n\t\t//\t\t\tCChar(self & 0xff),\n\t\t//\t\t\t0\n\t\t//\t\t]\n\n\t\treturn String(cString: bytes).trimmingCharacters(in: .whitespaces)\n\t}\n}\n\n\nenum AVFormat: String {\n\tcase hevc\n\tcase h264\n\tcase av1\n\tcase vp9\n\tcase appleProResRAWHQ\n\tcase appleProResRAW\n\tcase appleProRes4444XQ\n\tcase appleProRes4444\n\tcase appleProRes422HQ\n\tcase appleProRes422\n\tcase appleProRes422LT\n\tcase appleProRes422Proxy\n\tcase appleAnimation\n\n\t// https://hap.video/using-hap.html\n\t// https://github.com/Vidvox/hap/blob/master/documentation/HapVideoDRAFT.md#names-and-identifiers\n\tcase hap1\n\tcase hap5\n\tcase hapY\n\tcase hapM\n\tcase hapA\n\tcase hap7\n\n\tcase cineFormHD\n\n\t// https://en.wikipedia.org/wiki/QuickTime_Graphics\n\tcase quickTimeGraphics\n\n\t// https://en.wikipedia.org/wiki/Avid_DNxHD\n\tcase avidDNxHD\n\n\tinit?(fourCC: String) {\n\t\tswitch fourCC.trimmingCharacters(in: .whitespaces) {\n\t\tcase \"hvc1\":\n\t\t\tself = .hevc\n\t\tcase \"avc1\":\n\t\t\tself = .h264\n\t\tcase \"av01\":\n\t\t\tself = .av1\n\t\tcase \"vp09\":\n\t\t\tself = .vp9\n\t\tcase \"aprh\": // From https://avpres.net/Glossar/ProResRAW.html\n\t\t\tself = .appleProResRAWHQ\n\t\tcase \"aprn\":\n\t\t\tself = .appleProResRAW\n\t\tcase \"ap4x\":\n\t\t\tself = .appleProRes4444XQ\n\t\tcase \"ap4h\":\n\t\t\tself = .appleProRes4444\n\t\tcase \"apch\":\n\t\t\tself = .appleProRes422HQ\n\t\tcase \"apcn\":\n\t\t\tself = .appleProRes422\n\t\tcase \"apcs\":\n\t\t\tself = .appleProRes422LT\n\t\tcase \"apco\":\n\t\t\tself = .appleProRes422Proxy\n\t\tcase \"rle\":\n\t\t\tself = .appleAnimation\n\t\tcase \"Hap1\":\n\t\t\tself = .hap1\n\t\tcase \"Hap5\":\n\t\t\tself = .hap5\n\t\tcase \"HapY\":\n\t\t\tself = .hapY\n\t\tcase \"HapM\":\n\t\t\tself = .hapM\n\t\tcase \"HapA\":\n\t\t\tself = .hapA\n\t\tcase \"Hap7\":\n\t\t\tself = .hap7\n\t\tcase \"CFHD\":\n\t\t\tself = .cineFormHD\n\t\tcase \"smc\":\n\t\t\tself = .quickTimeGraphics\n\t\tcase \"AVdh\":\n\t\t\tself = .avidDNxHD\n\t\tdefault:\n\t\t\treturn nil\n\t\t}\n\t}\n\n\tinit?(fourCC: FourCharCode) {\n\t\tself.init(fourCC: fourCC.fourCharCodeToString())\n\t}\n\n\tvar fourCC: String {\n\t\tswitch self {\n\t\tcase .hevc:\n\t\t\t\"hvc1\"\n\t\tcase .h264:\n\t\t\t\"avc1\"\n\t\tcase .av1:\n\t\t\t\"av01\"\n\t\tcase .vp9:\n\t\t\t\"vp09\"\n\t\tcase .appleProResRAWHQ:\n\t\t\t\"aprh\"\n\t\tcase .appleProResRAW:\n\t\t\t\"aprn\"\n\t\tcase .appleProRes4444XQ:\n\t\t\t\"ap4x\"\n\t\tcase .appleProRes4444:\n\t\t\t\"ap4h\"\n\t\tcase .appleProRes422HQ:\n\t\t\t\"apcn\"\n\t\tcase .appleProRes422:\n\t\t\t\"apch\"\n\t\tcase .appleProRes422LT:\n\t\t\t\"apcs\"\n\t\tcase .appleProRes422Proxy:\n\t\t\t\"apco\"\n\t\tcase .appleAnimation:\n\t\t\t\"rle \"\n\t\tcase .hap1:\n\t\t\t\"Hap1\"\n\t\tcase .hap5:\n\t\t\t\"Hap5\"\n\t\tcase .hapY:\n\t\t\t\"HapY\"\n\t\tcase .hapM:\n\t\t\t\"HapM\"\n\t\tcase .hapA:\n\t\t\t\"HapA\"\n\t\tcase .hap7:\n\t\t\t\"Hap7\"\n\t\tcase .cineFormHD:\n\t\t\t\"CFHD\"\n\t\tcase .quickTimeGraphics:\n\t\t\t\"smc\"\n\t\tcase .avidDNxHD:\n\t\t\t\"AVdh\"\n\t\t}\n\t}\n\n\tvar isAppleProRes: Bool {\n\t\t[\n\t\t\t.appleProResRAWHQ,\n\t\t\t.appleProResRAW,\n\t\t\t.appleProRes4444XQ,\n\t\t\t.appleProRes4444,\n\t\t\t.appleProRes422HQ,\n\t\t\t.appleProRes422,\n\t\t\t.appleProRes422LT,\n\t\t\t.appleProRes422Proxy\n\t\t].contains(self)\n\t}\n\n\t/**\n\t- Important: This check only covers known (by us) compatible formats. It might be missing some. Don't use it for strict matching. Also keep in mind that even though a codec is supported, it might still not be decodable as the codec profile level might not be supported.\n\t*/\n\tvar isSupported: Bool {\n\t\tself == .hevc || self == .h264 || isAppleProRes\n\t}\n}\n\nextension AVFormat: CustomStringConvertible {\n\tvar description: String {\n\t\tswitch self {\n\t\tcase .hevc:\n\t\t\t\"HEVC\"\n\t\tcase .h264:\n\t\t\t\"H264\"\n\t\tcase .av1:\n\t\t\t\"AV1\"\n\t\tcase .vp9:\n\t\t\t\"VP9\"\n\t\tcase .appleProResRAWHQ:\n\t\t\t\"Apple ProRes RAW HQ\"\n\t\tcase .appleProResRAW:\n\t\t\t\"Apple ProRes RAW\"\n\t\tcase .appleProRes4444XQ:\n\t\t\t\"Apple ProRes 4444 XQ\"\n\t\tcase .appleProRes4444:\n\t\t\t\"Apple ProRes 4444\"\n\t\tcase .appleProRes422HQ:\n\t\t\t\"Apple ProRes 422 HQ\"\n\t\tcase .appleProRes422:\n\t\t\t\"Apple ProRes 422\"\n\t\tcase .appleProRes422LT:\n\t\t\t\"Apple ProRes 422 LT\"\n\t\tcase .appleProRes422Proxy:\n\t\t\t\"Apple ProRes 422 Proxy\"\n\t\tcase .appleAnimation:\n\t\t\t\"Apple Animation\"\n\t\tcase .hap1:\n\t\t\t\"Vidvox Hap\"\n\t\tcase .hap5:\n\t\t\t\"Vidvox Hap Alpha\"\n\t\tcase .hapY:\n\t\t\t\"Vidvox Hap Q\"\n\t\tcase .hapM:\n\t\t\t\"Vidvox Hap Q Alpha\"\n\t\tcase .hapA:\n\t\t\t\"Vidvox Hap Alpha-Only\"\n\t\tcase .hap7:\n\t\t\t// No official name for this.\n\t\t\t\"Vidvox Hap\"\n\t\tcase .cineFormHD:\n\t\t\t\"CineForm HD\"\n\t\tcase .quickTimeGraphics:\n\t\t\t\"QuickTime Graphics\"\n\t\tcase .avidDNxHD:\n\t\t\t\"Avid DNxHD\"\n\t\t}\n\t}\n}\n\nextension AVFormat: CustomDebugStringConvertible {\n\tvar debugDescription: String {\n\t\t\"\\(description) (\\(fourCC.trimmingCharacters(in: .whitespaces)))\"\n\t}\n}\n\n\nextension AVMediaType: @retroactive CustomDebugStringConvertible {\n\tpublic var debugDescription: String {\n\t\tswitch self {\n\t\tcase .audio:\n\t\t\treturn \"Audio\"\n\t\tcase .closedCaption:\n\t\t\treturn \"Closed-caption content\"\n\t\tcase .depthData:\n\t\t\treturn \"Depth data\"\n\t\tcase .metadata:\n\t\t\treturn \"Metadata\"\n\t\t#if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst)\n\t\tcase .metadataObject:\n\t\t\treturn \"Metadata objects\"\n\t\t#endif\n\t\tcase .muxed:\n\t\t\treturn \"Muxed media\"\n\t\tcase .subtitle:\n\t\t\treturn \"Subtitles\"\n\t\tcase .text:\n\t\t\treturn \"Text\"\n\t\tcase .timecode:\n\t\t\treturn \"Time code\"\n\t\tcase .video:\n\t\t\treturn \"Video\"\n\t\tdefault:\n\t\t\treturn \"Unknown\"\n\t\t}\n\t}\n}\n\n\nextension AVAsset {\n\t/**\n\tWhether the first video track is decodable.\n\t*/\n\tvar isVideoDecodable: Bool {\n\t\tget async throws {\n\t\t\tguard\n\t\t\t\ttry await load(.isReadable),\n\t\t\t\tlet firstVideoTrack = try await firstVideoTrack\n\t\t\telse {\n\t\t\t\treturn false\n\t\t\t}\n\n\t\t\treturn try await firstVideoTrack.load(.isDecodable)\n\t\t}\n\t}\n\n\t/**\n\tReturns a boolean of whether there are any video tracks.\n\t*/\n\tvar hasVideo: Bool {\n\t\tget async throws {\n\t\t\ttry await !loadTracks(withMediaType: .video).isEmpty\n\t\t}\n\t}\n\n\t/**\n\tReturns a boolean of whether there are any audio tracks.\n\t*/\n\tvar hasAudio: Bool {\n\t\tget async throws {\n\t\t\ttry await !loadTracks(withMediaType: .audio).isEmpty\n\t\t}\n\t}\n\n\t/**\n\tReturns the first video track if any.\n\t*/\n\tvar firstVideoTrack: AVAssetTrack? {\n\t\tget async throws {\n\t\t\ttry await loadTracks(withMediaType: .video).first\n\t\t}\n\t}\n\n\t/**\n\tReturns the first audio track if any.\n\t*/\n\tvar firstAudioTrack: AVAssetTrack? {\n\t\tget async throws {\n\t\t\ttry await loadTracks(withMediaType: .audio).first\n\t\t}\n\t}\n\n\t/**\n\tReturns the dimensions of the first video track if any.\n\t*/\n\tvar dimensions: CGSize? {\n\t\tget async throws {\n\t\t\ttry await firstVideoTrack?.dimensions\n\t\t}\n\t}\n\n\t/**\n\tReturns the frame rate of the first video track if any.\n\t*/\n\tvar frameRate: Double? {\n\t\tget async throws {\n\t\t\ttry await firstVideoTrack?.frameRate\n\t\t}\n\t}\n\n\t/**\n\tReturns the aspect ratio of the first video track if any.\n\t*/\n\tvar aspectRatio: Double? {\n\t\tget async throws {\n\t\t\ttry await firstVideoTrack?.aspectRatio\n\t\t}\n\t}\n\n\t/**\n\tReturns the video codec of the first video track if any.\n\t*/\n\tvar videoCodec: AVFormat? {\n\t\tget async throws {\n\t\t\ttry await firstVideoTrack?.codec\n\t\t}\n\t}\n\n\t/**\n\tReturns the audio codec of the first audio track if any.\n\n\tExample: `aac`\n\t*/\n\tvar audioCodec: String? {\n\t\tget async throws {\n\t\t\ttry await firstAudioTrack?.codecIdentifier\n\t\t}\n\t}\n\n\t/**\n\tThe file size of the asset, in bytes.\n\n\t- Note: If self is an `AVAsset` and not an `AVURLAsset`, the file size will just be an estimate.\n\t*/\n\tvar fileSize: Int {\n\t\tget async throws {\n\t\t\tguard let urlAsset = self as? AVURLAsset else {\n\t\t\t\t// TODO: Use `concurrentMap` when targeting macOS 15.\n\t\t\t\treturn try await load(.tracks)\n\t\t\t\t\t.asyncMap { try await $0.estimatedFileSize }\n\t\t\t\t\t.sum()\n\t\t\t}\n\n\t\t\treturn urlAsset.url.fileSize\n\t\t}\n\t}\n\n\tvar fileSizeFormatted: String {\n\t\tget async throws {\n\t\t\ttry await fileSize.formatted(.byteCount(style: .file))\n\t\t}\n\t}\n\tvar trackPreferredTransform: CGAffineTransform? {\n\t\tget async throws {\n\t\t\ttry await firstVideoTrack?.load(.preferredTransform)\n\t\t}\n\t}\n}\n\n\nextension AVAsset {\n\t/**\n\tReturns debug info for the asset to use in logging and error messages.\n\t*/\n\tvar debugInfo: String {\n\t\tget async throws {\n\t\t\tvar output = [String]()\n\n\t\t\tlet durationFormatter = DateComponentsFormatter()\n\t\t\tdurationFormatter.unitsStyle = .abbreviated\n\n\t\t\tlet fileExtension = (self as? AVURLAsset)?.url.fileExtension\n\t\t\tasync let codec = asyncNilCoalescing(videoCodec?.debugDescription, default: await self.firstVideoTrack?.codecIdentifier) ?? \"\"\n\t\t\tasync let audioCodec = audioCodec\n\t\t\tasync let duration = Duration.seconds(load(.duration).seconds).formatted()\n\t\t\tasync let dimensions = dimensions?.formatted\n\t\t\tasync let frameRate = frameRate?.rounded(toDecimalPlaces: 2).formatted()\n\t\t\tasync let fileSizeFormatted = fileSizeFormatted\n\t\t\tasync let (isReadable, isPlayable, isExportable, hasProtectedContent) = load(.isReadable, .isPlayable, .isExportable, .hasProtectedContent)\n\n\t\t\toutput.append(\n\t\t\t\t\"\"\"\n\t\t\t\t## AVAsset debug info ##\n\t\t\t\tExtension: \\(describing: fileExtension)\n\t\t\t\tVideo codec: \\(try await codec)\n\t\t\t\tAudio codec: \\(describing: try await audioCodec)\n\t\t\t\tDuration: \\(describing: try await duration)\n\t\t\t\tDimension: \\(describing: try await dimensions)\n\t\t\t\tFrame rate: \\(describing: try await frameRate)\n\t\t\t\tFile size: \\(try await fileSizeFormatted)\n\t\t\t\tIs readable: \\(try await isReadable)\n\t\t\t\tIs playable: \\(try await isPlayable)\n\t\t\t\tIs exportable: \\(try await isExportable)\n\t\t\t\tHas protected content: \\(try await hasProtectedContent)\n\t\t\t\t\"\"\"\n\t\t\t)\n\n\t\t\tfor track in try await load(.tracks) {\n\t\t\t\tasync let codec = track.mediaType == .video ? asyncNilCoalescing(track.codec?.debugDescription, default: try await track.codecIdentifier) : track.codecIdentifier\n\t\t\t\tasync let duration = Duration.seconds(track.load(.timeRange).duration.seconds).formatted()\n\t\t\t\tasync let dimensions = track.dimensions?.formatted\n\t\t\t\tasync let frameRate = track.frameRate?.rounded(toDecimalPlaces: 2).formatted()\n\t\t\t\tasync let (naturalSize, isPlayable, isDecodable) = track.load(.naturalSize, .isPlayable, .isDecodable)\n\n\t\t\t\toutput.append(\n\t\t\t\t\t\"\"\"\n\t\t\t\t\tTrack #\\(track.trackID)\n\t\t\t\t\t----\n\t\t\t\t\tType: \\(track.mediaType.debugDescription)\n\t\t\t\t\tCodec: \\(describing: try await codec)\n\t\t\t\t\tDuration: \\(describing: try await duration)\n\t\t\t\t\tDimensions: \\(describing: try await dimensions)\n\t\t\t\t\tNatural size: \\(describing: try await naturalSize)\n\t\t\t\t\tFrame rate: \\(describing: try await frameRate)\n\t\t\t\t\tIs playable: \\(try await isPlayable)\n\t\t\t\t\tIs decodable: \\(try await isDecodable)\n\t\t\t\t\t----\n\t\t\t\t\t\"\"\"\n\t\t\t\t)\n\t\t\t}\n\n\t\t\treturn output.joined(separator: \"\\n\\n\")\n\t\t}\n\t}\n}\n\n\nextension AVAsset {\n\tstruct VideoMetadata: Hashable {\n\t\tlet dimensions: CGSize\n\t\tlet duration: Duration\n\t\tlet frameRate: Double\n\t\tlet fileSize: Int\n\t\tvar hasAudio: Bool\n\t\tlet trackPreferredTransform: CGAffineTransform?\n\t}\n\n\tvar videoMetadata: VideoMetadata? {\n\t\tget async throws {\n\t\t\tasync let dimensionsResult = dimensions\n\t\t\tasync let frameRateResult = frameRate\n\t\t\tasync let fileSizeResult = fileSize\n\t\t\tasync let durationResult = load(.duration)\n\t\t\tasync let trackPreferredTransformResult = trackPreferredTransform\n\t\t\tasync let hasAudioResult = firstAudioTrack != nil\n\n\t\t\tguard\n\t\t\t\tlet dimensions = try await dimensionsResult,\n\t\t\t\tlet frameRate = try await frameRateResult\n\t\t\telse {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tlet fileSize = try await fileSizeResult\n\t\t\tlet duration = try await durationResult\n\t\t\tlet hasAudio = try await hasAudioResult\n\t\t\tlet trackPreferredTransform = try await trackPreferredTransformResult\n\n\t\t\treturn .init(\n\t\t\t\tdimensions: dimensions,\n\t\t\t\tduration: .seconds(duration.seconds),\n\t\t\t\tframeRate: frameRate,\n\t\t\t\tfileSize: fileSize,\n\t\t\t\thasAudio: hasAudio,\n\t\t\t\ttrackPreferredTransform: trackPreferredTransform\n\t\t\t)\n\t\t}\n\t}\n}\n\nextension URL {\n\tvar videoMetadata: AVAsset.VideoMetadata? {\n\t\tget async throws {\n\t\t\ttry await AVURLAsset(url: self).videoMetadata\n\t\t}\n\t}\n\n\tvar isVideoDecodable: Bool {\n\t\tget async throws {\n\t\t\ttry await AVURLAsset(url: self).isVideoDecodable\n\t\t}\n\t}\n}\n\n\nextension NSView {\n\tfunc constrainEdgesToSuperview(with insets: NSEdgeInsets = .zero) {\n\t\tguard let superview else {\n\t\t\tassertionFailure(\"There is no superview for this view\")\n\t\t\treturn\n\t\t}\n\n\t\tsuperview.translatesAutoresizingMaskIntoConstraints = false\n\t\ttranslatesAutoresizingMaskIntoConstraints = false\n\n\t\tNSLayoutConstraint.activate([\n\t\t\tleadingAnchor.constraint(equalTo: superview.leadingAnchor, constant: insets.left),\n\t\t\ttrailingAnchor.constraint(equalTo: superview.trailingAnchor, constant: -insets.right),\n\t\t\ttopAnchor.constraint(equalTo: superview.topAnchor, constant: insets.top),\n\t\t\tbottomAnchor.constraint(equalTo: superview.bottomAnchor, constant: -insets.bottom)\n\t\t])\n\t}\n\n\tfunc getConstraintConstantFromSuperView(attribute: NSLayoutConstraint.Attribute) -> Double? {\n\t\tguard let constant = getConstraintFromSuperview(attribute: attribute)?.constant else {\n\t\t\treturn nil\n\t\t}\n\n\t\treturn Double(constant)\n\t}\n\n\tfunc getConstraintFromSuperview(attribute: NSLayoutConstraint.Attribute) -> NSLayoutConstraint? {\n\t\tguard let superview else {\n\t\t\treturn nil\n\t\t}\n\n\t\treturn superview.constraints.first {\n\t\t\t($0.secondItem as? NSView == self && $0.secondAttribute == attribute) ||\n\t\t\t($0.firstItem as? NSView == self && $0.firstAttribute == attribute)\n\t\t}\n\t}\n}\n\n\nextension NSPasteboard.PasteboardType {\n\t/**\n\tThe name of the URL if you put a URL on the pasteboard.\n\t*/\n\tstatic let urlName = Self(\"public.url-name\")\n}\n\nextension NSPasteboard.PasteboardType {\n\t/**\n\tConvention for getting the bundle identifier of the source app.\n\n\t> This marker’s presence indicates that the source of the content is the application with the bundle identifier matching its UTF–8 string content. For example: `pasteboard.setString(\"com.sindresorhus.Foo\" forType: \"org.nspasteboard.source\")`. This is useful when the source is not the foreground application. This is meant to be shown to the user by a supporting app for informational purposes only. Note that an empty string is a valid value as explained below.\n\t> - http://nspasteboard.org\n\t*/\n\tstatic let sourceAppBundleIdentifier = Self(\"org.nspasteboard.source\")\n}\n\nextension NSPasteboard {\n\t/**\n\tAdd a marker to the pasteboard indicating which app put the current data on the pasteboard.\n\n\tThis helps clipboard managers identity the source app.\n\n\t- Important: All pasteboard operation should call this, unless you use `NSPasteboard#with`.\n\n\tRead more: http://nspasteboard.org\n\t*/\n\tfunc setSourceApp() {\n\t\tsetString(SSApp.idString, forType: .sourceAppBundleIdentifier)\n\t}\n}\n\nextension NSPasteboard {\n\t/**\n\tStarts a new pasteboard writing session. Do all pasteboard write operations in the given closure.\n\n\tIt takes care of calling `NSPasteboard#prepareForNewContents()` for you and also adds a marker for the source app (`NSPasteboard#setSourceApp()`).\n\n\t```\n\tNSPasteboard.general.with {\n\t\t$0.setString(\"Unicorn\", forType: .string)\n\t}\n\t```\n\t*/\n\tfunc with(_ callback: (NSPasteboard) -> Void) {\n\t\tprepareForNewContents()\n\t\tcallback(self)\n\t\tsetSourceApp()\n\t}\n}\n\n\nextension NSPasteboard {\n\t/**\n\tGet the file URLs from dragged and dropped files.\n\t*/\n\tfunc fileURLs(contentTypes: [UTType] = []) -> [URL] {\n\t\tvar options: [ReadingOptionKey: Any] = [\n\t\t\t.urlReadingFileURLsOnly: true\n\t\t]\n\n\t\tif !contentTypes.isEmpty {\n\t\t\toptions[.urlReadingContentsConformToTypes] = contentTypes.map(\\.identifier)\n\t\t}\n\n\t\tguard\n\t\t\t// swiftlint:disable:next legacy_objc_type\n\t\t\tlet urls = readObjects(forClasses: [NSURL.self], options: options) as? [URL]\n\t\telse {\n\t\t\treturn []\n\t\t}\n\n\t\treturn urls\n\t}\n}\n\n\nenum AssociationPolicy {\n\tcase assign\n\tcase retainNonatomic\n\tcase copyNonatomic\n\tcase retain\n\tcase copy\n\n\tvar rawValue: objc_AssociationPolicy {\n\t\tswitch self {\n\t\tcase .assign:\n\t\t\t.OBJC_ASSOCIATION_ASSIGN\n\t\tcase .retainNonatomic:\n\t\t\t.OBJC_ASSOCIATION_RETAIN_NONATOMIC\n\t\tcase .copyNonatomic:\n\t\t\t.OBJC_ASSOCIATION_COPY_NONATOMIC\n\t\tcase .retain:\n\t\t\t.OBJC_ASSOCIATION_RETAIN\n\t\tcase .copy:\n\t\t\t.OBJC_ASSOCIATION_COPY\n\t\t}\n\t}\n}\n\nfinal class ObjectAssociation<Value: Any> {\n\tprivate let defaultValue: Value\n\tprivate let policy: AssociationPolicy\n\n\tinit(defaultValue: Value, policy: AssociationPolicy = .retainNonatomic) {\n\t\tself.defaultValue = defaultValue\n\t\tself.policy = policy\n\t}\n\n\tsubscript(index: AnyObject) -> Value {\n\t\tget {\n\t\t\tobjc_getAssociatedObject(index, Unmanaged.passUnretained(self).toOpaque()) as? Value ?? defaultValue\n\t\t}\n\t\tset {\n\t\t\tobjc_setAssociatedObject(index, Unmanaged.passUnretained(self).toOpaque(), newValue, policy.rawValue)\n\t\t}\n\t}\n}\n\nextension ObjectAssociation {\n\tconvenience init<T>(policy: AssociationPolicy = .retainNonatomic) where Value == T? {\n\t\tself.init(defaultValue: nil, policy: policy)\n\t}\n}\n\n\nextension AnyCancellable {\n\tprivate static var foreverStore = Set<AnyCancellable>()\n\n\tfunc storeForever() {\n\t\tstore(in: &Self.foreverStore)\n\t}\n}\n\n\nextension CAMediaTimingFunction {\n\tstatic let `default` = CAMediaTimingFunction(name: .default)\n\tstatic let linear = CAMediaTimingFunction(name: .linear)\n\tstatic let easeIn = CAMediaTimingFunction(name: .easeIn)\n\tstatic let easeOut = CAMediaTimingFunction(name: .easeOut)\n\tstatic let easeInOut = CAMediaTimingFunction(name: .easeInEaseOut)\n}\n\n\nextension String {\n\t/**\n\t`NSString` has some useful properties that `String` does not.\n\t*/\n\tvar toNS: NSString { self as NSString } // swiftlint:disable:this legacy_objc_type\n}\n\n\nenum SSApp {\n\tstatic let idString = Bundle.main.bundleIdentifier!\n\tstatic let name = Bundle.main.object(forInfoDictionaryKey: kCFBundleNameKey as String) as! String\n\tstatic let version = Bundle.main.object(forInfoDictionaryKey: \"CFBundleShortVersionString\") as! String\n\tstatic let build = Bundle.main.object(forInfoDictionaryKey: kCFBundleVersionKey as String) as! String\n\tstatic let versionWithBuild = \"\\(version) (\\(build))\"\n}\n\nextension SSApp {\n\tstatic let isFirstLaunch: Bool = {\n\t\tlet key = \"SS_hasLaunched\"\n\n\t\tif UserDefaults.standard.bool(forKey: key) {\n\t\t\treturn false\n\t\t}\n\n\t\tUserDefaults.standard.set(true, forKey: key)\n\t\treturn true\n\t}()\n}\n\nextension SSApp {\n\tstatic func setUpExternalEventListeners() {\n\t\tDistributedNotificationCenter.default.publisher(for: .init(\"\\(SSApp.idString):openSendFeedback\"))\n\t\t\t.sink { _ in\n\t\t\t\tDispatchQueue.main.async {\n\t\t\t\t\tSSApp.appFeedbackUrl().open()\n\t\t\t\t}\n\t\t\t}\n\t\t\t.storeForever()\n\n\t\tDistributedNotificationCenter.default.publisher(for: .init(\"\\(SSApp.idString):copyDebugInfo\"))\n\t\t\t.sink { _ in\n\t\t\t\tDispatchQueue.main.async {\n\t\t\t\t\tNSPasteboard.general.prepareForNewContents()\n\t\t\t\t\tNSPasteboard.general.setString(SSApp.debugInfo, forType: .string)\n\t\t\t\t}\n\t\t\t}\n\t\t\t.storeForever()\n\t}\n}\n\nextension SSApp {\n\tstatic var debugInfo: String {\n\t\t\"\"\"\n\t\t\\(name) \\(versionWithBuild) - \\(idString)\n\t\tmacOS \\(Device.osVersion)\n\t\t\\(Device.hardwareModel)\n\t\t\\(Device.architecture)\n\t\t\"\"\"\n\t}\n\n\t/**\n\t- Note: Call this lazily only when actually needed as otherwise it won't get the live info.\n\t*/\n\tstatic func appFeedbackUrl() -> URL {\n\t\tlet info: [String: String] = [\n\t\t\t\"product\": name,\n\t\t\t\"metadata\": debugInfo\n\t\t]\n\n\t\treturn URL(\"https://sindresorhus.com/feedback\").settingQueryItems(from: info)\n\t}\n}\n\nextension SSApp {\n\t@MainActor\n\tstatic var swiftUIMainWindow: NSWindow? {\n\t\t// It seems like the main window is always the first one.\n\t\tNSApp.windows.first { $0.simpleClassName == \"AppKitWindow\" }\n\t}\n}\n\nextension SSApp {\n\tstatic func runOnce(identifier: String, _ execute: () -> Void) {\n\t\tlet key = \"SS_App_runOnce__\\(identifier)\"\n\n\t\tif !UserDefaults.standard.bool(forKey: key) {\n\t\t\tUserDefaults.standard.set(true, forKey: key)\n\t\t\texecute()\n\t\t}\n\t}\n}\n\nextension SSApp {\n\t/**\n\tInitialize Sentry.\n\t*/\n\tstatic func initSentry(_ dsn: String) {\n\t\t#if !DEBUG && canImport(Sentry)\n\t\tSentrySDK.start {\n\t\t\t$0.dsn = dsn\n\t\t\t$0.enableSwizzling = false\n\t\t\t$0.enableAppHangTracking = false // https://github.com/getsentry/sentry-cocoa/issues/2643\n\t\t}\n\t\t#endif\n\t}\n}\n\nextension SSApp {\n\t/**\n\tReport an error to the chosen crash reporting solution.\n\t*/\n\t@inlinable\n\tstatic func reportError(\n\t\t_ error: Error,\n\t\tuserInfo: [String: Any] = [:],\n\t\tfile: String = #fileID,\n\t\tline: Int = #line\n\t) {\n\t\tguard !(error is CancellationError) else {\n\t\t\t#if DEBUG\n\t\t\tprint(\"[\\(file):\\(line)] CancellationError:\", error)\n\t\t\t#endif\n\t\t\treturn\n\t\t}\n\n\t\tlet userInfo = userInfo\n\t\t\t.appending([\n\t\t\t\t\"file\": file,\n\t\t\t\t\"line\": line\n\t\t\t])\n\n\t\tlet error = NSError.from(\n\t\t\terror: error,\n\t\t\tuserInfo: userInfo\n\t\t)\n\n\t\t#if DEBUG\n\t\tprint(\"[\\(file):\\(line)] Reporting error:\", error)\n\t\t#endif\n\n\t\t#if canImport(Sentry)\n\t\tSentrySDK.capture(error: error)\n\t\t#endif\n\t}\n\n\t/**\n\tReport an error message to the chosen crash reporting solution.\n\t*/\n\t@inlinable\n\tstatic func reportError(\n\t\t_ message: String,\n\t\tuserInfo: [String: Any] = [:],\n\t\tfile: String = #fileID,\n\t\tline: Int = #line\n\t) {\n\t\treportError(\n\t\t\tmessage.toError,\n\t\t\tuserInfo: userInfo,\n\t\t\tfile: file,\n\t\t\tline: line\n\t\t)\n\t}\n}\n\n\nstruct GeneralError: LocalizedError, CustomNSError {\n\t// LocalizedError\n\tlet errorDescription: String?\n\tlet recoverySuggestion: String?\n\tlet helpAnchor: String?\n\n\t// CustomNSError\n\tlet errorUserInfo: [String: Any]\n\t// We don't define `errorDomain` as it will generate something like `AppName.GeneralError` by default.\n\n\tinit(\n\t\t_ description: String,\n\t\trecoverySuggestion: String? = nil,\n\t\tuserInfo: [String: Any] = [:],\n\t\turl: URL? = nil,\n\t\tunderlyingErrors: [Error] = [],\n\t\thelpAnchor: String? = nil\n\t) {\n\t\tself.errorDescription = description\n\t\tself.recoverySuggestion = recoverySuggestion\n\t\tself.helpAnchor = helpAnchor\n\n\t\tself.errorUserInfo = {\n\t\t\tvar userInfo = userInfo\n\n\t\t\tif !underlyingErrors.isEmpty {\n\t\t\t\tuserInfo[NSMultipleUnderlyingErrorsKey] = underlyingErrors\n\t\t\t}\n\n\t\t\tif let url {\n\t\t\t\tuserInfo[NSURLErrorKey] = url\n\t\t\t}\n\n\t\t\treturn userInfo\n\t\t}()\n\t}\n}\n\nextension String {\n\t/**\n\tConvert a string into an error.\n\t*/\n\tvar toError: some LocalizedError { GeneralError(self) }\n}\n\n\nextension URL: @retroactive ExpressibleByStringLiteral {\n\t/**\n\tExample:\n\n\t```\n\tlet url: URL = \"https://sindresorhus.com\"\n\t```\n\t*/\n\tpublic init(stringLiteral value: StaticString) {\n\t\tself.init(string: \"\\(value)\")!\n\t}\n}\n\nextension URL {\n\t/**\n\tExample:\n\n\t```\n\tURL(\"https://sindresorhus.com\")\n\t```\n\t*/\n\tinit(_ staticString: StaticString) {\n\t\tself.init(string: \"\\(staticString)\")!\n\t}\n}\n\n\nextension URL {\n\t/**\n\tConvenience for opening URLs.\n\t*/\n\tfunc open() {\n\t\tNSWorkspace.shared.open(self)\n\t}\n}\n\nextension String {\n\t/*\n\t```\n\t\"https://sindresorhus.com\".openURL()\n\t```\n\t*/\n\tfunc openURL() {\n\t\tURL(string: self)?.open()\n\t}\n}\n\n\nenum Device {\n\tstatic let osVersion: String = {\n\t\tlet os = ProcessInfo.processInfo.operatingSystemVersion\n\t\treturn \"\\(os.majorVersion).\\(os.minorVersion).\\(os.patchVersion)\"\n\t}()\n\n\tstatic let hardwareModel: String = {\n\t\tvar size = 0\n\t\tsysctlbyname(\"hw.model\", nil, &size, nil, 0)\n\t\tvar model = [CChar](repeating: 0, count: size)\n\t\tsysctlbyname(\"hw.model\", &model, &size, nil, 0)\n\t\treturn String(cString: model)\n\t}()\n\n\t/**\n\tThe CPU architecture.\n\n\t```\n\tDevice.architecture\n\t//=> \"arm64\"\n\t```\n\t*/\n\tstatic let architecture: String = {\n\t\tvar sysinfo = utsname()\n\t\tlet result = uname(&sysinfo)\n\n\t\tguard result == EXIT_SUCCESS else {\n\t\t\treturn \"unknown\"\n\t\t}\n\n\t\tlet data = Data(bytes: &sysinfo.machine, count: Int(_SYS_NAMELEN))\n\n\t\tguard let identifier = String(bytes: data, encoding: .ascii) else {\n\t\t\treturn \"unknown\"\n\t\t}\n\n\t\treturn identifier.trimmingCharacters(in: .controlCharacters)\n\t}()\n\n\tstatic let isRunningNativelyOnMacWithAppleSilicon: Bool = {\n\t\t#if os(macOS) && arch(arm64)\n\t\ttrue\n\t\t#else\n\t\tfalse\n\t\t#endif\n\t}()\n\n\tstatic let supportedVideoTypes: [UTType] = [\n\t\t.mpeg4Movie,\n\t\t.quickTimeMovie\n\t]\n}\n\n\ntypealias QueryDictionary = [String: String]\n\n\nextension CharacterSet {\n\t/**\n\tCharacters allowed to be unescaped in an URL.\n\n\thttps://tools.ietf.org/html/rfc3986#section-2.3\n\t*/\n\tstatic let urlUnreservedRFC3986 = CharacterSet(charactersIn: \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~\")\n}\n\n/**\nThis should really not be necessary, but it's at least needed for my `formspree.io` form...\n\nOtherwise is results in \"Internal Server Error\" after submitting the form.\n\nRelevant: https://www.djackson.org/why-we-do-not-use-urlcomponents/\n*/\nprivate func escapeQueryComponent(_ query: String) -> String {\n\tquery.addingPercentEncoding(withAllowedCharacters: .urlUnreservedRFC3986)!\n}\n\n\nextension Dictionary where Key == String {\n\t/**\n\tThis correctly escapes items. See `escapeQueryComponent`.\n\t*/\n\tvar toQueryItems: [URLQueryItem] {\n\t\tmap {\n\t\t\tURLQueryItem(\n\t\t\t\tname: escapeQueryComponent($0),\n\t\t\t\tvalue: escapeQueryComponent(\"\\($1)\")\n\t\t\t)\n\t\t}\n\t}\n\n\tvar toQueryString: String {\n\t\tvar components = URLComponents()\n\t\tcomponents.queryItems = toQueryItems\n\t\treturn components.query!\n\t}\n}\n\n\nextension Dictionary {\n\tfunc compactValues<T>() -> [Key: T] where Value == T? {\n\t\tcompactMapValues { $0 }\n\t}\n}\n\n\nextension URLComponents {\n\t/**\n\tThis correctly escapes items. See `escapeQueryComponent`.\n\t*/\n\tinit?(string: String, query: QueryDictionary) {\n\t\tself.init(string: string)\n\t\tself.queryDictionary = query\n\t}\n\n\t/**\n\tThis correctly escapes items. See `escapeQueryComponent`.\n\t*/\n\tvar queryDictionary: QueryDictionary {\n\t\tget {\n\t\t\tqueryItems?.toDictionary { ($0.name, $0.value) }.compactValues() ?? [:]\n\t\t}\n\t\tset {\n\t\t\t// Using `percentEncodedQueryItems` instead of `queryItems` since the query items are already custom-escaped. See `escapeQueryComponent`.\n\t\t\tpercentEncodedQueryItems = newValue.toQueryItems\n\t\t}\n\t}\n}\n\n\nextension URL {\n\tvar directoryURL: Self { deletingLastPathComponent() }\n\n\tvar directory: String { directoryURL.path }\n\n\tvar filename: String {\n\t\tget { lastPathComponent }\n\t\tset {\n\t\t\tdeleteLastPathComponent()\n\t\t\tappendPathComponent(newValue)\n\t\t}\n\t}\n\n\tvar fileExtension: String {\n\t\tget { pathExtension }\n\t\tset {\n\t\t\tdeletePathExtension()\n\t\t\tappendPathExtension(newValue)\n\t\t}\n\t}\n\n\tvar filenameWithoutExtension: String {\n\t\tget { deletingPathExtension().lastPathComponent }\n\t\tset {\n\t\t\tlet fileExtension = pathExtension\n\t\t\tdeleteLastPathComponent()\n\t\t\tappendPathComponent(newValue)\n\t\t\tappendPathExtension(fileExtension)\n\t\t}\n\t}\n\n\tfunc changingFileExtension(to fileExtension: String) -> Self {\n\t\tvar url = self\n\t\turl.fileExtension = fileExtension\n\t\treturn url\n\t}\n\n\t/**\n\tReturns `self` with the given query dictionary merged in.\n\n\tThe keys in the given dictionary overwrites any existing keys.\n\t*/\n\tfunc settingQueryItems(from queryDictionary: QueryDictionary) -> Self {\n\t\tguard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else {\n\t\t\treturn self\n\t\t}\n\n\t\tcomponents.queryDictionary = components.queryDictionary.appending(queryDictionary)\n\n\t\treturn components.url ?? self\n\t}\n\n\tprivate func resourceValue<T>(forKey key: URLResourceKey) -> T? {\n\t\tguard let values = try? resourceValues(forKeys: [key]) else {\n\t\t\treturn nil\n\t\t}\n\n\t\treturn values.allValues[key] as? T\n\t}\n\n\tprivate func boolResourceValue(forKey key: URLResourceKey, defaultValue: Bool = false) -> Bool {\n\t\tguard let values = try? resourceValues(forKeys: [key]) else {\n\t\t\treturn defaultValue\n\t\t}\n\n\t\treturn values.allValues[key] as? Bool ?? defaultValue\n\t}\n\n\tvar contentType: UTType? { resourceValue(forKey: .contentTypeKey) }\n\n\t/**\n\tFile size in bytes.\n\t*/\n\tvar fileSize: Int { resourceValue(forKey: .fileSizeKey) ?? 0 }\n\n\tvar fileSizeFormatted: String {\n\t\tfileSize.formatted(.byteCount(style: .file))\n\t}\n\n\tvar exists: Bool { FileManager.default.fileExists(atPath: path) }\n\n\tvar isReadable: Bool { boolResourceValue(forKey: .isReadableKey) }\n\n\tvar isWritable: Bool { boolResourceValue(forKey: .isWritableKey) }\n\n\tvar isVolumeReadonly: Bool { boolResourceValue(forKey: .volumeIsReadOnlyKey) }\n}\n\n\nextension URL {\n\t/**\n\tReturns the user's real home directory when called in a sandboxed app.\n\t*/\n\tstatic let realHomeDirectory = Self(\n\t\tfileURLWithFileSystemRepresentation: getpwuid(getuid())!.pointee.pw_dir!,\n\t\tisDirectory: true,\n\t\trelativeTo: nil\n\t)\n}\n\n\nextension URL {\n\tfunc relationship(to url: Self) -> FileManager.URLRelationship {\n\t\tvar relationship = FileManager.URLRelationship.other\n\t\t_ = try? FileManager.default.getRelationship(&relationship, ofDirectoryAt: self, toItemAt: url)\n\t\treturn relationship\n\t}\n}\n\n\nextension URL {\n\t/**\n\tCheck whether the URL is inside the home directory.\n\t*/\n\tvar isInsideHomeDirectory: Bool {\n\t\tSelf.realHomeDirectory.relationship(to: self) == .contains\n\t}\n\n\t/**\n\tCheck whether the URL path is on the main volume; The volume with the root file system.\n\n\t- Note: The URL does not need to exist.\n\t*/\n\tvar isOnMainVolume: Bool {\n\t\t// We intentionally do a string check instead of `try? resourceValues(forKeys: [.volumeIsRootFileSystemKey]).volumeIsRootFileSystem` as it's faster and it works on URLs that doesn't exist.\n\t\t!path.hasPrefix(\"/Volumes/\")\n\t}\n}\n\n\nextension URL {\n\t/**\n\tWhether the directory URL is suitable for use as a default directory for a save panel.\n\t*/\n\tvar canBeDefaultSavePanelDirectory: Bool {\n\t\t// We allow if it's inside the home directory on the main volume or on a different writable volume.\n\t\tisInsideHomeDirectory || (!isOnMainVolume && !isVolumeReadonly)\n\t}\n}\n\n\nextension CGSize {\n\tstatic func * (lhs: Self, rhs: Double) -> Self {\n\t\t.init(width: lhs.width * rhs, height: lhs.height * rhs)\n\t}\n\n\tstatic func / (lhs: Self, rhs: Self) -> Self {\n\t\t.init(width: lhs.width / rhs.width, height: lhs.height / rhs.height)\n\t}\n\n\tstatic func > (lhs: Self, rhs: Double) -> Bool {\n\t\tlhs.width > rhs && lhs.height > rhs\n\t}\n\n\tstatic let one = Self(widthHeight: 1)\n\n\tinit(widthHeight: Double) {\n\t\tself.init(width: widthHeight, height: widthHeight)\n\t}\n\n\tvar cgRect: CGRect { .init(origin: .zero, size: self) }\n\n\tvar longestSide: Double { max(width, height) }\n\n\tvar aspectRatio: Double { width / height }\n\n\tfunc aspectFit(to boundingSize: CGSize) -> Self {\n\t\tlet ratio = min(boundingSize.width / width, boundingSize.height / height)\n\t\treturn self * ratio\n\t}\n\n\tfunc aspectFit(to widthHeight: Double) -> Self {\n\t\taspectFit(to: Self(width: widthHeight, height: widthHeight))\n\t}\n\n\tfunc aspectFill(to boundingSize: CGSize) -> Self {\n\t\tlet ratio = max(boundingSize.width / width, boundingSize.height / height)\n\t\treturn self * ratio\n\t}\n\n\tfunc aspectFill(to widthHeight: Double) -> Self {\n\t\taspectFill(to: Self(width: widthHeight, height: widthHeight))\n\t}\n\n\t/**\n\tReturns the simplest integer aspect ratio (width, height) for the current size.\n\n\t```\n\tlet (widthRatio, heightRatio) = size.integerAspectRatio()\n\t```\n\t*/\n\tfunc integerAspectRatio() -> (Int, Int) {\n\t\tlet roundedWidth = Int(width.rounded())\n\t\tlet roundedHeight = Int(height.rounded())\n\t\tlet divisor = greatestCommonDivisor(roundedWidth, roundedHeight)\n\t\tlet widthRatio = roundedWidth / divisor\n\t\tlet heightRatio = roundedHeight / divisor\n\t\treturn (widthRatio, heightRatio)\n\t}\n}\n\n\nextension CGRect {\n\tinit(origin: CGPoint = .zero, width: Double, height: Double) {\n\t\tself.init(origin: origin, size: CGSize(width: width, height: height))\n\t}\n\n\tinit(widthHeight: Double) {\n\t\tself.init()\n\t\tself.origin = .zero\n\t\tself.size = CGSize(widthHeight: widthHeight)\n\t}\n\n\tvar x: Double {\n\t\tget { origin.x }\n\t\tset {\n\t\t\torigin.x = newValue\n\t\t}\n\t}\n\n\tvar y: Double {\n\t\tget { origin.y }\n\t\tset {\n\t\t\torigin.y = newValue\n\t\t}\n\t}\n\n\tvar width: Double {\n\t\tget { size.width }\n\t\tset {\n\t\t\tsize.width = newValue\n\t\t}\n\t}\n\n\tvar height: Double {\n\t\tget { size.height }\n\t\tset {\n\t\t\tsize.height = newValue\n\t\t}\n\t}\n\n\t// MARK: - Edges\n\n\tvar left: Double {\n\t\tget { x }\n\t\tset {\n\t\t\tx = newValue\n\t\t}\n\t}\n\n\tvar right: Double {\n\t\tget { x + width }\n\t\tset {\n\t\t\tx = newValue - width\n\t\t}\n\t}\n\n\tvar top: Double {\n\t\tget { y + height }\n\t\tset {\n\t\t\ty = newValue - height\n\t\t}\n\t}\n\n\tvar bottom: Double {\n\t\tget { y }\n\t\tset {\n\t\t\ty = newValue\n\t\t}\n\t}\n\n\t// MARK: -\n\n\tvar center: CGPoint {\n\t\tget { CGPoint(x: midX, y: midY) }\n\t\tset {\n\t\t\torigin = CGPoint(\n\t\t\t\tx: newValue.x - (size.width / 2),\n\t\t\t\ty: newValue.y - (size.height / 2)\n\t\t\t)\n\t\t}\n\t}\n\n\tvar centerX: Double {\n\t\tget { midX }\n\t\tset {\n\t\t\tcenter = CGPoint(x: newValue, y: midY)\n\t\t}\n\t}\n\n\tvar centerY: Double {\n\t\tget { midY }\n\t\tset {\n\t\t\tcenter = CGPoint(x: midX, y: newValue)\n\t\t}\n\t}\n\n\t/**\n\tReturns a `CGRect` where `self` is centered in `rect`.\n\t*/\n\tfunc centered(\n\t\tin rect: Self,\n\t\txOffset: Double = 0,\n\t\tyOffset: Double = 0\n\t) -> Self {\n\t\t.init(\n\t\t\tx: ((rect.width - size.width) / 2) + xOffset,\n\t\t\ty: ((rect.height - size.height) / 2) + yOffset,\n\t\t\twidth: size.width,\n\t\t\theight: size.height\n\t\t)\n\t}\n\n\t/**\n\tReturns a CGRect where `self` is centered in `rect`.\n\n\t- Parameters:\n\t\t- xOffsetPercent: The offset in percentage of `rect.width`.\n\t*/\n\tfunc centered(\n\t\tin rect: Self,\n\t\txOffsetPercent: Double,\n\t\tyOffsetPercent: Double\n\t) -> Self {\n\t\tcentered(\n\t\t\tin: rect,\n\t\t\txOffset: rect.width * xOffsetPercent,\n\t\t\tyOffset: rect.height * yOffsetPercent\n\t\t)\n\t}\n\n\t/**\n\tReturns a `CGRect` with the same center position, but a new size.\n\t*/\n\tfunc centeredRectWith(size: CGSize) -> Self {\n\t\tCGRect(\n\t\t\tx: midX - size.width / 2.0,\n\t\t\ty: midY - size.height / 2.0,\n\t\t\twidth: size.width,\n\t\t\theight: size.height\n\t\t)\n\t}\n\n\t/**\n\tReturns a Crop Rect of the current Rect given a certain size\n\t*/\n\tfunc toCropRect(forVideoDimensions dimensions: CGSize) -> CropRect {\n\t\t.init(\n\t\t\tx: x / dimensions.width,\n\t\t\ty: y / dimensions.height,\n\t\t\twidth: width / dimensions.width,\n\t\t\theight: height / dimensions.height\n\t\t)\n\t}\n}\n\n\nextension Error {\n\tpublic var isCancelled: Bool {\n\t\tdo {\n\t\t\tthrow self\n\t\t} catch is CancellationError, URLError.cancelled, CocoaError.userCancelled {\n\t\t\treturn true\n\t\t} catch {\n\t\t\treturn false\n\t\t}\n\t}\n}\n\n\nextension NSResponder {\n\t/**\n\tPresents the error in the given window if it's not nil, otherwise falls back to an app-modal dialog.\n\t*/\n\tpublic func presentError(_ error: Error, modalFor window: NSWindow?) {\n\t\tguard let window else {\n\t\t\tpresentError(error)\n\t\t\treturn\n\t\t}\n\n\t\tpresentError(error, modalFor: window, delegate: nil, didPresent: nil, contextInfo: nil)\n\t}\n}\n\n\nextension Error {\n\tvar isNsError: Bool { Self.self is NSError.Type }\n}\n\n\nextension NSError {\n\tstatic func from(error: Error, userInfo: [String: Any] = [:]) -> NSError {\n\t\tlet nsError = error as NSError\n\n\t\t// Since Error and NSError are often bridged between each other, we check if it was originally an NSError and then return that.\n\t\tguard !error.isNsError else {\n\t\t\tguard !userInfo.isEmpty else {\n\t\t\t\treturn nsError\n\t\t\t}\n\n\t\t\treturn nsError.appending(userInfo: userInfo)\n\t\t}\n\n\t\tvar userInfo = userInfo\n\t\tuserInfo[NSLocalizedDescriptionKey] = error.localizedDescription\n\n\t\t// This is needed as `localizedDescription` often lacks important information, for example, when an NSError is wrapped in a Swift.Error.\n\t\tuserInfo[\"Swift.Error\"] = \"\\(nsError.domain).\\(error)\"\n\n\t\t// Awful, but no better way to get the enum case name.\n\t\t// This gets `Error.generateFrameFailed` from `Error.generateFrameFailed(Error Domain=AVFoundationErrorDomain Code=-11832 […]`.\n\t\tlet errorName = \"\\(error)\".split(separator: \"(\").first ?? \"\"\n\n\t\treturn .init(\n\t\t\tdomain: \"\\(SSApp.idString) - \\(nsError.domain)\\(errorName.isEmpty ? \"\" : \".\")\\(errorName)\",\n\t\t\tcode: nsError.code,\n\t\t\tuserInfo: userInfo\n\t\t)\n\t}\n\n\t/**\n\tReturns a new error with the user info appended.\n\t*/\n\tfunc appending(userInfo newUserInfo: [String: Any]) -> Self {\n\t\t.init(\n\t\t\tdomain: domain,\n\t\t\tcode: code,\n\t\t\tuserInfo: userInfo.appending(newUserInfo)\n\t\t)\n\t}\n}\n\n\nextension NSError {\n\t/**\n\tUse this for generic app errors.\n\n\t- Note: Prefer using a specific enum-type error whenever possible.\n\n\t- Parameter description: The description of the error. This is shown as the first line in error dialogs.\n\t- Parameter recoverySuggestion: Explain how the user how they can recover from the error. For example, \"Try choosing a different directory\". This is usually shown as the second line in error dialogs.\n\t- Parameter userInfo: Metadata to add to the error. Can be a custom key or any of the `NSLocalizedDescriptionKey` keys except `NSLocalizedDescriptionKey` and `NSLocalizedRecoverySuggestionErrorKey`.\n\t- Parameter domainPostfix: String to append to the `domain` to make it easier to identify the error. The domain is the app's bundle identifier.\n\t*/\n\tstatic func appError(\n\t\t_ description: String,\n\t\trecoverySuggestion: String? = nil,\n\t\tuserInfo: [String: Any] = [:],\n\t\tdomainPostfix: String? = nil\n\t) -> Self {\n\t\tvar userInfo = userInfo\n\t\tuserInfo[NSLocalizedDescriptionKey] = description\n\n\t\tif let recoverySuggestion {\n\t\t\tuserInfo[NSLocalizedRecoverySuggestionErrorKey] = recoverySuggestion\n\t\t}\n\n\t\treturn .init(\n\t\t\tdomain: domainPostfix.map { \"\\(SSApp.idString) - \\($0)\" } ?? SSApp.idString,\n\t\t\tcode: 1, // This is what Swift errors end up as.\n\t\t\tuserInfo: userInfo\n\t\t)\n\t}\n}\n\n\nextension Dictionary {\n\t/**\n\tAdds the elements of the given dictionary to a copy of self and returns that.\n\n\tIdentical keys in the given dictionary overwrites keys in the copy of self.\n\t*/\n\tfunc appending(_ dictionary: [Key: Value]) -> [Key: Value] {\n\t\tvar newDictionary = self\n\n\t\tfor (key, value) in dictionary {\n\t\t\tnewDictionary[key] = value\n\t\t}\n\n\t\treturn newDictionary\n\t}\n}\n\n\nextension Sequence where Element: AdditiveArithmetic {\n\tfunc sum() -> Element {\n\t\treduce(into: .zero, +=)\n\t}\n}\n\nextension Sequence {\n\t/**\n\tReturns the sum of elements in a sequence by mapping the elements with a numerator.\n\n\t```\n\t[1, 2, 3].sum { $0 == 1 ? 10 : $0 }\n\t//=> 15\n\t```\n\t*/\n\tfunc sum<T: AdditiveArithmetic, E>(_ numerator: (Element) throws(E) -> T) throws(E) -> T {\n\t\tvar result = T.zero\n\n\t\tfor element in self {\n\t\t\tresult += try numerator(element)\n\t\t}\n\n\t\treturn result\n\t}\n}\n\n\nextension Sequence {\n\t/**\n\tConvert a sequence to a dictionary by mapping over the values and using the returned key as the key and the current sequence element as value.\n\n\t```\n\t[1, 2, 3].toDictionary { $0 }\n\t//=> [1: 1, 2: 2, 3: 3]\n\t```\n\t*/\n\tfunc toDictionary<Key: Hashable>(with pickKey: (Element) -> Key) -> [Key: Element] {\n\t\tvar dictionary = [Key: Element]()\n\t\tfor element in self {\n\t\t\tdictionary[pickKey(element)] = element\n\t\t}\n\t\treturn dictionary\n\t}\n\n\t/**\n\tConvert a sequence to a dictionary by mapping over the elements and returning a key/value tuple representing the new dictionary element.\n\n\t```\n\t[(1, \"a\"), (2, \"b\")].toDictionary { ($1, $0) }\n\t//=> [\"a\": 1, \"b\": 2]\n\t```\n\t*/\n\tfunc toDictionary<Key: Hashable, Value>(with pickKeyValue: (Element) -> (Key, Value)) -> [Key: Value] {\n\t\tvar dictionary = [Key: Value]()\n\t\tfor element in self {\n\t\t\tlet newElement = pickKeyValue(element)\n\t\t\tdictionary[newElement.0] = newElement.1\n\t\t}\n\t\treturn dictionary\n\t}\n\n\t/**\n\tSame as the above but supports returning optional values.\n\n\t```\n\t[(1, \"a\"), (nil, \"b\")].toDictionary { ($1, $0) }\n\t//=> [\"a\": 1, \"b\": nil]\n\t```\n\t*/\n\tfunc toDictionary<Key: Hashable, Value>(with pickKeyValue: (Element) -> (Key, Value?)) -> [Key: Value?] {\n\t\tvar dictionary = [Key: Value?]()\n\t\tfor element in self {\n\t\t\tlet newElement = pickKeyValue(element)\n\t\t\tdictionary[newElement.0] = newElement.1\n\t\t}\n\t\treturn dictionary\n\t}\n}\n\n\nextension BinaryFloatingPoint {\n\tfunc rounded(\n\t\ttoDecimalPlaces decimalPlaces: Int,\n\t\trule: FloatingPointRoundingRule = .toNearestOrAwayFromZero\n\t) -> Self {\n\t\tguard decimalPlaces >= 0 else {\n\t\t\treturn self\n\t\t}\n\n\t\tvar divisor: Self = 1\n\t\tfor _ in 0..<decimalPlaces {\n\t\t\tdivisor *= 10\n\t\t}\n\n\t\treturn (self * divisor).rounded(rule) / divisor\n\t}\n}\n\nextension CGSize {\n\tfunc rounded(_ rule: FloatingPointRoundingRule = .toNearestOrAwayFromZero) -> Self {\n\t\tSelf(width: width.rounded(rule), height: height.rounded(rule))\n\t}\n}\n\n\nextension Collection {\n\t/**\n\tReturns the element at the specified index if it is within bounds, otherwise `nil`.\n\t*/\n\tsubscript(safe index: Index) -> Element? {\n\t\tindices.contains(index) ? self[index] : nil\n\t}\n}\n\n\nprotocol Copyable {\n\tinit(instance: Self)\n}\n\nextension Copyable {\n\tfunc copy() -> Self {\n\t\tSelf(instance: self)\n\t}\n}\n\n\n// swiftlint:disable all\nextension FloatingPoint {\n\t@inlinable\n\tpublic func isAlmostEqual(\n\t\tto other: Self,\n\t\ttolerance: Self = ulpOfOne.squareRoot()\n\t) -> Bool {\n\t\tassert(tolerance >= .ulpOfOne && tolerance < 1, \"tolerance should be in [.ulpOfOne, 1).\")\n\n\t\tguard isFinite, other.isFinite else {\n\t\t\treturn rescaledAlmostEqual(to: other, tolerance: tolerance)\n\t\t}\n\n\t\tlet scale = max(abs(self), abs(other), .leastNormalMagnitude)\n\t\treturn abs(self - other) < scale * tolerance\n\t}\n\n\t@inlinable\n\tpublic func isAlmostZero(\n\t\tabsoluteTolerance tolerance: Self = ulpOfOne.squareRoot()\n\t) -> Bool {\n\t\tassert(tolerance > 0)\n\t\treturn abs(self) < tolerance\n\t}\n\n\t@usableFromInline\n\tfunc rescaledAlmostEqual(to other: Self, tolerance: Self) -> Bool {\n\t\tif isNaN || other.isNaN {\n\t\t\treturn false\n\t\t}\n\n\t\tif isInfinite {\n\t\t\tif other.isInfinite {\n\t\t\t\treturn self == other\n\t\t\t}\n\n\t\t\tlet scaledSelf = Self(\n\t\t\t\tsign: sign,\n\t\t\t\texponent: Self.greatestFiniteMagnitude.exponent,\n\t\t\t\tsignificand: 1\n\t\t\t)\n\t\t\tlet scaledOther = Self(\n\t\t\t\tsign: .plus,\n\t\t\t\texponent: -1,\n\t\t\t\tsignificand: other\n\t\t\t)\n\n\t\t\treturn scaledSelf.isAlmostEqual(to: scaledOther, tolerance: tolerance)\n\t\t}\n\n\t\treturn other.rescaledAlmostEqual(to: self, tolerance: tolerance)\n\t}\n}\n\n// swiftlint:enable all\n\n\nextension NSEdgeInsets {\n\tstatic let zero = NSEdgeInsetsZero\n\n\tinit(\n\t\ttop: Double = 0,\n\t\tleft: Double = 0,\n\t\tbottom: Double = 0,\n\t\tright: Double = 0\n\t) {\n\t\tself.init()\n\t\tself.top = top\n\t\tself.left = left\n\t\tself.bottom = bottom\n\t\tself.right = right\n\t}\n\n\tinit(all: Double) {\n\t\tself.init(\n\t\t\ttop: all,\n\t\t\tleft: all,\n\t\t\tbottom: all,\n\t\t\tright: all\n\t\t)\n\t}\n\n\tvar vertical: Double { top + bottom }\n\tvar horizontal: Double { left + right }\n}\n\n\nextension URL {\n\tfunc setAppAsItemCreator() throws {\n\t\ttry systemMetadata.set(kMDItemCreator as String, value: \"\\(SSApp.name) \\(SSApp.version)\")\n\t}\n}\n\n\nextension URL {\n\tvar components: URLComponents? {\n\t\tURLComponents(url: self, resolvingAgainstBaseURL: true)\n\t}\n\n\tvar queryDictionary: [String: String] { components?.queryDictionary ?? [:] }\n}\n\n\nextension NSView {\n\t/**\n\tGet a subview matching a condition.\n\t*/\n\tfunc firstSubview(deep: Bool = false, where matches: (NSView) -> Bool) -> NSView? {\n\t\tfor subview in subviews {\n\t\t\tif matches(subview) {\n\t\t\t\treturn subview\n\t\t\t}\n\n\t\t\tif deep, let match = subview.firstSubview(deep: deep, where: matches) {\n\t\t\t\treturn match\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}\n}\n\n\nextension NSLayoutConstraint {\n\t/**\n\tReturns copy of the constraint with changed properties provided as arguments.\n\t*/\n\tfunc changing(\n\t\tfirstItem: Any? = nil,\n\t\tfirstAttribute: Attribute? = nil,\n\t\trelation: Relation? = nil,\n\t\tsecondItem: NSView? = nil,\n\t\tsecondAttribute: Attribute? = nil,\n\t\tmultiplier: Double? = nil,\n\t\tconstant: Double? = nil\n\t) -> Self {\n\t\t.init(\n\t\t\titem: firstItem ?? self.firstItem as Any,\n\t\t\tattribute: firstAttribute ?? self.firstAttribute,\n\t\t\trelatedBy: relation ?? self.relation,\n\t\t\ttoItem: secondItem ?? self.secondItem,\n\t\t\tattribute: secondAttribute ?? self.secondAttribute,\n\t\t\t// The compiler fails to auto-convert to CGFloat here.\n\t\t\tmultiplier: multiplier.flatMap(CGFloat.init) ?? self.multiplier,\n\t\t\tconstant: constant.flatMap(CGFloat.init) ?? self.constant\n\t\t)\n\t}\n\n\tfunc animate(\n\t\tto constant: Double,\n\t\tduration: Duration,\n\t\ttimingFunction: CAMediaTimingFunction = .init(name: .easeInEaseOut),\n\t\tcompletionHandler: (() -> Void)? = nil\n\t) {\n\t\tNSAnimationContext.runAnimationGroup { context in\n\t\t\tcontext.duration = duration.toTimeInterval\n\t\t\tcontext.timingFunction = timingFunction\n\t\t\tanimator().constant = constant\n\t\t} completionHandler: {\n\t\t\tcompletionHandler?()\n\t\t}\n\t}\n}\n\n\nextension NSObject {\n\t// Note: It's intentionally a getter to get the dynamic self.\n\t/**\n\tReturns the class name without module name.\n\t*/\n\tstatic var simpleClassName: String { String(describing: self) }\n\n\t/**\n\tReturns the class name of the instance without module name.\n\t*/\n\tvar simpleClassName: String { Self.simpleClassName }\n}\n\n\nextension CMTime {\n\t/**\n\tGet the `CMTime` as a duration from zero to the seconds value of `self`.\n\n\tCan be `nil` when the `.duration` is not available, for example, when an asset has not yet been fully loaded or if it's a live stream.\n\t*/\n\tvar durationRange: ClosedRange<Double>? {\n\t\tguard isNumeric else {\n\t\t\treturn nil\n\t\t}\n\n\t\treturn 0...seconds\n\t}\n}\n\n\nextension CMTimeRange {\n\t/**\n\tGet `self` as a range in seconds.\n\n\tCan be `nil` when the range is not available, for example, when an asset has not yet been fully loaded or if it's a live stream.\n\t*/\n\tvar range: ClosedRange<Double>? {\n\t\tguard\n\t\t\tstart.isNumeric,\n\t\t\tend.isNumeric\n\t\telse {\n\t\t\treturn nil\n\t\t}\n\n\t\treturn start.seconds...end.seconds\n\t}\n}\n\nextension ClosedRange<Double> {\n\tvar cmTimeRange: CMTimeRange {\n\t\t.init(start: .init(seconds: lowerBound, preferredTimescale: .video), end: .init(seconds: upperBound, preferredTimescale: .video))\n\t}\n}\n\n\nextension AVPlayerItem {\n\t/**\n\tThe duration range of the item.\n\n\tCan be `nil` when the `.duration` is not available, for example, when the asset has not yet been fully loaded or if it's a live stream.\n\t*/\n\tvar durationRange: ClosedRange<Double>? { duration.durationRange }\n\n\t/**\n\tThe playable range of the item.\n\n\tCan be `nil` when the `.duration` is not available, for example, when the asset has not yet been fully loaded or if it's a live stream. Or if the user is dragging the trim handle of a video.\n\t*/\n\tvar playbackRange: ClosedRange<Double>? {\n\t\tget {\n\t\t\t// These are not available while the user is dragging the video trim handle of `AVPlayerView`.\n\t\t\tguard\n\t\t\t\treversePlaybackEndTime.isNumeric,\n\t\t\t\tforwardPlaybackEndTime.isNumeric\n\t\t\telse {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tlet startTime = reversePlaybackEndTime.seconds\n\t\t\tlet endTime = forwardPlaybackEndTime.seconds\n\n\t\t\treturn .fromGraceful(startTime, endTime)\n\t\t}\n\t\tset {\n\t\t\tguard let newValue else {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tforwardPlaybackEndTime = CMTime(seconds: newValue.upperBound, preferredTimescale: .video)\n\t\t\treversePlaybackEndTime = CMTime(seconds: newValue.lowerBound, preferredTimescale: .video)\n\t\t}\n\t}\n}\n\n\nextension FileManager {\n\t/**\n\tCopy a file and optionally overwrite the destination if it exists.\n\t*/\n\tfunc copyItem(\n\t\tat sourceURL: URL,\n\t\tto destinationURL: URL,\n\t\toverwrite: Bool = false\n\t) throws {\n\t\tif overwrite {\n\t\t\ttry? removeItem(at: destinationURL)\n\t\t}\n\n\t\ttry copyItem(at: sourceURL, to: destinationURL)\n\t}\n}\n\n\nextension ClosedRange where Bound: AdditiveArithmetic {\n\t/**\n\tGet the length between the lower and upper bound.\n\t*/\n\tvar length: Bound { upperBound - lowerBound }\n}\n\nextension ClosedRange {\n\t/**\n\tReturns true if `self` is a superset of the given range.\n\n\t```\n\t(1.0...1.5).isSuperset(of: 1.2...1.3)\n\t//=> true\n\t```\n\t*/\n\tfunc isSuperset(of other: Self) -> Bool {\n\t\tother.isEmpty ||\n\t\t\t(\n\t\t\t\tlowerBound <= other.lowerBound &&\n\t\t\t\tother.upperBound <= upperBound\n\t\t\t)\n\t}\n\n\t/**\n\tReturns true if `self` is a subset of the given range.\n\n\t```\n\t(1.2...1.3).isSubset(of: 1.0...1.5)\n\t//=> true\n\t```\n\t*/\n\tfunc isSubset(of other: Self) -> Bool {\n\t\tother.isSuperset(of: self)\n\t}\n}\n\nextension ClosedRange<Double> {\n\t// TODO: Add support for negative ranges.\n\t/**\n\tMake a new range where the length (difference between the lower and upper bound) is at least the given amount.\n\n\tThe use-case for this method is being able to ensure a sub-range inside a range is of a certain size.\n\n\tIt will first try to expand on both the lower and upper bound, and if not possible, it will expand the lower bound, and if that is not possible, it will expand the upper bound. If the resulting range is larger than the given `fullRange`, it will be clamped to `fullRange`.\n\n\t- Precondition: The range and the given range must be positive.\n\t- Precondition: The range must be a subset of the given range.\n\n\t```\n\t(1...1.2).minimumRangeLength(of: 1, in: 0...4)\n\t//=> 0.5...1.7\n\n\t(0...0.5).minimumRangeLength(of: 1, in: 0...4)\n\t//=> 0...1\n\n\t(3.5...4).minimumRangeLength(of: 1, in: 0...4)\n\t//=> 3...4\n\n\t(0...0.1).minimumRangeLength(of: 1, in: 0...4)\n\t//=> 0...1\n\t```\n\t*/\n\tfunc minimumRangeLength(of length: Bound, in fullRange: Self) -> Self {\n\t\tguard length > self.length else {\n\t\t\treturn self\n\t\t}\n\n\t\tassert(isSubset(of: fullRange), \"`self` must be a subset of the given range\")\n\t\tassert(lowerBound >= 0 && upperBound >= 0, \"`self` must the positive\")\n\t\tassert(fullRange.lowerBound >= 0 && fullRange.upperBound >= 0, \"The given range must be positive\")\n\n\t\tlet lower = lowerBound - (length / 2)\n\t\tlet upper = upperBound + (length / 2)\n\n\t\tif\n\t\t\tfullRange.contains(lower),\n\t\t\tfullRange.contains(upper)\n\t\t{\n\t\t\treturn lower...upper\n\t\t}\n\n\t\tif\n\t\t\t!fullRange.contains(lower),\n\t\t\tfullRange.contains(upper)\n\t\t{\n\t\t\treturn fullRange.lowerBound...length\n\t\t}\n\n\t\tif\n\t\t\tfullRange.contains(lower),\n\t\t\t!fullRange.contains(upper)\n\t\t{\n\t\t\treturn (fullRange.upperBound - length)...fullRange.upperBound\n\t\t}\n\n\t\treturn self\n\t}\n}\n\n\nextension BinaryInteger {\n\tvar isEven: Bool { isMultiple(of: 2) }\n\tvar isOdd: Bool { !isEven }\n}\n\n\nfinal class LaunchCompletions {\n\tprivate static var shouldAddObserver = true\n\tprivate static var shouldRunInstantly = false\n\tprivate static var finishedLaunchingCompletions = [() -> Void]()\n\n\tstatic func add(_ completion: @escaping () -> Void) {\n\t\tfinishedLaunchingCompletions.append(completion)\n\n\t\tif shouldAddObserver {\n\t\t\tNotificationCenter.default.addObserver(\n\t\t\t\tself,\n\t\t\t\tselector: #selector(runFinishedLaunchingCompletions),\n\t\t\t\tname: NSApplication.didFinishLaunchingNotification,\n\t\t\t\tobject: nil\n\t\t\t)\n\n\t\t\tshouldAddObserver = false\n\t\t}\n\n\t\tif shouldRunInstantly {\n\t\t\trunFinishedLaunchingCompletions()\n\t\t}\n\t}\n\n\tstatic func applicationDidLaunch() {\n\t\tshouldAddObserver = false\n\t\tshouldRunInstantly = true\n\t}\n\n\t@objc\n\tprivate static func runFinishedLaunchingCompletions() {\n\t\tfor completion in finishedLaunchingCompletions {\n\t\t\tcompletion()\n\t\t}\n\n\t\tfinishedLaunchingCompletions = []\n\t}\n}\n\n\nextension NSResponder {\n\t// This method is internally implemented on `NSResponder` as `Error` is generic which comes with many limitations.\n\tfileprivate func presentErrorAsSheet(\n\t\t_ error: Error,\n\t\tfor window: NSWindow,\n\t\tdidPresent: (() -> Void)?\n\t) {\n\t\tfinal class DelegateHandler {\n\t\t\tvar didPresent: (() -> Void)?\n\n\t\t\t@objc\n\t\t\tfunc didPresentHandler() {\n\t\t\t\tdidPresent?()\n\t\t\t}\n\t\t}\n\n\t\tlet delegate = DelegateHandler()\n\t\tdelegate.didPresent = didPresent\n\n\t\tpresentError(\n\t\t\terror,\n\t\t\tmodalFor: window,\n\t\t\tdelegate: delegate,\n\t\t\tdidPresent: #selector(delegate.didPresentHandler),\n\t\t\tcontextInfo: nil\n\t\t)\n\t}\n}\n\nextension Error {\n\t/**\n\tPresent the error as an async sheet on the given window.\n\n\t- Note: This exists because the built-in `NSResponder#presentError(forModal:)` method requires too many arguments, selector as callback, and it says it's modal but it's not blocking, which is surprising.\n\t*/\n\tfunc presentAsSheet(for window: NSWindow, didPresent: (() -> Void)?) {\n\t\tNSApp.presentErrorAsSheet(self, for: window, didPresent: didPresent)\n\t}\n\n\t/**\n\tPresent the error as a blocking modal sheet on the given window.\n\n\tIf the window is nil, the error will be presented in an app-level modal dialog.\n\t*/\n\tfunc presentAsModalSheet(for window: NSWindow?) {\n\t\tguard let window else {\n\t\t\tpresentAsModal()\n\t\t\treturn\n\t\t}\n\n\t\tpresentAsSheet(for: window) {\n\t\t\tNSApp.stopModal()\n\t\t}\n\n\t\tNSApp.runModal(for: window)\n\t}\n\n\t/**\n\tPresent the error as a blocking app-level modal dialog.\n\t*/\n\tfunc presentAsModal() {\n\t\tNSApp.presentError(self)\n\t}\n}\n\n\nextension AVPlayer {\n\t/**\n\tSeek to the start of the playable range of the video.\n\n\tThe start might not be at `0` if, for example, the video has been trimmed in `AVPlayerView` trim mode.\n\t*/\n\tfunc seekToStart() {\n\t\tlet seconds = currentItem?.playbackRange?.lowerBound ?? 0\n\n\t\tseek(\n\t\t\tto: CMTime(seconds: seconds, preferredTimescale: .video),\n\t\t\ttoleranceBefore: .zero,\n\t\t\ttoleranceAfter: .zero\n\t\t)\n\t}\n\n\t/**\n\tSeek to the end of the playable range of the video.\n\n\tThe start might not be at `duration` if, for example, the video has been trimmed in `AVPlayerView` trim mode.\n\t*/\n\tfunc seekToEnd() {\n\t\tguard let seconds = currentItem?.playbackRange?.upperBound ?? currentItem?.duration.seconds else {\n\t\t\treturn\n\t\t}\n\n\t\tseek(\n\t\t\tto: CMTime(seconds: seconds, preferredTimescale: .video),\n\t\t\ttoleranceBefore: .zero,\n\t\t\ttoleranceAfter: .zero\n\t\t)\n\t}\n}\n\n\nfinal class LoopingPlayer: AVPlayer {\n\tprivate var cancellable: AnyCancellable?\n\n\t/**\n\tLoop the playback.\n\t*/\n\tvar loopPlayback = false {\n\t\tdidSet {\n\t\t\tupdateObserver()\n\t\t}\n\t}\n\n\t/**\n\tBounce the playback.\n\t*/\n\tvar bouncePlayback = false {\n\t\tdidSet {\n\t\t\tupdateObserver()\n\n\t\t\tif !bouncePlayback, rate == -1 {\n\t\t\t\trate = 1\n\t\t\t}\n\t\t}\n\t}\n\n\toverride func replaceCurrentItem(with item: AVPlayerItem?) {\n\t\tsuper.replaceCurrentItem(with: item)\n\t\tcancellable = nil\n\t\tupdateObserver()\n\t}\n\n\tprivate func updateObserver() {\n\t\tguard bouncePlayback || loopPlayback else {\n\t\t\tcancellable = nil\n\t\t\tactionAtItemEnd = .pause\n\t\t\treturn\n\t\t}\n\n\t\tactionAtItemEnd = .none\n\n\t\tguard cancellable == nil else {\n\t\t\t// Already observing. No need to update.\n\t\t\treturn\n\t\t}\n\n\t\tcancellable = NotificationCenter.default\n\t\t\t.publisher(for: .AVPlayerItemDidPlayToEndTime, object: currentItem)\n\t\t\t.sink { [weak self] _ in\n\t\t\t\tguard let self else {\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tpause()\n\n\t\t\t\tif\n\t\t\t\t\tbouncePlayback,\n\t\t\t\t\tcurrentItem?.canPlayReverse == true,\n\t\t\t\t\tcurrentTime().seconds > currentItem?.playbackRange?.lowerBound ?? 0\n\t\t\t\t{\n\t\t\t\t\tseekToEnd()\n\t\t\t\t\tplayImmediately(atRate: -defaultRate)\n\t\t\t\t} else if loopPlayback {\n\t\t\t\t\tseekToStart()\n\t\t\t\t\tplayImmediately(atRate: defaultRate)\n\t\t\t\t}\n\t\t\t}\n\t}\n}\n\n\nextension Numeric {\n\tmutating func increment(by value: Self = 1) -> Self {\n\t\tself += value\n\t\treturn self\n\t}\n\n\tmutating func decrement(by value: Self = 1) -> Self {\n\t\tself -= value\n\t\treturn self\n\t}\n}\n\n\nextension Sequence {\n\t/**\n\tReturns an array of elements split into groups of the given size.\n\n\tIf it can't be split evenly, the final chunk will be the remaining elements.\n\n\tIf the requested chunk size is larger than the sequence, the chunk will be smaller than requested.\n\n\t```\n\t[1, 2, 3, 4].chunked(by: 2)\n\t//=> [[1, 2], [3, 4]]\n\t```\n\t*/\n\tfunc chunked(by chunkSize: Int) -> [[Element]] {\n\t\treduce(into: []) { result, current in\n\t\t\tif\n\t\t\t\tlet last = result.last,\n\t\t\t\tlast.count < chunkSize\n\t\t\t{\n\t\t\t\tresult.append(result.removeLast() + [current])\n\t\t\t} else {\n\t\t\t\tresult.append([current])\n\t\t\t}\n\t\t}\n\t}\n}\n\n\nextension Collection where Index == Int {\n\t/**\n\tReturn a subset of the array of the given length by sampling \"evenly distributed\" elements.\n\t*/\n\tfunc sample(length: Int) -> [Element] {\n\t\tprecondition(length >= 0, \"The length cannot be negative.\")\n\n\t\tguard length < count else {\n\t\t\treturn Array(self)\n\t\t}\n\n\t\treturn (0..<length).map { self[($0 * count + count / 2) / length] }\n\t}\n}\n\n\nfinal class AtomicDictionary<Key: Hashable, Value>: CustomDebugStringConvertible {\n\tprivate var storage = [Key: Value]()\n\n\tprivate let queue = DispatchQueue(\n\t\tlabel: \"com.sindresorhus.AtomicDictionary.\\(UUID().uuidString)\",\n\t\tqos: .utility,\n\t\tattributes: .concurrent,\n\t\tautoreleaseFrequency: .inherit,\n\t\ttarget: .global()\n\t)\n\n\tsubscript(key: Key) -> Value? {\n\t\tget {\n\t\t\tqueue.sync { storage[key] }\n\t\t}\n\t\tset {\n\t\t\tqueue.async(flags: .barrier) { [weak self] in\n\t\t\t\tself?.storage[key] = newValue\n\t\t\t}\n\t\t}\n\t}\n\n\tvar debugDescription: String { storage.debugDescription }\n}\n\n/**\nDebounce a function call.\n\nThread-safe.\n\n```\nfinal class Foo {\n\tprivate let debounce = Debouncer(delay: 0.2)\n\n\tfunc reset() {\n\t\tdebounce(_reset)\n\t}\n\n\tprivate func _reset() {\n\t\t// …\n\t}\n}\n```\n\nor\n\n```\nfinal class Foo {\n\tfunc reset() {\n\t\tDebouncer.debounce(delay: 0.2, _reset)\n\t}\n\n\tprivate func _reset() {\n\t\t// …\n\t}\n}\n```\n*/\nfinal class Debouncer {\n\tprivate let delay: Duration\n\tprivate var workItem: DispatchWorkItem?\n\n\tinit(delay: Duration) {\n\t\tself.delay = delay\n\t}\n\n\tfunc callAsFunction(_ action: @escaping () -> Void) {\n\t\tworkItem?.cancel()\n\t\tlet newWorkItem = DispatchWorkItem(block: action)\n\t\tDispatchQueue.main.asyncAfter(delay, execute: newWorkItem)\n\t\tworkItem = newWorkItem\n\t}\n}\n\nextension Debouncer {\n\tprivate static var debouncers = AtomicDictionary<String, Debouncer>()\n\n\tprivate static func debounce(\n\t\tidentifier: String,\n\t\tdelay: Duration,\n\t\taction: @escaping () -> Void\n\t) {\n\t\tlet debouncer = { () -> Debouncer in\n\t\t\tguard let debouncer = debouncers[identifier] else {\n\t\t\t\tlet debouncer = self.init(delay: delay)\n\t\t\t\tdebouncers[identifier] = debouncer\n\t\t\t\treturn debouncer\n\t\t\t}\n\n\t\t\treturn debouncer\n\t\t}()\n\n\t\tdebouncer {\n\t\t\tdebouncers[identifier] = nil\n\t\t\taction()\n\t\t}\n\t}\n\n\t/**\n\tDebounce a function call.\n\n\tThis is less efficient than the instance method, but more convenient.\n\n\tThread-safe.\n\t*/\n\tstatic func debounce(\n\t\tfile: String = #fileID,\n\t\tfunction: StaticString = #function,\n\t\tline: Int = #line,\n\t\tdelay: Duration,\n\t\taction: @escaping () -> Void\n\t) {\n\t\tlet identifier = \"\\(file)-\\(function)-\\(line)\"\n\t\tdebounce(identifier: identifier, delay: delay, action: action)\n\t}\n}\n\n\nextension Sequence where Element: Sequence {\n\tfunc flatten() -> [Element.Element] {\n\t\tflatMap(\\.self)\n\t}\n}\n\n\nextension String {\n\tvar trimmedTrailing: Self {\n\t\treplacingOccurrences(of: #\"\\s+$\"#, with: \"\", options: .regularExpression)\n\t}\n\n\t/**\n\t```\n\t\"Unicorn\".truncating(to: 4)\n\t//=> \"Uni…\"\n\t```\n\t*/\n\tfunc truncating(to number: Int, truncationIndicator: Self = \"…\") -> Self {\n\t\tif number <= 0 {\n\t\t\treturn \"\"\n\t\t}\n\n\t\tif count > number {\n\t\t\treturn String(prefix(number - truncationIndicator.count)).trimmedTrailing + truncationIndicator\n\t\t}\n\n\t\treturn self\n\t}\n}\n\n\nextension CGImage {\n\tstatic let empty = NSImage(size: CGSize(widthHeight: 1), flipped: false) { _ in true }\n\t\t.cgImage(forProposedRect: nil, context: nil, hints: nil)!\n}\n\n\nextension CGImage {\n\tvar size: CGSize { CGSize(width: width, height: height) }\n}\n\n\nextension CGImage {\n\t/**\n\tConvert an image to a `vImage` buffer of the given pixel format.\n\n\t- Parameter premultiplyAlpha: Whether the alpha channel should be premultiplied.\n\t*/\n\tfunc toVImageBuffer(\n\t\tpixelFormat: PixelFormat,\n\t\tpremultiplyAlpha: Bool\n\t) throws -> vImage.PixelBuffer<vImage.Interleaved8x4> {\n\t\tguard\n\t\t\tvar imageFormat = vImage_CGImageFormat(\n\t\t\t\tbitsPerComponent: vImage.Interleaved8x4.bitCountPerComponent,\n\t\t\t\tbitsPerPixel: vImage.Interleaved8x4.bitCountPerPixel,\n\t\t\t\tcolorSpace: CGColorSpaceCreateDeviceRGB(),\n\t\t\t\tbitmapInfo: pixelFormat.toBitmapInfo(premultiplyAlpha: premultiplyAlpha),\n\t\t\t\trenderingIntent: .perceptual\n\t\t\t)\n\t\telse {\n\t\t\tthrow NSError.appError(\"Could not initialize vImage_CGImageFormat\")\n\t\t}\n\n\t\treturn try vImage.PixelBuffer(\n\t\t\tcgImage: self,\n\t\t\tcgImageFormat: &imageFormat,\n\t\t\tpixelFormat: vImage.Interleaved8x4.self\n\t\t)\n\t}\n}\n\n\nextension CGImage {\n\tenum PixelFormat {\n\t\t/**\n\t\tBig-endian, alpha first.\n\t\t*/\n\t\tcase argb\n\n\t\t/**\n\t\tBig-endian, alpha last.\n\t\t*/\n\t\tcase rgba\n\n\t\t/**\n\t\tLittle-endian, alpha first.\n\t\t*/\n\t\tcase bgra\n\n\t\t/**\n\t\tLittle-endian, alpha last.\n\t\t*/\n\t\tcase abgr\n\n\t\tvar title: String {\n\t\t\tswitch self {\n\t\t\tcase .argb:\n\t\t\t\t\"ARGB\"\n\t\t\tcase .rgba:\n\t\t\t\t\"RGBA\"\n\t\t\tcase .bgra:\n\t\t\t\t\"BGRA\"\n\t\t\tcase .abgr:\n\t\t\t\t\"ABGR\"\n\t\t\t}\n\t\t}\n\t}\n}\n\nextension CGImage.PixelFormat: CustomDebugStringConvertible {\n\tvar debugDescription: String { \"CGImage.PixelFormat(\\(title)\" }\n}\n\nextension CGImage.PixelFormat {\n\tfunc toBitmapInfo(premultiplyAlpha: Bool) -> CGBitmapInfo {\n\t\tlet alphaFirst = premultiplyAlpha ? CGImageAlphaInfo.premultipliedFirst : .first\n\t\tlet alphaLast = premultiplyAlpha ? CGImageAlphaInfo.premultipliedLast : .last\n\n\t\tlet byteOrder: CGBitmapInfo\n\t\tlet alphaInfo: CGImageAlphaInfo\n\t\tswitch self {\n\t\tcase .argb:\n\t\t\tbyteOrder = .byteOrder32Big\n\t\t\talphaInfo = alphaFirst\n\t\tcase .rgba:\n\t\t\tbyteOrder = .byteOrder32Big\n\t\t\talphaInfo = alphaLast\n\t\tcase .bgra:\n\t\t\tbyteOrder = .byteOrder32Little\n\t\t\talphaInfo = alphaFirst // This might look wrong, but the order is inverse because of little endian.\n\t\tcase .abgr:\n\t\t\tbyteOrder = .byteOrder32Little\n\t\t\talphaInfo = alphaLast\n\t\t}\n\n\t\treturn CGBitmapInfo(rawValue: byteOrder.rawValue | alphaInfo.rawValue)\n\t}\n}\n\n\nextension CGImage {\n\tstruct Pixels {\n\t\tlet bytes: [UInt8]\n\t\tlet width: Int\n\t\tlet height: Int\n\t\tlet bytesPerRow: Int\n\t}\n\n\t/**\n\tGet the pixels of an image.\n\n\t- Parameter premultiplyAlpha: Whether the alpha channel should be premultiplied.\n\n\tIf you pass the pixels to a C API or external library, you most likely want `premultiplyAlpha: false`.\n\t*/\n\tfunc pixels(\n\t\tas pixelFormat: PixelFormat,\n\t\tpremultiplyAlpha: Bool\n\t) throws -> Pixels {\n\t\tlet buffer = try toVImageBuffer(pixelFormat: pixelFormat, premultiplyAlpha: premultiplyAlpha)\n\n\t\treturn Pixels(\n\t\t\tbytes: buffer.array,\n\t\t\twidth: buffer.width,\n\t\t\theight: buffer.height,\n\t\t\tbytesPerRow: buffer.byteCountPerRow\n\t\t)\n\t}\n}\n\n\nextension vImage.PixelBuffer where Format: StaticPixelFormat {\n\tvar byteCountPerRow: Int { width * byteCountPerPixel }\n}\n\n\nextension CGBitmapInfo {\n\t/**\n\tThe alpha info of the current `CGBitmapInfo`.\n\t*/\n\tvar alphaInfo: CGImageAlphaInfo {\n\t\tget {\n\t\t\tCGImageAlphaInfo(rawValue: rawValue & Self.alphaInfoMask.rawValue) ?? .none\n\t\t}\n\t\tset {\n\t\t\tremove(.alphaInfoMask)\n\t\t\tinsert(.init(rawValue: newValue.rawValue))\n\t\t}\n\t}\n\n\t/**\n\tThe pixel format of the image.\n\n\tReturns `nil` if the pixel format is not supported, for example, non-alpha.\n\t*/\n\tvar pixelFormat: CGImage.PixelFormat? {\n\t\t// While the host byte order is little-endian, by default, `CGImage` is stored in big-endian format on Intel Macs and little-endian on Apple silicon Macs.\n\n\t\tlet alphaInfo = alphaInfo\n\t\tlet isLittleEndian = contains(.byteOrder32Little)\n\n\t\tguard alphaInfo != .none else {\n\t\t\t// TODO: Support non-alpha formats.\n\t\t\t// return isLittleEndian ? .bgr : .rgb\n\t\t\treturn nil\n\t\t}\n\n\t\tlet isAlphaFirst = alphaInfo == .premultipliedFirst || alphaInfo == .first || alphaInfo == .noneSkipFirst\n\n\t\tif isLittleEndian {\n\t\t\treturn isAlphaFirst ? .bgra : .abgr\n\t\t}\n\n\t\treturn isAlphaFirst ? .argb : .rgba\n\t}\n\n\t/**\n\tWhether the alpha channel is premultipled.\n\t*/\n\tvar isPremultipliedAlpha: Bool {\n\t\tlet alphaInfo = alphaInfo\n\t\treturn alphaInfo == .premultipliedFirst || alphaInfo == .premultipliedLast\n\t}\n}\n\n\nextension CGColorSpace {\n\t/**\n\tPresentable title of the color space.\n\t*/\n\tvar title: String {\n\t\tguard let name else {\n\t\t\treturn \"Unknown\"\n\t\t}\n\n\t\treturn (name as String).replacingOccurrences(of: #\"^kCGColorSpace\"#, with: \"\", options: .regularExpression, range: nil)\n\t}\n}\n\n\nextension CGImage {\n\t/**\n\tDebug info for the image.\n\n\t```\n\tprint(image.debugInfo)\n\t```\n\t*/\n\tvar debugInfo: String {\n\t\t\"\"\"\n\t\t## CGImage debug info ##\n\t\tDimension: \\(size.formatted)\n\t\tPixel format: \\(bitmapInfo.pixelFormat?.title, default: \"Unknown\")\n\t\tPremultiplied alpha: \\(bitmapInfo.isPremultipliedAlpha)\n\t\tColor space: \\(colorSpace?.title, default: \"nil\")\n\t\t\"\"\"\n\t}\n}\n\n\nextension Font {\n\t/**\n\tThe default system font size.\n\t*/\n\tstatic let systemFontSize = NSFont.systemFontSize.toDouble\n\n\t/**\n\tThe system font in default size.\n\t*/\n\tstatic func system(\n\t\tweight: Font.Weight = .regular,\n\t\tdesign: Font.Design = .default\n\t) -> Self {\n\t\tsystem(size: systemFontSize, weight: weight, design: design)\n\t}\n}\n\nextension Font {\n\t/**\n\tThe default small system font size.\n\t*/\n\tstatic let smallSystemFontSize = NSFont.smallSystemFontSize.toDouble\n\n\t/**\n\tThe system font in small size.\n\t*/\n\tstatic func smallSystem(\n\t\tweight: Font.Weight = .regular,\n\t\tdesign: Font.Design = .default\n\t) -> Self {\n\t\tsystem(size: smallSystemFontSize, weight: weight, design: design)\n\t}\n}\n\n\nextension CMTime {\n\tstatic func * (lhs: Self, rhs: Double) -> Self {\n\t\tCMTimeMultiplyByFloat64(lhs, multiplier: rhs)\n\t}\n\n\tstatic func *= (lhs: inout Self, rhs: Double) {\n\t\tlhs = lhs * rhs\n\t}\n\n\tstatic func / (lhs: Self, rhs: Double) -> Self {\n\t\tlhs * (1.0 / rhs)\n\t}\n\n\tstatic func /= (lhs: inout Self, rhs: Double) {\n\t\tlhs = lhs / rhs\n\t}\n}\n\n\nextension AVMutableCompositionTrack {\n\t/**\n\tChange the speed of the track using the given multiplier.\n\n\t1 is the current speed. 2 means doubled speed. Etc.\n\t*/\n\tfunc changeSpeed(by speedMultiplier: Double) {\n\t\tscaleTimeRange(timeRange, toDuration: timeRange.duration / speedMultiplier)\n\t}\n}\n\n\nextension AVAssetTrack {\n\t/**\n\tExtract the track to a new asset and also change the speed of the track using the given multiplier.\n\n\t1 is the current speed. 2 means doubled speed. Etc.\n\t*/\n\tfunc extractToNewAssetAndChangeSpeed(to speedMultiplier: Double) async throws -> AVAsset? {\n\t\ttry await extractToNewAsset {\n\t\t\t$0.changeSpeed(by: speedMultiplier)\n\t\t}\n\t}\n}\n\n\nextension AVPlayerItem {\n\t/**\n\tThe played duration percentage (`0...1`).\n\t*/\n\tvar playbackProgress: Double {\n\t\tlet totalDuration = duration.seconds\n\t\tlet duration = currentTime().seconds\n\n\t\tguard\n\t\t\ttotalDuration != 0,\n\t\t\tduration != 0\n\t\telse {\n\t\t\treturn 0\n\t\t}\n\n\t\treturn duration / totalDuration\n\t}\n\n\t/**\n\tSeek to the given percentage (`0...1`) of the total duration.\n\t*/\n\tfunc seek(toPercentage percentage: Double) {\n\t\tseek(\n\t\t\tto: duration * percentage,\n\t\t\ttoleranceBefore: .zero,\n\t\t\ttoleranceAfter: .zero,\n\t\t\tcompletionHandler: nil\n\t\t)\n\t}\n}\n\n\nextension AVPlayerItem {\n\t/**\n\tThe playable range of the item as percentage of the total duration.\n\n\tFor example, if the video has a duration of 10 seconds and you trim it to the last half, this would return `0.5...1`.\n\n\tCan be `nil` when the `.duration` is not available, for example, when the asset has not yet been fully loaded or if it's a live stream.\n\t*/\n\tvar playbackRangePercentage: ClosedRange<Double>? {\n\t\tget {\n\t\t\tguard\n\t\t\t\tlet playbackRange,\n\t\t\t\tlet duration = durationRange?.upperBound\n\t\t\telse {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tlet lowerPercentage = playbackRange.lowerBound / duration\n\t\t\tlet upperPercentage = playbackRange.upperBound / duration\n\t\t\treturn lowerPercentage...upperPercentage\n\t\t}\n\t\tset {\n\t\t\tguard\n\t\t\t\tlet duration = durationRange?.upperBound,\n\t\t\t\tlet playbackPercentageRange = newValue\n\t\t\telse {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tlet lowerBound = duration * playbackPercentageRange.lowerBound\n\t\t\tlet upperBound = duration * playbackPercentageRange.upperBound\n\t\t\tplaybackRange = lowerBound...upperBound\n\t\t}\n\t}\n}\n\n\nenum OperatingSystem {\n\tcase macOS\n\tcase iOS\n\tcase tvOS\n\tcase watchOS\n\tcase visionOS\n\n\t#if os(macOS)\n\tstatic let current = macOS\n\t#elseif os(iOS)\n\tstatic let current = iOS\n\t#elseif os(tvOS)\n\tstatic let current = tvOS\n\t#elseif os(watchOS)\n\tstatic let current = watchOS\n\t#elseif os(visionOS)\n\tstatic let current = visionOS\n\t#else\n\t#error(\"Unsupported platform\")\n\t#endif\n}\n\nextension OperatingSystem {\n\tstatic let isMacOS = current == .macOS\n\tstatic let isIOS = current == .iOS\n\tstatic let isVisionOS = current == .visionOS\n\tstatic let isMacOrVision = isMacOS || isVisionOS\n\tstatic let isIOSOrVision = isIOS || isVisionOS\n\n\tstatic let isMacOS26OrLater: Bool = {\n\t\t#if os(macOS)\n\t\tif #available(macOS 26, *) {\n\t\t\treturn true\n\t\t}\n\n\t\treturn false\n\t\t#else\n\t\tfalse\n\t\t#endif\n\t}()\n\n\tstatic let isMacOS27OrLater: Bool = {\n\t\t#if os(macOS)\n\t\tif #available(macOS 27, *) {\n\t\t\treturn true\n\t\t}\n\n\t\treturn false\n\t\t#else\n\t\tfalse\n\t\t#endif\n\t}()\n}\n\ntypealias OS = OperatingSystem\n\n\nextension ClosedRange {\n\t/**\n\tCreate a `ClosedRange` where it does not matter which bound is upper and lower.\n\n\tUsing a range literal would hard crash if the lower bound is higher than the upper bound.\n\t*/\n\tstatic func fromGraceful(_ bound1: Bound, _ bound2: Bound) -> Self {\n\t\tbound1 <= bound2 ? bound1...bound2 : bound2...bound1\n\t}\n}\n\n\nextension Duration {\n\tvar nanoseconds: Int64 {\n\t\tlet (seconds, attoseconds) = components\n\t\tlet secondsNanos = seconds * 1_000_000_000\n\t\tlet attosecondsNanons = attoseconds / 1_000_000_000\n\t\tlet (totalNanos, isOverflow) = secondsNanos.addingReportingOverflow(attosecondsNanons)\n\t\treturn isOverflow ? .max : totalNanos\n\t}\n\n\tvar toTimeInterval: TimeInterval { Double(nanoseconds) / 1_000_000_000 }\n}\n\n\nstruct ImportedVideoFile: Transferable {\n\tlet url: URL\n\n\tstatic var transferRepresentation: some TransferRepresentation {\n\t\tFileRepresentation.importedURL(\n\t\t\t.mpeg4Movie,\n\t\t\t.quickTimeMovie\n\t\t) {\n\t\t\tSelf(url: $0)\n\t\t}\n\t}\n}\n\n\nextension FileRepresentation {\n\t/**\n\tAn importing-only file representation that copies the URL to a temporary directory and returns that.\n\n\t```\n\tstruct VideoFile: Transferable {\n\t\tlet url: URL\n\n\t\tstatic var transferRepresentation: some TransferRepresentation {\n\t\t\tFileRepresentation.importedURL(contentType: .mpeg4Movie) { Self(url: $0) }\n\t\t}\n\t}\n\t```\n\t*/\n\tstatic func importedURL(\n\t\t_ contentType: UTType,\n\t\tcreateItem: @escaping (URL) async throws -> Item\n\t) -> Self {\n\t\t.init(importedContentType: contentType) {\n\t\t\ttry await createItem(try $0.file.copyToUniqueTemporaryDirectory())\n\t\t}\n\t}\n\n\t// TODO: Use variadic generics here when targeting macOS 15.\n\t@TransferRepresentationBuilder<Item>\n\tstatic func importedURL(\n\t\t_ contentType1: UTType,\n\t\t_ contentType2: UTType,\n\t\tcreateItem: @escaping (URL) async throws -> Item\n\t) -> some TransferRepresentation<Item> {\n\t\timportedURL(contentType1, createItem: createItem)\n\t\timportedURL(contentType2, createItem: createItem)\n\t}\n\n\t@TransferRepresentationBuilder<Item>\n\tstatic func importedURL(\n\t\t_ contentType1: UTType,\n\t\t_ contentType2: UTType,\n\t\t_ contentType3: UTType,\n\t\tcreateItem: @escaping (URL) async throws -> Item\n\t) -> some TransferRepresentation<Item> {\n\t\timportedURL(contentType1, createItem: createItem)\n\t\timportedURL(contentType2, createItem: createItem)\n\t\timportedURL(contentType3, createItem: createItem)\n\t}\n\n\t@TransferRepresentationBuilder<Item>\n\tstatic func importedURL(\n\t\t_ contentType1: UTType,\n\t\t_ contentType2: UTType,\n\t\t_ contentType3: UTType,\n\t\t_ contentType4: UTType,\n\t\tcreateItem: @escaping (URL) async throws -> Item\n\t) -> some TransferRepresentation<Item> {\n\t\timportedURL(contentType1, createItem: createItem)\n\t\timportedURL(contentType2, createItem: createItem)\n\t\timportedURL(contentType3, createItem: createItem)\n\t\timportedURL(contentType4, createItem: createItem)\n\t}\n}\n\n\nextension View {\n\t/**\n\tFills the frame.\n\t*/\n\tfunc fillFrame(\n\t\t_ axis: Axis.Set = [.horizontal, .vertical],\n\t\talignment: Alignment = .center\n\t) -> some View {\n\t\tframe(\n\t\t\tmaxWidth: axis.contains(.horizontal) ? .infinity : nil,\n\t\t\tmaxHeight: axis.contains(.vertical) ? .infinity : nil,\n\t\t\talignment: alignment\n\t\t)\n\t}\n}\n\n\n// TODO: Try to use `ContainerRelativeShape` when it's supported outside of widgets. (as of macOS 11.2.3, it's only supported in widgets)\n// Note: I have extensively tested and researched the current code. Don't change it lightly.\nextension View {\n\t/**\n\tCorner radius with a custom corner style.\n\t*/\n\tfunc cornerRadius(_ radius: Double, style: RoundedCornerStyle = .continuous) -> some View {\n\t\tclipShape(.rect(cornerRadius: radius, style: style))\n\t}\n\n\t/**\n\tDraws a border inside the view.\n\t*/\n\t@_disfavoredOverload\n\tfunc border(\n\t\t_ content: some ShapeStyle,\n\t\twidth lineWidth: Double = 1,\n\t\tcornerRadius: Double,\n\t\tcornerStyle: RoundedCornerStyle = .circular\n\t) -> some View {\n\t\tself.cornerRadius(cornerRadius, style: cornerStyle)\n\t\t\t.overlay {\n\t\t\t\tRoundedRectangle(cornerRadius: cornerRadius, style: cornerStyle)\n\t\t\t\t\t.strokeBorder(content, lineWidth: lineWidth)\n\t\t\t}\n\t}\n\n\t// I considered supporting an `inside`/`center` position option, but there's really no benefit to drawing the border at center as we need to pad the view anyway because of the clipping.\n\t/**\n\tDraws a border inside the view.\n\t*/\n\tfunc border(\n\t\t_ color: Color,\n\t\twidth lineWidth: Double = 1,\n\t\tcornerRadius: Double,\n\t\tcornerStyle: RoundedCornerStyle = .circular\n\t) -> some View {\n\t\tself.cornerRadius(cornerRadius, style: cornerStyle)\n\t\t\t.overlay {\n\t\t\t\tRoundedRectangle(cornerRadius: cornerRadius, style: cornerStyle)\n\t\t\t\t\t.strokeBorder(color, lineWidth: lineWidth)\n\t\t\t}\n\t}\n}\n\n\n// TODO: Remove these when targeting macOS 15.\nextension NSItemProvider {\n\tfunc loadObject<T>(ofClass: T.Type) async throws -> T? where T: NSItemProviderReading {\n\t\ttry await withCheckedThrowingContinuation { continuation in\n\t\t\t_ = loadObject(ofClass: ofClass) { data, error in\n\t\t\t\tif let error {\n\t\t\t\t\tcontinuation.resume(throwing: error)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tguard let object = data as? T else {\n\t\t\t\t\tcontinuation.resume(returning: nil)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tcontinuation.resume(returning: object)\n\t\t\t}\n\t\t}\n\t}\n\n\tfunc loadObject<T>(ofClass: T.Type) async throws -> T? where T: _ObjectiveCBridgeable, T._ObjectiveCType: NSItemProviderReading {\n\t\ttry await withCheckedThrowingContinuation { continuation in\n\t\t\t_ = loadObject(ofClass: ofClass) { data, error in\n\t\t\t\tif let error {\n\t\t\t\t\tcontinuation.resume(throwing: error)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tguard let data else {\n\t\t\t\t\tcontinuation.resume(returning: nil)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tcontinuation.resume(returning: data)\n\t\t\t}\n\t\t}\n\t}\n}\n\nextension NSItemProvider {\n\t/**\n\tGet a URL from the item provider, if any.\n\t*/\n\tfunc getURL() async -> URL? {\n\t\ttry? await loadObject(ofClass: URL.self)\n\t}\n}\n\n\nextension Sequence {\n\tfunc asyncFlatMap<T: Sequence, E>(\n\t\t_ transform: (Element) async throws(E) -> T\n\t) async throws(E) -> [T.Element] {\n\t\tvar values = [T.Element]()\n\n\t\tfor element in self {\n\t\t\ttry await values.append(contentsOf: transform(element))\n\t\t}\n\n\t\treturn values\n\t}\n}\n\n\nextension Sequence where Element: Sendable {\n\tfunc concurrentCompactMap<T: Sendable>(\n\t\twithPriority priority: TaskPriority? = nil,\n\t\tconcurrencyLimit: Int? = nil,\n\t\t_ transform: @Sendable (Element) async -> T?\n\t) async -> [T] {\n\t\tawait chunked(by: concurrencyLimit ?? .max).asyncFlatMap { chunk in\n\t\t\tawait withoutActuallyEscaping(transform) { escapingTransform in\n\t\t\t\tawait withTaskGroup(of: (offset: Int, value: T?).self) { group -> [T] in\n\t\t\t\t\tfor (offset, element) in chunk.enumerated() {\n\t\t\t\t\t\tgroup.addTask(priority: priority) {\n\t\t\t\t\t\t\tawait (offset, escapingTransform(element))\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tvar result = [(offset: Int, value: T)]()\n\t\t\t\t\tresult.reserveCapacity(chunk.count)\n\n\t\t\t\t\twhile let next = await group.next() {\n\t\t\t\t\t\tif let value = next.value {\n\t\t\t\t\t\t\tresult.append((offset: next.offset, value: value))\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\treturn result\n\t\t\t\t\t\t.sorted { $0.offset < $1.offset }\n\t\t\t\t\t\t.map(\\.value)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\n\nstruct NativeVisualEffectsView: NSViewRepresentable {\n\ttypealias NSViewType = NSVisualEffectView\n\n\tvar material: NSVisualEffectView.Material\n\tvar blendingMode = NSVisualEffectView.BlendingMode.withinWindow\n\tvar state = NSVisualEffectView.State.followsWindowActiveState\n\tvar isEmphasized = false\n\tvar cornerRadius = 0.0\n\n\tfunc makeNSView(context: Context) -> NSViewType {\n\t\tlet nsView = NSVisualEffectView()\n\t\tnsView.wantsLayer = true\n\t\tnsView.translatesAutoresizingMaskIntoConstraints = false\n\t\tnsView.setContentHuggingPriority(.defaultHigh, for: .vertical)\n\t\tnsView.setContentHuggingPriority(.defaultHigh, for: .horizontal)\n\t\tnsView.setAccessibilityHidden(true)\n\t\tnsView.layer?.masksToBounds = true\n\t\treturn nsView\n\t}\n\n\tfunc updateNSView(_ nsView: NSViewType, context: Context) {\n\t\tnsView.material = material\n\t\tnsView.blendingMode = blendingMode\n\t\tnsView.state = state\n\t\tnsView.isEmphasized = isEmphasized\n\t\tnsView.layer?.cornerRadius = cornerRadius\n\t}\n}\n\nextension View {\n\t/**\n\tAdd a material as a background.\n\n\tOnly use this over the native materials when either:\n\t- You need to blend with what's behind the window.\n\t- You need the material to be visible even when the window is inactive.\n\t*/\n\tfunc backgroundWithMaterial(\n\t\t_ material: NSVisualEffectView.Material,\n\t\tblendingMode: NSVisualEffectView.BlendingMode = .withinWindow,\n\t\tstate: NSVisualEffectView.State = .followsWindowActiveState,\n\t\tisEmphasized: Bool = false,\n\t\tcornerRadius: Double = 0,\n\t\tignoresSafeAreaEdges edges: Edge.Set = .all\n\t) -> some View {\n\t\tbackground {\n\t\t\tNativeVisualEffectsView(\n\t\t\t\tmaterial: material,\n\t\t\t\tblendingMode: blendingMode,\n\t\t\t\tstate: state,\n\t\t\t\tisEmphasized: isEmphasized,\n\t\t\t\tcornerRadius: cornerRadius\n\t\t\t)\n\t\t\t\t.ignoresSafeArea(edges: edges)\n\t\t}\n\t}\n}\n\nextension View {\n\t/**\n\thttps://twitter.com/oskargroth/status/1323013160333381641\n\t*/\n\tfunc visualEffectsViewVibrancy(_ level: Double) -> some View {\n\t\tblendMode(.overlay)\n\t\t\t.overlay {\n\t\t\t\topacity(1 - level)\n\t\t\t}\n\t}\n}\n\n\nextension Binding {\n\t/**\n\tConverts the binding of an optional value to a binding to a boolean for whether the value is non-nil.\n\n\tYou could use this in a `isPresent` parameter for a sheet, alert, etc, to have it show when the value is non-nil.\n\t*/\n\tfunc isPresent<Wrapped>() -> Binding<Bool> where Value == Wrapped? {\n\t\t.init(\n\t\t\tget: { wrappedValue != nil },\n\t\t\tset: { isPresented in\n\t\t\t\tif !isPresented {\n\t\t\t\t\twrappedValue = nil\n\t\t\t\t}\n\t\t\t}\n\t\t)\n\t}\n}\n\n\nextension Binding {\n\tfunc map<Result>(\n\t\tget: @escaping (Value) -> Result,\n\t\tset: @escaping (Result) -> Value\n\t) -> Binding<Result> {\n\t\t.init(\n\t\t\tget: { get(wrappedValue) },\n\t\t\tset: { newValue in\n\t\t\t\twrappedValue = set(newValue)\n\t\t\t}\n\t\t)\n\t}\n}\n\n\nextension View {\n\tfunc alert(error: Binding<Error?>) -> some View {\n\t\talert2(\n\t\t\ttitle: { ($0 as NSError).localizedDescription },\n\t\t\tmessage: { ($0 as NSError).localizedRecoverySuggestion },\n\t\t\tpresenting: error\n\t\t) {\n\t\t\tlet nsError = $0 as NSError\n\t\t\tif\n\t\t\t\tlet options = nsError.localizedRecoveryOptions,\n\t\t\t\tlet recoveryAttempter = nsError.recoveryAttempter\n\t\t\t{\n\t\t\t\t// Alert only supports 3 buttons, so we limit it to 2 attempters, otherwise it would take over the cancel button.\n\t\t\t\tForEach(Array(options.prefix(2).enumerated()), id: \\.0) { index, option in\n\t\t\t\t\tButton(option) {\n\t\t\t\t\t\t// We use the old NSError mechanism for recovery attempt as recoverable NSError's are not bridged to RecoverableError.\n\t\t\t\t\t\t_ = (recoveryAttempter as AnyObject).attemptRecovery(fromError: nsError, optionIndex: index)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tButton(\"Cancel\", role: .cancel) {}\n\t\t\t}\n\t\t}\n\t}\n}\n\n\nextension View {\n\t/**\n\tThis allows multiple sheets on a single view, which `.sheet()` doesn't.\n\t*/\n\tfunc sheet2(\n\t\tisPresented: Binding<Bool>,\n\t\tonDismiss: (() -> Void)? = nil,\n\t\t@ViewBuilder content: @escaping () -> some View\n\t) -> some View {\n\t\tbackground(\n\t\t\tEmptyView().sheet(\n\t\t\t\tisPresented: isPresented,\n\t\t\t\tonDismiss: onDismiss,\n\t\t\t\tcontent: content\n\t\t\t)\n\t\t)\n\t}\n\n\t/**\n\tThis allows multiple sheets on a single view, which `.sheet()` doesn't.\n\t*/\n\tfunc sheet2<Item: Identifiable>(\n\t\titem: Binding<Item?>,\n\t\tonDismiss: (() -> Void)? = nil,\n\t\t@ViewBuilder content: @escaping (Item) -> some View\n\t) -> some View {\n\t\tbackground(\n\t\t\tEmptyView().sheet(\n\t\t\t\titem: item,\n\t\t\t\tonDismiss: onDismiss,\n\t\t\t\tcontent: content\n\t\t\t)\n\t\t)\n\t}\n}\n\n\nextension View {\n\t/**\n\tThis allows multiple popovers on a single view, which `.popover()` doesn't.\n\t*/\n\tfunc popover2(\n\t\tisPresented: Binding<Bool>,\n\t\tattachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds),\n\t\tarrowEdge: Edge = .top,\n\t\t@ViewBuilder content: @escaping () -> some View\n\t) -> some View {\n\t\tbackground(\n\t\t\tEmptyView()\n\t\t\t\t.popover(\n\t\t\t\t\tisPresented: isPresented,\n\t\t\t\t\tattachmentAnchor: attachmentAnchor,\n\t\t\t\t\tarrowEdge: arrowEdge,\n\t\t\t\t\tcontent: content\n\t\t\t\t)\n\t\t)\n\t}\n}\n\n\n\n// Multiple `.alert` are stil broken in iOS 15.0\nextension View {\n\t/**\n\tThis allows multiple alerts on a single view, which `.alert()` doesn't.\n\t*/\n\tfunc alert2(\n\t\t_ title: Text,\n\t\tisPresented: Binding<Bool>,\n\t\t@ViewBuilder actions: () -> some View,\n\t\t@ViewBuilder message: () -> some View\n\t) -> some View {\n\t\tbackground(\n\t\t\tEmptyView()\n\t\t\t\t.alert(\n\t\t\t\t\ttitle,\n\t\t\t\t\tisPresented: isPresented,\n\t\t\t\t\tactions: actions,\n\t\t\t\t\tmessage: message\n\t\t\t\t)\n\t\t)\n\t}\n\n\t/**\n\tThis allows multiple alerts on a single view, which `.alert()` doesn't.\n\t*/\n\tfunc alert2(\n\t\t_ title: String,\n\t\tisPresented: Binding<Bool>,\n\t\t@ViewBuilder actions: () -> some View,\n\t\t@ViewBuilder message: () -> some View\n\t) -> some View {\n\t\talert2(\n\t\t\tText(title),\n\t\t\tisPresented: isPresented,\n\t\t\tactions: actions,\n\t\t\tmessage: message\n\t\t)\n\t}\n\n\t/**\n\tThis allows multiple alerts on a single view, which `.alert()` doesn't.\n\t*/\n\tfunc alert2(\n\t\t_ title: Text,\n\t\tmessage: String? = nil,\n\t\tisPresented: Binding<Bool>,\n\t\t@ViewBuilder actions: () -> some View\n\t) -> some View {\n\t\talert2(\n\t\t\ttitle,\n\t\t\tisPresented: isPresented,\n\t\t\tactions: actions,\n\t\t\tmessage: { // swiftlint:disable:this trailing_closure\n\t\t\t\tif let message {\n\t\t\t\t\tText(message)\n\t\t\t\t}\n\t\t\t}\n\t\t)\n\t}\n\n\t// This is a convenience method and does not exist natively.\n\t/**\n\tThis allows multiple alerts on a single view, which `.alert()` doesn't.\n\t*/\n\tfunc alert2(\n\t\t_ title: String,\n\t\tmessage: String? = nil,\n\t\tisPresented: Binding<Bool>,\n\t\t@ViewBuilder actions: () -> some View\n\t) -> some View {\n\t\talert2(\n\t\t\ttitle,\n\t\t\tisPresented: isPresented,\n\t\t\tactions: actions,\n\t\t\tmessage: { // swiftlint:disable:this trailing_closure\n\t\t\t\tif let message {\n\t\t\t\t\tText(message)\n\t\t\t\t}\n\t\t\t}\n\t\t)\n\t}\n\n\t/**\n\tThis allows multiple alerts on a single view, which `.alert()` doesn't.\n\t*/\n\tfunc alert2(\n\t\t_ title: Text,\n\t\tmessage: String? = nil,\n\t\tisPresented: Binding<Bool>\n\t) -> some View {\n\t\talert2(\n\t\t\ttitle,\n\t\t\tmessage: message,\n\t\t\tisPresented: isPresented,\n\t\t\tactions: {} // swiftlint:disable:this trailing_closure\n\t\t)\n\t}\n\n\t// This is a convenience method and does not exist natively.\n\t/**\n\tThis allows multiple alerts on a single view, which `.alert()` doesn't.\n\t*/\n\tfunc alert2(\n\t\t_ title: String,\n\t\tmessage: String? = nil,\n\t\tisPresented: Binding<Bool>\n\t) -> some View {\n\t\talert2(\n\t\t\ttitle,\n\t\t\tmessage: message,\n\t\t\tisPresented: isPresented,\n\t\t\tactions: {} // swiftlint:disable:this trailing_closure\n\t\t)\n\t}\n}\n\n\nextension View {\n\t// This exist as the new `item`-type alert APIs in iOS 15 are shit.\n\t// This is a convenience method and does not exist natively.\n\t/**\n\tThis allows multiple alerts on a single view, which `.alert()` doesn't.\n\t*/\n\tfunc alert2<T>(\n\t\ttitle: (T) -> Text,\n\t\tpresenting data: Binding<T?>,\n\t\t@ViewBuilder actions: (T) -> some View,\n\t\t@ViewBuilder message: (T) -> some View\n\t) -> some View {\n\t\tbackground(\n\t\t\tEmptyView()\n\t\t\t\t.alert(\n\t\t\t\t\tdata.wrappedValue.map(title) ?? Text(\"\"),\n\t\t\t\t\tisPresented: data.isPresent(),\n\t\t\t\t\tpresenting: data.wrappedValue,\n\t\t\t\t\tactions: actions,\n\t\t\t\t\tmessage: message\n\t\t\t\t)\n\t\t)\n\t}\n\n\t// This is a convenience method and does not exist natively.\n\t/**\n\tThis allows multiple alerts on a single view, which `.alert()` doesn't.\n\t*/\n\tfunc alert2<T>(\n\t\ttitle: (T) -> Text,\n\t\tmessage: ((T) -> String?)? = nil,\n\t\tpresenting data: Binding<T?>,\n\t\t@ViewBuilder actions: (T) -> some View\n\t) -> some View {\n\t\talert2(\n\t\t\ttitle: { title($0) },\n\t\t\tpresenting: data,\n\t\t\tactions: actions,\n\t\t\tmessage: { // swiftlint:disable:this trailing_closure\n\t\t\t\tif let message = message?($0) {\n\t\t\t\t\tText(message)\n\t\t\t\t}\n\t\t\t}\n\t\t)\n\t}\n\n\t// This is a convenience method and does not exist natively.\n\t/**\n\tThis allows multiple alerts on a single view, which `.alert()` doesn't.\n\t*/\n\tfunc alert2<T>(\n\t\ttitle: (T) -> String,\n\t\tmessage: ((T) -> String?)? = nil,\n\t\tpresenting data: Binding<T?>,\n\t\t@ViewBuilder actions: (T) -> some View\n\t) -> some View {\n\t\talert2(\n\t\t\ttitle: { Text(title($0)) },\n\t\t\tmessage: message,\n\t\t\tpresenting: data,\n\t\t\tactions: actions\n\t\t)\n\t}\n\n\t// This is a convenience method and does not exist natively.\n\t/**\n\tThis allows multiple alerts on a single view, which `.alert()` doesn't.\n\t*/\n\tfunc alert2<T>(\n\t\ttitle: (T) -> Text,\n\t\tmessage: ((T) -> String?)? = nil,\n\t\tpresenting data: Binding<T?>\n\t) -> some View {\n\t\talert2(\n\t\t\ttitle: title,\n\t\t\tmessage: message,\n\t\t\tpresenting: data,\n\t\t\tactions: { _ in } // swiftlint:disable:this trailing_closure\n\t\t)\n\t}\n\n\t// This is a convenience method and does not exist natively.\n\t/**\n\tThis allows multiple alerts on a single view, which `.alert()` doesn't.\n\t*/\n\tfunc alert2<T>(\n\t\ttitle: (T) -> String,\n\t\tmessage: ((T) -> String?)? = nil,\n\t\tpresenting data: Binding<T?>\n\t) -> some View {\n\t\talert2(\n\t\t\ttitle: { Text(title($0)) },\n\t\t\tmessage: message,\n\t\t\tpresenting: data\n\t\t)\n\t}\n}\n\n\n// Multiple `.confirmationDialog` are broken in iOS 15.0\nextension View {\n\t/**\n\tThis allows multiple confirmation dialogs on a single view, which `.confirmationDialog()` doesn't.\n\t*/\n\tfunc confirmationDialog2(\n\t\t_ title: Text,\n\t\tisPresented: Binding<Bool>,\n\t\ttitleVisibility: Visibility = .automatic,\n\t\t@ViewBuilder actions: () -> some View,\n\t\t@ViewBuilder message: () -> some View\n\t) -> some View {\n\t\tbackground(\n\t\t\tEmptyView()\n\t\t\t\t.confirmationDialog(\n\t\t\t\t\ttitle,\n\t\t\t\t\tisPresented: isPresented,\n\t\t\t\t\ttitleVisibility: titleVisibility,\n\t\t\t\t\tactions: actions,\n\t\t\t\t\tmessage: message\n\t\t\t\t)\n\t\t)\n\t}\n\n\t/**\n\tThis allows multiple confirmation dialogs on a single view, which `.confirmationDialog()` doesn't.\n\t*/\n\tfunc confirmationDialog2(\n\t\t_ title: Text,\n\t\tmessage: String? = nil,\n\t\tisPresented: Binding<Bool>,\n\t\ttitleVisibility: Visibility = .automatic,\n\t\t@ViewBuilder actions: () -> some View\n\t) -> some View {\n\t\tconfirmationDialog2(\n\t\t\ttitle,\n\t\t\tisPresented: isPresented,\n\t\t\ttitleVisibility: titleVisibility,\n\t\t\tactions: actions,\n\t\t\tmessage: { // swiftlint:disable:this trailing_closure\n\t\t\t\tif let message {\n\t\t\t\t\tText(message)\n\t\t\t\t}\n\t\t\t}\n\t\t)\n\t}\n\n\t/**\n\tThis allows multiple confirmation dialogs on a single view, which `.confirmationDialog()` doesn't.\n\t*/\n\tfunc confirmationDialog2(\n\t\t_ title: String,\n\t\tmessage: String? = nil,\n\t\tisPresented: Binding<Bool>,\n\t\ttitleVisibility: Visibility = .automatic,\n\t\t@ViewBuilder actions: () -> some View\n\t) -> some View {\n\t\tconfirmationDialog2(\n\t\t\tText(title),\n\t\t\tmessage: message,\n\t\t\tisPresented: isPresented,\n\t\t\ttitleVisibility: titleVisibility,\n\t\t\tactions: actions\n\t\t)\n\t}\n}\n\n\n// This exist as the new `item`-type alert APIs in iOS 15 are shit.\nextension View {\n\t// This is a convenience method and does not exist natively.\n\t/**\n\tThis allows multiple confirmation dialogs on a single view, which `.confirmationDialog()` doesn't.\n\t*/\n\tfunc confirmationDialog2<T>(\n\t\ttitle: (T) -> Text,\n\t\ttitleVisibility: Visibility = .automatic,\n\t\tpresenting data: Binding<T?>,\n\t\t@ViewBuilder actions: (T) -> some View,\n\t\t@ViewBuilder message: (T) -> some View\n\t) -> some View {\n\t\tbackground(\n\t\t\tEmptyView()\n\t\t\t\t.confirmationDialog(\n\t\t\t\t\tdata.wrappedValue.map(title) ?? Text(\"\"),\n\t\t\t\t\tisPresented: data.isPresent(),\n\t\t\t\t\ttitleVisibility: titleVisibility,\n\t\t\t\t\tpresenting: data.wrappedValue,\n\t\t\t\t\tactions: actions,\n\t\t\t\t\tmessage: message\n\t\t\t\t)\n\t\t)\n\t}\n\n\t// This is a convenience method and does not exist natively.\n\t/**\n\tThis allows multiple confirmation dialogs on a single view, which `.confirmationDialog()` doesn't.\n\t*/\n\tfunc confirmationDialog2<T>(\n\t\ttitle: (T) -> Text,\n\t\tmessage: ((T) -> String?)? = nil,\n\t\ttitleVisibility: Visibility = .automatic,\n\t\tpresenting data: Binding<T?>,\n\t\t@ViewBuilder actions: (T) -> some View\n\t) -> some View {\n\t\tconfirmationDialog2(\n\t\t\ttitle: { title($0) },\n\t\t\ttitleVisibility: titleVisibility,\n\t\t\tpresenting: data,\n\t\t\tactions: actions,\n\t\t\tmessage: { // swiftlint:disable:this trailing_closure\n\t\t\t\tif let message = message?($0) {\n\t\t\t\t\tText(message)\n\t\t\t\t}\n\t\t\t}\n\t\t)\n\t}\n\n\t// This is a convenience method and does not exist natively.\n\t/**\n\tThis allows multiple confirmation dialogs on a single view, which `.confirmationDialog()` doesn't.\n\t*/\n\tfunc confirmationDialog2<T>(\n\t\ttitle: (T) -> String,\n\t\tmessage: ((T) -> String?)? = nil,\n\t\ttitleVisibility: Visibility = .automatic,\n\t\tpresenting data: Binding<T?>,\n\t\t@ViewBuilder actions: (T) -> some View\n\t) -> some View {\n\t\tconfirmationDialog2(\n\t\t\ttitle: { Text(title($0)) },\n\t\t\tmessage: message,\n\t\t\ttitleVisibility: titleVisibility,\n\t\t\tpresenting: data,\n\t\t\tactions: actions\n\t\t)\n\t}\n}\n\n\nstruct ImageView: NSViewRepresentable {\n\ttypealias NSViewType = NSImageView\n\n\tlet image: NSImage\n\n\tfunc makeNSView(context: Context) -> NSViewType {\n\t\tlet nsView = NSImageView()\n\t\tnsView.wantsLayer = true\n\t\tnsView.translatesAutoresizingMaskIntoConstraints = false\n\t\tnsView.setContentHuggingPriority(.defaultHigh, for: .vertical)\n\t\tnsView.setContentHuggingPriority(.defaultHigh, for: .horizontal)\n\t\treturn nsView\n\t}\n\n\tfunc updateNSView(_ nsView: NSViewType, context: Context) {\n\t\tnsView.image = image\n\t}\n\n\tfunc sizeThatFits(_ proposal: ProposedViewSize, nsView: NSImageView, context: Context) -> CGSize? {\n\t\tguard let size = proposal.toCGSize else {\n\t\t\treturn nil\n\t\t}\n\n\t\treturn image.size.aspectFitInside(size)\n\t}\n}\n\n\nextension ProposedViewSize {\n\tvar toCGSize: CGSize? {\n\t\tguard\n\t\t\tlet width,\n\t\t\tlet height\n\t\telse {\n\t\t\treturn nil\n\t\t}\n\n\t\treturn .init(width: width, height: height)\n\t}\n}\n\n\nextension CGSize {\n\t/**\n\tReturns a new size that fits within a target size while maintaining the aspect ratio and ensuring it does not exceed the original size.\n\n\t- Parameter targetSize: The target size within which the original size should fit.\n\t- Returns: A new size fitting within `targetSize` and not exceeding the original size.\n\n\tUse-cases:\n\t- Scaling images without distortion.\n\t- Adapting a UI element size to fit within certain bounds without exceeding its original dimensions.\n\t*/\n\tfunc aspectFitInside(_ targetSize: Self) -> Self {\n\t\tlet originalAspectRatio = width / height\n\t\tlet targetAspectRatio = targetSize.width / targetSize.height\n\n\n\t\tvar newSize = if targetAspectRatio > originalAspectRatio {\n\t\t\tCGSize(width: targetSize.height * originalAspectRatio, height: targetSize.height)\n\t\t} else {\n\t\t\tCGSize(width: targetSize.width, height: targetSize.width / originalAspectRatio)\n\t\t}\n\n\t\t// Ensure the size is not larger than the original.\n\t\tnewSize.width = min(newSize.width, width)\n\t\tnewSize.height = min(newSize.height, height)\n\n\t\treturn newSize\n\t}\n}\n\n\nextension SetAlgebra {\n\t/**\n\tInsert the `value` if it doesn't exist, otherwise remove it.\n\t*/\n\tmutating func toggleExistence(_ value: Element) {\n\t\tif contains(value) {\n\t\t\tremove(value)\n\t\t} else {\n\t\t\tinsert(value)\n\t\t}\n\t}\n\n\t/**\n\tInsert the `value` if `shouldExist` is true, otherwise remove it.\n\t*/\n\tmutating func toggleExistence(_ value: Element, shouldExist: Bool) {\n\t\tif shouldExist {\n\t\t\tinsert(value)\n\t\t} else {\n\t\t\tremove(value)\n\t\t}\n\t}\n}\n\n\nprivate struct WindowAccessor: NSViewRepresentable {\n\tprivate final class WindowAccessorView: NSView {\n\t\t@Binding var windowBinding: NSWindow?\n\n\t\tinit(binding: Binding<NSWindow?>) {\n\t\t\tself._windowBinding = binding\n\t\t\tsuper.init(frame: .zero)\n\t\t}\n\n\t\toverride func viewWillMove(toWindow newWindow: NSWindow?) {\n\t\t\tsuper.viewWillMove(toWindow: newWindow)\n\n\t\t\tguard let newWindow else {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\twindowBinding = newWindow\n\t\t}\n\n\t\toverride func viewDidMoveToWindow() {\n\t\t\tsuper.viewDidMoveToWindow()\n\t\t\twindowBinding = window\n\t\t}\n\n\t\t@available(*, unavailable)\n\t\trequired init?(coder: NSCoder) {\n\t\t\tfatalError(\"\") // swiftlint:disable:this fatal_error_message\n\t\t}\n\t}\n\n\t@Binding var window: NSWindow?\n\n\tinit(_ window: Binding<NSWindow?>) {\n\t\tself._window = window\n\t}\n\n\tfunc makeNSView(context: Context) -> NSView {\n\t\tWindowAccessorView(binding: $window)\n\t}\n\n\tfunc updateNSView(_ nsView: NSView, context: Context) {}\n}\n\nextension View {\n\t/**\n\tBind the native backing-window of a SwiftUI window to a property.\n\t*/\n\tfunc bindHostingWindow(_ window: Binding<NSWindow?>) -> some View {\n\t\tbackground(WindowAccessor(window))\n\t}\n}\n\nprivate struct WindowViewModifier: ViewModifier {\n\t@State private var window: NSWindow?\n\n\tlet onWindow: (NSWindow?) -> Void\n\n\tfunc body(content: Content) -> some View {\n\t\t// We're intentionally not using `.onChange` as we need it to execute for every SwiftUI change as the window properties can be changed at any time by SwiftUI.\n\t\tonWindow(window)\n\n\t\treturn content\n\t\t\t.bindHostingWindow($window)\n\t}\n}\n\nextension View {\n\t/**\n\tAccess the native backing-window of a SwiftUI window.\n\t*/\n\tfunc accessHostingWindow(_ onWindow: @escaping (NSWindow?) -> Void) -> some View {\n\t\tmodifier(WindowViewModifier(onWindow: onWindow))\n\t}\n\n\t/**\n\tSet the window tabbing mode of a SwiftUI window.\n\t*/\n\tfunc windowTabbingMode(_ tabbingMode: NSWindow.TabbingMode) -> some View {\n\t\taccessHostingWindow {\n\t\t\t$0?.tabbingMode = tabbingMode\n\t\t}\n\t}\n\n\t/**\n\tSet whether the SwiftUI window should be resizable.\n\n\tSetting this to false disables the green zoom button on the window.\n\t*/\n\tfunc windowIsResizable(_ isResizable: Bool = true) -> some View {\n\t\taccessHostingWindow {\n\t\t\t$0?.styleMask.toggleExistence(.resizable, shouldExist: isResizable)\n\t\t}\n\t}\n\n\t/**\n\tSet whether the SwiftUI window should be restorable.\n\t*/\n\tfunc windowIsRestorable(_ isRestorable: Bool = true) -> some View {\n\t\taccessHostingWindow {\n\t\t\t$0?.isRestorable = isRestorable\n\t\t}\n\t}\n\n\t/**\n\tMake a SwiftUI window draggable by clicking and dragging anywhere in the window.\n\t*/\n\tfunc windowIsMovableByWindowBackground(_ isMovableByWindowBackground: Bool = true) -> some View {\n\t\taccessHostingWindow {\n\t\t\t$0?.isMovableByWindowBackground = isMovableByWindowBackground\n\t\t}\n\t}\n\n\t/**\n\tSet whether to show the title bar appears transparent.\n\t*/\n\tfunc windowTitlebarAppearsTransparent(_ isActive: Bool = true) -> some View {\n\t\taccessHostingWindow { window in\n\t\t\twindow?.titlebarAppearsTransparent = isActive\n\t\t}\n\t}\n\n\t/**\n\tSet the collection behavior of a SwiftUI window.\n\t*/\n\tfunc windowCollectionBehavior(_ collectionBehavior: NSWindow.CollectionBehavior) -> some View {\n\t\taccessHostingWindow { window in\n\t\t\twindow?.collectionBehavior = collectionBehavior\n\n\t\t\t// This is needed on windows with `.windowResizability(.contentSize)`. (macOS 13.4)\n\t\t\t// If it's not set, the window will not show in fullscreen mode for some reason.\n\t\t\tDispatchQueue.main.async {\n\t\t\t\twindow?.collectionBehavior = collectionBehavior\n\t\t\t}\n\t\t}\n\t}\n\n\tfunc windowIsVibrant() -> some View {\n\t\taccessHostingWindow {\n\t\t\t$0?.makeVibrant()\n\t\t}\n\t}\n}\n\n\nextension NSColor {\n\tconvenience init(light: NSColor, dark: NSColor?) {\n\t\tself.init(name: nil) { $0.isDarkMode ? (dark ?? light) : light }\n\t}\n}\n\nextension Color {\n\tinit(dynamicProvider: @escaping (Bool) -> Self) {\n\t\tself.init(\n\t\t\tNSColor(name: nil) {\n\t\t\t\tNSColor(dynamicProvider($0.isDarkMode))\n\t\t\t}\n\t\t)\n\t}\n}\n\n\nextension Color {\n\tinit(light: Self, dark: Self?) {\n\t\tself.init { $0 ? (dark ?? light) : light }\n\t}\n}\n\n\nextension NSAppearance {\n\tvar isDarkMode: Bool { bestMatch(from: [.darkAqua, .aqua]) == .darkAqua }\n}\n\n\nextension FloatingPointFormatStyle.Percent {\n\t/**\n\tDo not show fraction.\n\t*/\n\tvar noFraction: Self { precision(.fractionLength(0)) }\n}\n\n\nprivate struct EqualWidthWithBindingPreferenceKey: PreferenceKey {\n\tstatic let defaultValue = 0.0\n\n\tstatic func reduce(value: inout Double, nextValue: () -> Double) {\n\t\tvalue = nextValue()\n\t}\n}\n\nprivate struct EqualWidthWithBinding: ViewModifier {\n\t@Binding var width: Double?\n\tlet alignment: Alignment\n\n\tfunc body(content: Content) -> some View {\n\t\tcontent\n\t\t\t.frame(width: width?.nilIfZero?.toCGFloat, alignment: alignment)\n\t\t\t.background {\n\t\t\t\tGeometryReader {\n\t\t\t\t\tColor.clear\n\t\t\t\t\t\t.preference(\n\t\t\t\t\t\t\tkey: EqualWidthWithBindingPreferenceKey.self,\n\t\t\t\t\t\t\tvalue: $0.size.width\n\t\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t}\n\t\t\t.onPreferenceChange(EqualWidthWithBindingPreferenceKey.self) {\n\t\t\t\twidth = max(width ?? 0, $0)\n\t\t\t}\n\t}\n}\n\nextension View {\n\tfunc equalWidthWithBinding(\n\t\t_ width: Binding<Double?>,\n\t\talignment: Alignment = .center\n\t) -> some View {\n\t\tmodifier(EqualWidthWithBinding(width: width, alignment: alignment))\n\t}\n}\n\n\nextension PrimitiveButtonStyle where Self == WidthButtonStyle {\n\t/**\n\tMake button have equal width.\n\t*/\n\tstatic func equalWidth(\n\t\t_ width: Binding<Double?>,\n\t\tminimumWidth: Double? = nil\n\t) -> Self {\n\t\t.init(\n\t\t\twidth: width,\n\t\t\tminimumWidth: minimumWidth\n\t\t)\n\t}\n}\n\nstruct WidthButtonStyle: PrimitiveButtonStyle {\n\t@Binding var width: Double?\n\tvar minimumWidth: Double?\n\n\tfunc makeBody(configuration: Configuration) -> some View {\n\t\tButton(role: configuration.role) {\n\t\t\tconfiguration.trigger()\n\t\t} label: {\n\t\t\tconfiguration.label\n\t\t\t\t.frame(minWidth: minimumWidth?.toCGFloat)\n\t\t\t\t.equalWidthWithBinding($width)\n\t\t}\n\t}\n}\n\n\nextension StringProtocol {\n\t@inlinable\n\tvar isWhitespace: Bool {\n\t\tallSatisfy(\\.isWhitespace)\n\t}\n\n\t@inlinable\n\tvar isEmptyOrWhitespace: Bool { isEmpty || isWhitespace }\n}\n\n\nextension Collection {\n\t/**\n\tWorks on strings too, since they're just collections.\n\t*/\n\t@inlinable\n\tvar nilIfEmpty: Self? { isEmpty ? nil : self }\n}\n\nextension StringProtocol {\n\t@inlinable\n\tvar nilIfEmptyOrWhitespace: Self? { isEmptyOrWhitespace ? nil : self }\n}\n\nextension AdditiveArithmetic {\n\t/**\n\tReturns `nil` if the value is `0`.\n\t*/\n\t@inlinable\n\tvar nilIfZero: Self? { self == .zero ? nil : self }\n}\n\nextension CGSize {\n\t/**\n\tReturns `nil` if the value is `0`.\n\t*/\n\t@inlinable\n\tvar nilIfZero: Self? { self == .zero ? nil : self }\n}\n\nextension CGRect {\n\t/**\n\tReturns `nil` if the value is `0`.\n\t*/\n\t@inlinable\n\tvar nilIfZero: Self? { self == .zero ? nil : self }\n}\n\n\nstruct CopyButton: View {\n\t@State private var isShowingSuccess = false\n\tprivate let action: () -> Void\n\n\tinit(_ action: @escaping () -> Void) {\n\t\tself.action = action\n\t}\n\n\tvar body: some View {\n\t\tButton {\n\t\t\tisShowingSuccess = true\n\n\t\t\tTask {\n\t\t\t\ttry? await Task.sleep(for: .seconds(1))\n\t\t\t\tisShowingSuccess = false\n\t\t\t}\n\n\t\t\taction()\n\t\t} label: {\n\t\t\tLabel(\"Copy\", systemImage: \"doc.on.doc\")\n\t\t\t\t.opacity(isShowingSuccess ? 0 : 1)\n\t\t\t\t.overlay {\n\t\t\t\t\tif isShowingSuccess {\n\t\t\t\t\t\tImage(systemName: \"checkmark\")\n\t\t\t\t\t\t\t.bold()\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t}\n\t\t\t.disabled(isShowingSuccess)\n\t\t\t.animation(.easeInOut(duration: 0.3), value: isShowingSuccess)\n\t}\n}\n\n\nextension IntentFile {\n\t/**\n\tWrite the data to a unique temporary path and return the `URL`.\n\t*/\n\tfunc writeToUniqueTemporaryFile() throws -> URL {\n\t\ttry data.writeToUniqueTemporaryFile(\n\t\t\tfilename: filename,\n\t\t\tcontentType: type ?? .data\n\t\t)\n\t}\n}\n\n\nextension Data {\n\t/**\n\tCreate an `IntentFile` from the data.\n\t*/\n\tfunc toIntentFile(\n\t\tcontentType: UTType,\n\t\tfilename: String? = nil\n\t) -> IntentFile {\n\t\t.init(\n\t\t\tdata: self,\n\t\t\tfilename: filename ?? \"file\",\n\t\t\ttype: contentType\n\t\t)\n\t}\n}\n\n\nextension Data {\n\t/**\n\tWrite the data to a unique temporary path and return the `URL`.\n\n\tBy default, the file has no file extension.\n\t*/\n\tfunc writeToUniqueTemporaryFile(\n\t\tfilename: String? = nil,\n\t\tcontentType: UTType = .data\n\t) throws -> URL {\n\t\tlet destinationUrl = try URL.uniqueTemporaryDirectory()\n\t\t\t.appendingPathComponent(filename ?? \"file\", conformingTo: contentType)\n\n\t\ttry write(to: destinationUrl)\n\n\t\treturn destinationUrl\n\t}\n}\n\n\nextension URL {\n\t/**\n\tCreates a unique temporary directory and returns the URL.\n\n\tThe URL is unique for each call.\n\n\tThe system ensures the directory is not cleaned up until after the app quits.\n\t*/\n\tstatic func uniqueTemporaryDirectory(\n\t\tappropriateFor: Self? = nil\n\t) throws -> Self {\n\t\ttry FileManager.default.url(\n\t\t\tfor: .itemReplacementDirectory,\n\t\t\tin: .userDomainMask,\n\t\t\tappropriateFor: appropriateFor ?? URL.temporaryDirectory,\n\t\t\tcreate: true\n\t\t)\n\t}\n\n\t/**\n\tCopy the file at the current URL to a unique temporary directory and return the new URL.\n\t*/\n\tfunc copyToUniqueTemporaryDirectory(filename: String? = nil) throws -> Self {\n\t\tlet destinationUrl = try Self.uniqueTemporaryDirectory(appropriateFor: self)\n\t\t\t.appendingPathComponent(filename ?? lastPathComponent, isDirectory: false)\n\n\t\ttry FileManager.default.copyItem(at: self, to: destinationUrl)\n\n\t\treturn destinationUrl\n\t}\n}\n\n\nextension View {\n\t@ViewBuilder\n\tfunc `if`(\n\t\t_ condition: @autoclosure () -> Bool,\n\t\tmodify: (Self) -> some View\n\t) -> some View {\n\t\tif condition() {\n\t\t\tmodify(self)\n\t\t} else {\n\t\t\tself\n\t\t}\n\t}\n\n\tfunc `if`(\n\t\t_ condition: @autoclosure () -> Bool,\n\t\tmodify: (Self) -> Self\n\t) -> Self {\n\t\tcondition() ? modify(self) : self\n\t}\n}\n\n\nextension View {\n\t@ViewBuilder\n\tfunc `if`(\n\t\t_ condition: @autoclosure () -> Bool,\n\t\tif modifyIf: (Self) -> some View,\n\t\telse modifyElse: (Self) -> some View\n\t) -> some View {\n\t\tif condition() {\n\t\t\tmodifyIf(self)\n\t\t} else {\n\t\t\tmodifyElse(self)\n\t\t}\n\t}\n\n\tfunc `if`(\n\t\t_ condition: @autoclosure () -> Bool,\n\t\tif modifyIf: (Self) -> Self,\n\t\telse modifyElse: (Self) -> Self\n\t) -> Self {\n\t\tcondition() ? modifyIf(self) : modifyElse(self)\n\t}\n}\n\n\nextension ProgressViewStyleConfiguration {\n\tvar isFinished: Bool {\n\t\t(fractionCompleted ?? 0) >= 1\n\t}\n}\n\n\nstruct CircularProgressViewStyle: ProgressViewStyle {\n\tprivate struct CheckmarkShape: Shape {\n\t\tfunc path(in rect: CGRect) -> Path {\n\t\t\tPath {\n\t\t\t\t$0.move(to: CGPoint(x: rect.width * 0.3, y: rect.height * 0.52))\n\t\t\t\t$0.addLine(to: CGPoint(x: rect.width * 0.48, y: rect.height * 0.68))\n\t\t\t\t$0.addLine(to: CGPoint(x: rect.width * 0.7, y: rect.height * 0.34))\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate let fill: AnyShapeStyle\n\tprivate let lineWidth: Double\n\tprivate let text: String?\n\n\tinit(\n\t\tfill: (some ShapeStyle)? = nil,\n\t\tlineWidth: Double? = nil,\n\t\ttext: String? = nil\n\t) {\n\t\tself.fill = fill.flatMap(AnyShapeStyle.init) ?? AnyShapeStyle(LinearGradient(gradient: .init(colors: [.purple, .blue]), startPoint: .top, endPoint: .bottom))\n\t\tself.lineWidth = lineWidth ?? 12\n\t\tself.text = text\n\t}\n\n\tfunc makeBody(configuration: Configuration) -> some View {\n\t\tlet progress = configuration.fractionCompleted ?? 0\n\t\tZStack {\n\t\t\t// Background\n\t\t\tCircle()\n\t\t\t\t.stroke(lineWidth: lineWidth)\n\t\t\t\t.opacity(0.3)\n\t\t\t\t.foregroundStyle(.secondary)\n\t\t\t\t.visualEffectsViewVibrancy(0.5)\n\t\t\t// Progress\n\t\t\tCircle()\n\t\t\t\t.trim(from: 0, to: progress)\n\t\t\t\t.stroke(fill, style: .init(lineWidth: lineWidth, lineCap: .round, lineJoin: .round))\n\t\t\t\t.rotationEffect(.init(degrees: 270))\n\t\t\t\t.saturation((progress * 2).clamped(to: 0.5...1.2))\n\t\t\t\t.animation(.easeInOut, value: progress)\n\t\t\tif !configuration.isFinished {\n\t\t\t\tif let text {\n\t\t\t\t\tText(text)\n\t\t\t\t\t\t.fontDesign(.rounded)\n\t\t\t\t\t\t.minimumScaleFactor(0.4)\n\t\t\t\t\t\t.foregroundStyle(.secondary)\n\t\t\t\t} else {\n\t\t\t\t\tText(progress.formatted(.percent.precision(.fractionLength(0))))\n\t\t\t\t\t\t.font(.system(size: 30, weight: .bold, design: .rounded))\n\t\t\t\t\t\t.monospacedDigit()\n\t\t\t\t}\n\t\t\t}\n\t\t\tCheckmarkShape()\n\t\t\t\t.stroke(style: .init(lineWidth: lineWidth / 1.5, lineCap: .round, lineJoin: .round))\n\t\t\t\t.scaleEffect(configuration.isFinished ? 1 : 0.4)\n\t\t\t\t.animation(.spring(response: 0.55, dampingFraction: 0.35).speed(1.3), value: configuration.isFinished)\n\t\t\t\t.opacity(configuration.isFinished ? 1 : 0)\n\t\t\t\t.animation(.easeInOut, value: configuration.isFinished)\n\t\t\t\t.scaledToFit()\n\t\t}\n\t}\n}\n\nextension ProgressViewStyle where Self == CircularProgressViewStyle {\n\tstatic func ssCircular(\n\t\tfill: (some ShapeStyle)? = nil,\n\t\tlineWidth: Double? = nil,\n\t\ttext: String? = nil\n\t) -> Self {\n\t\t.init(\n\t\t\tfill: fill,\n\t\t\tlineWidth: lineWidth,\n\t\t\ttext: text\n\t\t)\n\t}\n}\n\n\nextension View {\n\t/**\n\tAdd a keyboard shortcut to a view, not a button.\n\t*/\n\tfunc onKeyboardShortcut(\n\t\t_ shortcut: KeyboardShortcut?,\n\t\tperform action: @escaping () -> Void\n\t) -> some View {\n\t\toverlay {\n\t\t\tButton(\"\", action: action)\n\t\t\t\t.labelsHidden()\n\t\t\t\t.opacity(0)\n\t\t\t\t.frame(width: 0, height: 0)\n\t\t\t\t.keyboardShortcut(shortcut)\n\t\t\t\t.accessibilityHidden(true)\n\t\t}\n\t}\n\n\t/**\n\tAdd a keyboard shortcut to a view, not a button.\n\t*/\n\tfunc onKeyboardShortcut(\n\t\t_ key: KeyEquivalent,\n\t\tmodifiers: SwiftUI.EventModifiers = .command,\n\t\tisEnabled: Bool = true,\n\t\tperform action: @escaping () -> Void\n\t) -> some View {\n\t\tonKeyboardShortcut(isEnabled ? .init(key, modifiers: modifiers) : nil, perform: action)\n\t}\n}\n\n\nextension Device {\n\tstatic var isReduceMotionEnabled: Bool {\n\t\t#if os(macOS)\n\t\tNSWorkspace.shared.accessibilityDisplayShouldReduceMotion\n\t\t#else\n\t\tUIAccessibility.isReduceMotionEnabled\n\t\t#endif\n\t}\n}\n\n\nfunc withAnimationIf<Result>(\n\t_ condition: Bool,\n\tanimation: Animation? = .default,\n\t_ body: () throws -> Result\n) rethrows -> Result {\n\tcondition\n\t\t? try withAnimation(animation, body)\n\t\t: try body()\n}\n\nfunc withAnimationWhenNotReduced<Result>(\n\t_ animation: Animation? = .default,\n\t_ body: () throws -> Result\n) rethrows -> Result {\n\ttry withAnimationIf(\n\t\t!Device.isReduceMotionEnabled,\n\t\tanimation: animation,\n\t\tbody\n\t)\n}\n\n\nstruct AnyDropDelegate: DropDelegate {\n\tvar isTargeted: Binding<Bool>?\n\tvar onValidate: ((DropInfo) -> Bool)?\n\tlet onPerform: (DropInfo) -> Bool\n\tvar onEntered: ((DropInfo) -> Void)?\n\tvar onExited: ((DropInfo) -> Void)?\n\tvar onUpdated: ((DropInfo) -> DropProposal?)?\n\n\tfunc performDrop(info: DropInfo) -> Bool {\n\t\tisTargeted?.wrappedValue = false\n\t\treturn onPerform(info)\n\t}\n\n\tfunc validateDrop(info: DropInfo) -> Bool {\n\t\tonValidate?(info) ?? true\n\t}\n\n\tfunc dropEntered(info: DropInfo) {\n\t\tisTargeted?.wrappedValue = true\n\t\tonEntered?(info)\n\t}\n\n\tfunc dropExited(info: DropInfo) {\n\t\tisTargeted?.wrappedValue = false\n\t\tonExited?(info)\n\t}\n\n\tfunc dropUpdated(info: DropInfo) -> DropProposal? {\n\t\tonUpdated?(info)\n\t}\n}\n\n\nextension DropInfo {\n\t/**\n\tThis is useful as `DropInfo` usually on has `NSItemProvider` items and they have to be fetched async, while the validation has to happen synchronously.\n\t*/\n\tfunc fileURLsConforming(to contentTypes: [UTType]) -> [URL] {\n\t\tNSPasteboard(name: .drag).fileURLs(contentTypes: contentTypes)\n\t}\n\n\t/**\n\tIndicates whether at least one file URL conforms to at least one of the specified uniform type identifiers.\n\t*/\n\tfunc hasFileURLsConforming(to contentTypes: [UTType]) -> Bool {\n\t\t!fileURLsConforming(to: contentTypes).isEmpty\n\t}\n}\n\n\nextension CGSize {\n\tvar toInt: (width: Int, height: Int) {\n\t\t(Int(width), Int(height))\n\t}\n\n\tvar videoSizeDescription: String {\n\t\t\"\\(Int(width))x\\(Int(height))\"\n\t}\n}\n\n\nextension ClosedRange<Double> {\n\tvar toInt: ClosedRange<Int> {\n\t\tInt(lowerBound)...Int(upperBound)\n\t}\n}\n\nextension Range<Double> {\n\tvar toInt: Range<Int> {\n\t\tInt(lowerBound)..<Int(upperBound)\n\t}\n}\n\n\nextension AVPlayerView {\n\t/**\n\tActivates trim mode without waiting for trimming to finish.\n\t*/\n\tfunc activateTrimming() async throws { // TODO: `throws(CancellationError)` when `checkCancellation` has typed throws.\n\t\t_ = await updates(for: \\.canBeginTrimming).first(where: \\.self)\n\n\t\ttry Task.checkCancellation()\n\n\t\tTask {\n\t\t\t/**\n\t\t\tIn about 20% of debug sessions, `beginTrimming` will crash because `canBeginTrimming` is false. We have seen multiple cases where this guard catches into the else statement and the trimming controls work just fine: in each and every case where `canBeginTrimming` was false, this function gets called again with a value of true.\n\t\t\t*/\n\t\t\tguard canBeginTrimming else {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tawait beginTrimming()\n\t\t}\n\n\t\tawait Task.yield()\n\t}\n}\n\n\nextension NSObjectProtocol where Self: NSObject {\n\tfunc updates<Value>(\n\t\tfor keyPath: KeyPath<Self, Value>,\n\t\toptions: NSKeyValueObservingOptions = [.initial, .new]\n\t) -> some AsyncSequence<Value, Never> {\n\t\tpublisher(for: keyPath, options: options).toAsyncSequence\n\t}\n}\n\n\nprotocol ReflectiveEquatable: Equatable {}\n\nextension ReflectiveEquatable {\n\tvar reflectedValue: String { String(reflecting: self) }\n\n\tstatic func == (lhs: Self, rhs: Self) -> Bool {\n\t\tlhs.reflectedValue == rhs.reflectedValue\n\t}\n}\n\nprotocol ReflectiveHashable: Hashable, ReflectiveEquatable {}\n\nextension ReflectiveHashable {\n\tfunc hash(into hasher: inout Hasher) {\n\t\thasher.combine(reflectedValue)\n\t}\n}\n\n\nextension CGSize {\n\t/**\n\tCalculates a new size that maintains the aspect ratio, based on given width or height constraints.\n\tIf only one dimension is provided, calculates the other dimension accordingly to preserve the aspect ratio.\n\tIf both dimensions are provided, adjusts them to fit within the given dimensions while maintaining the aspect ratio.\n\t*/\n\tfunc aspectFittedSize(targetWidth: Double?, targetHeight: Double?) -> Self {\n\t\tlet originalAspectRatio = width / height\n\n\t\tswitch (targetWidth, targetHeight) {\n\t\tcase (let width?, nil):\n\t\t\treturn CGSize(\n\t\t\t\twidth: width,\n\t\t\t\theight: width / originalAspectRatio\n\t\t\t)\n\t\tcase (nil, let height?):\n\t\t\treturn CGSize(\n\t\t\t\twidth: height * originalAspectRatio,\n\t\t\t\theight: height\n\t\t\t)\n\t\tcase (let width?, let height?):\n\t\t\tlet targetAspectRatio = width / height\n\n\t\t\tif originalAspectRatio > targetAspectRatio {\n\t\t\t\treturn CGSize(\n\t\t\t\t\twidth: width,\n\t\t\t\t\theight: width / originalAspectRatio\n\t\t\t\t)\n\t\t\t}\n\n\t\t\treturn CGSize(\n\t\t\t\twidth: height * originalAspectRatio,\n\t\t\t\theight: height\n\t\t\t)\n\t\tdefault:\n\t\t\treturn self\n\t\t}\n\t}\n\n\tfunc aspectFittedSize(targetWidthHeight: Double) -> Self {\n\t\taspectFittedSize(\n\t\t\ttargetWidth: targetWidthHeight,\n\t\t\ttargetHeight: targetWidthHeight\n\t\t)\n\t}\n\n\tfunc aspectFittedSize(targetWidth: Int?, targetHeight: Int?) -> Self {\n\t\taspectFittedSize(\n\t\t\ttargetWidth: targetWidth.flatMap { Double($0) },\n\t\t\ttargetHeight: targetHeight.flatMap { Double($0) }\n\t\t)\n\t}\n}\n\n\n@dynamicMemberLookup\nstruct Tuple3<A, B, C> {\n\tlet (first, second, third): (A, B, C)\n\n\tinit(_ first: A, _ second: B, _ third: C) {\n\t\t(self.first, self.second, self.third) = (first, second, third)\n\t}\n\n\tsubscript<T>(dynamicMember keyPath: KeyPath<(A, B, C), T>) -> T {\n\t\t(first, second, third)[keyPath: keyPath]\n\t}\n}\n\nextension Tuple3: Equatable where A: Equatable, B: Equatable, C: Equatable {}\nextension Tuple3: Hashable where A: Hashable, B: Hashable, C: Hashable {}\nextension Tuple3: Encodable where A: Encodable, B: Encodable, C: Encodable {}\nextension Tuple3: Decodable where A: Decodable, B: Decodable, C: Decodable {}\nextension Tuple3: Sendable where A: Sendable, B: Sendable, C: Sendable {}\n\n\n@propertyWrapper\nstruct ViewStorage<Value>: DynamicProperty {\n\tprivate final class ValueBox {\n\t\tvar value: Value\n\n\t\tinit(_ value: Value) {\n\t\t\tself.value = value\n\t\t}\n\t}\n\n\t@State private var valueBox: ValueBox\n\n\tvar wrappedValue: Value {\n\t\tget { valueBox.value }\n\t\tnonmutating set {\n\t\t\tvalueBox.value = newValue\n\t\t}\n\t}\n\n\tvar projectedValue: Binding<Value> {\n\t\t.init(\n\t\t\tget: { wrappedValue },\n\t\t\tset: {\n\t\t\t\twrappedValue = $0\n\t\t\t}\n\t\t)\n\t}\n\n\tinit(wrappedValue value: @autoclosure @escaping () -> Value) {\n\t\tself._valueBox = .init(wrappedValue: ValueBox(value()))\n\t}\n}\n\n\nextension SSApp {\n\tfinal class Activity {\n\t\tprivate let activity: NSObjectProtocol\n\n\t\tinit(\n\t\t\t_ options: ProcessInfo.ActivityOptions = [],\n\t\t\treason: String\n\t\t) {\n\t\t\tself.activity = ProcessInfo.processInfo.beginActivity(options: options, reason: reason)\n\t\t}\n\n\t\tdeinit {\n\t\t\tProcessInfo.processInfo.endActivity(activity)\n\t\t}\n\t}\n\n\tstatic func beginActivity(\n\t\t_ options: ProcessInfo.ActivityOptions = [],\n\t\treason: String\n\t) -> Activity {\n\t\t.init(options, reason: reason)\n\t}\n}\n\nextension View {\n\tfunc activity(\n\t\t_ isActive: Bool = true,\n\t\toptions: ProcessInfo.ActivityOptions = [],\n\t\treason: String\n\t) -> some View {\n\t\tmodifier(\n\t\t\tAppActivityModifier(\n\t\t\t\tisActive: isActive,\n\t\t\t\toptions: options,\n\t\t\t\treason: reason\n\t\t\t)\n\t\t)\n\t}\n}\n\nprivate struct AppActivityModifier: ViewModifier {\n\t@ViewStorage private var activity: SSApp.Activity?\n\n\tlet isActive: Bool\n\tlet options: ProcessInfo.ActivityOptions\n\tlet reason: String\n\n\tfunc body(content: Content) -> some View {\n\t\tcontent\n\t\t\t.task(id: Tuple3(isActive, options, reason)) { // TODO: Use a tuple here when it can be equatable.\n\t\t\t\tactivity = isActive ? SSApp.beginActivity(options, reason: reason) : nil\n\t\t\t}\n\t}\n}\n\n\nfunc greatestCommonDivisor<T: BinaryInteger>(_ a: T, _ b: T) -> T {\n\tlet result = a % b\n\treturn result == 0 ? b : greatestCommonDivisor(b, result)\n}\n\n\nextension View {\n\tfunc staticPopover(\n\t\tisPresented: Binding<Bool>,\n\t\t@ViewBuilder content: @escaping () -> some View\n\t) -> some View {\n\t\tmodifier(\n\t\t\tStaticPopover(\n\t\t\t\tisPresented: isPresented,\n\t\t\t\tpopoverContent: content\n\t\t\t)\n\t\t)\n\t}\n}\n\n/**\nUse the size of the select box when it is opened, so the popover doesn't move as the select box changes shape.\n*/\nstruct StaticPopover<PopoverContent: View>: ViewModifier {\n\t@State private var size: CGSize?\n\t@State private var visibleSize: CGSize?\n\n\t@Binding var isPresented: Bool\n\tlet popoverContent: () -> PopoverContent\n\n\tfunc body(content: Content) -> some View {\n\t\tZStack(alignment: .trailing) {\n\t\t\tcontent\n\t\t\t\t.readSize(into: $size)\n\t\t\t\t.onChange(of: isPresented) {\n\t\t\t\t\tvisibleSize = size\n\t\t\t\t}\n\t\t\tif isPresented {\n\t\t\t\tColor.clear\n\t\t\t\t\t.fillFrame()\n\t\t\t\t\t.frame(width: visibleSize?.width, height: visibleSize?.height)\n\t\t\t\t\t.popover2(isPresented: $isPresented, arrowEdge: .bottom) {\n\t\t\t\t\t\tpopoverContent()\n\t\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\n\nextension View {\n\tfunc readSize(_ onChange: @escaping (CGSize) -> Void) -> some View {\n\t\tonGeometryChange(for: CGSize.self) { proxy in\n\t\t\tproxy.size\n\t\t} action: {\n\t\t\tonChange($0)\n\t\t}\n\t}\n\n\tfunc readSize(into binding: Binding<CGSize?>) -> some View {\n\t\treadSize {\n\t\t\tbinding.wrappedValue = $0\n\t\t}\n\t}\n}\n\n\nextension ColorScheme {\n\tvar isDark: Bool {\n\t\tself == .dark\n\t}\n}\n\n\nextension Color {\n\tvar ciColor: CIColor? {\n\t\tCIColor(color: NSColor(self))\n\t}\n\n\tvar simd4: SIMD4<Float> {\n\t\tlet color = NSColor(self)\n\t\treturn .init(\n\t\t\tx: Float(color.redComponent),\n\t\t\ty: Float(color.greenComponent),\n\t\t\tz: Float(color.blueComponent),\n\t\t\tw: Float(color.alphaComponent)\n\t\t)\n\t}\n}\n\n\nextension CVPixelBuffer {\n\tvar planeCount: Int {\n\t\tCVPixelBufferGetPlaneCount(self)\n\t}\n\n\tvar width: Int {\n\t\tCVPixelBufferGetWidth(self)\n\t}\n\n\tvar height: Int {\n\t\tCVPixelBufferGetHeight(self)\n\t}\n\n\tvar pixelFormatType: OSType {\n\t\tCVPixelBufferGetPixelFormatType(self)\n\t}\n\n\tvar bytesPerRow: Int {\n\t\tCVPixelBufferGetBytesPerRow(self)\n\t}\n\n\tvar baseAddress: UnsafeMutableRawPointer? {\n\t\tCVPixelBufferGetBaseAddress(self)\n\t}\n\n\tvar creationAttributes: [String: Any] {\n\t\tCVPixelBufferCopyCreationAttributes(self) as NSDictionary as? [String: Any] ?? [:]\n\t}\n\n\tvar attachments: [String: Any] {\n\t\tguard let attachments = CVBufferCopyAttachments(self, .shouldPropagate) else {\n\t\t\treturn [:]\n\t\t}\n\n\t\treturn attachments as NSDictionary as? [String: Any] ?? [:]\n\t}\n\n\tfunc baseAddressOfPlane(_ plane: Int) -> UnsafeMutableRawPointer? {\n\t\tCVPixelBufferGetBaseAddressOfPlane(self, plane)\n\t}\n\n\tfunc bytesPerRowOfPlane(_ plane: Int) -> Int {\n\t\tCVPixelBufferGetBytesPerRowOfPlane(self, plane)\n\t}\n\n\tfunc heightOfPlane(_ plane: Int) -> Int {\n\t\tCVPixelBufferGetHeightOfPlane(self, plane)\n\t}\n\n\tvar colorSpace: CGColorSpace? {\n\t\tattachments[kCVImageBufferCGColorSpaceKey as String] as! CGColorSpace?\n\t}\n\n\tstatic func create(\n\t\twidth: Int,\n\t\theight: Int,\n\t\tpixelFormatType: OSType,\n\t\tpixelBufferAttributes: [String: Any]? = nil // swiftlint:disable:this discouraged_optional_collection\n\t) throws(CreationError) -> CVPixelBuffer {\n\t\tvar out: CVPixelBuffer?\n\t\tlet status = CVPixelBufferCreate(\n\t\t\tkCFAllocatorDefault,\n\t\t\twidth,\n\t\t\theight,\n\t\t\tpixelFormatType,\n\t\t\tpixelBufferAttributes as CFDictionary?,\n\t\t\t&out\n\t\t)\n\n\t\tguard status == kCVReturnSuccess else {\n\t\t\tthrow .creationError(status: status)\n\t\t}\n\n\t\tguard let out else {\n\t\t\tthrow .noBuffer\n\t\t}\n\n\t\treturn out\n\t}\n\n\tenum CreationError: Error {\n\t\tcase creationError(status: CVReturn)\n\t\tcase noBuffer\n\t}\n\n\tfunc copy(to destination: CVPixelBuffer) throws {\n\t\ttry withLockedPlanes(flags: [.readOnly]) { sourcePlanes in\n\t\t\ttry destination.withLockedPlanes(flags: []) { destinationPlanes in\n\t\t\t\tguard sourcePlanes.count == destinationPlanes.count else {\n\t\t\t\t\tthrow CopyError.planesMismatch\n\t\t\t\t}\n\n\t\t\t\tfor (sourcePlane, destinationPlane) in zip(sourcePlanes, destinationPlanes) {\n\t\t\t\t\ttry sourcePlane.copy(to: destinationPlane)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tfunc lockBaseAddress(flags: CVPixelBufferLockFlags = []) throws(LockError) {\n\t\tlet status = CVPixelBufferLockBaseAddress(self, flags)\n\n\t\tguard status == kCVReturnSuccess else {\n\t\t\tthrow .lockFailed(status: status)\n\t\t}\n\t}\n\n\tenum LockError: Error {\n\t\tcase lockFailed(status: CVReturn)\n\t\tcase noBaseAddress\n\t}\n\n\tfunc unlockBaseAddress(flags: CVPixelBufferLockFlags = []) {\n\t\tCVPixelBufferUnlockBaseAddress(self, flags)\n\t}\n\n\tenum CopyError: Error {\n\t\tcase planesMismatch\n\t\tcase heightMismatch\n\t}\n\n\tfunc withLockedBaseAddress<T>(\n\t\tflags: CVPixelBufferLockFlags = [],\n\t\t_ body: (CVPixelBuffer) throws -> T\n\t) throws -> T {\n\t\ttry lockBaseAddress(flags: flags)\n\n\t\tdefer {\n\t\t\tself.unlockBaseAddress(flags: flags)\n\t\t}\n\n\t\treturn try body(self)\n\t}\n\n\tfunc withLockedPlanes<T>(\n\t\tflags: CVPixelBufferLockFlags = [],\n\t\t_ body: ([LockedPlane]) throws -> T\n\t) throws -> T {\n\t\ttry withLockedBaseAddress(flags: flags) { buffer in\n\t\t\tlet planeCount = buffer.planeCount\n\n\t\t\tif planeCount == 0 {\n\t\t\t\tguard let baseAddress = buffer.baseAddress else {\n\t\t\t\t\tthrow LockError.noBaseAddress\n\t\t\t\t}\n\n\t\t\t\treturn try body([\n\t\t\t\t\t.init(\n\t\t\t\t\t\tbase: baseAddress,\n\t\t\t\t\t\tbytesPerRow: buffer.bytesPerRow,\n\t\t\t\t\t\theight: buffer.height\n\t\t\t\t\t)\n\t\t\t\t])\n\t\t\t}\n\n\t\t\tlet planes = try (0..<planeCount).compactMap { planeIndex -> LockedPlane? in\n\t\t\t\tguard let baseAddress = buffer.baseAddressOfPlane(planeIndex) else {\n\t\t\t\t\tthrow LockError.noBaseAddress\n\t\t\t\t}\n\n\t\t\t\treturn .init(\n\t\t\t\t\tbase: baseAddress,\n\t\t\t\t\tbytesPerRow: buffer.bytesPerRowOfPlane(planeIndex),\n\t\t\t\t\theight: buffer.heightOfPlane(planeIndex)\n\t\t\t\t)\n\t\t\t}\n\n\t\t\tguard planes.count == planeCount else {\n\t\t\t\tthrow CopyError.planesMismatch\n\t\t\t}\n\n\t\t\treturn try body(planes)\n\t\t}\n\t}\n\n\tfunc makeCompatibleBuffer() throws(CreationError) -> CVPixelBuffer {\n\t\ttry Self.create(\n\t\t\twidth: width,\n\t\t\theight: height,\n\t\t\tpixelFormatType: pixelFormatType,\n\t\t\tpixelBufferAttributes: creationAttributes\n\t\t)\n\t}\n\n\tstruct LockedPlane {\n\t\tlet base: UnsafeMutableRawPointer\n\t\tlet bytesPerRow: Int\n\t\tlet height: Int\n\n\t\tfunc copy(to destination: Self) throws(CopyError) {\n\t\t\tguard height == destination.height else {\n\t\t\t\tthrow .heightMismatch\n\t\t\t}\n\n\t\t\tguard bytesPerRow != destination.bytesPerRow else {\n\t\t\t\tmemcpy(destination.base, base, height * bytesPerRow)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tvar destinationBase = destination.base\n\t\t\tvar sourceBase = base\n\t\t\tlet minBytesPerRow = min(bytesPerRow, destination.bytesPerRow)\n\n\t\t\tfor _ in 0..<height {\n\t\t\t\tmemcpy(destinationBase, sourceBase, minBytesPerRow)\n\t\t\t\tsourceBase = sourceBase.advanced(by: bytesPerRow)\n\t\t\t\tdestinationBase = destinationBase.advanced(by: destination.bytesPerRow)\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\tSetup the video to use sRGB color space.\n\n\tWe  set `kCVImageBufferColorPrimariesKey`  and  `kCVImageBufferYCbCrMatrixKey` to 709 because sRGB and 709 \"share the same primary chromaticities, but they have different transfer functions\". [Source](https://web.archive.org/web/20250416122435/https://www.image-engineering.de/library/technotes/714-color-spaces-rec-709-vs-srgb)\n\t*/\n\tfunc setSRGBColorSpace() {\n\t\tsetAttachment(key: kCVImageBufferColorPrimariesKey, value: kCVImageBufferColorPrimaries_ITU_R_709_2, attachmentMode: .shouldPropagate)\n\t\tsetAttachment(key: kCVImageBufferTransferFunctionKey, value: kCVImageBufferTransferFunction_sRGB, attachmentMode: .shouldPropagate)\n\t\tsetAttachment(key: kCVImageBufferYCbCrMatrixKey, value: kCVImageBufferYCbCrMatrix_ITU_R_709_2, attachmentMode: .shouldPropagate)\n\t}\n\n\tfunc setAttachment(\n\t\tkey: CFString,\n\t\tvalue: CFTypeRef,\n\t\tattachmentMode: CVAttachmentMode\n\t) {\n\t\tCVBufferSetAttachment(self, key, value, attachmentMode)\n\t}\n\n\t/**\n\tMark that the pixel buffer will not be modified in any way except by `PreviewRenderer`.\n\t*/\n\tvar previewSendable: PreviewRenderer.SendableCVPixelBuffer {\n\t\tPreviewRenderer.SendableCVPixelBuffer(pixelBuffer: self)\n\t}\n}\n\n\nextension CGImageSource {\n\tenum CreateError: Error {\n\t\tcase failedToCreateImageSource\n\t}\n\n\tenum CreateImageError: Error {\n\t\tcase failedToCreateImage(status: CGImageSourceStatus)\n\t}\n\n\tstatic func from(\n\t\t_ data: Data,\n\t\toptions: [String: Any]? = nil // swiftlint:disable:this discouraged_optional_collection\n\t) throws(CreateError) -> CGImageSource {\n\t\tguard\n\t\t\tlet imageSource = CGImageSourceCreateWithData(\n\t\t\t\tdata as CFData,\n\t\t\t\toptions as CFDictionary?\n\t\t\t)\n\t\telse {\n\t\t\tthrow .failedToCreateImageSource\n\t\t}\n\n\t\treturn imageSource\n\t}\n\n\tvar count: Int {\n\t\tCGImageSourceGetCount(self)\n\t}\n\n\tfunc createImage(\n\t\tatIndex index: Int,\n\t\toptions: [String: Any]? = nil // swiftlint:disable:this discouraged_optional_collection\n\t) throws(CreateImageError) -> CGImage {\n\t\tguard\n\t\t\tlet image = CGImageSourceCreateImageAtIndex(\n\t\t\t\tself,\n\t\t\t\tindex,\n\t\t\t\toptions as CFDictionary?\n\t\t\t)\n\t\telse {\n\t\t\tthrow .failedToCreateImage(status: CGImageSourceGetStatusAtIndex(self, index))\n\t\t}\n\n\t\treturn image\n\t}\n}\n\nextension CGImage {\n\tfunc convertToData(\n\t\twithNewType type: String,\n\t\tdestinationOptions: [String: Any]? = nil, // swiftlint:disable:this discouraged_optional_collection\n\t\taddOptions: [String: Any]? = nil // swiftlint:disable:this discouraged_optional_collection\n\t) throws -> Data {\n\t\tlet mutableData = NSMutableData()\n\n\t\tlet destination = try CGImageDestination.from(\n\t\t\twithData: mutableData,\n\t\t\ttype: type,\n\t\t\tcount: 1,\n\t\t\toptions: destinationOptions\n\t\t)\n\n\t\tdestination.addImage(self, properties: addOptions)\n\n\t\ttry destination.finalize()\n\n\t\treturn mutableData as Data\n\t}\n}\n\nextension CGImageDestination {\n\tstatic func from(\n\t\twithData: NSMutableData,\n\t\ttype: String,\n\t\tcount: Int = 1,\n\t\toptions: [String: Any]? = nil // swiftlint:disable:this discouraged_optional_collection\n\t) throws(CreateError) -> CGImageDestination {\n\t\tguard\n\t\t\tlet imageDestination = CGImageDestinationCreateWithData(\n\t\t\t\twithData,\n\t\t\t\ttype as CFString,\n\t\t\t\tcount,\n\t\t\t\toptions as CFDictionary?\n\t\t\t)\n\t\telse {\n\t\t\tthrow .failedToCreate\n\t\t}\n\n\t\treturn imageDestination\n\t}\n\n\tenum CreateError: Error {\n\t\tcase failedToCreate\n\t}\n\n\tfunc addImage(\n\t\t_ image: CGImage,\n\t\tproperties: [String: Any]? = nil // swiftlint:disable:this discouraged_optional_collection\n\t) {\n\t\tCGImageDestinationAddImage(\n\t\t\tself,\n\t\t\timage,\n\t\t\tproperties as CFDictionary?\n\t\t)\n\t}\n\n\tenum FinalizeError: Error {\n\t\tcase failedToFinalize\n\t}\n\n\tfunc finalize() throws(FinalizeError) {\n\t\tguard CGImageDestinationFinalize(self) else {\n\t\t\tthrow .failedToFinalize\n\t\t}\n\t}\n}\n\n\nextension Data {\n\tfunc readLittleEndianUInt24(_ start: Int) -> UInt32 {\n\t\tUInt32(self[start]) | UInt32(self[start + 1]) << 8 | UInt32(self[start + 2]) << 16\n\t}\n}\n\n\nextension MTLCommandBuffer {\n\ttypealias RenderError = MTLCommandBufferRenderError\n\n\t/**\n\tSubmits the commands to the GPU and awaits completion.\n\t*/\n\tfunc commit() async throws {\n\t\ttry await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in\n\t\t\taddCompletedHandler { [weak self] _ in\n\t\t\t\tguard let self else {\n\t\t\t\t\tcontinuation.resume(throwing: RenderError.functionOutlivedTheCommandBuffer)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tguard status == .completed else {\n\t\t\t\t\tcontinuation.resume(throwing: RenderError.failedToRender(status: status))\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tcontinuation.resume()\n\t\t\t}\n\n\t\t\tcommit()\n\t\t}\n\t}\n\n\t/**\n\tCreates a render command encoder, runs your operation, then ends encoding with `endEncoding`.\n\t*/\n\tfunc withRenderCommandEncoder(\n\t\trenderPassDescriptor: MTLRenderPassDescriptor,\n\t\toperation: (MTLRenderCommandEncoder) throws -> Void\n\t) throws {\n\t\tguard let renderEncoder = makeRenderCommandEncoder(descriptor: renderPassDescriptor) else {\n\t\t\tthrow RenderError.failedToMakeRenderCommandEncoder\n\t\t}\n\n\t\tdefer {\n\t\t\trenderEncoder.endEncoding()\n\t\t}\n\n\t\ttry operation(renderEncoder)\n\t}\n}\n\nenum MTLCommandBufferRenderError: Error {\n\tcase failedToRender(status: MTLCommandBufferStatus)\n\tcase functionOutlivedTheCommandBuffer\n\tcase failedToMakeRenderCommandEncoder\n}\n\nextension MTLCommandQueue {\n\ttypealias RenderError = MTLCommandQueueRenderError\n\n\t/**\n\tCreates a command buffer, runs your operation, commits (sends commands to the GPU), and awaits completion\n\t*/\n\tfunc withCommandBuffer(\n\t\tisolated actor: isolated any Actor,\n\t\toperation: (MTLCommandBuffer) throws -> Void\n\t) async throws {\n\t\tguard let commandBuffer = makeCommandBuffer() else {\n\t\t\tthrow MTLCommandQueueRenderError.failedToCreateCommandBuffer\n\t\t}\n\n\t\ttry operation(commandBuffer)\n\t\ttry await commandBuffer.commit()\n\t}\n}\n\nenum MTLCommandQueueRenderError: Error {\n\tcase failedToCreateCommandBuffer\n}\n\n\nextension CGPoint {\n\tvar simdFloat2: SIMD2<Float> {\n\t\t.init(x: Float(x), y: Float(y))\n\t}\n\n\tfunc nearestPointInsideRectBounds(_ rect: CGRect) -> Self {\n\t\t.init(\n\t\t\tx: x.clamped(from: rect.minX, to: rect.maxX),\n\t\t\ty: y.clamped(from: rect.minY, to: rect.maxY)\n\t\t)\n\t}\n\n\tstatic func / (lhs: CGPoint, rhs: CGSize) -> CGPoint {\n\t\t.init(x: lhs.x / rhs.width, y: lhs.y / rhs.height)\n\t}\n\n\tstatic prefix func - (lhs: CGPoint) -> CGPoint {\n\t\t.init(x: -lhs.x, y: -lhs.y)\n\t}\n}\n\nextension CGSize {\n\tvar simdFloat2: SIMD2<Float> {\n\t\t.init(x: Float(width), y: Float(height))\n\t}\n\n\tfunc clamped(within rect: CGRect) -> CGPoint {\n\t\t.init(\n\t\t\tx: width.clamped(from: rect.minX, to: rect.maxX),\n\t\t\ty: height.clamped(from: rect.minY, to: rect.maxY)\n\t\t)\n\t}\n}\n\n\n/**\nWe need to keep a strong reference to the `CVMetalTexture` until the GPU command completes. This struct ensures that the `CVMetalTexture` is not garbage collected as long as the `MTLTexture` is around. [See](https://developer.apple.com/documentation/corevideo/cvmetaltexturecachecreatetexturefromimage(_:_:_:_:_:_:_:_:_:))\n*/\nstruct CVMetalTextureReference {\n\tprivate let coreVideoTextureReference: CVMetalTexture\n\n\tlet texture: MTLTexture\n\n\tfileprivate init(\n\t\tcoreVideoTextureReference: CVMetalTexture,\n\t\ttexture: MTLTexture\n\t) {\n\t\tself.coreVideoTextureReference = coreVideoTextureReference\n\t\tself.texture = texture\n\t}\n}\n\n\nextension CVMetalTextureCache {\n\tenum Error: LocalizedError {\n\t\tcase invalidArgument\n\t\tcase allocationFailed\n\t\tcase unsupported\n\t\tcase invalidPixelFormat\n\t\tcase invalidPixelBufferAttributes\n\t\tcase invalidSize\n\t\tcase pixelBufferNotMetalCompatible\n\t\tcase failedToCreateTexture\n\t\tcase unknown(CVReturn)\n\n\t\tinit(cvReturn: CVReturn) {\n\t\t\tself = switch cvReturn {\n\t\t\tcase kCVReturnInvalidArgument:\n\t\t\t\t.invalidArgument\n\t\t\tcase kCVReturnAllocationFailed:\n\t\t\t\t.allocationFailed\n\t\t\tcase kCVReturnUnsupported:\n\t\t\t\t.unsupported\n\t\t\tcase kCVReturnInvalidPixelFormat:\n\t\t\t\t.invalidPixelFormat\n\t\t\tcase kCVReturnInvalidPixelBufferAttributes:\n\t\t\t\t.invalidPixelBufferAttributes\n\t\t\tcase kCVReturnInvalidSize:\n\t\t\t\t.invalidSize\n\t\t\tcase kCVReturnPixelBufferNotMetalCompatible:\n\t\t\t\t.pixelBufferNotMetalCompatible\n\t\t\tdefault:\n\t\t\t\t.unknown(cvReturn)\n\t\t\t}\n\t\t}\n\n\t\tvar errorDescription: String? {\n\t\t\tswitch self {\n\t\t\tcase .invalidArgument:\n\t\t\t\t\"Invalid argument provided to CVMetalTextureCache\"\n\t\t\tcase .allocationFailed:\n\t\t\t\t\"Memory allocation failed\"\n\t\t\tcase .unsupported:\n\t\t\t\t\"Operation not supported\"\n\t\t\tcase .invalidPixelFormat:\n\t\t\t\t\"Invalid pixel format\"\n\t\t\tcase .invalidPixelBufferAttributes:\n\t\t\t\t\"Invalid pixel buffer attributes\"\n\t\t\tcase .invalidSize:\n\t\t\t\t\"Invalid size\"\n\t\t\tcase .pixelBufferNotMetalCompatible:\n\t\t\t\t\"Pixel buffer is not Metal compatible\"\n\t\t\tcase .failedToCreateTexture:\n\t\t\t\t\"Failed to create Metal texture\"\n\t\t\tcase .unknown(let cvReturn):\n\t\t\t\t\"Unknown CVReturn error: \\(cvReturn)\"\n\t\t\t}\n\t\t}\n\t}\n\n\tfunc createTexture(\n\t\tfrom image: CVPixelBuffer,\n\t\tpixelFormat: MTLPixelFormat,\n\t\ttextureAttributes: [String: Any]? = nil // swiftlint:disable:this discouraged_optional_collection\n\t) throws(Error) -> CVMetalTextureReference {\n\t\tvar coreVideoTextureReference: CVMetalTexture?\n\n\t\tlet result = CVMetalTextureCacheCreateTextureFromImage(\n\t\t\tnil,\n\t\t\tself,\n\t\t\timage,\n\t\t\ttextureAttributes as CFDictionary?,\n\t\t\tpixelFormat,\n\t\t\timage.width,\n\t\t\timage.height,\n\t\t\t0,\n\t\t\t&coreVideoTextureReference\n\t\t)\n\n\t\tguard result == kCVReturnSuccess else {\n\t\t\tthrow .init(cvReturn: result)\n\t\t}\n\n\t\tguard\n\t\t\tlet coreVideoTextureReference,\n\t\t\tlet texture = CVMetalTextureGetTexture(coreVideoTextureReference)\n\t\telse {\n\t\t\tthrow .failedToCreateTexture\n\t\t}\n\n\t\treturn .init(\n\t\t\tcoreVideoTextureReference: coreVideoTextureReference,\n\t\t\ttexture: texture\n\t\t)\n\t}\n}\n\n\n/**\nSupport for [Adaptable Scalable Texture Compression (ASTC)](https://www.khronos.org/opengl/wiki/ASTC_Texture_Compression) images.\n\nASTC files have a [16-byte header](https://github.com/ARM-software/astc-encoder/blob/main/Docs/FileFormat.md). We parse it to get render information, like the size of the blocks and the image size.\n*/\nstruct ASTCImage {\n\tenum CreateError: Error {\n\t\tcase invalidDataSize\n\t\tcase notASTCData\n\t}\n\n\tenum WriteError: Error {\n\t\tcase failedToGetASTCBaseAddress\n\t}\n\n\tprivate static let headerSize = 16\n\tprivate static let astcBlockSize = 16\n\n\tprivate let data: Data\n\tprivate let blockSize: (UInt8, UInt8, UInt8)\n\tprivate let imageSize: (Int, Int, Int)\n\n\tinit(data: Data) throws(CreateError) {\n\t\tself.data = data\n\n\t\tguard data.count >= Self.headerSize else {\n\t\t\tthrow .invalidDataSize\n\t\t}\n\n\t\t// Check the magic number\n\t\tguard\n\t\t\tdata[0] == 0x13,\n\t\t\tdata[1] == 0xAB,\n\t\t\tdata[2] == 0xA1,\n\t\t\tdata[3] == 0x5C\n\t\telse {\n\t\t\tthrow .notASTCData\n\t\t}\n\n\t\tself.blockSize = (data[4], data[5], data[6])\n\n\t\tself.imageSize = (\n\t\t\tInt(data.readLittleEndianUInt24(7)),\n\t\t\tInt(data.readLittleEndianUInt24(10)),\n\t\t\tInt(data.readLittleEndianUInt24(13))\n\t\t)\n\t}\n\n\tvar width: Int {\n\t\timageSize.0\n\t}\n\n\tvar height: Int {\n\t\timageSize.1\n\t}\n\n\t/**\n\tA Metal descriptor that describes this image.\n\t*/\n\tfunc descriptor() throws(MTLPixelFormat.ASTCPixelFormatError) -> MTLTextureDescriptor {\n\t\tMTLTextureDescriptor.texture2DDescriptor(\n\t\t\tpixelFormat: try .astcLowDynamicRange(fromBlockSize: blockSize),\n\t\t\twidth: width,\n\t\t\theight: height,\n\t\t\tmipmapped: false\n\t\t)\n\t}\n\n\tfunc write(to texture: MTLTexture) throws {\n\t\ttry data.withUnsafeBytes { bytes in\n\t\t\tguard let baseAddress = bytes.baseAddress else {\n\t\t\t\tthrow WriteError.failedToGetASTCBaseAddress\n\t\t\t}\n\n\t\t\tlet imageDataStart = baseAddress.advanced(by: Self.headerSize)\n\n\t\t\ttexture.replace(\n\t\t\t\tregion: MTLRegionMake2D(0, 0, width, height),\n\t\t\t\tmipmapLevel: 0,\n\t\t\t\twithBytes: imageDataStart,\n\t\t\t\tbytesPerRow: bytesPerRow\n\t\t\t)\n\t\t}\n\t}\n\n\tprivate var bytesPerRow: Int {\n\t\tblocksPerRow * Self.astcBlockSize\n\t}\n\n\tprivate var blocksPerRow: Int {\n\t\tInt(ceil(Double(width) / Double(blockSize.0)))\n\t}\n}\n\n\nextension MTLPixelFormat {\n\tenum ASTCPixelFormatError: Error {\n\t\tcase notImplemented\n\t}\n\n\t/**\n\t[ASTC](https://registry.khronos.org/OpenGL/extensions/OES/OES_texture_compression_astc.txt) low dynamic range at a given block size:\n\n\t\"...the number of bits per pixel that ASTC takes up is determined by the block size used. So the 4x4 version of ASTC, the smallest block size, takes up 8 bits per pixel, while the 12x12 version takes up only 0.89bpp.\" [*](https://www.khronos.org/opengl/wiki/ASTC_Texture_Compression)\n\n\t- Parameters:\n\t\t- blockSize: Block size in (x, y ,z)\n\n\t- Returns: the appropriate pixel format given an astc block size.\n\t*/\n\tstatic func astcLowDynamicRange(\n\t\tfromBlockSize blockSize: (UInt8, UInt8, UInt8)\n\t) throws(ASTCPixelFormatError) -> Self {\n\t\tlet (width, height, depth) = blockSize\n\n\t\tguard depth == 1 else {\n\t\t\tthrow .notImplemented\n\t\t}\n\n\t\tif\n\t\t\twidth == 4,\n\t\t\theight == 4\n\t\t{\n\t\t\treturn .astc_4x4_ldr\n\t\t}\n\n\t\tif\n\t\t\twidth == 8,\n\t\t\theight == 8\n\t\t{\n\t\t\treturn .astc_8x8_ldr\n\t\t}\n\n\t\tthrow .notImplemented\n\t}\n}\n\n\n/**\nA task with a progress AsyncStream.\n*/\nstruct ProgressableTask<Progress: Sendable, Result: Sendable>: Sendable {\n\tlet progress: AsyncStream<Progress>\n\tlet task: Task<Result, any Error>\n\n\tinit(\n\t\toperation: @escaping (AsyncStream<Progress>.Continuation) async throws -> Result\n\t) {\n\t\tlet (progressStream, progressContinuation) = AsyncStream<Progress>.makeStream()\n\n\t\tself.progress = progressStream\n\n\t\tself.task = Task {\n\t\t\tdo {\n\t\t\t\tlet out = try await operation(progressContinuation)\n\t\t\t\tprogressContinuation.finish()\n\t\t\t\treturn out\n\t\t\t} catch {\n\t\t\t\tprogressContinuation.finish()\n\t\t\t\tthrow error\n\t\t\t}\n\t\t}\n\t}\n\n\tfunc cancel() {\n\t\ttask.cancel()\n\t}\n\n\tvar value: Result {\n\t\tget async throws {\n\t\t\ttry await task.value\n\t\t}\n\t}\n}\n\nextension ProgressableTask where Progress == Double {\n\tfunc monitorProgressWithCancellation(\n\t\tprogressWeight: Double = 1,\n\t\tprogressOffset: Double = 0,\n\t\tprogressContinuation: AsyncStream<Progress>.Continuation,\n\t) async throws -> Result {\n\t\tawait withTaskCancellationHandler {\n\t\t\tfor await currentProgress in progress {\n\t\t\t\tprogressContinuation.yield(progressOffset + currentProgress * progressWeight)\n\t\t\t}\n\t\t} onCancel: {\n\t\t\tcancel()\n\t\t}\n\n\t\ttry Task.checkCancellation()\n\n\t\treturn try await value\n\t}\n\n\t/**\n\tCompose this task with another task, weighting the progress of the task by `weight`.\n\t*/\n\tfunc then<Result2>(\n\t\tprogressWeight: Double = 0.5,\n\t\tcomposeWith nextTask: @Sendable @escaping (Result) async throws -> ProgressableTask<Double, Result2>\n\t) -> ProgressableTask<Double, Result2> {\n\t\tProgressableTask<Double, Result2> { progressContinuation in\n\t\t\tlet result1 = try await monitorProgressWithCancellation(\n\t\t\t\tprogressWeight: progressWeight,\n\t\t\t\tprogressContinuation: progressContinuation\n\t\t\t)\n\n\t\t\ttry Task.checkCancellation()\n\n\t\t\tlet task2 = try await nextTask(result1)\n\n\t\t\ttry Task.checkCancellation()\n\n\t\t\treturn try await task2.monitorProgressWithCancellation(\n\t\t\t\tprogressWeight: 1 - progressWeight,\n\t\t\t\tprogressOffset: progressWeight,\n\t\t\t\tprogressContinuation: progressContinuation\n\t\t\t)\n\t\t}\n\t}\n}\n\n\n/**\nProtocol for preview equivalence comparison using the `~=` operator.\n\nThis ignores transient properties that don't affect visual output.\n*/\nprotocol PreviewComparable {\n\tstatic func ~= (lhs: Self, rhs: Self) -> Bool\n}\n\n\nextension CompositePreviewFragmentUniforms: Equatable {\n\tinit() {\n\t\tself.init(\n\t\t\tvideoOrigin: .one,\n\t\t\tvideoSize: .one,\n\t\t\tfirstColor: .zero,\n\t\t\tsecondColor: .one,\n\t\t\tgridSize: 1\n\t\t)\n\t}\n\n\tinit(isDarkMode: Bool, videoBounds: CGRect) {\n\t\tself.init(\n\t\t\tvideoOrigin: videoBounds.origin.nearestPointInsideRectBounds(.init(origin: .zero, width: .infinity, height: .infinity)).simdFloat2,\n\t\t\tvideoSize: videoBounds.size.clamped(within: .init(origin: .zero, width: .infinity, height: .infinity)).simdFloat2,\n\t\t\tfirstColor: (isDarkMode ? CheckerboardViewConstants.firstColorDark : CheckerboardViewConstants.firstColorLight).simd4,\n\t\t\tsecondColor: (isDarkMode ? CheckerboardViewConstants.secondColorDark : CheckerboardViewConstants.secondColorLight).simd4,\n\t\t\tgridSize: Int32(CheckerboardViewConstants.gridSize).clamped(from: 1, to: .max)\n\t\t)\n\t}\n\n\tpublic static func == (lhs: CompositePreviewFragmentUniforms, rhs: CompositePreviewFragmentUniforms) -> Bool {\n\t\tlhs.videoOrigin == rhs.videoOrigin &&\n\t\tlhs.videoSize == rhs.videoSize &&\n\t\tlhs.firstColor == rhs.firstColor &&\n\t\tlhs.secondColor == rhs.secondColor &&\n\t\tlhs.gridSize == rhs.gridSize\n\t}\n}\n\nextension CGAffineTransform {\n\tinit(scaledBy size: CGSize) {\n\t\tself = Self(scaleX: size.width, y: size.height)\n\t}\n\n\tfunc translated(by point: CGPoint) -> Self {\n\t\ttranslatedBy(x: point.x, y: point.y)\n\t}\n}\n\nextension ClosedRange<Double> {\n\tpublic static func - (lhs: ClosedRange<Double>, rhs: Double) -> ClosedRange<Double> {\n\t\t(lhs.lowerBound - rhs) ... (lhs.upperBound - rhs)\n\t}\n\n\tpublic static func + (lhs: ClosedRange<Double>, rhs: Double) -> ClosedRange<Double> {\n\t\t(lhs.lowerBound + rhs) ... (lhs.upperBound + rhs)\n\t}\n\n\tpublic static func * (lhs: ClosedRange<Double>, rhs: Double) -> ClosedRange<Double> {\n\t\t(lhs.lowerBound * rhs) ... (lhs.upperBound * rhs)\n\t}\n}\n\n\nextension ToolbarContent {\n\tnonisolated func ss_sharedBackgroundVisibility_hidden() -> some ToolbarContent {\n\t\tif #available(macOS 26, iOS 26, tvOS 26, watchOS 26, visionOS 26, *) {\n\t\t\treturn sharedBackgroundVisibility(.hidden)\n\t\t}\n\n\t\treturn self\n\t}\n}\n"
  },
  {
    "path": "Gifski/VideoValidator.swift",
    "content": "import AVFoundation\n\nenum VideoValidator {\n\tstatic func validate(_ inputUrl: URL) async throws -> (asset: AVAsset, metadata: AVAsset.VideoMetadata) {\n//\t\tCrashlytics.record(\n//\t\t\tkey: \"Does input file exist\",\n//\t\t\tvalue: inputUrl.exists\n//\t\t)\n//\t\tCrashlytics.record(\n//\t\t\tkey: \"Is input file reachable\",\n//\t\t\tvalue: try? inputUrl.checkResourceIsReachable()\n//\t\t)\n//\t\tCrashlytics.record(\n//\t\t\tkey: \"Is input file readable\",\n//\t\t\tvalue: inputUrl.isReadable\n//\t\t)\n//\t\tCrashlytics.record(\n//\t\t\tkey: \"File size\",\n//\t\t\tvalue: inputUrl.fileSize\n//\t\t)\n\n\t\tguard inputUrl.fileSize > 0 else {\n\t\t\tthrow NSError.appError(\n\t\t\t\t\"The selected file is empty.\",\n\t\t\t\trecoverySuggestion: \"Try selecting a different file.\"\n\t\t\t)\n\t\t}\n\n\t\t// This is very unlikely to happen. We have a lot of file type filters in place, so the only way this can happen is if the user right-clicks a non-video in Finder, chooses \"Open With\", then \"Other…\", chooses \"All Applications\", and then selects Gifski. Yet, some people are doing this…\n\t\tguard inputUrl.contentType?.conforms(to: .movie) == true else {\n\t\t\tthrow NSError.appError(\n\t\t\t\t\"The selected file could not be converted because it's not a video.\",\n\t\t\t\trecoverySuggestion: \"Try again with a video file, usually with the file extension “mp4”, “m4v”, or “mov”.\"\n\t\t\t)\n\t\t}\n\n\t\tlet asset = AVURLAsset(\n\t\t\turl: inputUrl,\n\t\t\toptions: [\n\t\t\t\tAVURLAssetPreferPreciseDurationAndTimingKey: true\n\t\t\t]\n\t\t)\n\n\t\tlet hasProtectedContent = try await asset.load(.hasProtectedContent)\n\n//\t\tCrashlytics.record(key: \"AVAsset debug info\", value: asset.debugInfo)\n\n\t\tguard try await asset.videoCodec != .appleAnimation else {\n\t\t\tthrow NSError.appError(\n\t\t\t\t\"The QuickTime Animation format is not supported.\",\n\t\t\t\trecoverySuggestion: \"Re-export or convert the video to ProRes 4444 XQ instead. It's more efficient, more widely supported, and like QuickTime Animation, it also supports alpha channel. To convert an existing video, open it in QuickTime Player, which will automatically convert it, and then save it.\"\n\t\t\t)\n\t\t}\n\n\t\t// TODO: Parallelize these checks.\n\t\tif\n\t\t\ttry await asset.hasAudio,\n\t\t\ttry await !asset.hasVideo\n\t\t{\n\t\t\tthrow NSError.appError(\n\t\t\t\t\"Audio files are not supported.\",\n\t\t\t\trecoverySuggestion: \"Gifski converts video files but the provided file is audio-only. Please provide a file that contains video.\"\n\t\t\t)\n\t\t}\n\n\t\tguard let firstVideoTrack = try await asset.firstVideoTrack else {\n\t\t\tthrow NSError.appError(\n\t\t\t\t\"Could not read any video from the video file.\",\n\t\t\t\trecoverySuggestion: \"Either the video format is unsupported by macOS or the file is corrupt.\"\n\t\t\t)\n\t\t}\n\n\t\tguard !hasProtectedContent else {\n\t\t\tthrow NSError.appError(\"The video is DRM-protected and cannot be converted.\")\n\t\t}\n\n\t\tlet cannotReadVideoExplanation = \"This could happen if the video is corrupt or the codec profile level is not supported. macOS unfortunately doesn't provide Gifski a reason for why the video could not be decoded. Try re-exporting using a different configuration or try converting the video to HEVC (MP4) with the free HandBrake app.\"\n\n\t\tlet codecTitle = try await firstVideoTrack.codecTitle\n\n\t\t// We already specify the UTIs we support, so this can only happen on invalid video files or unsupported codecs.\n\t\tguard try await asset.isVideoDecodable else {\n\t\t\tif\n\t\t\t\tlet codec = try await firstVideoTrack.codec,\n\t\t\t\tcodec.isSupported\n\t\t\t{\n\t\t\t\tthrow NSError.appError(\n\t\t\t\t\t\"The video could not be decoded even though its codec “\\(codec)” is supported.\",\n\t\t\t\t\trecoverySuggestion: cannotReadVideoExplanation\n\t\t\t\t)\n\t\t\t}\n\n\t\t\tguard let codecTitle else {\n\t\t\t\tthrow NSError.appError(\n\t\t\t\t\t\"The video file is not supported.\",\n\t\t\t\t\trecoverySuggestion: \"I'm trying to figure out why this happens. It would be amazing if you could email the below details to sindresorhus@gmail.com\\n\\n\\(try await asset.debugInfo)\"\n\t\t\t\t)\n\t\t\t}\n\n\t\t\tguard codecTitle != \"hev1\" else {\n\t\t\t\tthrow NSError.appError(\n\t\t\t\t\t\"This variant of the HEVC video codec is not supported by macOS.\",\n\t\t\t\t\trecoverySuggestion: \"The video uses the “hev1” variant of HEVC while macOS only supports “hvc1”. Try re-exporting the video using a different configuration or use the free HandBrake app to convert the video to the supported HEVC variant.\"\n\t\t\t\t)\n\t\t\t}\n\n\t\t\tthrow NSError.appError(\n\t\t\t\t\"The video codec “\\(codecTitle)” is not supported.\",\n\t\t\t\trecoverySuggestion: \"Re-export or convert the video to a supported format. For the best possible quality, export to ProRes 4444 XQ (supports alpha). Alternatively, use the free HandBrake app to convert the video to HEVC (MP4).\"\n\t\t\t)\n\t\t}\n\n\t\t// AVFoundation reports some videos as `.isReadable == true` even though they are not. We detect this through missing codec info. See \"Fixture 211\". (macOS 13.1)\n\t\tguard codecTitle != nil else {\n\t\t\tthrow NSError.appError(\n\t\t\t\t\"The video file is not supported.\",\n\t\t\t\trecoverySuggestion: cannotReadVideoExplanation\n\t\t\t)\n\t\t}\n\n\t\tguard let oldVideoMetadata = try await asset.videoMetadata else {\n\t\t\tthrow NSError.appError(\n\t\t\t\t\"The video metadata is not readable.\",\n\t\t\t\trecoverySuggestion: \"Please open an issue on https://github.com/sindresorhus/Gifski or email sindresorhus@gmail.com. ZIP the video and attach it.\\n\\nInclude this info:\\n\\n\\(try await asset.debugInfo)\"\n\t\t\t)\n\t\t}\n\n\t\tguard\n\t\t\tlet dimensions = try await asset.dimensions,\n\t\t\tdimensions.width >= 4,\n\t\t\tdimensions.height >= 4\n\t\telse {\n\t\t\tthrow NSError.appError(\n\t\t\t\t\"The video dimensions must be at least 4×4.\",\n\t\t\t\trecoverySuggestion: \"The dimensions of the video are \\((try? await asset.dimensions?.formatted) ?? \"0×0\").\"\n\t\t\t)\n\t\t}\n\n\t\t// We extract the video track into a new asset to remove the audio and to prevent problems if the video track duration is shorter than the total asset duration. If we don't do this, the video will show as black in the trim view at the duration where there's no video track, and it will confuse users. Also, if the user trims the video to just the black no video track part, the conversion would continue, but there's nothing to convert, so it would be stuck at 0%.\n\t\tguard\n\t\t\tlet newAsset = try await firstVideoTrack.extractToNewAsset(),\n\t\t\tvar newVideoMetadata = try await newAsset.videoMetadata\n\t\telse {\n\t\t\tthrow NSError.appError(\n\t\t\t\t\"Could not read the video.\",\n\t\t\t\trecoverySuggestion: \"This should not happen. Email sindresorhus@gmail.com and include this info:\\n\\n\\(try await asset.debugInfo)\"\n\t\t\t)\n\t\t}\n\n\t\tnewVideoMetadata.hasAudio = oldVideoMetadata.hasAudio\n\n\t\t// Trim asset\n\t\tdo {\n\t\t\tlet trimmedAsset = try await newAsset.trimmingBlankFramesFromFirstVideoTrack()\n\t\t\treturn (trimmedAsset, newVideoMetadata)\n\t\t} catch AVAssetTrack.VideoTrimmingError.codecNotSupported {\n\t\t\t// Allow user to continue\n\t\t\treturn (newAsset, newVideoMetadata)\n\t\t} catch {\n\t\t\tthrow NSError.appError(\n\t\t\t\t\"Could not trim empty leading frames from video.\",\n\t\t\t\trecoverySuggestion: \"\\(error.localizedDescription)\\n\\nThis should not happen. Email sindresorhus@gmail.com and include this info:\\n\\n\\(try await newAsset.debugInfo)\"\n\t\t\t)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "Gifski.xcodeproj/project.pbxproj",
    "content": "// !$*UTF8*$!\n{\n\tarchiveVersion = 1;\n\tclasses = {\n\t};\n\tobjectVersion = 77;\n\tobjects = {\n\n/* Begin PBXBuildFile section */\n\t\t0CBD7F2E2E0F044C00E2C5E4 /* ExportModifiedVideo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBD7F2D2E0F044C00E2C5E4 /* ExportModifiedVideo.swift */; };\n\t\t0E7925202329BDBE00058B94 /* ShareController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E79251F2329BDBE00058B94 /* ShareController.swift */; };\n\t\t0E7925282329BDBE00058B94 /* Share Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0E79251B2329BDBE00058B94 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };\n\t\t8588EB0D22A424B800030A59 /* ResizableDimensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8588EB0C22A424B800030A59 /* ResizableDimensions.swift */; };\n\t\t85A5C44822CA41B500CAA94D /* VideoValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85A5C44722CA41B500CAA94D /* VideoValidator.swift */; };\n\t\tC2040B8920435871004EE259 /* GifskiWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2040B8820435871004EE259 /* GifskiWrapper.swift */; };\n\t\tE30C8EEF29387E7A002E053F /* Gifski.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30C8EEE29387E7A002E053F /* Gifski.swift */; };\n\t\tE31054142DCBEBA1008B7E7F /* libgifski_static.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E310540F2DCBEB4A008B7E7F /* libgifski_static.a */; };\n\t\tE31A4F3124AD36870097B1A5 /* InternetAccessPolicy.json in Resources */ = {isa = PBXBuildFile; fileRef = E31A4F2C24AD36870097B1A5 /* InternetAccessPolicy.json */; };\n\t\tE3339E932395766800303839 /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = E3339E922395766800303839 /* Defaults */; };\n\t\tE3339E9D2395789500303839 /* DockProgress in Frameworks */ = {isa = PBXBuildFile; productRef = E3339E9C2395789500303839 /* DockProgress */; };\n\t\tE33552EF2ACAC3190023AAE9 /* MainScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E33552EE2ACAC3190023AAE9 /* MainScreen.swift */; };\n\t\tE33552F12ACAC3280023AAE9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E33552F02ACAC3280023AAE9 /* AppState.swift */; };\n\t\tE33552F32ACAC5D80023AAE9 /* StartScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E33552F22ACAC5D80023AAE9 /* StartScreen.swift */; };\n\t\tE339F011203820ED003B78FB /* GIFGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E339F010203820ED003B78FB /* GIFGenerator.swift */; };\n\t\tE36BD7A52B9E2C2400B8D86C /* ExtendedAttributes in Frameworks */ = {isa = PBXBuildFile; productRef = E36BD7A42B9E2C2400B8D86C /* ExtendedAttributes */; };\n\t\tE37F68E02ACAD9D1007F1A7F /* CompletedScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E37F68DF2ACAD9D1007F1A7F /* CompletedScreen.swift */; };\n\t\tE37F68E22ACAD9F1007F1A7F /* ConversionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E37F68E12ACAD9F1007F1A7F /* ConversionScreen.swift */; };\n\t\tE37F68E42ACADA40007F1A7F /* EditScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E37F68E32ACADA40007F1A7F /* EditScreen.swift */; };\n\t\tE3908B7426754568000723A7 /* EstimatedFileSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3908B7326754568000723A7 /* EstimatedFileSize.swift */; };\n\t\tE3961F802AC9F2A700708EB7 /* Intents.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3961F7F2AC9F2A700708EB7 /* Intents.swift */; };\n\t\tE3A6BD112245345C00F62256 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3A6BD102245345C00F62256 /* Constants.swift */; };\n\t\tE3AE62871E5CD2F300035A2F /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AE62861E5CD2F300035A2F /* App.swift */; };\n\t\tE3AE62891E5CD2F300035A2F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E3AE62881E5CD2F300035A2F /* Assets.xcassets */; };\n\t\tE3AE7E9B2E8AE01F00D22FF8 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = E3AE7E9A2E8AE01F00D22FF8 /* Sentry */; };\n\t\tE3AE7E9D2E8AE0A100D22FF8 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = E3AE7E9C2E8AE0A100D22FF8 /* AppIcon.icon */; };\n\t\tE3C3DB4F203F154300CB8BB9 /* Credits.rtf in Resources */ = {isa = PBXBuildFile; fileRef = E3C3DB4E203F154300CB8BB9 /* Credits.rtf */; };\n\t\tE3D08F6E1E5D7BFD00F465DF /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3D08F6D1E5D7BFD00F465DF /* Utilities.swift */; };\n\t\tE3E9A7D6256EBE0800E2B9FD /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3E9A7D5256EBE0800E2B9FD /* Utilities.swift */; };\n\t\tE3FC365C2377FA0000CF7C59 /* Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3FC365B2377FA0000CF7C59 /* Shared.swift */; };\n\t\tE3FC365E2377FA9F00CF7C59 /* Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3FC365B2377FA0000CF7C59 /* Shared.swift */; };\n/* End PBXBuildFile section */\n\n/* Begin PBXContainerItemProxy section */\n\t\t0E7925262329BDBE00058B94 /* PBXContainerItemProxy */ = {\n\t\t\tisa = PBXContainerItemProxy;\n\t\t\tcontainerPortal = E3AE627B1E5CD2F300035A2F /* Project object */;\n\t\t\tproxyType = 1;\n\t\t\tremoteGlobalIDString = 0E79251A2329BDBE00058B94;\n\t\t\tremoteInfo = ShareExtension;\n\t\t};\n\t\t5FF0DFFC278BA5E200A80F09 /* PBXContainerItemProxy */ = {\n\t\t\tisa = PBXContainerItemProxy;\n\t\t\tcontainerPortal = 5F6ABD7D278BA5A20040DDF0 /* gifski.xcodeproj */;\n\t\t\tproxyType = 1;\n\t\t\tremoteGlobalIDString = CA007E48158959EA34BF617B;\n\t\t\tremoteInfo = \"gifski-staticlib\";\n\t\t};\n\t\tE310540E2DCBEB4A008B7E7F /* PBXContainerItemProxy */ = {\n\t\t\tisa = PBXContainerItemProxy;\n\t\t\tcontainerPortal = 5F6ABD7D278BA5A20040DDF0 /* gifski.xcodeproj */;\n\t\t\tproxyType = 2;\n\t\t\tremoteGlobalIDString = CA007E4815895A689885C260;\n\t\t\tremoteInfo = \"gifski.a (static library)\";\n\t\t};\n/* End PBXContainerItemProxy section */\n\n/* Begin PBXCopyFilesBuildPhase section */\n\t\t0E7925292329BDBE00058B94 /* Embed Foundation Extensions */ = {\n\t\t\tisa = PBXCopyFilesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tdstPath = \"\";\n\t\t\tdstSubfolderSpec = 13;\n\t\t\tfiles = (\n\t\t\t\t0E7925282329BDBE00058B94 /* Share Extension.appex in Embed Foundation Extensions */,\n\t\t\t);\n\t\t\tname = \"Embed Foundation Extensions\";\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXCopyFilesBuildPhase section */\n\n/* Begin PBXFileReference section */\n\t\t0CBD7F2D2E0F044C00E2C5E4 /* ExportModifiedVideo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportModifiedVideo.swift; sourceTree = \"<group>\"; };\n\t\t0E79251B2329BDBE00058B94 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = \"wrapper.app-extension\"; includeInIndex = 0; path = \"Share Extension.appex\"; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\t0E79251F2329BDBE00058B94 /* ShareController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = ShareController.swift; sourceTree = \"<group>\"; usesTabs = 1; };\n\t\t0E7925242329BDBE00058B94 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = \"<group>\"; };\n\t\t0E7925252329BDBE00058B94 /* Share_Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Share_Extension.entitlements; sourceTree = \"<group>\"; };\n\t\t5F6ABD7D278BA5A20040DDF0 /* gifski.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = \"wrapper.pb-project\"; name = gifski.xcodeproj; path = \"gifski-api/gifski.xcodeproj\"; sourceTree = SOURCE_ROOT; };\n\t\t8588EB0C22A424B800030A59 /* ResizableDimensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = ResizableDimensions.swift; sourceTree = \"<group>\"; usesTabs = 1; };\n\t\t85A5C44722CA41B500CAA94D /* VideoValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = VideoValidator.swift; sourceTree = \"<group>\"; usesTabs = 1; };\n\t\tC2040B8820435871004EE259 /* GifskiWrapper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = GifskiWrapper.swift; sourceTree = \"<group>\"; usesTabs = 1; };\n\t\tE304EB8725F3A4D2003BCE1F /* gifski.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = gifski.h; path = \"gifski-api/gifski.h\"; sourceTree = SOURCE_ROOT; };\n\t\tE30C8EEE29387E7A002E053F /* Gifski.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Gifski.swift; sourceTree = \"<group>\"; };\n\t\tE31A4F2C24AD36870097B1A5 /* InternetAccessPolicy.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = InternetAccessPolicy.json; sourceTree = \"<group>\"; };\n\t\tE33552EE2ACAC3190023AAE9 /* MainScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainScreen.swift; sourceTree = \"<group>\"; };\n\t\tE33552F02ACAC3280023AAE9 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = \"<group>\"; };\n\t\tE33552F22ACAC5D80023AAE9 /* StartScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartScreen.swift; sourceTree = \"<group>\"; };\n\t\tE339F010203820ED003B78FB /* GIFGenerator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = GIFGenerator.swift; sourceTree = \"<group>\"; usesTabs = 1; };\n\t\tE37F68DF2ACAD9D1007F1A7F /* CompletedScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletedScreen.swift; sourceTree = \"<group>\"; };\n\t\tE37F68E12ACAD9F1007F1A7F /* ConversionScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversionScreen.swift; sourceTree = \"<group>\"; };\n\t\tE37F68E32ACADA40007F1A7F /* EditScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditScreen.swift; sourceTree = \"<group>\"; };\n\t\tE3805F542466E68900489E6C /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = \"<group>\"; };\n\t\tE3908B7326754568000723A7 /* EstimatedFileSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EstimatedFileSize.swift; sourceTree = \"<group>\"; };\n\t\tE3961F7F2AC9F2A700708EB7 /* Intents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Intents.swift; sourceTree = \"<group>\"; };\n\t\tE3A6BD102245345C00F62256 /* Constants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = Constants.swift; sourceTree = \"<group>\"; usesTabs = 1; };\n\t\tE3ACE84E2F0EC74C004F95CC /* maintaining.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = maintaining.md; sourceTree = \"<group>\"; };\n\t\tE3AE62831E5CD2F300035A2F /* Gifski.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Gifski.app; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\tE3AE62861E5CD2F300035A2F /* App.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = App.swift; sourceTree = \"<group>\"; usesTabs = 1; };\n\t\tE3AE62881E5CD2F300035A2F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = \"<group>\"; };\n\t\tE3AE628D1E5CD2F300035A2F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = \"<group>\"; };\n\t\tE3AE7E9C2E8AE0A100D22FF8 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = \"<group>\"; };\n\t\tE3BF14CC1E5CD5A30049FD4B /* Gifski.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Gifski.entitlements; sourceTree = \"<group>\"; };\n\t\tE3C3DB4E203F154300CB8BB9 /* Credits.rtf */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; path = Credits.rtf; sourceTree = \"<group>\"; };\n\t\tE3D08F6D1E5D7BFD00F465DF /* Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = Utilities.swift; sourceTree = \"<group>\"; usesTabs = 1; };\n\t\tE3E9A7D5256EBE0800E2B9FD /* Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utilities.swift; sourceTree = \"<group>\"; };\n\t\tE3FC365B2377FA0000CF7C59 /* Shared.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; name = Shared.swift; path = Gifski/Shared.swift; sourceTree = SOURCE_ROOT; usesTabs = 1; };\n\t\tE3FD6190201BCBC30087160A /* Gifski-Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; lineEnding = 0; path = \"Gifski-Bridging-Header.h\"; sourceTree = \"<group>\"; usesTabs = 1; };\n/* End PBXFileReference section */\n\n/* Begin PBXFileSystemSynchronizedRootGroup section */\n\t\tE31053F12DCBD2EA008B7E7F /* Crop */ = {\n\t\t\tisa = PBXFileSystemSynchronizedRootGroup;\n\t\t\tpath = Crop;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tE31053F82DCBD325008B7E7F /* Components */ = {\n\t\t\tisa = PBXFileSystemSynchronizedRootGroup;\n\t\t\tpath = Components;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tE39128F52E0DF96C0010E6CF /* Preview */ = {\n\t\t\tisa = PBXFileSystemSynchronizedRootGroup;\n\t\t\tpath = Preview;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n/* End PBXFileSystemSynchronizedRootGroup section */\n\n/* Begin PBXFrameworksBuildPhase section */\n\t\t0E7925182329BDBE00058B94 /* Frameworks */ = {\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\tE3AE62801E5CD2F300035A2F /* Frameworks */ = {\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\tE3AE7E9B2E8AE01F00D22FF8 /* Sentry in Frameworks */,\n\t\t\t\tE31054142DCBEBA1008B7E7F /* libgifski_static.a in Frameworks */,\n\t\t\t\tE36BD7A52B9E2C2400B8D86C /* ExtendedAttributes in Frameworks */,\n\t\t\t\tE3339E932395766800303839 /* Defaults in Frameworks */,\n\t\t\t\tE3339E9D2395789500303839 /* DockProgress in Frameworks */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXFrameworksBuildPhase section */\n\n/* Begin PBXGroup section */\n\t\t0E79251C2329BDBE00058B94 /* Share Extension */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t0E79251F2329BDBE00058B94 /* ShareController.swift */,\n\t\t\t\tE3E9A7D5256EBE0800E2B9FD /* Utilities.swift */,\n\t\t\t\t0E7925242329BDBE00058B94 /* Info.plist */,\n\t\t\t\t0E7925252329BDBE00058B94 /* Share_Extension.entitlements */,\n\t\t\t);\n\t\t\tpath = \"Share Extension\";\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t5F6ABD7E278BA5A20040DDF0 /* Products */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tE310540F2DCBEB4A008B7E7F /* libgifski_static.a */,\n\t\t\t);\n\t\t\tname = Products;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tE356A15D21028942000148AD /* Other */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tE3AE7E9C2E8AE0A100D22FF8 /* AppIcon.icon */,\n\t\t\t\tE3FD6190201BCBC30087160A /* Gifski-Bridging-Header.h */,\n\t\t\t\tE304EB8725F3A4D2003BCE1F /* gifski.h */,\n\t\t\t\tE3C3DB4E203F154300CB8BB9 /* Credits.rtf */,\n\t\t\t\tE3AE628D1E5CD2F300035A2F /* Info.plist */,\n\t\t\t\tE3BF14CC1E5CD5A30049FD4B /* Gifski.entitlements */,\n\t\t\t\tE31A4F2C24AD36870097B1A5 /* InternetAccessPolicy.json */,\n\t\t\t\t5F6ABD7D278BA5A20040DDF0 /* gifski.xcodeproj */,\n\t\t\t);\n\t\t\tname = Other;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tE36BD79F2B9E243800B8D86C /* Frameworks */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t);\n\t\t\tname = Frameworks;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tE3AE627A1E5CD2F300035A2F = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tE3ACE84E2F0EC74C004F95CC /* maintaining.md */,\n\t\t\t\tE3805F542466E68900489E6C /* Config.xcconfig */,\n\t\t\t\tE3AE62851E5CD2F300035A2F /* Gifski */,\n\t\t\t\t0E79251C2329BDBE00058B94 /* Share Extension */,\n\t\t\t\tE3AE62841E5CD2F300035A2F /* Products */,\n\t\t\t\tE36BD79F2B9E243800B8D86C /* Frameworks */,\n\t\t\t);\n\t\t\tsourceTree = \"<group>\";\n\t\t\tusesTabs = 1;\n\t\t};\n\t\tE3AE62841E5CD2F300035A2F /* Products */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tE3AE62831E5CD2F300035A2F /* Gifski.app */,\n\t\t\t\t0E79251B2329BDBE00058B94 /* Share Extension.appex */,\n\t\t\t);\n\t\t\tname = Products;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tE3AE62851E5CD2F300035A2F /* Gifski */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tE3AE62861E5CD2F300035A2F /* App.swift */,\n\t\t\t\tE3A6BD102245345C00F62256 /* Constants.swift */,\n\t\t\t\tE33552F02ACAC3280023AAE9 /* AppState.swift */,\n\t\t\t\tE3FC365B2377FA0000CF7C59 /* Shared.swift */,\n\t\t\t\tE33552EE2ACAC3190023AAE9 /* MainScreen.swift */,\n\t\t\t\tE33552F22ACAC5D80023AAE9 /* StartScreen.swift */,\n\t\t\t\tE37F68E32ACADA40007F1A7F /* EditScreen.swift */,\n\t\t\t\t0CBD7F2D2E0F044C00E2C5E4 /* ExportModifiedVideo.swift */,\n\t\t\t\tE37F68E12ACAD9F1007F1A7F /* ConversionScreen.swift */,\n\t\t\t\tE37F68DF2ACAD9D1007F1A7F /* CompletedScreen.swift */,\n\t\t\t\tE3908B7326754568000723A7 /* EstimatedFileSize.swift */,\n\t\t\t\tE339F010203820ED003B78FB /* GIFGenerator.swift */,\n\t\t\t\tE30C8EEE29387E7A002E053F /* Gifski.swift */,\n\t\t\t\tC2040B8820435871004EE259 /* GifskiWrapper.swift */,\n\t\t\t\t8588EB0C22A424B800030A59 /* ResizableDimensions.swift */,\n\t\t\t\t85A5C44722CA41B500CAA94D /* VideoValidator.swift */,\n\t\t\t\tE31053F12DCBD2EA008B7E7F /* Crop */,\n\t\t\t\tE31053F82DCBD325008B7E7F /* Components */,\n\t\t\t\tE3961F7F2AC9F2A700708EB7 /* Intents.swift */,\n\t\t\t\tE39128F52E0DF96C0010E6CF /* Preview */,\n\t\t\t\tE3D08F6D1E5D7BFD00F465DF /* Utilities.swift */,\n\t\t\t\tE3AE62881E5CD2F300035A2F /* Assets.xcassets */,\n\t\t\t\tE356A15D21028942000148AD /* Other */,\n\t\t\t);\n\t\t\tpath = Gifski;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n/* End PBXGroup section */\n\n/* Begin PBXNativeTarget section */\n\t\t0E79251A2329BDBE00058B94 /* Share Extension */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = 0E7925302329BDBE00058B94 /* Build configuration list for PBXNativeTarget \"Share Extension\" */;\n\t\t\tbuildPhases = (\n\t\t\t\t0E7925172329BDBE00058B94 /* Sources */,\n\t\t\t\t0E7925182329BDBE00058B94 /* Frameworks */,\n\t\t\t\t0E7925192329BDBE00058B94 /* Resources */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t);\n\t\t\tname = \"Share Extension\";\n\t\t\tproductName = ShareExtension;\n\t\t\tproductReference = 0E79251B2329BDBE00058B94 /* Share Extension.appex */;\n\t\t\tproductType = \"com.apple.product-type.app-extension\";\n\t\t};\n\t\tE3AE62821E5CD2F300035A2F /* Gifski */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = E3AE62901E5CD2F300035A2F /* Build configuration list for PBXNativeTarget \"Gifski\" */;\n\t\t\tbuildPhases = (\n\t\t\t\tE36D89991EFF79F7005042A8 /* SwiftLint */,\n\t\t\t\tE3AE627F1E5CD2F300035A2F /* Sources */,\n\t\t\t\tE3AE62801E5CD2F300035A2F /* Frameworks */,\n\t\t\t\tE3AE62811E5CD2F300035A2F /* Resources */,\n\t\t\t\t0E7925292329BDBE00058B94 /* Embed Foundation Extensions */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t\t5FF0DFFD278BA5E200A80F09 /* PBXTargetDependency */,\n\t\t\t\t0E7925272329BDBE00058B94 /* PBXTargetDependency */,\n\t\t\t);\n\t\t\tfileSystemSynchronizedGroups = (\n\t\t\t\tE31053F12DCBD2EA008B7E7F /* Crop */,\n\t\t\t\tE31053F82DCBD325008B7E7F /* Components */,\n\t\t\t\tE39128F52E0DF96C0010E6CF /* Preview */,\n\t\t\t);\n\t\t\tname = Gifski;\n\t\t\tpackageProductDependencies = (\n\t\t\t\tE3339E922395766800303839 /* Defaults */,\n\t\t\t\tE3339E9C2395789500303839 /* DockProgress */,\n\t\t\t\tE36BD7A42B9E2C2400B8D86C /* ExtendedAttributes */,\n\t\t\t\tE3AE7E9A2E8AE01F00D22FF8 /* Sentry */,\n\t\t\t);\n\t\t\tproductName = Gifski;\n\t\t\tproductReference = E3AE62831E5CD2F300035A2F /* Gifski.app */;\n\t\t\tproductType = \"com.apple.product-type.application\";\n\t\t};\n/* End PBXNativeTarget section */\n\n/* Begin PBXProject section */\n\t\tE3AE627B1E5CD2F300035A2F /* Project object */ = {\n\t\t\tisa = PBXProject;\n\t\t\tattributes = {\n\t\t\t\tBuildIndependentTargetsInParallel = YES;\n\t\t\t\tLastSwiftUpdateCheck = 1100;\n\t\t\t\tLastUpgradeCheck = 2600;\n\t\t\t\tORGANIZATIONNAME = \"Sindre Sorhus\";\n\t\t\t\tTargetAttributes = {\n\t\t\t\t\t0E79251A2329BDBE00058B94 = {\n\t\t\t\t\t\tCreatedOnToolsVersion = 11.0;\n\t\t\t\t\t};\n\t\t\t\t\tE3AE62821E5CD2F300035A2F = {\n\t\t\t\t\t\tCreatedOnToolsVersion = 8.2.1;\n\t\t\t\t\t\tLastSwiftMigration = 1020;\n\t\t\t\t\t\tSystemCapabilities = {\n\t\t\t\t\t\t\tcom.apple.HardenedRuntime = {\n\t\t\t\t\t\t\t\tenabled = 1;\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\tcom.apple.Sandbox = {\n\t\t\t\t\t\t\t\tenabled = 1;\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t};\n\t\t\t\t\t};\n\t\t\t\t};\n\t\t\t};\n\t\t\tbuildConfigurationList = E3AE627E1E5CD2F300035A2F /* Build configuration list for PBXProject \"Gifski\" */;\n\t\t\tdevelopmentRegion = en;\n\t\t\thasScannedForEncodings = 0;\n\t\t\tknownRegions = (\n\t\t\t\ten,\n\t\t\t\tBase,\n\t\t\t);\n\t\t\tmainGroup = E3AE627A1E5CD2F300035A2F;\n\t\t\tminimizedProjectReferenceProxies = 1;\n\t\t\tpackageReferences = (\n\t\t\t\tE3339E912395766800303839 /* XCRemoteSwiftPackageReference \"Defaults\" */,\n\t\t\t\tE3339E9B2395789500303839 /* XCRemoteSwiftPackageReference \"DockProgress\" */,\n\t\t\t\tE3998CF02ACD7148009F8117 /* XCRemoteSwiftPackageReference \"sentry-cocoa\" */,\n\t\t\t\tE36BD7A32B9E2C1600B8D86C /* XCRemoteSwiftPackageReference \"ExtendedAttributes\" */,\n\t\t\t);\n\t\t\tpreferredProjectObjectVersion = 77;\n\t\t\tproductRefGroup = E3AE62841E5CD2F300035A2F /* Products */;\n\t\t\tprojectDirPath = \"\";\n\t\t\tprojectReferences = (\n\t\t\t\t{\n\t\t\t\t\tProductGroup = 5F6ABD7E278BA5A20040DDF0 /* Products */;\n\t\t\t\t\tProjectRef = 5F6ABD7D278BA5A20040DDF0 /* gifski.xcodeproj */;\n\t\t\t\t},\n\t\t\t);\n\t\t\tprojectRoot = \"\";\n\t\t\ttargets = (\n\t\t\t\tE3AE62821E5CD2F300035A2F /* Gifski */,\n\t\t\t\t0E79251A2329BDBE00058B94 /* Share Extension */,\n\t\t\t);\n\t\t};\n/* End PBXProject section */\n\n/* Begin PBXReferenceProxy section */\n\t\tE310540F2DCBEB4A008B7E7F /* libgifski_static.a */ = {\n\t\t\tisa = PBXReferenceProxy;\n\t\t\tfileType = archive.ar;\n\t\t\tpath = libgifski_static.a;\n\t\t\tremoteRef = E310540E2DCBEB4A008B7E7F /* PBXContainerItemProxy */;\n\t\t\tsourceTree = BUILT_PRODUCTS_DIR;\n\t\t};\n/* End PBXReferenceProxy section */\n\n/* Begin PBXResourcesBuildPhase section */\n\t\t0E7925192329BDBE00058B94 /* Resources */ = {\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\tE3AE62811E5CD2F300035A2F /* Resources */ = {\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\tE3AE62891E5CD2F300035A2F /* Assets.xcassets in Resources */,\n\t\t\t\tE3AE7E9D2E8AE0A100D22FF8 /* AppIcon.icon in Resources */,\n\t\t\t\tE31A4F3124AD36870097B1A5 /* InternetAccessPolicy.json in Resources */,\n\t\t\t\tE3C3DB4F203F154300CB8BB9 /* Credits.rtf in Resources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXResourcesBuildPhase section */\n\n/* Begin PBXShellScriptBuildPhase section */\n\t\tE36D89991EFF79F7005042A8 /* SwiftLint */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\talwaysOutOfDate = 1;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputPaths = (\n\t\t\t);\n\t\t\tname = SwiftLint;\n\t\t\toutputPaths = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"PATH=\\\"/opt/homebrew/bin/:${PATH}\\\"\\nswiftlint\\n\";\n\t\t\tshowEnvVarsInLog = 0;\n\t\t};\n/* End PBXShellScriptBuildPhase section */\n\n/* Begin PBXSourcesBuildPhase section */\n\t\t0E7925172329BDBE00058B94 /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\tE3E9A7D6256EBE0800E2B9FD /* Utilities.swift in Sources */,\n\t\t\t\tE3FC365E2377FA9F00CF7C59 /* Shared.swift in Sources */,\n\t\t\t\t0E7925202329BDBE00058B94 /* ShareController.swift in Sources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\tE3AE627F1E5CD2F300035A2F /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\tE3D08F6E1E5D7BFD00F465DF /* Utilities.swift in Sources */,\n\t\t\t\tE339F011203820ED003B78FB /* GIFGenerator.swift in Sources */,\n\t\t\t\tE33552F12ACAC3280023AAE9 /* AppState.swift in Sources */,\n\t\t\t\tE3A6BD112245345C00F62256 /* Constants.swift in Sources */,\n\t\t\t\tC2040B8920435871004EE259 /* GifskiWrapper.swift in Sources */,\n\t\t\t\t8588EB0D22A424B800030A59 /* ResizableDimensions.swift in Sources */,\n\t\t\t\tE3908B7426754568000723A7 /* EstimatedFileSize.swift in Sources */,\n\t\t\t\tE33552F32ACAC5D80023AAE9 /* StartScreen.swift in Sources */,\n\t\t\t\tE37F68E42ACADA40007F1A7F /* EditScreen.swift in Sources */,\n\t\t\t\tE3961F802AC9F2A700708EB7 /* Intents.swift in Sources */,\n\t\t\t\tE30C8EEF29387E7A002E053F /* Gifski.swift in Sources */,\n\t\t\t\t0CBD7F2E2E0F044C00E2C5E4 /* ExportModifiedVideo.swift in Sources */,\n\t\t\t\tE3FC365C2377FA0000CF7C59 /* Shared.swift in Sources */,\n\t\t\t\tE37F68E02ACAD9D1007F1A7F /* CompletedScreen.swift in Sources */,\n\t\t\t\tE33552EF2ACAC3190023AAE9 /* MainScreen.swift in Sources */,\n\t\t\t\tE37F68E22ACAD9F1007F1A7F /* ConversionScreen.swift in Sources */,\n\t\t\t\t85A5C44822CA41B500CAA94D /* VideoValidator.swift in Sources */,\n\t\t\t\tE3AE62871E5CD2F300035A2F /* App.swift in Sources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXSourcesBuildPhase section */\n\n/* Begin PBXTargetDependency section */\n\t\t0E7925272329BDBE00058B94 /* PBXTargetDependency */ = {\n\t\t\tisa = PBXTargetDependency;\n\t\t\ttarget = 0E79251A2329BDBE00058B94 /* Share Extension */;\n\t\t\ttargetProxy = 0E7925262329BDBE00058B94 /* PBXContainerItemProxy */;\n\t\t};\n\t\t5FF0DFFD278BA5E200A80F09 /* PBXTargetDependency */ = {\n\t\t\tisa = PBXTargetDependency;\n\t\t\tname = \"gifski-staticlib\";\n\t\t\ttargetProxy = 5FF0DFFC278BA5E200A80F09 /* PBXContainerItemProxy */;\n\t\t};\n/* End PBXTargetDependency section */\n\n/* Begin XCBuildConfiguration section */\n\t\t0E79252A2329BDBE00058B94 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tCLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++14\";\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = YES;\n\t\t\t\tCLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = \"Share Extension/Share_Extension.entitlements\";\n\t\t\t\tCODE_SIGN_IDENTITY = \"Apple Development\";\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCOMBINE_HIDPI_IMAGES = YES;\n\t\t\t\tDEAD_CODE_STRIPPING = YES;\n\t\t\t\tDEVELOPMENT_TEAM = YG56YK5RN5;\n\t\t\t\tENABLE_HARDENED_RUNTIME = YES;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu11;\n\t\t\t\tGENERATE_INFOPLIST_FILE = YES;\n\t\t\t\tINFOPLIST_FILE = \"Share Extension/Info.plist\";\n\t\t\t\tINFOPLIST_KEY_CFBundleDisplayName = Gifski;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/../Frameworks\",\n\t\t\t\t\t\"@executable_path/../../../../Frameworks\",\n\t\t\t\t);\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;\n\t\t\t\tMTL_FAST_MATH = YES;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.sindresorhus.Gifski.ShareExtension;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tPROVISIONING_PROFILE_SPECIFIER = \"\";\n\t\t\t\tREGISTER_APP_GROUPS = YES;\n\t\t\t\tSKIP_INSTALL = YES;\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t0E79252B2329BDBE00058B94 /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tCLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++14\";\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = YES;\n\t\t\t\tCLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = \"Share Extension/Share_Extension.entitlements\";\n\t\t\t\tCODE_SIGN_IDENTITY = \"Apple Development\";\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCOMBINE_HIDPI_IMAGES = YES;\n\t\t\t\tDEAD_CODE_STRIPPING = YES;\n\t\t\t\tDEVELOPMENT_TEAM = YG56YK5RN5;\n\t\t\t\tENABLE_HARDENED_RUNTIME = YES;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu11;\n\t\t\t\tGENERATE_INFOPLIST_FILE = YES;\n\t\t\t\tINFOPLIST_FILE = \"Share Extension/Info.plist\";\n\t\t\t\tINFOPLIST_KEY_CFBundleDisplayName = Gifski;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/../Frameworks\",\n\t\t\t\t\t\"@executable_path/../../../../Frameworks\",\n\t\t\t\t);\n\t\t\t\tMTL_FAST_MATH = YES;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.sindresorhus.Gifski.ShareExtension;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tPROVISIONING_PROFILE_SPECIFIER = \"\";\n\t\t\t\tREGISTER_APP_GROUPS = YES;\n\t\t\t\tSKIP_INSTALL = YES;\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\tE3AE628E1E5CD2F300035A2F /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = E3805F542466E68900489E6C /* Config.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;\n\t\t\t\tCLANG_ANALYZER_NONNULL = YES;\n\t\t\t\tCLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++17\";\n\t\t\t\tCLANG_CXX_LIBRARY = \"libc++\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_DOCUMENTATION_COMMENTS = YES;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tDEAD_CODE_STRIPPING = YES;\n\t\t\t\tDEBUG_INFORMATION_FORMAT = dwarf;\n\t\t\t\tDEVELOPMENT_TEAM = YG56YK5RN5;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tENABLE_TESTABILITY = YES;\n\t\t\t\tENABLE_USER_SCRIPT_SANDBOXING = NO;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu11;\n\t\t\t\tGCC_DYNAMIC_NO_PIC = NO;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_OPTIMIZATION_LEVEL = 0;\n\t\t\t\tGCC_PREPROCESSOR_DEFINITIONS = (\n\t\t\t\t\t\"DEBUG=1\",\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t);\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = 15.3;\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;\n\t\t\t\tMTL_FAST_MATH = YES;\n\t\t\t\tONLY_ACTIVE_ARCH = YES;\n\t\t\t\tSDKROOT = macosx;\n\t\t\t\tSTRING_CATALOG_GENERATE_SYMBOLS = YES;\n\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-Onone\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\tE3AE628F1E5CD2F300035A2F /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = E3805F542466E68900489E6C /* Config.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;\n\t\t\t\tCLANG_ANALYZER_NONNULL = YES;\n\t\t\t\tCLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++17\";\n\t\t\t\tCLANG_CXX_LIBRARY = \"libc++\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_DOCUMENTATION_COMMENTS = YES;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tDEAD_CODE_STRIPPING = YES;\n\t\t\t\tDEBUG_INFORMATION_FORMAT = \"dwarf-with-dsym\";\n\t\t\t\tDEVELOPMENT_TEAM = YG56YK5RN5;\n\t\t\t\tENABLE_NS_ASSERTIONS = NO;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tENABLE_USER_SCRIPT_SANDBOXING = NO;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu11;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = 15.3;\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = NO;\n\t\t\t\tMTL_FAST_MATH = YES;\n\t\t\t\tSDKROOT = macosx;\n\t\t\t\tSTRING_CATALOG_GENERATE_SYMBOLS = YES;\n\t\t\t\tSWIFT_COMPILATION_MODE = wholemodule;\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-O\";\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\tE3AE62911E5CD2F300035A2F /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = Gifski/Gifski.entitlements;\n\t\t\t\tCODE_SIGN_IDENTITY = \"Apple Development\";\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCOMBINE_HIDPI_IMAGES = YES;\n\t\t\t\tDEAD_CODE_STRIPPING = YES;\n\t\t\t\tDEVELOPMENT_TEAM = YG56YK5RN5;\n\t\t\t\tENABLE_APP_SANDBOX = YES;\n\t\t\t\tENABLE_HARDENED_RUNTIME = YES;\n\t\t\t\tENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;\n\t\t\t\tENABLE_USER_SELECTED_FILES = readwrite;\n\t\t\t\tFRAMEWORK_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"$(PROJECT_DIR)\",\n\t\t\t\t\t\"$(PROJECT_DIR)/Frameworks\",\n\t\t\t\t);\n\t\t\t\tGENERATE_INFOPLIST_FILE = YES;\n\t\t\t\tHEADER_SEARCH_PATHS = \"\";\n\t\t\t\tINFOPLIST_FILE = \"$(SRCROOT)/Gifski/Info.plist\";\n\t\t\t\tINFOPLIST_KEY_LSApplicationCategoryType = \"public.app-category.video\";\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/../Frameworks\",\n\t\t\t\t);\n\t\t\t\tLIBRARY_SEARCH_PATHS = \"$(inherited)\";\n\t\t\t\tMTL_LANGUAGE_REVISION = Metal20;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.sindresorhus.Gifski;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tPROVISIONING_PROFILE_SPECIFIER = \"\";\n\t\t\t\tREGISTER_APP_GROUPS = YES;\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;\n\t\t\t\tSWIFT_OBJC_BRIDGING_HEADER = \"Gifski/Gifski-Bridging-Header.h\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\tE3AE62921E5CD2F300035A2F /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = Gifski/Gifski.entitlements;\n\t\t\t\tCODE_SIGN_IDENTITY = \"Apple Development\";\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCOMBINE_HIDPI_IMAGES = YES;\n\t\t\t\tDEAD_CODE_STRIPPING = YES;\n\t\t\t\tDEVELOPMENT_TEAM = YG56YK5RN5;\n\t\t\t\tENABLE_APP_SANDBOX = YES;\n\t\t\t\tENABLE_HARDENED_RUNTIME = YES;\n\t\t\t\tENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;\n\t\t\t\tENABLE_USER_SELECTED_FILES = readwrite;\n\t\t\t\tFRAMEWORK_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"$(PROJECT_DIR)\",\n\t\t\t\t\t\"$(PROJECT_DIR)/Frameworks\",\n\t\t\t\t);\n\t\t\t\tGENERATE_INFOPLIST_FILE = YES;\n\t\t\t\tHEADER_SEARCH_PATHS = \"\";\n\t\t\t\tINFOPLIST_FILE = \"$(SRCROOT)/Gifski/Info.plist\";\n\t\t\t\tINFOPLIST_KEY_LSApplicationCategoryType = \"public.app-category.video\";\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/../Frameworks\",\n\t\t\t\t);\n\t\t\t\tLIBRARY_SEARCH_PATHS = \"$(inherited)\";\n\t\t\t\tMTL_LANGUAGE_REVISION = Metal20;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.sindresorhus.Gifski;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tPROVISIONING_PROFILE_SPECIFIER = \"\";\n\t\t\t\tREGISTER_APP_GROUPS = YES;\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;\n\t\t\t\tSWIFT_OBJC_BRIDGING_HEADER = \"Gifski/Gifski-Bridging-Header.h\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n/* End XCBuildConfiguration section */\n\n/* Begin XCConfigurationList section */\n\t\t0E7925302329BDBE00058B94 /* Build configuration list for PBXNativeTarget \"Share Extension\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t0E79252A2329BDBE00058B94 /* Debug */,\n\t\t\t\t0E79252B2329BDBE00058B94 /* Release */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\tE3AE627E1E5CD2F300035A2F /* Build configuration list for PBXProject \"Gifski\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\tE3AE628E1E5CD2F300035A2F /* Debug */,\n\t\t\t\tE3AE628F1E5CD2F300035A2F /* Release */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\tE3AE62901E5CD2F300035A2F /* Build configuration list for PBXNativeTarget \"Gifski\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\tE3AE62911E5CD2F300035A2F /* Debug */,\n\t\t\t\tE3AE62921E5CD2F300035A2F /* Release */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n/* End XCConfigurationList section */\n\n/* Begin XCRemoteSwiftPackageReference section */\n\t\tE3339E912395766800303839 /* XCRemoteSwiftPackageReference \"Defaults\" */ = {\n\t\t\tisa = XCRemoteSwiftPackageReference;\n\t\t\trepositoryURL = \"https://github.com/sindresorhus/Defaults\";\n\t\t\trequirement = {\n\t\t\t\tkind = upToNextMajorVersion;\n\t\t\t\tminimumVersion = 9.0.0;\n\t\t\t};\n\t\t};\n\t\tE3339E9B2395789500303839 /* XCRemoteSwiftPackageReference \"DockProgress\" */ = {\n\t\t\tisa = XCRemoteSwiftPackageReference;\n\t\t\trepositoryURL = \"https://github.com/sindresorhus/DockProgress\";\n\t\t\trequirement = {\n\t\t\t\tkind = upToNextMajorVersion;\n\t\t\t\tminimumVersion = 4.3.0;\n\t\t\t};\n\t\t};\n\t\tE36BD7A32B9E2C1600B8D86C /* XCRemoteSwiftPackageReference \"ExtendedAttributes\" */ = {\n\t\t\tisa = XCRemoteSwiftPackageReference;\n\t\t\trepositoryURL = \"https://github.com/sindresorhus/ExtendedAttributes\";\n\t\t\trequirement = {\n\t\t\t\tkind = upToNextMajorVersion;\n\t\t\t\tminimumVersion = 1.0.0;\n\t\t\t};\n\t\t};\n\t\tE3998CF02ACD7148009F8117 /* XCRemoteSwiftPackageReference \"sentry-cocoa\" */ = {\n\t\t\tisa = XCRemoteSwiftPackageReference;\n\t\t\trepositoryURL = \"https://github.com/getsentry/sentry-cocoa\";\n\t\t\trequirement = {\n\t\t\t\tkind = upToNextMajorVersion;\n\t\t\t\tminimumVersion = 8.21.0;\n\t\t\t};\n\t\t};\n/* End XCRemoteSwiftPackageReference section */\n\n/* Begin XCSwiftPackageProductDependency section */\n\t\tE3339E922395766800303839 /* Defaults */ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tpackage = E3339E912395766800303839 /* XCRemoteSwiftPackageReference \"Defaults\" */;\n\t\t\tproductName = Defaults;\n\t\t};\n\t\tE3339E9C2395789500303839 /* DockProgress */ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tpackage = E3339E9B2395789500303839 /* XCRemoteSwiftPackageReference \"DockProgress\" */;\n\t\t\tproductName = DockProgress;\n\t\t};\n\t\tE36BD7A42B9E2C2400B8D86C /* ExtendedAttributes */ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tpackage = E36BD7A32B9E2C1600B8D86C /* XCRemoteSwiftPackageReference \"ExtendedAttributes\" */;\n\t\t\tproductName = ExtendedAttributes;\n\t\t};\n\t\tE3AE7E9A2E8AE01F00D22FF8 /* Sentry */ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tpackage = E3998CF02ACD7148009F8117 /* XCRemoteSwiftPackageReference \"sentry-cocoa\" */;\n\t\t\tproductName = Sentry;\n\t\t};\n/* End XCSwiftPackageProductDependency section */\n\t};\n\trootObject = E3AE627B1E5CD2F300035A2F /* Project object */;\n}\n"
  },
  {
    "path": "Gifski.xcodeproj/project.xcworkspace/contents.xcworkspacedata",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Workspace\n   version = \"1.0\">\n   <FileRef\n      location = \"self:\">\n   </FileRef>\n</Workspace>\n"
  },
  {
    "path": "Gifski.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>IDEDidComputeMac32BitWarning</key>\n\t<true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "Gifski.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved",
    "content": "{\n  \"originHash\" : \"0c90c366404e200efeaefbb832e9ea0e7faea9e1f96c7c74f6e39e86d8f25618\",\n  \"pins\" : [\n    {\n      \"identity\" : \"defaults\",\n      \"kind\" : \"remoteSourceControl\",\n      \"location\" : \"https://github.com/sindresorhus/Defaults\",\n      \"state\" : {\n        \"revision\" : \"9a1675508a69eea31ec12f7902c004a6449e6dd1\",\n        \"version\" : \"9.0.5\"\n      }\n    },\n    {\n      \"identity\" : \"dockprogress\",\n      \"kind\" : \"remoteSourceControl\",\n      \"location\" : \"https://github.com/sindresorhus/DockProgress\",\n      \"state\" : {\n        \"revision\" : \"d4f23b5a8f5ca0fac393eb7ba78c2fe3e32e52da\",\n        \"version\" : \"4.3.1\"\n      }\n    },\n    {\n      \"identity\" : \"extendedattributes\",\n      \"kind\" : \"remoteSourceControl\",\n      \"location\" : \"https://github.com/sindresorhus/ExtendedAttributes\",\n      \"state\" : {\n        \"revision\" : \"bf0cade5654fbdc0cfcb3ac34bcc644c156fb902\",\n        \"version\" : \"1.1.0\"\n      }\n    },\n    {\n      \"identity\" : \"sentry-cocoa\",\n      \"kind\" : \"remoteSourceControl\",\n      \"location\" : \"https://github.com/getsentry/sentry-cocoa\",\n      \"state\" : {\n        \"revision\" : \"9e193ac0b71760603aa666bad7e9e303dd7031a8\",\n        \"version\" : \"8.56.2\"\n      }\n    },\n    {\n      \"identity\" : \"swift-syntax\",\n      \"kind\" : \"remoteSourceControl\",\n      \"location\" : \"https://github.com/swiftlang/swift-syntax\",\n      \"state\" : {\n        \"revision\" : \"4799286537280063c85a32f09884cfbca301b1a1\",\n        \"version\" : \"602.0.0\"\n      }\n    }\n  ],\n  \"version\" : 3\n}\n"
  },
  {
    "path": "Gifski.xcodeproj/xcshareddata/xcschemes/Gifski.xcscheme",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n   LastUpgradeVersion = \"2600\"\n   version = \"1.8\">\n   <BuildAction\n      parallelizeBuildables = \"YES\"\n      buildImplicitDependencies = \"YES\">\n      <BuildActionEntries>\n         <BuildActionEntry\n            buildForTesting = \"YES\"\n            buildForRunning = \"YES\"\n            buildForProfiling = \"YES\"\n            buildForArchiving = \"YES\"\n            buildForAnalyzing = \"YES\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"E3AE62821E5CD2F300035A2F\"\n               BuildableName = \"Gifski.app\"\n               BlueprintName = \"Gifski\"\n               ReferencedContainer = \"container:Gifski.xcodeproj\">\n            </BuildableReference>\n         </BuildActionEntry>\n      </BuildActionEntries>\n   </BuildAction>\n   <TestAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      codeCoverageEnabled = \"YES\">\n      <MacroExpansion>\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"E3AE62821E5CD2F300035A2F\"\n            BuildableName = \"Gifski.app\"\n            BlueprintName = \"Gifski\"\n            ReferencedContainer = \"container:Gifski.xcodeproj\">\n         </BuildableReference>\n      </MacroExpansion>\n      <Testables>\n      </Testables>\n   </TestAction>\n   <LaunchAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      enableASanStackUseAfterReturn = \"YES\"\n      launchStyle = \"0\"\n      useCustomWorkingDirectory = \"NO\"\n      ignoresPersistentStateOnLaunch = \"NO\"\n      debugDocumentVersioning = \"YES\"\n      migratedStopOnEveryIssue = \"YES\"\n      debugServiceExtension = \"internal\"\n      allowLocationSimulation = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"E3AE62821E5CD2F300035A2F\"\n            BuildableName = \"Gifski.app\"\n            BlueprintName = \"Gifski\"\n            ReferencedContainer = \"container:Gifski.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n      <EnvironmentVariables>\n         <EnvironmentVariable\n            key = \"RUST_BACKTRACE\"\n            value = \"1\"\n            isEnabled = \"YES\">\n         </EnvironmentVariable>\n         <EnvironmentVariable\n            key = \"CG_NUMERICS_SHOW_BACKTRACE\"\n            value = \"1\"\n            isEnabled = \"YES\">\n         </EnvironmentVariable>\n      </EnvironmentVariables>\n   </LaunchAction>\n   <ProfileAction\n      buildConfiguration = \"Release\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      savedToolIdentifier = \"\"\n      useCustomWorkingDirectory = \"NO\"\n      debugDocumentVersioning = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"E3AE62821E5CD2F300035A2F\"\n            BuildableName = \"Gifski.app\"\n            BlueprintName = \"Gifski\"\n            ReferencedContainer = \"container:Gifski.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n   </ProfileAction>\n   <AnalyzeAction\n      buildConfiguration = \"Debug\">\n   </AnalyzeAction>\n   <ArchiveAction\n      buildConfiguration = \"Release\"\n      revealArchiveInOrganizer = \"YES\">\n   </ArchiveAction>\n</Scheme>\n"
  },
  {
    "path": "Gifski.xcodeproj/xcshareddata/xcschemes/Share Extension.xcscheme",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n   LastUpgradeVersion = \"2600\"\n   wasCreatedForAppExtension = \"YES\"\n   version = \"2.0\">\n   <BuildAction\n      parallelizeBuildables = \"YES\"\n      buildImplicitDependencies = \"YES\"\n      buildArchitectures = \"Automatic\">\n      <BuildActionEntries>\n         <BuildActionEntry\n            buildForTesting = \"YES\"\n            buildForRunning = \"YES\"\n            buildForProfiling = \"YES\"\n            buildForArchiving = \"YES\"\n            buildForAnalyzing = \"YES\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"0E79251A2329BDBE00058B94\"\n               BuildableName = \"Share Extension.appex\"\n               BlueprintName = \"Share Extension\"\n               ReferencedContainer = \"container:Gifski.xcodeproj\">\n            </BuildableReference>\n         </BuildActionEntry>\n         <BuildActionEntry\n            buildForTesting = \"YES\"\n            buildForRunning = \"YES\"\n            buildForProfiling = \"YES\"\n            buildForArchiving = \"YES\"\n            buildForAnalyzing = \"YES\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"E3AE62821E5CD2F300035A2F\"\n               BuildableName = \"Gifski.app\"\n               BlueprintName = \"Gifski\"\n               ReferencedContainer = \"container:Gifski.xcodeproj\">\n            </BuildableReference>\n         </BuildActionEntry>\n      </BuildActionEntries>\n   </BuildAction>\n   <TestAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      shouldAutocreateTestPlan = \"YES\">\n   </TestAction>\n   <LaunchAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"\"\n      selectedLauncherIdentifier = \"Xcode.IDEFoundation.Launcher.PosixSpawn\"\n      launchStyle = \"0\"\n      askForAppToLaunch = \"Yes\"\n      useCustomWorkingDirectory = \"NO\"\n      ignoresPersistentStateOnLaunch = \"NO\"\n      debugDocumentVersioning = \"YES\"\n      debugServiceExtension = \"internal\"\n      allowLocationSimulation = \"YES\"\n      launchAutomaticallySubstyle = \"2\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"E3AE62821E5CD2F300035A2F\"\n            BuildableName = \"Gifski.app\"\n            BlueprintName = \"Gifski\"\n            ReferencedContainer = \"container:Gifski.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n   </LaunchAction>\n   <ProfileAction\n      buildConfiguration = \"Release\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      savedToolIdentifier = \"\"\n      useCustomWorkingDirectory = \"NO\"\n      debugDocumentVersioning = \"YES\"\n      askForAppToLaunch = \"Yes\"\n      launchAutomaticallySubstyle = \"2\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"E3AE62821E5CD2F300035A2F\"\n            BuildableName = \"Gifski.app\"\n            BlueprintName = \"Gifski\"\n            ReferencedContainer = \"container:Gifski.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n   </ProfileAction>\n   <AnalyzeAction\n      buildConfiguration = \"Debug\">\n   </AnalyzeAction>\n   <ArchiveAction\n      buildConfiguration = \"Release\"\n      revealArchiveInOrganizer = \"YES\">\n   </ArchiveAction>\n</Scheme>\n"
  },
  {
    "path": "Share Extension/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>ITSAppUsesNonExemptEncryption</key>\n\t<false/>\n\t<key>NSExtension</key>\n\t<dict>\n\t\t<key>NSExtensionAttributes</key>\n\t\t<dict>\n\t\t\t<key>NSExtensionActivationRule</key>\n\t\t\t<string>SUBQUERY (\n\t\t\t\textensionItems,\n\t\t\t\t$extensionItem,\n\t\t\t\tSUBQUERY (\n\t\t\t\t\t$extensionItem.attachments,\n\t\t\t\t\t$attachment,\n\t\t\t\t\tANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO &quot;public.mpeg-4&quot; ||\n\t\t\t\t\tANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO &quot;com.apple.m4v-video&quot; ||\n\t\t\t\t\tANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO &quot;com.apple.quicktime-movie&quot;\n\t\t\t\t).@count == $extensionItem.attachments.@count\n\t\t\t).@count == 1</string>\n\t\t</dict>\n\t\t<key>NSExtensionPointIdentifier</key>\n\t\t<string>com.apple.share-services</string>\n\t\t<key>NSExtensionPrincipalClass</key>\n\t\t<string>$(PRODUCT_MODULE_NAME).ShareController</string>\n\t</dict>\n</dict>\n</plist>\n"
  },
  {
    "path": "Share Extension/ShareController.swift",
    "content": "import SwiftUI\n\nfinal class ShareController: ExtensionController {\n\toverride func run(_ context: NSExtensionContext) async throws -> [NSExtensionItem] {\n\t\tguard\n\t\t\tlet url = try await (context.attachments.first { $0.hasItemConforming(to: .url) })?.loadTransferable(type: URL.self)\n\t\telse {\n\t\t\tcontext.cancel()\n\t\t\treturn []\n\t\t}\n\n\t\tlet filename = url.lastPathComponent\n\n\t\tguard\n\t\t\tlet appGroupShareVideoURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Shared.appGroupIdentifier)?.appendingPathComponent(filename, isDirectory: false)\n\t\telse {\n\t\t\tcontext.cancel()\n\t\t\treturn []\n\t\t}\n\n\t\ttry? FileManager.default.removeItem(at: appGroupShareVideoURL)\n\t\ttry FileManager.default.copyItem(at: url, to: appGroupShareVideoURL)\n\n\t\tlet gifskiURL = createMainAppUrl(\n\t\t\tqueryItems: [\n\t\t\t\tURLQueryItem(name: \"path\", value: filename)\n\t\t\t]\n\t\t)\n\n\t\tNSWorkspace.shared.open(gifskiURL)\n\n\t\treturn []\n\t}\n\n\tprivate func createMainAppUrl(queryItems: [URLQueryItem]) -> URL {\n\t\tvar components = URLComponents()\n\t\tcomponents.scheme = \"gifski\"\n\t\tcomponents.host = \"shareExtension\"\n\t\tcomponents.queryItems = queryItems\n\t\treturn components.url!\n\t}\n}\n"
  },
  {
    "path": "Share Extension/Share_Extension.entitlements",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>com.apple.security.app-sandbox</key>\n\t<true/>\n\t<key>com.apple.security.application-groups</key>\n\t<array>\n\t\t<string>group.com.sindresorhus.Gifski</string>\n\t</array>\n</dict>\n</plist>\n"
  },
  {
    "path": "Share Extension/Utilities.swift",
    "content": "import SwiftUI\nimport UniformTypeIdentifiers\n\n\nextension Sequence where Element: Sequence {\n\tfunc flatten() -> [Element.Element] {\n\t\tflatMap(\\.self)\n\t}\n}\n\n\nextension NSExtensionContext {\n\tvar inputItemsTyped: [NSExtensionItem] { inputItems as! [NSExtensionItem] }\n\n\tvar attachments: [NSItemProvider] {\n\t\tinputItemsTyped.compactMap(\\.attachments).flatten()\n\t}\n}\n\n\n// Strongly-typed versions of some of the methods.\nextension NSItemProvider {\n\tfunc hasItemConforming(to contentType: UTType) -> Bool {\n\t\thasItemConformingToTypeIdentifier(contentType.identifier)\n\t}\n}\n\n\nextension NSError {\n\tstatic let userCancelled = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError, userInfo: nil)\n}\n\n\nextension NSExtensionContext {\n\tfunc cancel() {\n\t\tcancelRequest(withError: NSError.userCancelled)\n\t}\n}\n\n\nextension NSItemProvider {\n\tfunc loadTransferable<T: Transferable & Sendable>(type transferableType: T.Type) async throws -> T {\n\t\ttry await withCheckedThrowingContinuation { continuation in\n\t\t\t_ = loadTransferable(type: transferableType) {\n\t\t\t\tcontinuation.resume(with: $0)\n\t\t\t}\n\t\t}\n\t}\n}\n\n\nclass ExtensionController: NSViewController { // swiftlint:disable:this final_class\n\tinit() {\n\t\tsuper.init(nibName: nil, bundle: nil)\n\t}\n\n\t@available(*, unavailable)\n\trequired init?(coder: NSCoder) {\n\t\tfatalError() // swiftlint:disable:this fatal_error_message\n\t}\n\n\toverride func loadView() {\n\t\tTask { @MainActor in // Not sure if this is needed, but added just in case.\n\t\t\tdo {\n\t\t\t\textensionContext!.completeRequest(\n\t\t\t\t\treturningItems: try await run(extensionContext!),\n\t\t\t\t\tcompletionHandler: nil\n\t\t\t\t)\n\t\t\t} catch {\n\t\t\t\textensionContext!.cancelRequest(withError: error)\n\t\t\t}\n\t\t}\n\t}\n\n\tfunc run(_ context: NSExtensionContext) async throws -> [NSExtensionItem] { [] }\n}\n\n\n// TODO: Check if any of these can be removed when targeting macOS 15.\nextension NSItemProvider: @retroactive @unchecked Sendable {}\n"
  },
  {
    "path": "app-store-description.txt",
    "content": "Convert videos to high-quality GIFs.\n\nGifski converts videos to animated GIFs that use thousands of colors per frame. This is made possible by some fancy features for efficient cross-frame palettes and temporal dithering.\n\nKeep in mind that Gifski is a converter, not a GIF creator. It will never support features like adding text and elements to a GIF. That's better done in a proper video editing app.\n\n\n■ Features\n\n- Video trimming\n- Precise control of dimensions\n- Control over GIF looping and bouncing (yo-yo) playback\n- Adjust the speed\n- Copy, share, or drag the GIF\n- Share extension\n- System service\n- Optionally produce smaller lower quality GIFs\n- Generate up to 50 FPS GIFs (for showing off design work on Dribbble)\n- Shortcuts support\n\n\n■ To convert, either:\n\n- Drag and drop your video onto the window or the Dock icon.\n- Click the “Open” button in the window or in the “File” menu and then choose a video.\n- Right-click a video in Finder and select this app in the “Open With” menu.\n\nGifski supports all the video formats that macOS supports (.mp4 or .mov with H264, HEVC, ProRes, etc). The QuickTime Animation format is not supported. Use ProRes 4444 XQ instead. It's more efficient, more widely supported, and like QuickTime Animation, it also supports alpha channel.\n\n\n■ Share extension\n\nGifski includes a share extension that lets you share videos to Gifski. Just select Gifski from the Share menu of any macOS app.\n\nYou can share a macOS screen recording with Gifski by clicking on the thumbnail that pops up once you are done recording and selecting “Share” from there.\n\n\n■ System service\n\nGifski includes a system service that lets you quickly convert a video to GIF from the “Services” menu in any app that provides a compatible video file.\n\n\n■ Bounce (yo-yo) GIF playback\n\nGifski includes the option to create GIFs that bounce back and forth between forward and backward playback. This option doubles the number of frames in the GIF so the file size will double as well.\n\n\n■ Tips\n\n‣ Quickly copy or save the GIF\n\nAfter converting, press Command+C to copy the GIF or Command+S to save it.\n\n‣ Change GIF dimensions with the keyboard\n\nIn the width/height input fields in the editor view, press the arrow up/down keys to change the value by 1. Hold the Option key meanwhile to change it by 10.\n\n\n■ FAQ\n\n‣ The generated GIFs are huge!\n\nThe GIF image format is very space inefficient. It works best with short video clips. Try reducing the dimensions, FPS, or quality.\n\n‣ Why are 60 FPS and higher not supported?\n\nBrowsers throttle frame rates above 50 FPS, playing them at 10 FPS.\n\n\n■ Support\n\nClick the “Send Feedback” button in the “Help” menu in the app.\n"
  },
  {
    "path": "app-store-keywords.txt",
    "content": "gif,video,movie,image,convert,converter,mp4,mov,photo,picture,photography,resize,design,bounce,yoyo\n"
  },
  {
    "path": "contributing.md",
    "content": "## Contributing\n\n### New features\n\nNew features should use SwiftUI and Combine.\n"
  },
  {
    "path": "gifski-api/.github/dependabot.yml",
    "content": "version: 2\nupdates:\n- package-ecosystem: cargo\n  directory: \"/\"\n  schedule:\n    interval: monthly\n  open-pull-requests-limit: 10\n  ignore:\n  - dependency-name: lodepng\n    versions:\n    - 3.4.5\n"
  },
  {
    "path": "gifski-api/.gitignore",
    "content": "/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.bz2\n"
  },
  {
    "path": "gifski-api/Cargo.toml",
    "content": "[package]\nauthors = [\"Kornel <kornel@geekhood.net>\"]\ncategories = [\"multimedia::video\", \"command-line-utilities\"]\ndescription = \"pngquant-based GIF maker for nice-looking animGIFs\"\ndocumentation = \"https://docs.rs/gifski\"\nhomepage = \"https://gif.ski\"\ninclude = [\"/README.md\", \"/Cargo.toml\", \"/src/**/*.rs\", \"/src/bin/*.rs\"]\nkeywords = [\"gif\", \"encoder\", \"converter\", \"maker\", \"gifquant\"]\nlicense = \"AGPL-3.0-or-later\"\nname = \"gifski\"\nreadme = \"README.md\"\nrepository = \"https://github.com/ImageOptim/gifski\"\nversion = \"1.34.0\"\nautobins = false\nedition = \"2021\"\nrust-version = \"1.74\"\n\n[[bin]]\ndoctest = false\nname = \"gifski\"\nrequired-features = [\"binary\"]\n\n[dependencies]\nclap = { version = \"4.5.32\", features = [\"cargo\"], optional = true }\ngif = { version = \"0.13.1\", default-features = false, features = [\"std\", \"raii_no_panic\"] }\ngif-dispose = \"5.0.1\"\nimagequant = \"4.3.4\"\nlodepng = { version = \"3.11.0\", optional = true }\nnatord = { version = \"1.0.9\", optional = true }\npbr = { version = \"1.1.1\", optional = true }\nquick-error = \"2.0.1\"\nresize = { version = \"0.8.8\", features = [\"rayon\"] }\nrgb = { version = \"0.8.50\", default-features = false, features = [\"bytemuck\"] }\ndunce = { version = \"1.0.5\", optional = true }\ncrossbeam-channel = \"0.5.14\"\nimgref = \"1.11.0\"\nloop9 = \"0.1.5\"\n# noisy-float 0.2 bug\nnum-traits = { version = \"0.2.19\", features = [\"i128\", \"std\"] }\ncrossbeam-utils = \"0.8.21\"\nordered-channel = { version = \"1.2.0\", features = [\"crossbeam-channel\"] }\nwild = { version = \"2.2.1\", optional = true, features = [\"glob-quoted-on-windows\"] }\ny4m = { version = \"0.8.0\", optional = true }\nyuv = { version = \"0.1.9\", optional = true }\n\n[dependencies.ffmpeg]\npackage = \"ffmpeg-next\"\nversion = \"6\"\noptional = true\ndefault-features = false\nfeatures = [\"codec\", \"format\", \"filter\", \"software-resampling\", \"software-scaling\"]\n\n[dev-dependencies]\nlodepng = \"3.11.0\"\n\n[features]\n# `cargo build` will skip the binaries with missing `required-features`\n# so all CLI dependencies have to be enabled by default.\ndefault = [\"gifsicle\", \"binary\"]\n# You can disable this feture when using gifski as a library.\nbinary = [\"dep:clap\", \"dep:yuv\", \"dep:y4m\", \"png\", \"pbr\", \"dep:wild\", \"dep:natord\", \"dep:dunce\"]\ncapi = [] # internal for cargo-c only\npng = [\"dep:lodepng\"]\n# Links dynamically to ffmpeg. Needs ffmpeg devel package installed on the system.\nvideo = [\"dep:ffmpeg\"]\n# Builds ffmpeg from source. Needs a C compiler, and all of ffmpeg's source dependencies.\nvideo-static = [\"video\", \"ffmpeg/build\"]\n# If you're lucky, this one might work with ffmpeg from vcpkg.\nvideo-prebuilt-static = [\"video\", \"ffmpeg/static\"]\n# Support lossy LZW encoding when lower quality is set\ngifsicle = []\n\n[lib]\npath = \"src/lib.rs\"\ncrate-type = [\"lib\", \"staticlib\", \"cdylib\"]\n\n[profile.dev]\ndebug = 1\nopt-level = 1\n\n[profile.dev.package.'*']\nopt-level = 2\ndebug = false\n\n[profile.release]\npanic = \"abort\"\nlto = true\ndebug = false\nopt-level = 3\nstrip = true\n\n[package.metadata.docs.rs]\ntargets = [\"x86_64-unknown-linux-gnu\"]\n\n[package.metadata.capi.header]\nsubdirectory = false\ngeneration = false\n\n[package.metadata.capi.install.include]\nasset = [{from = \"gifski.h\"}]\n\n[patch.crates-io]\n# ffmpeg-sys-next does not support cross-compilation, which I use to produce binaries https://github.com/zmwangx/rust-ffmpeg-sys/pull/30\nffmpeg-sys-next = { rev = \"fd5784d645df2ebe022a204ac36582074da1edf7\", git = \"https://github.com/kornelski/rust-ffmpeg-sys-1\"}\n"
  },
  {
    "path": "gifski-api/LICENSE",
    "content": "\nLet [me](https://kornel.ski/contact) know if you'd like to use it in a product incompatible with this license. I can offer alternative licensing options.\n\n----\n\n### GNU AFFERO GENERAL PUBLIC LICENSE\n\nVersion 3, 19 November 2007\n\n© 2007 Free Software Foundation, Inc.\n<https://fsf.org/>\n\nEveryone is permitted to copy and distribute verbatim copies of this\nlicense document, but changing it is not allowed.\n\n### Preamble\n\nThe GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\nThe licenses for most software and other practical works are designed\nto take away your freedom to share and change the works. By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains\nfree software for all its users.\n\nWhen we speak of free software, we are referring to freedom, not\nprice. Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\nDevelopers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\nA secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate. Many developers of free software are heartened and\nencouraged by the resulting cooperation. However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\nThe GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community. It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server. Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\nAn older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals. This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing\nunder this license.\n\nThe precise terms and conditions for copying, distribution and\nmodification follow.\n\n### TERMS AND CONDITIONS\n\n#### 0. Definitions.\n\n\"This License\" refers to version 3 of the GNU Affero General Public\nLicense.\n\n\"Copyright\" also means copyright-like laws that apply to other kinds\nof works, such as semiconductor masks.\n\n\"The Program\" refers to any copyrightable work licensed under this\nLicense. Each licensee is addressed as \"you\". \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\nTo \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of\nan exact copy. The resulting work is called a \"modified version\" of\nthe earlier work or a work \"based on\" the earlier work.\n\nA \"covered work\" means either the unmodified Program or a work based\non the Program.\n\nTo \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy. Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\nTo \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies. Mere interaction with a user\nthrough a computer network, with no transfer of a copy, is not\nconveying.\n\nAn interactive user interface displays \"Appropriate Legal Notices\" to\nthe extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License. If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n#### 1. Source Code.\n\nThe \"source code\" for a work means the preferred form of the work for\nmaking modifications to it. \"Object code\" means any non-source form of\na work.\n\nA \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\nThe \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form. A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\nThe \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities. However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work. For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\nThe Corresponding Source need not include anything that users can\nregenerate automatically from other parts of the Corresponding Source.\n\nThe Corresponding Source for a work in source code form is that same\nwork.\n\n#### 2. Basic Permissions.\n\nAll rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met. This License explicitly affirms your unlimited\npermission to run the unmodified Program. The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work. This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\nYou may make, run and propagate covered works that you do not convey,\nwithout conditions so long as your license otherwise remains in force.\nYou may convey covered works to others for the sole purpose of having\nthem make modifications exclusively for you, or provide you with\nfacilities for running those works, provided that you comply with the\nterms of this License in conveying all material for which you do not\ncontrol copyright. Those thus making or running the covered works for\nyou must do so exclusively on your behalf, under your direction and\ncontrol, on terms that prohibit them from making any copies of your\ncopyrighted material outside their relationship with you.\n\nConveying under any other circumstances is permitted solely under the\nconditions stated below. Sublicensing is not allowed; section 10 makes\nit unnecessary.\n\n#### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\nNo covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\nWhen you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such\ncircumvention is effected by exercising rights under this License with\nrespect to the covered work, and you disclaim any intention to limit\noperation or modification of the work as a means of enforcing, against\nthe work's users, your or third parties' legal rights to forbid\ncircumvention of technological measures.\n\n#### 4. Conveying Verbatim Copies.\n\nYou may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\nYou may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n#### 5. Conveying Modified Source Versions.\n\nYou may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these\nconditions:\n\n-   a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n-   b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under\n    section 7. This requirement modifies the requirement in section 4\n    to \"keep intact all notices\".\n-   c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy. This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged. This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n-   d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\nA compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit. Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n#### 6. Conveying Non-Source Forms.\n\nYou may convey a covered work in object code form under the terms of\nsections 4 and 5, provided that you also convey the machine-readable\nCorresponding Source under the terms of this License, in one of these\nways:\n\n-   a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n-   b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the Corresponding\n    Source from a network server at no charge.\n-   c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source. This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n-   d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge. You need not require recipients to copy the\n    Corresponding Source along with the object code. If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source. Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n-   e) Convey the object code using peer-to-peer transmission,\n    provided you inform other peers where the object code and\n    Corresponding Source of the work are being offered to the general\n    public at no charge under subsection 6d.\n\nA separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\nA \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal,\nfamily, or household purposes, or (2) anything designed or sold for\nincorporation into a dwelling. In determining whether a product is a\nconsumer product, doubtful cases shall be resolved in favor of\ncoverage. For a particular product received by a particular user,\n\"normally used\" refers to a typical or common use of that class of\nproduct, regardless of the status of the particular user or of the way\nin which the particular user actually uses, or expects or is expected\nto use, the product. A product is a consumer product regardless of\nwhether the product has substantial commercial, industrial or\nnon-consumer uses, unless such uses represent the only significant\nmode of use of the product.\n\n\"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to\ninstall and execute modified versions of a covered work in that User\nProduct from a modified version of its Corresponding Source. The\ninformation must suffice to ensure that the continued functioning of\nthe modified object code is in no case prevented or interfered with\nsolely because modification has been made.\n\nIf you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information. But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\nThe requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or\nupdates for a work that has been modified or installed by the\nrecipient, or for the User Product in which it has been modified or\ninstalled. Access to a network may be denied when the modification\nitself materially and adversely affects the operation of the network\nor violates the rules and protocols for communication across the\nnetwork.\n\nCorresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n#### 7. Additional Terms.\n\n\"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law. If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\nWhen you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit. (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.) You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\nNotwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders\nof that material) supplement the terms of this License with terms:\n\n-   a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n-   b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n-   c) Prohibiting misrepresentation of the origin of that material,\n    or requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n-   d) Limiting the use for publicity purposes of names of licensors\n    or authors of the material; or\n-   e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n-   f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions\n    of it) with contractual assumptions of liability to the recipient,\n    for any liability that these contractual assumptions directly\n    impose on those licensors and authors.\n\nAll other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10. If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term. If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\nIf you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\nAdditional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions; the\nabove requirements apply either way.\n\n#### 8. Termination.\n\nYou may not propagate or modify a covered work except as expressly\nprovided under this License. Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\nHowever, if you cease all violation of this License, then your license\nfrom a particular copyright holder is reinstated (a) provisionally,\nunless and until the copyright holder explicitly and finally\nterminates your license, and (b) permanently, if the copyright holder\nfails to notify you of the violation by some reasonable means prior to\n60 days after the cessation.\n\nMoreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\nTermination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License. If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n#### 9. Acceptance Not Required for Having Copies.\n\nYou are not required to accept this License in order to receive or run\na copy of the Program. Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance. However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work. These actions infringe copyright if you do\nnot accept this License. Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n#### 10. Automatic Licensing of Downstream Recipients.\n\nEach time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License. You are not responsible\nfor enforcing compliance by third parties with this License.\n\nAn \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations. If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\nYou may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License. For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n#### 11. Patents.\n\nA \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based. The\nwork thus licensed is called the contributor's \"contributor version\".\n\nA contributor's \"essential patent claims\" are all patent claims owned\nor controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version. For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\nEach contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\nIn the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement). To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\nIf you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients. \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\nIf, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\nA patent license is \"discriminatory\" if it does not include within the\nscope of its coverage, prohibits the exercise of, or is conditioned on\nthe non-exercise of one or more of the rights that are specifically\ngranted under this License. You may not convey a covered work if you\nare a party to an arrangement with a third party that is in the\nbusiness of distributing software, under which you make payment to the\nthird party based on the extent of your activity of conveying the\nwork, and under which the third party grants, to any of the parties\nwho would receive the covered work from you, a discriminatory patent\nlicense (a) in connection with copies of the covered work conveyed by\nyou (or copies made from those copies), or (b) primarily for and in\nconnection with specific products or compilations that contain the\ncovered work, unless you entered into that arrangement, or that patent\nlicense was granted, prior to 28 March 2007.\n\nNothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n#### 12. No Surrender of Others' Freedom.\n\nIf conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License. If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under\nthis License and any other pertinent obligations, then as a\nconsequence you may not convey it at all. For example, if you agree to\nterms that obligate you to collect a royalty for further conveying\nfrom those to whom you convey the Program, the only way you could\nsatisfy both those terms and this License would be to refrain entirely\nfrom conveying the Program.\n\n#### 13. Remote Network Interaction; Use with the GNU General Public License.\n\nNotwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your\nversion supports such interaction) an opportunity to receive the\nCorresponding Source of your version by providing access to the\nCorresponding Source from a network server at no charge, through some\nstandard or customary means of facilitating copying of software. This\nCorresponding Source shall include the Corresponding Source for any\nwork covered by version 3 of the GNU General Public License that is\nincorporated pursuant to the following paragraph.\n\nNotwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work. The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n#### 14. Revised Versions of this License.\n\nThe Free Software Foundation may publish revised and/or new versions\nof the GNU Affero General Public License from time to time. Such new\nversions will be similar in spirit to the present version, but may\ndiffer in detail to address new problems or concerns.\n\nEach version is given a distinguishing version number. If the Program\nspecifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation. If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever\npublished by the Free Software Foundation.\n\nIf the Program specifies that a proxy can decide which future versions\nof the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\nLater license versions may give you additional or different\npermissions. However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n#### 15. Disclaimer of Warranty.\n\nTHERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT\nWARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND\nPERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE\nDEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR\nCORRECTION.\n\n#### 16. Limitation of Liability.\n\nIN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR\nCONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,\nINCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES\nARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT\nNOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR\nLOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM\nTO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER\nPARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.\n\n#### 17. Interpretation of Sections 15 and 16.\n\nIf the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n"
  },
  {
    "path": "gifski-api/README.md",
    "content": "# [<img width=\"100%\" src=\"https://gif.ski/gifski.svg\" alt=\"gif.ski\">](https://gif.ski)\n\nHighest-quality GIF encoder based on [pngquant](https://pngquant.org).\n\n**[gifski](https://gif.ski)** converts video frames to GIF animations using pngquant's fancy features for efficient cross-frame palettes and temporal dithering. It produces animated GIFs that use thousands of colors per frame.\n\n![(CC) Blender Foundation | gooseberry.blender.org](https://gif.ski/demo.gif)\n\nIt's a CLI tool, but it can also be compiled [as a C library](https://docs.rs/gifski) for seamless use in other apps.\n\n## Download and install\n\nSee [releases](https://github.com/ImageOptim/gifski/releases) page for executables.\n\nIf you have [Homebrew](https://brew.sh/), you can also get it with `brew install gifski`.\n\nIf you have [Rust from rustup](https://www.rust-lang.org/install.html) (1.63+), you can also build it from source with [`cargo install gifski`](https://lib.rs/crates/gifski).\n\n## Usage\n\ngifski is a command-line tool. If you're not comfortable with a terminal, try the GUI version for [Windows][winmsi] or for [macOS][macapp].\n\n[winmsi]: https://github.com/ImageOptim/gifski/releases/download/1.14.4/gifski_1.14.4_x64_en-US.msi\n[macapp]: https://sindresorhus.com/gifski\n\n### From ffmpeg video\n\n> Tip: Instead of typing file paths, you can drag'n'drop files into the terminal window!\n\nIf you have ffmpeg installed, you can use it to stream a video directly to the gifski command by adding `-f yuv4mpegpipe` to `ffmpeg`'s arguments:\n\n```sh\nffmpeg -i video.mp4 -f yuv4mpegpipe - | gifski -o anim.gif -\n```\n\nReplace \"video.mp4\" in the above code with actual path to your video.\n\nNote that there's `-` at the end of the command. This tells `gifski` to read from standard input. Reading a `.y4m` file from disk would work too, but these files are huge.\n\n`gifski` may automatically downsize the video if it has resolution too high for a GIF. Use `--width=1280` if you can tolerate getting huge file sizes.\n\n### From PNG frames\n\nA directory full of PNG frames can be used as an input too. You can export them from any animation software. If you have `ffmpeg` installed, you can also export frames with it:\n\n```sh\nffmpeg -i video.webm frame%04d.png\n```\n\nand then make the GIF from the frames:\n\n```sh\ngifski -o anim.gif frame*.png\n```\n\nNote that `*` is a special wildcard character, and it won't work when placed inside quoted string (`\"*\"`).\n\nYou can also resize frames (with `-W <width in pixels>` option). If the input was ever encoded using a lossy video codec it's recommended to at least halve size of the frames to hide compression artefacts and counter chroma subsampling that was done by the video codec.\n\nSee `gifski --help` for more options.\n\n### Tips for smaller GIF files\n\nExpect to lose a lot of quality for little gain. GIF just isn't that good at compressing, no matter how much you compromise.\n\n* Use `--width` and `--height` to make the animation smaller. This makes the biggest difference.\n* Add `--quality=80` (or a lower number) to lower overall quality. You can fine-tune the quality with:\n    * `--lossy-quality=60` lower values make animations noisier/grainy, but reduce file sizes.\n    * `--motion-quality=60` lower values cause smearing or banding in frames with motion, but reduce file sizes.\n\nIf you need to make a GIF that fits a predefined file size, you have to experiment with different sizes and quality settings. The command line tool will display estimated total file size during compression, but keep in mind that the estimate is very imprecise.\n\n## Building\n\n1. [Install Rust via rustup](https://www.rust-lang.org/en-US/install.html). This project only supports up-to-date versions of Rust. You may get errors about \"unstable\" features if your compiler version is too old. Run `rustup update`.\n2. Clone the repository: `git clone https://github.com/ImageOptim/gifski`\n3. In the cloned directory, run: `cargo build --release`. This will build in `./target/release`.\n\n### Using from C\n\n[See `gifski.h`](https://github.com/ImageOptim/gifski/blob/main/gifski.h) for [the C API](https://docs.rs/gifski/latest/gifski/c_api/#functions). To build the library, run:\n\n```sh\nrustup update\ncargo build --release\n```\n\nand link with `target/release/libgifski.a`. Please observe the [LICENSE](LICENSE).\n\n### C dynamic library for package maintainers\n\nThe build process uses [`cargo-c`](https://lib.rs/cargo-c) for building the dynamic library correctly and generating the pkg-config file.\n\n```sh\nrustup update\ncargo install cargo-c\n# build\ncargo cbuild --prefix=/usr --release\n# install\ncargo cinstall --prefix=/usr --release --destdir=pkgroot\n```\n\nThe `cbuild` command can be omitted, since `cinstall` will trigger a build if it hasn't been done already.\n\n## License\n\nAGPL 3 or later. I can offer alternative licensing options, including [commercial licenses](https://supso.org/projects/pngquant). Let [me](https://kornel.ski/contact) know if you'd like to use it in a product incompatible with this license.\n\n## With built-in video support\n\nThe tool optionally supports decoding video directly, but unfortunately it relies on ffmpeg 6.x, which may be *very hard* to get working, so it's not enabled by default.\n\nYou must have `ffmpeg` and `libclang` installed, both with their C headers installed in default system include paths. Details depend on the platform and version, but you usually need to install packages such as `libavformat-dev`, `libavfilter-dev`, `libavdevice-dev`, `libclang-dev`, `clang`. Please note that installation of these dependencies may be quite difficult. Especially on macOS and Windows it takes *expert knowledge* to just get them installed without wasting several hours on endless stupid installation and compilation errors, which I can't help with. If you're cross-compiling, try uncommenting `[patch.crates-io]` section at the end of `Cargo.toml`, which includes some experimental fixes for ffmpeg.\n\nOnce you have dependencies installed, compile with `cargo build --release --features=video` or `cargo build --release --features=video-static`.\n\nWhen compiled with video support [ffmpeg licenses](https://www.ffmpeg.org/legal.html) apply. You may need to have a patent license to use H.264/H.265 video (I recommend using VP9/WebM instead).\n\n```sh\ngifski -o out.gif video.mp4\n```\n\n## Cross-compilation for iOS\n\nThe easy option is to use the included `gifski.xcodeproj` file to build the library automatically for all Apple platforms. Add it as a [subproject](https://lib.rs/crates/cargo-xcode) to your Xcode project, and link with `gifski-staticlib` Xcode target. See [the GUI app](https://github.com/sindresorhus/Gifski) for an example how to integrate the library.\n\n### Cross-compilation for iOS manually\n\nMake sure you have Rust installed via [rustup](https://rustup.rs/). Run once:\n\n```sh\nrustup target add aarch64-apple-ios\n```\n\nand then to build the library:\n\n```sh\nrustup update\ncargo build --lib --release --target=aarch64-apple-ios\n```\n\nThe build may print \"dropping unsupported crate type `cdylib`\" warning. This is expected when building for iOS.\n\nThis will create a static library in `./target/aarch64-apple-ios/release/libgifski.a`. You can add this library to your Xcode project. See [gifski.app](https://github.com/sindresorhus/Gifski) for an example how to use libgifski from Swift.\n\n"
  },
  {
    "path": "gifski-api/gifski.h",
    "content": "#include <stdarg.h>\n#include <stdint.h>\n#include <stdlib.h>\n#include <stdbool.h>\n\n\n#ifdef __cplusplus\nextern \"C\" {\n#endif\n\nstruct gifski;\ntypedef struct gifski gifski;\n\n/**\nHow to use from C\n\n```c\ngifski *g = gifski_new(&(GifskiSettings){\n  .quality = 90,\n});\ngifski_set_file_output(g, \"file.gif\");\n\nfor(int i=0; i < frames; i++) {\n     int res = gifski_add_frame_rgba(g, i, width, height, buffer, 5);\n     if (res != GIFSKI_OK) break;\n}\nint res = gifski_finish(g);\nif (res != GIFSKI_OK) return;\n```\n\nIt's safe and efficient to call `gifski_add_frame_*` in a loop as fast as you can get frames,\nbecause it blocks and waits until previous frames are written.\n\nTo cancel processing, make progress callback return 0 and call `gifski_finish()`. The write callback\nmay still be called between the cancellation and `gifski_finish()` returning.\n\nTo build as a library:\n\n```bash\ncargo build --release --lib\n```\n\nit will create `target/release/libgifski.a` (static library)\nand `target/release/libgifski.so`/`dylib` or `gifski.dll` (dynamic library)\n\nStatic is recommended.\n\nTo build for iOS:\n\n```bash\nrustup target add aarch64-apple-ios\ncargo build --release --lib --target aarch64-apple-ios\n```\n\nit will build `target/aarch64-apple-ios/release/libgifski.a` (ignore the warning about cdylib).\n\n*/\n\n/**\n * Settings for creating a new encoder instance. See `gifski_new`\n */\ntypedef struct GifskiSettings {\n  /**\n   * Resize to max this width if non-0.\n   */\n  uint32_t width;\n  /**\n   * Resize to max this height if width is non-0. Note that aspect ratio is not preserved.\n   */\n  uint32_t height;\n  /**\n   * 1-100, but useful range is 50-100. Recommended to set to 90.\n   */\n  uint8_t quality;\n  /**\n   * Lower quality, but faster encode.\n   */\n  bool fast;\n  /**\n   * If negative, looping is disabled. The number of times the sequence is repeated. 0 to loop forever.\n   */\n  int16_t repeat;\n} GifskiSettings;\n\nenum GifskiError {\n  GIFSKI_OK = 0,\n  /** one of input arguments was NULL */\n  GIFSKI_NULL_ARG,\n  /** a one-time function was called twice, or functions were called in wrong order */\n  GIFSKI_INVALID_STATE,\n  /** internal error related to palette quantization */\n  GIFSKI_QUANT,\n  /** internal error related to gif composing */\n  GIFSKI_GIF,\n  /** internal error - unexpectedly aborted */\n  GIFSKI_THREAD_LOST,\n  /** I/O error: file or directory not found */\n  GIFSKI_NOT_FOUND,\n  /** I/O error: permission denied */\n  GIFSKI_PERMISSION_DENIED,\n  /** I/O error: file already exists */\n  GIFSKI_ALREADY_EXISTS,\n  /** invalid arguments passed to function */\n  GIFSKI_INVALID_INPUT,\n  /** misc I/O error */\n  GIFSKI_TIMED_OUT,\n  /** misc I/O error */\n  GIFSKI_WRITE_ZERO,\n  /** misc I/O error */\n  GIFSKI_INTERRUPTED,\n  /** misc I/O error */\n  GIFSKI_UNEXPECTED_EOF,\n  /** progress callback returned 0, writing aborted */\n  GIFSKI_ABORTED,\n  /** should not happen, file a bug */\n  GIFSKI_OTHER,\n};\n\n/* workaround for a wrong definition in an older version of this header. Please use GIFSKI_ABORTED directly */\n#ifndef ABORTED\n#define ABORTED GIFSKI_ABORTED\n#endif\n\ntypedef enum GifskiError GifskiError;\n\n/**\n * Call to start the process\n *\n * See `gifski_add_frame_png_file` and `gifski_end_adding_frames`\n *\n * Returns a handle for the other functions, or `NULL` on error (if the settings are invalid).\n */\ngifski *gifski_new(const GifskiSettings *settings);\n\n\n/** Quality 1-100 of temporal denoising. Lower values reduce motion. Defaults to `settings.quality`.\n *\n * Only valid immediately after calling `gifski_new`, before any frames are added. */\nGifskiError gifski_set_motion_quality(gifski *handle, uint8_t quality);\n\n/** Quality 1-100 of gifsicle compression. Lower values add noise. Defaults to `settings.quality`.\n * Has no effect if the `gifsicle` feature hasn't been enabled.\n * Only valid immediately after calling `gifski_new`, before any frames are added. */\nGifskiError gifski_set_lossy_quality(gifski *handle, uint8_t quality);\n\n/** If `true`, encoding will be significantly slower, but may look a bit better.\n *\n * Only valid immediately after calling `gifski_new`, before any frames are added. */\nGifskiError gifski_set_extra_effort(gifski *handle, bool extra);\n\n/**\n * Adds a frame to the animation. This function is asynchronous.\n *\n * File path must be valid UTF-8.\n *\n * `frame_number` orders frames (consecutive numbers starting from 0).\n * You can add frames in any order, and they will be sorted by their `frame_number`.\n *\n * Presentation timestamp (PTS) is time in seconds, since start of the file, when this frame is to be displayed.\n * For a 20fps video it could be `frame_number/20.0`.\n * Frames with duplicate or out-of-order PTS will be skipped.\n *\n * The first frame should have PTS=0. If the first frame has PTS > 0, it'll be used as a delay after the last frame.\n *\n * This function may block and wait until the frame is processed. Make sure to call `gifski_set_write_callback` or `gifski_set_file_output` first to avoid a deadlock.\n *\n * Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error.\n */\nGifskiError gifski_add_frame_png_file(gifski *handle,\n                                      uint32_t frame_number,\n                                      const char *file_path,\n                                      double presentation_timestamp);\n\n/**\n * Adds a frame to the animation. This function is asynchronous.\n *\n * `pixels` is an array width×height×4 bytes large.\n * The array is copied, so you can free/reuse it immediately after this function returns.\n *\n * `frame_number` orders frames (consecutive numbers starting from 0).\n * You can add frames in any order, and they will be sorted by their `frame_number`.\n * However, out-of-order frames are buffered in RAM, and will cause high memory usage\n * if there are gaps in the frame numbers.\n *\n * Presentation timestamp (PTS) is time in seconds, since start of the file, when this frame is to be displayed.\n * For a 20fps video it could be `frame_number/20.0`. First frame must have PTS=0.\n * Frames with duplicate or out-of-order PTS will be skipped.\n *\n * The first frame should have PTS=0. If the first frame has PTS > 0, it'll be used as a delay after the last frame.\n *\n * Colors are in sRGB, uncorrelated RGBA, with alpha byte last.\n *\n * This function may block and wait until the frame is processed. Make sure to call `gifski_set_write_callback` or `gifski_set_file_output` first to avoid a deadlock.\n *\n * Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error.\n */\nGifskiError gifski_add_frame_rgba(gifski *handle,\n                                  uint32_t frame_number,\n                                  uint32_t width,\n                                  uint32_t height,\n                                  const unsigned char *pixels,\n                                  double presentation_timestamp);\n\n/** Same as `gifski_add_frame_rgba`, but with bytes per row arg */\nGifskiError gifski_add_frame_rgba_stride(gifski *handle,\n                                  uint32_t frame_number,\n                                  uint32_t width,\n                                  uint32_t height,\n                                  uint32_t bytes_per_row,\n                                  const unsigned char *pixels,\n                                  double presentation_timestamp);\n\n/** Same as `gifski_add_frame_rgba_stride`, except it expects components in ARGB order.\n\nBytes per row must be multiple of 4, and greater or equal width×4.\nIf the bytes per row value is invalid (e.g. an odd number), frames may look sheared/skewed.\n\nColors are in sRGB, uncorrelated ARGB, with alpha byte first.\n\n`gifski_add_frame_rgba` is preferred over this function.\n*/\nGifskiError gifski_add_frame_argb(gifski *handle,\n                                  uint32_t frame_number,\n                                  uint32_t width,\n                                  uint32_t bytes_per_row,\n                                  uint32_t height,\n                                  const unsigned char *pixels,\n                                  double presentation_timestamp);\n\n/** Same as `gifski_add_frame_rgba_stride`, except it expects RGB components (3 bytes per pixel)\n\nBytes per row must be multiple of 3, and greater or equal width×3.\nIf the bytes per row value is invalid (not multiple of 3), frames may look sheared/skewed.\n\nColors are in sRGB, red byte first.\n\n`gifski_add_frame_rgba` is preferred over this function.\n*/\nGifskiError gifski_add_frame_rgb(gifski *handle,\n                                 uint32_t frame_number,\n                                 uint32_t width,\n                                 uint32_t bytes_per_row,\n                                 uint32_t height,\n                                 const unsigned char *pixels,\n                                 double presentation_timestamp);\n\n/**\n * Get a callback for frame processed, and abort processing if desired.\n *\n * The callback is called once per input frame,\n * even if the encoder decides to skip some frames.\n *\n * It gets arbitrary pointer (`user_data`) as an argument. `user_data` can be `NULL`.\n *\n * The callback must return `1` to continue processing, or `0` to abort.\n *\n * The callback must be thread-safe (it will be called from another thread).\n * It must remain valid at all times, until `gifski_finish` completes.\n *\n * This function must be called before `gifski_set_file_output()` to take effect.\n */\nvoid gifski_set_progress_callback(gifski *handle, int (*progress_callback)(void *user_data), void *user_data);\n\n/**\n * Get a callback when an error occurs.\n * This is intended mostly for logging and debugging, not for user interface.\n *\n * The callback function has the following arguments:\n *  * A `\\0`-terminated C string in UTF-8 encoding. The string is only valid for the duration of the call. Make a copy if you need to keep it.\n *  * An arbitrary pointer (`user_data`). `user_data` can be `NULL`.\n *\n * The callback must be thread-safe (it will be called from another thread).\n * It must remain valid at all times, until `gifski_finish` completes.\n *\n * If the callback is not set, errors will be printed to stderr.\n *\n * This function must be called before `gifski_set_file_output()` to take effect.\n */\nGifskiError gifski_set_error_message_callback(gifski *handle, void (*error_message_callback)(const char*, void*), void *user_data);\n\n/**\n * Start writing to the file at `destination_path` (overwrites if needed).\n * The file path must be ASCII or valid UTF-8.\n *\n * This function has to be called before any frames are added.\n * This call will not block.\n *\n * Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error.\n */\nGifskiError gifski_set_file_output(gifski *handle, const char *destination_path);\n\n/**\n * Start writing via callback (any buffer, file, whatever you want). This has to be called before any frames are added.\n * This call will not block.\n *\n * The callback function receives 3 arguments:\n *  - size of the buffer to write, in bytes. IT MAY BE ZERO (when it's zero, either do nothing, or flush internal buffers if necessary).\n *  - pointer to the buffer.\n *  - context pointer to arbitrary user data, same as passed in to this function.\n *\n * The callback should return 0 (`GIFSKI_OK`) on success, and non-zero on error.\n *\n * The callback function must be thread-safe. It must remain valid at all times, until `gifski_finish` completes.\n *\n * Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error.\n */\nGifskiError gifski_set_write_callback(gifski *handle,\n                                      int (*write_callback)(size_t buffer_length, const uint8_t *buffer, void *user_data),\n                                      void *user_data);\n\n/**\n * The last step:\n *  - stops accepting any more frames (gifski_add_frame_* calls are blocked)\n *  - blocks and waits until all already-added frames have finished writing\n *\n * Returns final status of write operations. Remember to check the return value!\n *\n * Must always be called, otherwise it will leak memory.\n * After this call, the handle is freed and can't be used any more.\n *\n * Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error.\n */\nGifskiError gifski_finish(gifski *g);\n\n#ifdef __cplusplus\n}\n#endif\n"
  },
  {
    "path": "gifski-api/gifski.xcodeproj/project.pbxproj",
    "content": "// !$*UTF8*$!\n{\n\tarchiveVersion = 1;\n\tclasses = {\n\t};\n\tobjectVersion = 90;\n\tobjects = {\n\n/* Begin PBXBuildFile section */\n\t\tCA00E74C7D4159EA34BF617B /* Cargo.toml in Sources */ = {isa = PBXBuildFile; fileRef = CAF9AE29BDC33EF4668187A5 /* Cargo.toml */; settings = {COMPILER_FLAGS = \"--lib\"; }; };\n\t\tCA01E74C7D41A82EB53EFF50 /* Cargo.toml in Sources */ = {isa = PBXBuildFile; fileRef = CAF9AE29BDC33EF4668187A5 /* Cargo.toml */; settings = {COMPILER_FLAGS = \"--lib\"; }; };\n\t\tCA02E74C7D4162D760BFA4D3 /* Cargo.toml in Sources */ = {isa = PBXBuildFile; fileRef = CAF9AE29BDC33EF4668187A5 /* Cargo.toml */; settings = {COMPILER_FLAGS = \"--bin 'gifski' --features 'binary'\"; }; };\n/* End PBXBuildFile section */\n\n/* Begin PBXBuildRule section */\n\t\tCAF4AE29BDC3AC6C1400ACA8 /* PBXBuildRule */ = {\n\t\t\tisa = PBXBuildRule;\n\t\t\tcompilerSpec = com.apple.compilers.proxy.script;\n\t\t\tdependencyFile = \"$(DERIVED_FILE_DIR)/$(ARCHS)-$(EXECUTABLE_NAME).d\";\n\t\t\tfilePatterns = \"*/Cargo.toml\";\n\t\t\tfileType = pattern.proxy;\n\t\t\tisEditable = 0;\n\t\t\tname = \"Cargo project build\";\n\t\t\toutputFiles = (\n\t\t\t\t\"$(TARGET_BUILD_DIR)/$(EXECUTABLE_NAME)\",\n\t\t\t);\n\t\t\trunOncePerArchitecture = 0;\n\t\t\tscript = (\n\t\t\t\t\"# generated with cargo-xcode 1.11.0\",\n\t\t\t\t\"set -euo pipefail;\",\n\t\t\t\t\"export PATH=\\\"$HOME/.cargo/bin:$PATH:/usr/local/bin:/opt/homebrew/bin\\\";\",\n\t\t\t\t\"# don't use ios/watchos linker for build scripts and proc macros\",\n\t\t\t\t\"export CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER=/usr/bin/ld\",\n\t\t\t\t\"export CARGO_TARGET_X86_64_APPLE_DARWIN_LINKER=/usr/bin/ld\",\n\t\t\t\t\"export NO_COLOR=1\",\n\t\t\t\t\"\",\n\t\t\t\t\"case \\\"$PLATFORM_NAME\\\" in\",\n\t\t\t\t\" \\\"macosx\\\")\",\n\t\t\t\t\"  CARGO_XCODE_TARGET_OS=darwin\",\n\t\t\t\t\"  if [ \\\"${IS_MACCATALYST-NO}\\\" = YES ]; then\",\n\t\t\t\t\"   CARGO_XCODE_TARGET_OS=ios-macabi\",\n\t\t\t\t\"  fi\",\n\t\t\t\t\"  ;;\",\n\t\t\t\t\" \\\"iphoneos\\\") CARGO_XCODE_TARGET_OS=ios ;;\",\n\t\t\t\t\" \\\"iphonesimulator\\\") CARGO_XCODE_TARGET_OS=ios-sim ;;\",\n\t\t\t\t\" \\\"appletvos\\\" | \\\"appletvsimulator\\\") CARGO_XCODE_TARGET_OS=tvos ;;\",\n\t\t\t\t\" \\\"watchos\\\") CARGO_XCODE_TARGET_OS=watchos ;;\",\n\t\t\t\t\" \\\"watchsimulator\\\") CARGO_XCODE_TARGET_OS=watchos-sim ;;\",\n\t\t\t\t\" \\\"xros\\\") CARGO_XCODE_TARGET_OS=visionos ;;\",\n\t\t\t\t\" \\\"xrsimulator\\\") CARGO_XCODE_TARGET_OS=visionos-sim ;;\",\n\t\t\t\t\" *)\",\n\t\t\t\t\"  CARGO_XCODE_TARGET_OS=\\\"$PLATFORM_NAME\\\"\",\n\t\t\t\t\"  echo >&2 \\\"warning: cargo-xcode needs to be updated to handle $PLATFORM_NAME\\\"\",\n\t\t\t\t\"  ;;\",\n\t\t\t\t\"esac\",\n\t\t\t\t\"\",\n\t\t\t\t\"CARGO_XCODE_TARGET_TRIPLES=\\\"\\\"\",\n\t\t\t\t\"CARGO_XCODE_TARGET_FLAGS=\\\"\\\"\",\n\t\t\t\t\"LIPO_ARGS=\\\"\\\"\",\n\t\t\t\t\"for arch in $ARCHS; do\",\n\t\t\t\t\" if [[ \\\"$arch\\\" == \\\"arm64\\\" ]]; then arch=aarch64; fi\",\n\t\t\t\t\" if [[ \\\"$arch\\\" == \\\"i386\\\" && \\\"$CARGO_XCODE_TARGET_OS\\\" != \\\"ios\\\" ]]; then arch=i686; fi\",\n\t\t\t\t\" triple=\\\"${arch}-apple-$CARGO_XCODE_TARGET_OS\\\"\",\n\t\t\t\t\" CARGO_XCODE_TARGET_TRIPLES+=\\\" $triple\\\"\",\n\t\t\t\t\" CARGO_XCODE_TARGET_FLAGS+=\\\" --target=$triple\\\"\",\n\t\t\t\t\" LIPO_ARGS+=\\\"$CARGO_TARGET_DIR/$triple/$CARGO_XCODE_BUILD_PROFILE/$CARGO_XCODE_CARGO_FILE_NAME\",\n\t\t\t\t\"\\\"\",\n\t\t\t\t\"done\",\n\t\t\t\t\"\",\n\t\t\t\t\"echo >&2 \\\"Cargo $CARGO_XCODE_BUILD_PROFILE $ACTION for $PLATFORM_NAME $ARCHS =$CARGO_XCODE_TARGET_TRIPLES; using ${SDK_NAMES:-}. \\\\$PATH is:\\\"\",\n\t\t\t\t\"tr >&2 : '\\\\n' <<<\\\"$PATH\\\"\",\n\t\t\t\t\"\",\n\t\t\t\t\"if command -v rustup &> /dev/null; then\",\n\t\t\t\t\" for triple in $CARGO_XCODE_TARGET_TRIPLES; do\",\n\t\t\t\t\"  if ! rustup target list --installed | grep -Eq \\\"^$triple$\\\"; then\",\n\t\t\t\t\"   echo >&2 \\\"warning: this build requires rustup toolchain for $triple, but it isn't installed (will try rustup next)\\\"\",\n\t\t\t\t\"   rustup target add \\\"$triple\\\" || {\",\n\t\t\t\t\"    echo >&2 \\\"warning: can't install $triple, will try nightly -Zbuild-std\\\";\",\n\t\t\t\t\"    OTHER_INPUT_FILE_FLAGS+=\\\" -Zbuild-std\\\";\",\n\t\t\t\t\"    if [ -z \\\"${RUSTUP_TOOLCHAIN:-}\\\" ]; then\",\n\t\t\t\t\"     export RUSTUP_TOOLCHAIN=nightly\",\n\t\t\t\t\"    fi\",\n\t\t\t\t\"    break;\",\n\t\t\t\t\"   }\",\n\t\t\t\t\"  fi\",\n\t\t\t\t\" done\",\n\t\t\t\t\"fi\",\n\t\t\t\t\"\",\n\t\t\t\t\"if [ \\\"$CARGO_XCODE_BUILD_PROFILE\\\" = release ]; then\",\n\t\t\t\t\" OTHER_INPUT_FILE_FLAGS=\\\"$OTHER_INPUT_FILE_FLAGS --release\\\"\",\n\t\t\t\t\"fi\",\n\t\t\t\t\"\",\n\t\t\t\t\"if [ \\\"$ACTION\\\" = clean ]; then\",\n\t\t\t\t\" cargo clean --verbose --manifest-path=\\\"$SCRIPT_INPUT_FILE\\\" $CARGO_XCODE_TARGET_FLAGS $OTHER_INPUT_FILE_FLAGS;\",\n\t\t\t\t\" rm -f \\\"$SCRIPT_OUTPUT_FILE_0\\\"\",\n\t\t\t\t\" exit 0\",\n\t\t\t\t\"fi\",\n\t\t\t\t\"\",\n\t\t\t\t\"{ cargo build --manifest-path=\\\"$SCRIPT_INPUT_FILE\\\" --features=\\\"${CARGO_XCODE_FEATURES:-}\\\" $CARGO_XCODE_TARGET_FLAGS $OTHER_INPUT_FILE_FLAGS --verbose --message-format=short 2>&1 | sed -E 's/^([^ :]+:[0-9]+:[0-9]+: error)/\\\\1: /' >&2; } || { echo >&2 \\\"$SCRIPT_INPUT_FILE: error: cargo-xcode project build failed; $CARGO_XCODE_TARGET_TRIPLES\\\"; exit 1; }\",\n\t\t\t\t\"\",\n\t\t\t\t\"tr '\\\\n' '\\\\0' <<<\\\"$LIPO_ARGS\\\" | xargs -0 lipo -create -output \\\"$SCRIPT_OUTPUT_FILE_0\\\"\",\n\t\t\t\t\"\",\n\t\t\t\t\"if [ ${LD_DYLIB_INSTALL_NAME:+1} ]; then\",\n\t\t\t\t\" install_name_tool -id \\\"$LD_DYLIB_INSTALL_NAME\\\" \\\"$SCRIPT_OUTPUT_FILE_0\\\"\",\n\t\t\t\t\"fi\",\n\t\t\t\t\"\",\n\t\t\t\t\"DEP_FILE_DST=\\\"$DERIVED_FILE_DIR/${ARCHS}-${EXECUTABLE_NAME}.d\\\"\",\n\t\t\t\t\"echo \\\"\\\" > \\\"$DEP_FILE_DST\\\"\",\n\t\t\t\t\"for triple in $CARGO_XCODE_TARGET_TRIPLES; do\",\n\t\t\t\t\" BUILT_SRC=\\\"$CARGO_TARGET_DIR/$triple/$CARGO_XCODE_BUILD_PROFILE/$CARGO_XCODE_CARGO_FILE_NAME\\\"\",\n\t\t\t\t\"\",\n\t\t\t\t\" # cargo generates a dep file, but for its own path, so append our rename to it\",\n\t\t\t\t\" DEP_FILE_SRC=\\\"$CARGO_TARGET_DIR/$triple/$CARGO_XCODE_BUILD_PROFILE/$CARGO_XCODE_CARGO_DEP_FILE_NAME\\\"\",\n\t\t\t\t\" if [ -f \\\"$DEP_FILE_SRC\\\" ]; then\",\n\t\t\t\t\"  cat \\\"$DEP_FILE_SRC\\\" >> \\\"$DEP_FILE_DST\\\"\",\n\t\t\t\t\" fi\",\n\t\t\t\t\" echo >> \\\"$DEP_FILE_DST\\\" \\\"${SCRIPT_OUTPUT_FILE_0/ /\\\\\\\\ /}: ${BUILT_SRC/ /\\\\\\\\ /}\\\"\",\n\t\t\t\t\"done\",\n\t\t\t\t\"cat \\\"$DEP_FILE_DST\\\"\",\n\t\t\t\t\"\",\n\t\t\t\t\"echo \\\"success: $ACTION of $SCRIPT_OUTPUT_FILE_0 for $CARGO_XCODE_TARGET_TRIPLES\\\"\",\n\t\t\t\t\"\",\n\t\t\t\t\"\",\n\t\t\t);\n\t\t};\n/* End PBXBuildRule section */\n\n/* Begin PBXFileReference section */\n\t\tCA007E4815895A689885C260 /* libgifski_static.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libgifski_static.a; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\tCA013DB14D7B8559E8DD8BDF /* gifski.dylib */ = {isa = PBXFileReference; explicitFileType = \"compiled.mach-o.dylib\"; includeInIndex = 0; path = gifski.dylib; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\tCA026E6D6F94D179B4D3744F /* gifski */ = {isa = PBXFileReference; explicitFileType = \"compiled.mach-o.executable\"; includeInIndex = 0; path = gifski; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\tCAF9AE29BDC33EF4668187A5 /* Cargo.toml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Cargo.toml; sourceTree = \"<group>\"; };\n/* End PBXFileReference section */\n\n/* Begin PBXGroup section */\n\t\tCAF0AE29BDC3D65BC3C892A8 = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tCAF9AE29BDC33EF4668187A5 /* Cargo.toml */,\n\t\t\t\tCAF1AE29BDC322869D176AE5 /* Products */,\n\t\t\t\tCAF2AE29BDC398AF0B5890DB /* Frameworks */,\n\t\t\t);\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tCAF1AE29BDC322869D176AE5 /* Products */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tCA007E4815895A689885C260 /* libgifski_static.a */,\n\t\t\t\tCA013DB14D7B8559E8DD8BDF /* gifski.dylib */,\n\t\t\t\tCA026E6D6F94D179B4D3744F /* gifski */,\n\t\t\t);\n\t\t\tname = Products;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tCAF2AE29BDC398AF0B5890DB /* Frameworks */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t);\n\t\t\tname = Frameworks;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n/* End PBXGroup section */\n\n/* Begin PBXNativeTarget section */\n\t\tCA007E48158959EA34BF617B /* gifski.a (static library) */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = CA007084E6B259EA34BF617B /* Build configuration list for PBXNativeTarget \"gifski.a (static library)\" */;\n\t\t\tbuildPhases = (\n\t\t\t\tCA00A0D466D559EA34BF617B /* Sources */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t\tCAF4AE29BDC3AC6C1400ACA8 /* PBXBuildRule */,\n\t\t\t);\n\t\t\tname = \"gifski.a (static library)\";\n\t\t\tproductName = libgifski_static.a;\n\t\t\tproductReference = CA007E4815895A689885C260 /* libgifski_static.a */;\n\t\t\tproductType = \"com.apple.product-type.library.static\";\n\t\t};\n\t\tCA013DB14D7BA82EB53EFF50 /* gifski.dylib (cdylib) */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = CA017084E6B2A82EB53EFF50 /* Build configuration list for PBXNativeTarget \"gifski.dylib (cdylib)\" */;\n\t\t\tbuildPhases = (\n\t\t\t\tCA01A0D466D5A82EB53EFF50 /* Sources */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t\tCAF4AE29BDC3AC6C1400ACA8 /* PBXBuildRule */,\n\t\t\t);\n\t\t\tname = \"gifski.dylib (cdylib)\";\n\t\t\tproductName = gifski.dylib;\n\t\t\tproductReference = CA013DB14D7B8559E8DD8BDF /* gifski.dylib */;\n\t\t\tproductType = \"com.apple.product-type.library.dynamic\";\n\t\t};\n\t\tCA026E6D6F9462D760BFA4D3 /* gifski (standalone executable) */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = CA027084E6B262D760BFA4D3 /* Build configuration list for PBXNativeTarget \"gifski (standalone executable)\" */;\n\t\t\tbuildPhases = (\n\t\t\t\tCA02A0D466D562D760BFA4D3 /* Sources */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t\tCAF4AE29BDC3AC6C1400ACA8 /* PBXBuildRule */,\n\t\t\t);\n\t\t\tname = \"gifski (standalone executable)\";\n\t\t\tproductName = gifski;\n\t\t\tproductReference = CA026E6D6F94D179B4D3744F /* gifski */;\n\t\t\tproductType = \"com.apple.product-type.tool\";\n\t\t};\n/* End PBXNativeTarget section */\n\n/* Begin PBXProject section */\n\t\tCAF3AE29BDC3E04653AD465F /* Project object */ = {\n\t\t\tisa = PBXProject;\n\t\t\tattributes = {\n\t\t\t\tBuildIndependentTargetsInParallel = YES;\n\t\t\t\tLastUpgradeCheck = 2600;\n\t\t\t\tTargetAttributes = {\n\t\t\t\t\tCA007E48158959EA34BF617B = {\n\t\t\t\t\t\tCreatedOnToolsVersion = 9.2;\n\t\t\t\t\t\tProvisioningStyle = Automatic;\n\t\t\t\t\t};\n\t\t\t\t\tCA013DB14D7BA82EB53EFF50 = {\n\t\t\t\t\t\tCreatedOnToolsVersion = 9.2;\n\t\t\t\t\t\tProvisioningStyle = Automatic;\n\t\t\t\t\t};\n\t\t\t\t\tCA026E6D6F9462D760BFA4D3 = {\n\t\t\t\t\t\tCreatedOnToolsVersion = 9.2;\n\t\t\t\t\t\tProvisioningStyle = Automatic;\n\t\t\t\t\t};\n\t\t\t\t};\n\t\t\t};\n\t\t\tbuildConfigurationList = CAF6AE29BDC380E02D6C7F57 /* Build configuration list for PBXProject \"gifski\" */;\n\t\t\tdevelopmentRegion = en;\n\t\t\thasScannedForEncodings = 0;\n\t\t\tknownRegions = (\n\t\t\t\ten,\n\t\t\t\tBase,\n\t\t\t);\n\t\t\tmainGroup = CAF0AE29BDC3D65BC3C892A8;\n\t\t\tminimizedProjectReferenceProxies = 1;\n\t\t\tpreferredProjectObjectVersion = 90;\n\t\t\tproductRefGroup = CAF1AE29BDC322869D176AE5 /* Products */;\n\t\t\tprojectDirPath = \"\";\n\t\t\tprojectRoot = \"\";\n\t\t\ttargets = (\n\t\t\t\tCA007E48158959EA34BF617B /* gifski.a (static library) */,\n\t\t\t\tCA013DB14D7BA82EB53EFF50 /* gifski.dylib (cdylib) */,\n\t\t\t\tCA026E6D6F9462D760BFA4D3 /* gifski (standalone executable) */,\n\t\t\t);\n\t\t};\n/* End PBXProject section */\n\n/* Begin PBXSourcesBuildPhase section */\n\t\tCA00A0D466D559EA34BF617B /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tfiles = (\n\t\t\t\tCA00E74C7D4159EA34BF617B /* Cargo.toml in Sources */,\n\t\t\t);\n\t\t};\n\t\tCA01A0D466D5A82EB53EFF50 /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tfiles = (\n\t\t\t\tCA01E74C7D41A82EB53EFF50 /* Cargo.toml in Sources */,\n\t\t\t);\n\t\t};\n\t\tCA02A0D466D562D760BFA4D3 /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tfiles = (\n\t\t\t\tCA02E74C7D4162D760BFA4D3 /* Cargo.toml in Sources */,\n\t\t\t);\n\t\t};\n/* End PBXSourcesBuildPhase section */\n\n/* Begin XCBuildConfiguration section */\n\t\tCA008F2BE1C459EA34BF617B /* Debug configuration for PBXNativeTarget \"gifski.a (static library)\" */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tCARGO_XCODE_CARGO_DEP_FILE_NAME = libgifski.d;\n\t\t\t\tCARGO_XCODE_CARGO_FILE_NAME = libgifski.a;\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = YES;\n\t\t\t\tDEAD_CODE_STRIPPING = YES;\n\t\t\t\tINSTALL_GROUP = \"\";\n\t\t\t\tINSTALL_MODE_FLAG = \"\";\n\t\t\t\tINSTALL_OWNER = \"\";\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = 15.3;\n\t\t\t\tPRODUCT_NAME = gifski_static;\n\t\t\t\tSKIP_INSTALL = YES;\n\t\t\t\tSUPPORTED_PLATFORMS = \"xrsimulator xros watchsimulator watchos macosx iphonesimulator iphoneos driverkit appletvsimulator appletvos\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\tCA009A4E111D59EA34BF617B /* Release configuration for PBXNativeTarget \"gifski.a (static library)\" */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tCARGO_XCODE_CARGO_DEP_FILE_NAME = libgifski.d;\n\t\t\t\tCARGO_XCODE_CARGO_FILE_NAME = libgifski.a;\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = YES;\n\t\t\t\tDEAD_CODE_STRIPPING = YES;\n\t\t\t\tINSTALL_GROUP = \"\";\n\t\t\t\tINSTALL_MODE_FLAG = \"\";\n\t\t\t\tINSTALL_OWNER = \"\";\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = 15.3;\n\t\t\t\tPRODUCT_NAME = gifski_static;\n\t\t\t\tSKIP_INSTALL = YES;\n\t\t\t\tSUPPORTED_PLATFORMS = \"xrsimulator xros watchsimulator watchos macosx iphonesimulator iphoneos driverkit appletvsimulator appletvos\";\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\tCA018F2BE1C4A82EB53EFF50 /* Debug configuration for PBXNativeTarget \"gifski.dylib (cdylib)\" */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tCARGO_XCODE_CARGO_DEP_FILE_NAME = libgifski.d;\n\t\t\t\tCARGO_XCODE_CARGO_FILE_NAME = libgifski.dylib;\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = YES;\n\t\t\t\tDEAD_CODE_STRIPPING = YES;\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = 15.3;\n\t\t\t\tPRODUCT_NAME = gifski;\n\t\t\t\tSUPPORTED_PLATFORMS = \"macosx iphonesimulator iphoneos\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\tCA019A4E111DA82EB53EFF50 /* Release configuration for PBXNativeTarget \"gifski.dylib (cdylib)\" */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tCARGO_XCODE_CARGO_DEP_FILE_NAME = libgifski.d;\n\t\t\t\tCARGO_XCODE_CARGO_FILE_NAME = libgifski.dylib;\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = YES;\n\t\t\t\tDEAD_CODE_STRIPPING = YES;\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = 15.3;\n\t\t\t\tPRODUCT_NAME = gifski;\n\t\t\t\tSUPPORTED_PLATFORMS = \"macosx iphonesimulator iphoneos\";\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\tCA028F2BE1C462D760BFA4D3 /* Debug configuration for PBXNativeTarget \"gifski (standalone executable)\" */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tCARGO_XCODE_CARGO_DEP_FILE_NAME = gifski.d;\n\t\t\t\tCARGO_XCODE_CARGO_FILE_NAME = gifski;\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = YES;\n\t\t\t\tDEAD_CODE_STRIPPING = YES;\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = 15.3;\n\t\t\t\tPRODUCT_NAME = gifski;\n\t\t\t\tSUPPORTED_PLATFORMS = macosx;\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\tCA029A4E111D62D760BFA4D3 /* Release configuration for PBXNativeTarget \"gifski (standalone executable)\" */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tCARGO_XCODE_CARGO_DEP_FILE_NAME = gifski.d;\n\t\t\t\tCARGO_XCODE_CARGO_FILE_NAME = gifski;\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = YES;\n\t\t\t\tDEAD_CODE_STRIPPING = YES;\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = 15.3;\n\t\t\t\tPRODUCT_NAME = gifski;\n\t\t\t\tSUPPORTED_PLATFORMS = macosx;\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\tCAF7D702CA573CC16B37690B /* Release configuration for PBXProject \"gifski\" */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\t\"ADDITIONAL_SDKS[sdk=a*]\" = macosx;\n\t\t\t\t\"ADDITIONAL_SDKS[sdk=i*]\" = macosx;\n\t\t\t\t\"ADDITIONAL_SDKS[sdk=w*]\" = macosx;\n\t\t\t\t\"ADDITIONAL_SDKS[sdk=x*]\" = macosx;\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tCARGO_TARGET_DIR = \"$(PROJECT_TEMP_DIR)/cargo_target\";\n\t\t\t\tCARGO_XCODE_BUILD_PROFILE = release;\n\t\t\t\tCARGO_XCODE_FEATURES = \"\";\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\tCURRENT_PROJECT_VERSION = 1.32;\n\t\t\t\tDEAD_CODE_STRIPPING = YES;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tENABLE_USER_SCRIPT_SANDBOXING = NO;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tMARKETING_VERSION = 1.32.1;\n\t\t\t\tPRODUCT_NAME = gifski;\n\t\t\t\tRUSTUP_TOOLCHAIN = \"\";\n\t\t\t\tSDKROOT = macosx;\n\t\t\t\tSTRING_CATALOG_GENERATE_SYMBOLS = YES;\n\t\t\t\tSUPPORTS_MACCATALYST = YES;\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\tCAF8D702CA57228BE02872F8 /* Debug configuration for PBXProject \"gifski\" */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\t\"ADDITIONAL_SDKS[sdk=a*]\" = macosx;\n\t\t\t\t\"ADDITIONAL_SDKS[sdk=i*]\" = macosx;\n\t\t\t\t\"ADDITIONAL_SDKS[sdk=w*]\" = macosx;\n\t\t\t\t\"ADDITIONAL_SDKS[sdk=x*]\" = macosx;\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tCARGO_TARGET_DIR = \"$(PROJECT_TEMP_DIR)/cargo_target\";\n\t\t\t\tCARGO_XCODE_BUILD_PROFILE = debug;\n\t\t\t\tCARGO_XCODE_FEATURES = \"\";\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\tCURRENT_PROJECT_VERSION = 1.32;\n\t\t\t\tDEAD_CODE_STRIPPING = YES;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tENABLE_TESTABILITY = YES;\n\t\t\t\tENABLE_USER_SCRIPT_SANDBOXING = NO;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tMARKETING_VERSION = 1.32.1;\n\t\t\t\tONLY_ACTIVE_ARCH = YES;\n\t\t\t\tPRODUCT_NAME = gifski;\n\t\t\t\tRUSTUP_TOOLCHAIN = \"\";\n\t\t\t\tSDKROOT = macosx;\n\t\t\t\tSTRING_CATALOG_GENERATE_SYMBOLS = YES;\n\t\t\t\tSUPPORTS_MACCATALYST = YES;\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n/* End XCBuildConfiguration section */\n\n/* Begin XCConfigurationList section */\n\t\tCA007084E6B259EA34BF617B /* Build configuration list for PBXNativeTarget \"gifski.a (static library)\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\tCA009A4E111D59EA34BF617B /* Release configuration for PBXNativeTarget \"gifski.a (static library)\" */,\n\t\t\t\tCA008F2BE1C459EA34BF617B /* Debug configuration for PBXNativeTarget \"gifski.a (static library)\" */,\n\t\t\t);\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\tCA017084E6B2A82EB53EFF50 /* Build configuration list for PBXNativeTarget \"gifski.dylib (cdylib)\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\tCA019A4E111DA82EB53EFF50 /* Release configuration for PBXNativeTarget \"gifski.dylib (cdylib)\" */,\n\t\t\t\tCA018F2BE1C4A82EB53EFF50 /* Debug configuration for PBXNativeTarget \"gifski.dylib (cdylib)\" */,\n\t\t\t);\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\tCA027084E6B262D760BFA4D3 /* Build configuration list for PBXNativeTarget \"gifski (standalone executable)\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\tCA029A4E111D62D760BFA4D3 /* Release configuration for PBXNativeTarget \"gifski (standalone executable)\" */,\n\t\t\t\tCA028F2BE1C462D760BFA4D3 /* Debug configuration for PBXNativeTarget \"gifski (standalone executable)\" */,\n\t\t\t);\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\tCAF6AE29BDC380E02D6C7F57 /* Build configuration list for PBXProject \"gifski\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\tCAF7D702CA573CC16B37690B /* Release configuration for PBXProject \"gifski\" */,\n\t\t\t\tCAF8D702CA57228BE02872F8 /* Debug configuration for PBXProject \"gifski\" */,\n\t\t\t);\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n/* End XCConfigurationList section */\n\t};\n\trootObject = CAF3AE29BDC3E04653AD465F /* Project object */;\n}\n"
  },
  {
    "path": "gifski-api/gifski.xcodeproj/project.xcworkspace/contents.xcworkspacedata",
    "content": "<?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",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n   LastUpgradeVersion = \"2600\"\n   version = \"1.7\">\n   <BuildAction\n      parallelizeBuildables = \"YES\"\n      buildImplicitDependencies = \"YES\"\n      buildArchitectures = \"Automatic\">\n      <BuildActionEntries>\n         <BuildActionEntry\n            buildForTesting = \"YES\"\n            buildForRunning = \"YES\"\n            buildForProfiling = \"YES\"\n            buildForArchiving = \"YES\"\n            buildForAnalyzing = \"YES\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"CA007E48158959EA34BF617B\"\n               BuildableName = \"libgifski_static.a\"\n               BlueprintName = \"gifski.a (static library)\"\n               ReferencedContainer = \"container:gifski.xcodeproj\">\n            </BuildableReference>\n         </BuildActionEntry>\n      </BuildActionEntries>\n   </BuildAction>\n   <TestAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      shouldAutocreateTestPlan = \"YES\">\n   </TestAction>\n   <LaunchAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      launchStyle = \"0\"\n      useCustomWorkingDirectory = \"NO\"\n      ignoresPersistentStateOnLaunch = \"NO\"\n      debugDocumentVersioning = \"YES\"\n      debugServiceExtension = \"internal\"\n      allowLocationSimulation = \"YES\">\n   </LaunchAction>\n   <ProfileAction\n      buildConfiguration = \"Release\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      savedToolIdentifier = \"\"\n      useCustomWorkingDirectory = \"NO\"\n      debugDocumentVersioning = \"YES\">\n      <MacroExpansion>\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"CA007E48158959EA34BF617B\"\n            BuildableName = \"libgifski_static.a\"\n            BlueprintName = \"gifski.a (static library)\"\n            ReferencedContainer = \"container:gifski.xcodeproj\">\n         </BuildableReference>\n      </MacroExpansion>\n   </ProfileAction>\n   <AnalyzeAction\n      buildConfiguration = \"Debug\">\n   </AnalyzeAction>\n   <ArchiveAction\n      buildConfiguration = \"Release\"\n      revealArchiveInOrganizer = \"YES\">\n   </ArchiveAction>\n</Scheme>\n"
  },
  {
    "path": "gifski-api/snapcraft.yaml",
    "content": "name: gifski\nsummary: gifski\ndescription: |\n   GIF encoder based on libimagequant (pngquant).\n   Squeezes maximum possible quality from the awful\n   GIF format. https://gif.ski\n\nversion: git\n\ngrade: stable\nbase: core22\nconfinement: strict\n\napps:\n  gifski:\n    command: bin/gifski\n    plugs:\n    - home\n    - removable-media\n\nparts:\n  gifski:\n    source: https://github.com/ImageOptim/gifski.git\n    plugin: rust\n    rust-features:\n      - video\n    build-packages:\n      - pkg-config\n      - ffmpeg\n      - libavcodec-dev\n      - libavdevice-dev\n      - libavfilter-dev\n      - libavformat-dev\n      - libavutil-dev\n      - libswresample-dev\n      - libswscale-dev\n      - libclang-15-dev\n    stage-packages:\n      - ffmpeg\n      - freeglut3 # dep leak from one of ffmpeg dev libs?\n      - libglu1-mesa\n"
  },
  {
    "path": "gifski-api/src/bin/ffmpeg_source.rs",
    "content": "use crate::source::{Fps, Source};\nuse crate::{BinResult, SrcPath};\nuse gifski::{Collector, Settings};\nuse imgref::*;\nuse rgb::*;\n\npub struct FfmpegDecoder {\n    input_context: ffmpeg::format::context::Input,\n    frames: u64,\n    rate: Fps,\n    settings: Settings,\n}\n\nimpl Source for FfmpegDecoder {\n    fn total_frames(&self) -> Option<u64> {\n        Some(self.frames)\n    }\n\n    fn collect(&mut self, dest: &mut Collector) -> BinResult<()> {\n        self.collect_frames(dest)\n    }\n}\n\nimpl FfmpegDecoder {\n    pub fn new(src: SrcPath, rate: Fps, settings: Settings) -> BinResult<Self> {\n        ffmpeg::init().map_err(|e| format!(\"Unable to initialize ffmpeg: {}\", e))?;\n        let input_context = match src {\n            SrcPath::Path(path) => ffmpeg::format::input(&path)\n                .map_err(|e| format!(\"Unable to open video file {}: {}\", path.display(), e))?,\n            SrcPath::Stdin(_) => return Err(\"Video files must be specified as a path on disk. Input via stdin is not supported\".into()),\n        };\n\n        // take fps override into account\n        let filter_fps = rate.fps / rate.speed;\n        let stream = input_context.streams().best(ffmpeg::media::Type::Video).ok_or(\"The file has no video tracks\")?;\n        let time_base = stream.time_base().numerator() as f64 / stream.time_base().denominator() as f64;\n        let frames = (stream.duration() as f64 * time_base * filter_fps as f64).ceil() as u64;\n        Ok(Self { input_context, frames, rate, settings })\n    }\n\n    #[inline(never)]\n    pub fn collect_frames(&mut self, dest: &mut Collector) -> BinResult<()> {\n        let (stream_index, mut decoder, mut filter) = {\n            let filter_fps = self.rate.fps / self.rate.speed;\n            let stream = self.input_context.streams().best(ffmpeg::media::Type::Video).ok_or(\"The file has no video tracks\")?;\n\n            let mut codec_context = ffmpeg::codec::context::Context::new();\n            codec_context.set_parameters(stream.parameters())?;\n            let decoder = codec_context.decoder().video().map_err(|e| format!(\"Unable to decode the codec used in the video: {}\", e))?;\n\n            let (dest_width, dest_height) = self.settings.dimensions_for_image(decoder.width() as _, decoder.height() as _);\n\n            let buffer_args = format!(\"width={}:height={}:video_size={}x{}:pix_fmt={}:time_base={}:sar={}\",\n                dest_width,\n                dest_height,\n                decoder.width(),\n                decoder.height(),\n                decoder.format().descriptor().ok_or(\"ffmpeg format error\")?.name(),\n                stream.time_base(),\n                (|sar: ffmpeg::util::rational::Rational| match sar.numerator() {\n                    0 => \"1\".to_string(),\n                    _ => format!(\"{}/{}\", sar.numerator(), sar.denominator()),\n                })(decoder.aspect_ratio()),\n            );\n            let mut filter = ffmpeg::filter::Graph::new();\n            filter.add(&ffmpeg::filter::find(\"buffer\").ok_or(\"ffmpeg format error\")?, \"in\", &buffer_args)?;\n            filter.add(&ffmpeg::filter::find(\"buffersink\").ok_or(\"ffmpeg format error\")?, \"out\", \"\")?;\n            filter.output(\"in\", 0)?.input(\"out\", 0)?.parse(&format!(\"fps=fps={},format=rgba\", filter_fps))?;\n            filter.validate()?;\n            (stream.index(), decoder, filter)\n        };\n\n        let add_frame = |rgba_frame: &ffmpeg::util::frame::Video, pts: f64, pos: i64| -> BinResult<()> {\n            let stride = rgba_frame.stride(0) as usize;\n            if stride % 4 != 0 {\n                Err(\"incompatible video\")?;\n            }\n            let rgba_frame = ImgVec::new_stride(\n                rgba_frame.data(0).as_rgba().to_owned(),\n                rgba_frame.width() as usize,\n                rgba_frame.height() as usize,\n                stride / 4,\n            );\n            Ok(dest.add_frame_rgba(pos as usize, rgba_frame, pts)?)\n        };\n\n        let mut vid_frame = ffmpeg::util::frame::Video::empty();\n        let mut filt_frame = ffmpeg::util::frame::Video::empty();\n        let mut i = 0;\n        let mut pts_last_packet = 0;\n        let pts_frame_step = 1.0 / self.rate.fps as f64;\n\n        let packets = self.input_context.packets().filter_map(|(s, packet)| {\n            if s.index() != stream_index {\n                // ignore irrelevant streams\n                None\n            } else {\n                pts_last_packet = packet.pts()? + packet.duration();\n                Some(packet)\n            }\n        })\n        // extra packet to flush remaining frames\n        .chain(std::iter::once(ffmpeg::Packet::empty()));\n\n        for packet in packets {\n            decoder.send_packet(&packet)?;\n            loop {\n                match decoder.receive_frame(&mut vid_frame) {\n                    Ok(()) => (),\n                    Err(ffmpeg::Error::Other { errno: ffmpeg::error::EAGAIN }) | Err(ffmpeg::Error::Eof) => break,\n                    Err(e) => return Err(Box::new(e)),\n                }\n                filter.get(\"in\").ok_or(\"ffmpeg format error\")?.source().add(&vid_frame)?;\n                let mut out = filter.get(\"out\").ok_or(\"ffmpeg format error\")?;\n                let mut out = out.sink();\n                while let Ok(..) = out.frame(&mut filt_frame) {\n                    add_frame(&filt_frame, pts_frame_step * i as f64, i)?;\n                    i += 1;\n                }\n            }\n        }\n\n        // now flush filter's buffer\n        filter.get(\"in\").ok_or(\"ffmpeg format error\")?.source().close(pts_last_packet)?;\n        let mut out = filter.get(\"out\").ok_or(\"ffmpeg format error\")?;\n        let mut out = out.sink();\n        while let Ok(..) = out.frame(&mut filt_frame) {\n            add_frame(&filt_frame, pts_frame_step * i as f64, i)?;\n            i += 1;\n        }\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "gifski-api/src/bin/gif_source.rs",
    "content": "//! This is for reading GIFs as an input for re-encoding as another GIF\n\nuse crate::source::{Fps, Source};\nuse crate::{BinResult, SrcPath};\nuse gif::Decoder;\nuse gifski::Collector;\nuse std::io::Read;\n\npub struct GifDecoder {\n    speed: f32,\n    decoder: Decoder<Box<dyn Read>>,\n    screen: gif_dispose::Screen,\n}\n\nimpl GifDecoder {\n    pub fn new(src: SrcPath, fps: Fps) -> BinResult<Self> {\n        let input = match src {\n            SrcPath::Path(path) => Box::new(std::fs::File::open(path)?) as Box<dyn Read>,\n            SrcPath::Stdin(buf) => Box::new(buf),\n        };\n\n        let mut gif_opts = gif::DecodeOptions::new();\n        // Important:\n        gif_opts.set_color_output(gif::ColorOutput::Indexed);\n\n        let decoder = gif_opts.read_info(input)?;\n        let screen = gif_dispose::Screen::new_decoder(&decoder);\n\n        Ok(Self {\n            speed: fps.speed,\n            decoder,\n            screen,\n        })\n    }\n}\n\nimpl Source for GifDecoder {\n    fn total_frames(&self) -> Option<u64> { None }\n    fn collect(&mut self, c: &mut Collector) -> BinResult<()> {\n        let mut idx = 0;\n        let mut delay_ts = 0;\n        while let Some(frame) = self.decoder.read_next_frame()? {\n            self.screen.blit_frame(frame)?;\n            let pixels = self.screen.pixels_rgba().map_buf(|b| b.to_owned());\n            let presentation_timestamp = f64::from(delay_ts) * (1. / (100. * f64::from(self.speed)));\n            c.add_frame_rgba(idx, pixels, presentation_timestamp)?;\n            idx += 1;\n            delay_ts += u32::from(frame.delay);\n        }\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "gifski-api/src/bin/gifski.rs",
    "content": "#![allow(clippy::bool_to_int_with_if)]\n#![allow(clippy::cast_possible_truncation)]\n#![allow(clippy::enum_glob_use)]\n#![allow(clippy::match_same_arms)]\n#![allow(clippy::missing_errors_doc)]\n#![allow(clippy::module_name_repetitions)]\n#![allow(clippy::needless_pass_by_value)]\n#![allow(clippy::redundant_closure_for_method_calls)]\n#![allow(clippy::wildcard_imports)]\n\nuse clap::builder::NonEmptyStringValueParser;\nuse clap::error::ErrorKind::MissingRequiredArgument;\nuse clap::value_parser;\nuse yuv::color::MatrixCoefficients;\nuse gifski::{Repeat, Settings};\nuse std::io::stdin;\nuse std::io::BufRead;\nuse std::io::BufReader;\nuse std::io::IsTerminal;\nuse std::io::Read;\nuse std::io::StdinLock;\nuse std::io::Stdout;\n\n#[cfg(feature = \"video\")]\nmod ffmpeg_source;\nmod gif_source;\nmod png;\nmod source;\nmod y4m_source;\nuse crate::source::Source;\n\nuse gifski::progress::{NoProgress, ProgressReporter};\n\npub type BinResult<T, E = Box<dyn std::error::Error + Send + Sync>> = Result<T, E>;\n\nuse clap::{Arg, ArgAction, Command};\n\nuse std::env;\nuse std::fmt;\nuse std::fs::File;\nuse std::io;\nuse std::path::{Path, PathBuf};\nuse std::thread;\nuse std::time::Duration;\n\n#[cfg(feature = \"video\")]\nconst VIDEO_FRAMES_ARG_HELP: &str = \"one video file supported by FFmpeg, or multiple PNG image files\";\n#[cfg(not(feature = \"video\"))]\nconst VIDEO_FRAMES_ARG_HELP: &str = \"PNG image files for the animation frames, or a .y4m file\";\n\nfn main() {\n    if let Err(e) = bin_main() {\n        eprintln!(\"error: {e}\");\n        if let Some(e) = e.source() {\n            eprintln!(\"error: {e}\");\n        }\n        std::process::exit(1);\n    }\n}\n\n#[allow(clippy::float_cmp)]\nfn bin_main() -> BinResult<()> {\n    let matches = Command::new(clap::crate_name!())\n                        .version(clap::crate_version!())\n                        .about(\"https://gif.ski by Kornel Lesiński\")\n                        .arg_required_else_help(true)\n                        .allow_negative_numbers(true)\n                        .arg(Arg::new(\"output\")\n                            .long(\"output\")\n                            .short('o')\n                            .help(\"Destination file to write to; \\\"-\\\" means stdout\")\n                            .num_args(1)\n                            .value_name(\"a.gif\")\n                            .value_parser(value_parser!(PathBuf))\n                            .required(true))\n                        .arg(Arg::new(\"fps\")\n                            .long(\"fps\")\n                            .short('r')\n                            .help(\"Frame rate of animation. If using PNG files as \\\n                                   input, this means the speed, as all frames are \\\n                                   kept.\\nIf video is used, it will be resampled to \\\n                                   this constant rate by dropping and/or duplicating \\\n                                   frames\")\n                            .value_parser(value_parser!(f32))\n                            .value_name(\"num\")\n                            .default_value(\"20\"))\n                        .arg(Arg::new(\"fast-forward\")\n                            .long(\"fast-forward\")\n                            .help(\"Multiply speed of video by a factor\")\n                            .value_parser(value_parser!(f32))\n                            .value_name(\"x\")\n                            .default_value(\"1\"))\n                        .arg(Arg::new(\"fast\")\n                            .num_args(0)\n                            .action(ArgAction::SetTrue)\n                            .long(\"fast\")\n                            .help(\"50% faster encoding, but 10% worse quality and larger file size\"))\n                        .arg(Arg::new(\"extra\")\n                            .long(\"extra\")\n                            .conflicts_with(\"fast\")\n                            .num_args(0)\n                            .action(ArgAction::SetTrue)\n                            .help(\"50% slower encoding, but 1% better quality and usually larger file size\"))\n                        .arg(Arg::new(\"quality\")\n                            .long(\"quality\")\n                            .short('Q')\n                            .value_name(\"1-100\")\n                            .value_parser(value_parser!(u8).range(1..=100))\n                            .num_args(1)\n                            .default_value(\"90\")\n                            .help(\"Lower quality may give smaller file\"))\n                        .arg(Arg::new(\"motion-quality\")\n                            .long(\"motion-quality\")\n                            .value_name(\"1-100\")\n                            .value_parser(value_parser!(u8).range(1..=100))\n                            .num_args(1)\n                            .help(\"Lower values reduce motion\"))\n                        .arg(Arg::new(\"lossy-quality\")\n                            .long(\"lossy-quality\")\n                            .value_name(\"1-100\")\n                            .value_parser(value_parser!(u8).range(1..=100))\n                            .num_args(1)\n                            .help(\"Lower values introduce noise and streaks\"))\n                        .arg(Arg::new(\"width\")\n                            .long(\"width\")\n                            .short('W')\n                            .num_args(1)\n                            .value_parser(value_parser!(u32))\n                            .value_name(\"px\")\n                            .help(\"Maximum width.\\nBy default anims are limited to about 800x600\"))\n                        .arg(Arg::new(\"height\")\n                            .long(\"height\")\n                            .short('H')\n                            .num_args(1)\n                            .value_parser(value_parser!(u32))\n                            .value_name(\"px\")\n                            .help(\"Maximum height (stretches if the width is also set)\"))\n                        .arg(Arg::new(\"nosort\")\n                            .alias(\"nosort\")\n                            .long(\"no-sort\")\n                            .num_args(0)\n                            .action(ArgAction::SetTrue)\n                            .hide_short_help(true)\n                            .help(\"Use files exactly in the order given, rather than sorted\"))\n                        .arg(Arg::new(\"quiet\")\n                            .long(\"quiet\")\n                            .short('q')\n                            .num_args(0)\n                            .action(ArgAction::SetTrue)\n                            .help(\"Do not display anything on standard output/console\"))\n                        .arg(Arg::new(\"FILES\")\n                            .help(VIDEO_FRAMES_ARG_HELP)\n                            .num_args(1..)\n                            .value_parser(NonEmptyStringValueParser::new())\n                            .use_value_delimiter(false)\n                            .required(true))\n                        .arg(Arg::new(\"repeat\")\n                            .long(\"repeat\")\n                            .help(\"Number of times the animation is repeated (-1 none, 0 forever or <value> repetitions\")\n                            .num_args(1)\n                            .value_parser(value_parser!(i16))\n                            .value_name(\"num\"))\n                        .arg(Arg::new(\"bounce\")\n                            .long(\"bounce\")\n                            .num_args(0)\n                            .action(ArgAction::SetTrue)\n                            .hide_short_help(true)\n                            .help(\"Make animation play forwards then backwards\"))\n                        .arg(Arg::new(\"fixed-color\")\n                            .long(\"fixed-color\")\n                            .help(\"Always include this color in the palette\")\n                            .hide_short_help(true)\n                            .num_args(1)\n                            .action(ArgAction::Append)\n                            .value_parser(parse_colors)\n                            .value_name(\"RGBHEX\"))\n                        .arg(Arg::new(\"matte\")\n                            .long(\"matte\")\n                            .help(\"Background color for semitransparent pixels\")\n                            .num_args(1)\n                            .value_parser(parse_color)\n                            .value_name(\"RGBHEX\"))\n                        .arg(Arg::new(\"y4m-color-override\")\n                            .long(\"y4m-color-override\")\n                            .help(\"The color space of the input YUV4MPEG2 video\\n\\\n                                   Possible values: bt709 fcc bt470bg bt601 smpte240 ycgco\\n\\\n                                   Defaults to bt709 for HD and bt601 for SD resolutions\")\n                            .num_args(1)\n                            .hide_short_help(true)\n                            .value_parser(parse_color_space)\n                            .value_name(\"bt709\"))\n                        .try_get_matches_from(wild::args_os())\n                        .unwrap_or_else(|e| {\n                            if e.kind() == MissingRequiredArgument && !stdin().is_terminal() {\n                                eprintln!(\"If you're trying to pipe a file, use \\\"-\\\" as the input file name\");\n                            }\n                            e.exit()\n                        });\n\n    let mut frames: Vec<&str> = matches.get_many::<String>(\"FILES\").ok_or(\"?\")?.map(|s| s.as_str()).collect();\n    let bounce = matches.get_flag(\"bounce\");\n    if !matches.get_flag(\"nosort\") && frames.len() > 1 {\n        frames.sort_by(|a, b| natord::compare(a, b));\n    }\n    let mut frames: Vec<_> = frames.into_iter().map(PathBuf::from).collect();\n\n    let output_path = DestPath::new(matches.get_one::<PathBuf>(\"output\").ok_or(\"?\")?);\n    let width = matches.get_one::<u32>(\"width\").copied();\n    let height = matches.get_one::<u32>(\"height\").copied();\n    let repeat_int = matches.get_one::<i16>(\"repeat\").copied().unwrap_or(0);\n    let repeat = match repeat_int {\n        -1 => Repeat::Finite(0),\n        0 => Repeat::Infinite,\n        _ => Repeat::Finite(repeat_int as u16),\n    };\n\n    let extra = matches.get_flag(\"extra\");\n    let motion_quality = matches.get_one::<u8>(\"motion-quality\").copied();\n    let lossy_quality = matches.get_one::<u8>(\"lossy-quality\").copied();\n    let fast = matches.get_flag(\"fast\");\n    let settings = Settings {\n        width,\n        height,\n        quality: matches.get_one::<u8>(\"quality\").copied().unwrap_or(100),\n        fast,\n        repeat,\n    };\n    let quiet = matches.get_flag(\"quiet\") || output_path == DestPath::Stdout;\n    let fps: f32 = matches.get_one::<f32>(\"fps\").copied().ok_or(\"?\")?;\n    let speed: f32 = matches.get_one::<f32>(\"fast-forward\").copied().ok_or(\"?\")?;\n    let fixed_colors = matches.get_many::<Vec<rgb::RGB8>>(\"fixed-color\");\n    let matte = matches.get_one::<rgb::RGB8>(\"matte\");\n    let in_color_space = matches.get_one::<MatrixCoefficients>(\"y4m-color-override\").copied();\n\n    let rate = source::Fps { fps, speed };\n\n    if settings.quality < 20 {\n        if settings.quality < 1 {\n            return Err(\"Quality too low\".into());\n        } else if !quiet {\n            eprintln!(\"warning: quality {} will give really bad results\", settings.quality);\n        }\n    } else if settings.quality > 100 {\n        return Err(\"Quality 100 is maximum\".into());\n    }\n\n    if speed > 1000.0 || speed <= 0.0 {\n        return Err(\"Fast-forward must be 0..1000\".into());\n    }\n\n    if fps > 100.0 || fps <= 0.0 {\n        return Err(\"100 fps is maximum\".into());\n    } else if !quiet && fps > 50.0 {\n        eprintln!(\"warning: web browsers support max 50 fps\");\n    }\n\n    check_if_paths_exist(&frames)?;\n\n    std::thread::scope(move |scope| {\n\n    let (mut collector, mut writer) = gifski::new(settings)?;\n    if let Some(fixed_colors) = fixed_colors {\n        for f in fixed_colors.flatten() {\n            writer.add_fixed_color(*f);\n        }\n    }\n    if let Some(matte) = matte {\n        #[allow(deprecated)]\n        writer.set_matte_color(*matte);\n    }\n    if extra {\n        #[allow(deprecated)]\n        writer.set_extra_effort(true);\n    }\n    if let Some(motion_quality) = motion_quality {\n        #[allow(deprecated)]\n        writer.set_motion_quality(motion_quality);\n    }\n    if let Some(lossy_quality) = lossy_quality {\n        #[allow(deprecated)]\n        writer.set_lossy_quality(lossy_quality);\n    }\n\n    let (decoder_ready_send, decoder_ready_recv) = crossbeam_channel::bounded(1);\n\n    let decode_thread = thread::Builder::new().name(\"decode\".into()).spawn_scoped(scope, move || {\n        let mut decoder = if let [path] = &frames[..] {\n            if bounce {\n                eprintln!(\"warning: the bounce flag is supported only for individual files, not pipe or video\");\n            }\n            let mut src = if path.as_os_str() == \"-\" {\n                let fd = stdin().lock();\n                if fd.is_terminal() {\n                    eprintln!(\"warning: used '-' as the input path, but the stdin is a terminal, not a file.\");\n                }\n                SrcPath::Stdin(BufReader::new(fd))\n            } else {\n                SrcPath::Path(path.clone())\n            };\n            match file_type(&mut src).unwrap_or(FileType::Other) {\n                FileType::PNG | FileType::JPEG => return Err(\"Only a single image file was given as an input. This is not enough to make an animation.\".into()),\n                FileType::GIF => {\n                    if !quiet && (width.is_none() && settings.quality > 50) {\n                        eprintln!(\"warning: reading an existing GIF as an input. This can only worsen the quality. Use PNG frames instead.\");\n                    }\n                    Box::new(gif_source::GifDecoder::new(src, rate)?)\n                },\n                _ if path.is_dir() => {\n                    return Err(format!(\"{} is a directory, not a PNG file\", path.display()).into());\n                },\n                other_type => get_video_decoder(other_type, src, rate, in_color_space, settings)?,\n            }\n        } else {\n            if bounce {\n                let mut extra: Vec<_> = frames.iter().skip(1).rev().cloned().collect();\n                frames.append(&mut extra);\n            }\n            if speed != 1.0 {\n                eprintln!(\"warning: --fast-forward option is for videos. It doesn't make sense for images. Use --fps only.\");\n            }\n            let file_type = file_type(&mut SrcPath::Path(frames[0].clone())).unwrap_or(FileType::Other);\n            match file_type {\n                FileType::JPEG => {\n                    return Err(\"JPEG format is unsuitable for conversion to GIF.\\n\\n\\\n                        JPEG's compression artifacts and color space are very problematic for palette-based\\n\\\n                        compression. Please don't use JPEG for making GIF animations. Please re-export\\n\\\n                        your animation using the PNG format.\".into())\n                },\n                FileType::GIF => return unexpected(\"GIF\"),\n                FileType::Y4M => return unexpected(\"Y4M\"),\n                _ => Box::new(png::Lodecoder::new(frames, rate)),\n            }\n        };\n\n        decoder_ready_send.send(decoder.total_frames())?;\n\n        decoder.collect(&mut collector)\n    })?;\n\n    let mut file_tmp;\n    let mut stdio_tmp;\n    let mut print_terminal_err = false;\n    let out: &mut dyn io::Write = match output_path {\n        DestPath::Path(path) => {\n            file_tmp = File::create(path)\n                .map_err(|err| {\n                    let mut msg = format!(\"Can't write to \\\"{}\\\": {err}\", path.display());\n                    let canon = path.canonicalize();\n                    if let Some(parent) = canon.as_deref().unwrap_or(path).parent() {\n                        if parent.as_os_str() != \"\" {\n                            use std::fmt::Write;\n                            match parent.try_exists() {\n                                Ok(true) => {},\n                                Ok(false) => {\n                                    let _ = write!(&mut msg, \" (directory \\\"{}\\\" doesn't exist)\", parent.display());\n                                },\n                                Err(err) => {\n                                    let _ = write!(&mut msg, \" (directory \\\"{}\\\" is not accessible: {err})\", parent.display());\n                                },\n                            }\n                        }\n                    }\n                    msg\n                })?;\n            &mut file_tmp\n        },\n        DestPath::Stdout => {\n            stdio_tmp = io::stdout().lock();\n            print_terminal_err = stdio_tmp.is_terminal();\n            &mut stdio_tmp\n        },\n    };\n\n    let total_frames = match decoder_ready_recv.recv() {\n        Ok(t) => t,\n        Err(_) => {\n            // if the decoder failed to start,\n            // writer won't have any interesting error to report\n            return decode_thread.join().map_err(panic_err)?;\n        }\n    };\n\n    let mut pb;\n    let mut nopb = NoProgress {};\n    let progress: &mut dyn ProgressReporter = if quiet {\n        &mut nopb\n    } else {\n        pb = ProgressBar::new(total_frames);\n        &mut pb\n    };\n\n    if print_terminal_err {\n        eprintln!(\"warning: used '-' as the output path, but the stdout is a terminal, not a file\");\n        std::thread::sleep(Duration::from_secs(3));\n    }\n    let write_result = writer.write(io::BufWriter::new(out), progress);\n    let thread_result = decode_thread.join().map_err(panic_err)?;\n    check_errors(write_result, thread_result)?;\n    progress.done(&format!(\"gifski created {output_path}\"));\n\n    Ok(())\n    })\n}\n\nfn check_errors(err1: Result<(), gifski::Error>, err2: BinResult<()>) -> BinResult<()> {\n    use gifski::Error::*;\n    match err1 {\n        Ok(()) => err2,\n        Err(ThreadSend | Aborted | NoFrames) if err2.is_err() => err2,\n        Err(err1) => Err(err1.into()),\n    }\n}\n\n#[cold]\nfn unexpected(ftype: &'static str) -> BinResult<()> {\n    Err(format!(\"Too many arguments. Unexpectedly got a {ftype} as an input frame. Only PNG format is supported for individual frames.\").into())\n}\n\n#[cold]\nfn panic_err(err: Box<dyn std::any::Any + Send>) -> String {\n    err.downcast::<String>().map(|s| *s)\n    .unwrap_or_else(|e| e.downcast_ref::<&str>().copied().unwrap_or(\"panic\").to_owned())\n}\n\nfn parse_color(c: &str) -> Result<rgb::RGB8, String> {\n    let c = c.trim_matches(|c: char| c.is_ascii_whitespace());\n    let c = c.strip_prefix('#').unwrap_or(c);\n\n    if c.len() != 6 {\n        return Err(format!(\"color must be 6-char hex format, not '{c}'\"));\n    }\n    let mut c = c.as_bytes().chunks_exact(2)\n        .map(|c| u8::from_str_radix(std::str::from_utf8(c).unwrap_or_default(), 16).map_err(|e| e.to_string()));\n    Ok(rgb::RGB8::new(\n        c.next().ok_or_else(String::new)??,\n        c.next().ok_or_else(String::new)??,\n        c.next().ok_or_else(String::new)??,\n    ))\n}\n\nfn parse_colors(colors: &str) -> Result<Vec<rgb::RGB8>, String> {\n    colors.split([' ', ','])\n        .filter(|c| !c.is_empty())\n        .map(parse_color)\n        .collect()\n}\n\n#[test]\nfn color_parser() {\n    assert_eq!(parse_colors(\"#123456 78abCD,, ,\").unwrap(), vec![rgb::RGB8::new(0x12, 0x34, 0x56), rgb::RGB8::new(0x78, 0xab, 0xcd)]);\n    assert!(parse_colors(\"#12345\").is_err());\n}\n\nfn parse_color_space(value: &str) -> Result<MatrixCoefficients, String> {\n    let value = value.to_lowercase();\n    let value = value.trim();\n    let matrix = match value {\n        \"bt709\" => MatrixCoefficients::BT709,\n        \"fcc\" => MatrixCoefficients::FCC,\n        \"bt470bg\" => MatrixCoefficients::BT470BG,\n        \"bt601\" => MatrixCoefficients::BT601,\n        \"smpte240\" => MatrixCoefficients::SMPTE240,\n        \"ycgco\" => MatrixCoefficients::YCgCo,\n        _ => return Err(\"unsupported color space\".into()),\n    };\n    Ok(matrix)\n}\n\n#[allow(clippy::upper_case_acronyms)]\n#[derive(PartialEq)]\nenum FileType {\n    PNG, GIF, JPEG, Y4M, Other,\n}\n\nfn file_type(src: &mut SrcPath) -> BinResult<FileType> {\n    let mut buf = [0; 4];\n    match src {\n        SrcPath::Path(path) => match path.extension() {\n            Some(e) if e.eq_ignore_ascii_case(\"y4m\") => return Ok(FileType::Y4M),\n            Some(e) if e.eq_ignore_ascii_case(\"png\") => return Ok(FileType::PNG),\n            _ => {\n                let mut file = std::fs::File::open(path)?;\n                file.read_exact(&mut buf)?;\n            },\n        },\n        SrcPath::Stdin(stdin) => {\n            let buf_in = stdin.fill_buf()?;\n            let max_len = buf_in.len().min(4);\n            buf[..max_len].copy_from_slice(&buf_in[..max_len]);\n            // don't consume\n        },\n    }\n\n    if &buf == b\"\\x89PNG\" {\n        return Ok(FileType::PNG);\n    }\n    if &buf == b\"GIF8\" {\n        return Ok(FileType::GIF);\n    }\n    if &buf == b\"YUV4\" {\n        return Ok(FileType::Y4M);\n    }\n    if buf[..2] == [0xFF, 0xD8] {\n        return Ok(FileType::JPEG);\n    }\n    Ok(FileType::Other)\n}\n\nfn check_if_paths_exist(paths: &[PathBuf]) -> BinResult<()> {\n    for path in paths {\n        // stdin is ok\n        if path.as_os_str() == \"-\" && paths.len() == 1 {\n            break;\n        }\n        let mut msg = match path.try_exists() {\n            Ok(true) => continue,\n            Ok(false) => format!(\"Unable to find the input file: \\\"{}\\\"\", path.display()),\n            Err(err) => format!(\"Unable to access the input file \\\"{}\\\": {err}\", path.display()),\n        };\n        let canon = path.canonicalize();\n        if let Some(parent) = canon.as_deref().unwrap_or(path).parent() {\n            if parent.as_os_str() != \"\" && matches!(path.try_exists(), Ok(false)) {\n                use std::fmt::Write;\n                if msg.len() > 80 {\n                    msg.push('\\n');\n                }\n                write!(&mut msg, \" (directory \\\"{}\\\" doesn't exist either)\", parent.display())?;\n            }\n        }\n        if path.to_str().is_some_and(|p| p.contains(['*', '?', '['])) {\n            msg += \"\\nThe wildcard pattern did not match any files.\";\n        } else if path.is_relative() {\n            use std::fmt::Write;\n            write!(&mut msg, \" (searched in \\\"{}\\\")\", env::current_dir()?.display())?;\n        }\n        if path.extension() == Some(\"gif\".as_ref()) {\n            msg = format!(\"\\nDid you mean to use -o \\\"{}\\\" to specify it as the output file instead?\", path.display());\n        }\n        return Err(msg.into());\n    }\n    Ok(())\n}\n\n#[derive(PartialEq)]\nenum DestPath<'a> {\n    Path(&'a Path),\n    Stdout,\n}\n\nenum SrcPath {\n    Path(PathBuf),\n    Stdin(BufReader<StdinLock<'static>>),\n}\n\nimpl<'a> DestPath<'a> {\n    pub fn new(path: &'a Path) -> Self {\n        if path.as_os_str() == \"-\" {\n            Self::Stdout\n        } else {\n            Self::Path(Path::new(path))\n        }\n    }\n}\n\nimpl fmt::Display for DestPath<'_> {\n    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {\n        match self {\n            Self::Path(orig_path) => {\n                let abs_path = dunce::canonicalize(orig_path);\n                abs_path.as_ref().map(|p| p.as_path()).unwrap_or(orig_path).display().fmt(f)\n            },\n            Self::Stdout => f.write_str(\"stdout\"),\n        }\n    }\n}\n\n#[cfg(feature = \"video\")]\nfn get_video_decoder(ftype: FileType, src: SrcPath, fps: source::Fps, in_color_space: Option<MatrixCoefficients>, settings: Settings) -> BinResult<Box<dyn Source>> {\n    Ok(if ftype == FileType::Y4M {\n        Box::new(y4m_source::Y4MDecoder::new(src, fps, in_color_space)?)\n    } else {\n        Box::new(ffmpeg_source::FfmpegDecoder::new(src, fps, settings)?)\n    })\n}\n\n#[cfg(not(feature = \"video\"))]\n#[cold]\nfn get_video_decoder(ftype: FileType, src: SrcPath, fps: source::Fps, in_color_space: Option<MatrixCoefficients>, _: Settings) -> BinResult<Box<dyn Source>> {\n    if ftype == FileType::Y4M {\n        Ok(Box::new(y4m_source::Y4MDecoder::new(src, fps, in_color_space)?))\n    } else {\n        let path = match &src {\n            SrcPath::Path(path) => path,\n            SrcPath::Stdin(_) => Path::new(\"video.mp4\"),\n        };\n        let rel_path = path.file_name().map_or(path, Path::new);\n        Err(format!(r#\"Video support is permanently disabled in this distribution of gifski.\n\nThe only 'video' format supported at this time is YUV4MPEG2, which can be piped from ffmpeg:\n\n    ffmpeg -i \"{src}\" -f yuv4mpegpipe - | gifski -o \"{gif}\" -\n\nTo enable full video decoding you need to recompile gifski from source.\nhttps://github.com/imageoptim/gifski\n\nAlternatively, use ffmpeg or other tool to export PNG frames, and then specify\nthe PNG files as input for this executable. Instructions on https://gif.ski\n\"#,\nsrc = path.display(),\ngif = rel_path.with_extension(\"gif\").display()\n).into())\n    }\n}\n\nstruct ProgressBar {\n    pb: pbr::ProgressBar<Stdout>,\n    frames: u64,\n    total: Option<u64>,\n    previous_estimate: u64,\n    displayed_estimate: u64,\n}\nimpl ProgressBar {\n    fn new(total: Option<u64>) -> Self {\n        let mut pb = pbr::ProgressBar::new(total.unwrap_or(100));\n        pb.show_speed = false;\n        pb.show_percent = false;\n        pb.format(\" #_. \");\n        pb.message(\"Frame \");\n        pb.set_max_refresh_rate(Some(Duration::from_millis(250)));\n        Self {\n            pb, frames: 0, total, previous_estimate: 0, displayed_estimate: 0,\n        }\n    }\n}\n\nimpl ProgressReporter for ProgressBar {\n    fn increase(&mut self) -> bool {\n        self.frames += 1;\n        if self.total.is_none() {\n            self.pb.total = (self.frames + 50).max(100);\n        }\n        self.pb.inc();\n        true\n    }\n\n    fn written_bytes(&mut self, bytes: u64) {\n        let min_frames = self.total.map_or(10, |t| (t / 16).clamp(5, 50));\n        if self.frames > min_frames {\n            let total_size = bytes * self.pb.total / self.frames;\n            let new_estimate = if total_size >= self.previous_estimate { total_size } else { (self.previous_estimate + total_size) / 2 };\n            self.previous_estimate = new_estimate;\n            if self.displayed_estimate.abs_diff(new_estimate) > new_estimate / 10 {\n                self.displayed_estimate = new_estimate;\n                let (num, unit, x) = if new_estimate > 1_000_000 {\n                    (new_estimate as f64 / 1_000_000., \"MB\", if new_estimate > 10_000_000 { 0 } else { 1 })\n                } else {\n                    (new_estimate as f64 / 1_000., \"KB\", 0)\n                };\n                self.pb.message(&format!(\"{num:.x$}{unit} GIF; Frame \"));\n            }\n        }\n    }\n\n    fn done(&mut self, msg: &str) {\n        self.pb.finish_print(msg);\n    }\n}\n"
  },
  {
    "path": "gifski-api/src/bin/png.rs",
    "content": "use crate::source::{Fps, Source};\nuse crate::BinResult;\nuse gifski::Collector;\nuse std::path::PathBuf;\n\npub struct Lodecoder {\n    frames: Vec<PathBuf>,\n    fps: f64,\n}\n\nimpl Lodecoder {\n    pub fn new(frames: Vec<PathBuf>, params: Fps) -> Self {\n        Self {\n            frames,\n            fps: f64::from(params.fps) * f64::from(params.speed),\n        }\n    }\n}\n\nimpl Source for Lodecoder {\n    fn total_frames(&self) -> Option<u64> {\n        Some(self.frames.len() as u64)\n    }\n\n    #[inline(never)]\n    fn collect(&mut self, dest: &mut Collector) -> BinResult<()> {\n        let dest = &*dest;\n        let f = std::mem::take(&mut self.frames);\n        for (i, frame) in f.into_iter().enumerate() {\n            dest.add_frame_png_file(i, frame, i as f64 / self.fps)?;\n        }\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "gifski-api/src/bin/source.rs",
    "content": "use crate::BinResult;\nuse gifski::Collector;\n\npub trait Source {\n    fn total_frames(&self) -> Option<u64>;\n    fn collect(&mut self, dest: &mut Collector) -> BinResult<()>;\n}\n\n#[derive(Debug, Copy, Clone)]\npub struct Fps {\n    /// output rate\n    pub fps: f32,\n    /// skip frames\n    pub speed: f32,\n}\n"
  },
  {
    "path": "gifski-api/src/bin/y4m_source.rs",
    "content": "use std::io::BufReader;\nuse std::io::Read;\nuse imgref::ImgVec;\nuse gifski::Collector;\nuse y4m::{Colorspace, Decoder, ParseError};\nuse yuv::color::{MatrixCoefficients, Range};\nuse yuv::convert::RGBConvert;\nuse yuv::YUV;\nuse crate::{SrcPath, BinResult};\nuse crate::source::{Fps, Source};\n\npub struct Y4MDecoder {\n    fps: Fps,\n    in_color_space: Option<MatrixCoefficients>,\n    decoder: Decoder<Box<BufReader<dyn Read>>>,\n    file_size: Option<u64>,\n}\n\nimpl Y4MDecoder {\n    pub fn new(src: SrcPath, fps: Fps, in_color_space: Option<MatrixCoefficients>) -> BinResult<Self> {\n        let mut file_size = None;\n        let reader = match src {\n            SrcPath::Path(path) => {\n                let f = std::fs::File::open(path)?;\n                let m = f.metadata()?;\n                #[cfg(unix)] {\n                    use std::os::unix::fs::MetadataExt;\n                    file_size = Some(m.size());\n                }\n                #[cfg(windows)] {\n                    use std::os::windows::fs::MetadataExt;\n                    file_size = Some(m.file_size());\n                }\n                Box::new(BufReader::new(f)) as Box<BufReader<dyn Read>>\n            },\n            SrcPath::Stdin(buf) => Box::new(buf) as Box<BufReader<dyn Read>>,\n        };\n\n        Ok(Self {\n            file_size,\n            fps,\n            in_color_space,\n            decoder: Decoder::new(reader).map_err(|e| match e {\n                y4m::Error::EOF => \"The y4m file is truncated or invalid\",\n                y4m::Error::BadInput => \"The y4m file contains invalid metadata\",\n                y4m::Error::UnknownColorspace => \"y4m uses an unusual color format that is not supported\",\n                y4m::Error::OutOfMemory => \"Out of memory, or the y4m file has bogus dimensions\",\n                y4m::Error::ParseError(ParseError::InvalidY4M) => \"The input is not a y4m file\",\n                y4m::Error::ParseError(error) => return format!(\"y4m contains invalid data: {error}\"),\n                y4m::Error::IoError(error) => return format!(\"I/O error when reading a y4m file: {error}\"),\n            }.to_string())?,\n        })\n    }\n}\n\nenum Samp {\n    Mono,\n    S1x1,\n    S2x1,\n    S2x2,\n}\n\nimpl Source for Y4MDecoder {\n    fn total_frames(&self) -> Option<u64> {\n        self.file_size.map(|file_size| {\n            let w = self.decoder.get_width();\n            let h = self.decoder.get_height();\n            let d = self.decoder.get_bytes_per_sample();\n            let s = match self.decoder.get_colorspace() {\n                Colorspace::Cmono => 4,\n                Colorspace::Cmono12 => 4,\n                Colorspace::C420 => 6,\n                Colorspace::C420p10 => 6,\n                Colorspace::C420p12 => 6,\n                Colorspace::C420jpeg => 6,\n                Colorspace::C420paldv => 6,\n                Colorspace::C420mpeg2 => 6,\n                Colorspace::C422 => 8,\n                Colorspace::C422p10 => 8,\n                Colorspace::C422p12 => 8,\n                Colorspace::C444 => 12,\n                Colorspace::C444p10 => 12,\n                Colorspace::C444p12 => 12,\n                _ => 12,\n            };\n            file_size.saturating_sub(self.decoder.get_raw_params().len() as _) / (w * h * d * s / 4 + 6) as u64\n        })\n    }\n\n    fn collect(&mut self, c: &mut Collector) -> BinResult<()> {\n        let fps = self.decoder.get_framerate();\n        let frame_time = 1. / (fps.num as f64 / fps.den as f64);\n        let wanted_frame_time = 1. / f64::from(self.fps.fps);\n        let width = self.decoder.get_width();\n        let height = self.decoder.get_height();\n        let raw_params_str = &*String::from_utf8_lossy(self.decoder.get_raw_params()).into_owned();\n        let range = raw_params_str.split_once(\"COLORRANGE=\").map(|(_, r)| {\n            if r.starts_with(\"FULL\") { Range::Full } else { Range::Limited }\n        });\n\n        let matrix = self.in_color_space.unwrap_or({\n            if height <= 480 && width <= 720 { MatrixCoefficients::BT601 } else { MatrixCoefficients::BT709 }\n        });\n\n        let (samp, conv) = match self.decoder.get_colorspace() {\n            Colorspace::Cmono => (Samp::Mono, RGBConvert::<u8>::new(range.unwrap_or(Range::Limited), MatrixCoefficients::Identity)),\n            Colorspace::Cmono12 => return Err(\"Y4M with Cmono12 is not supported yet\".into()),\n            Colorspace::C420 => (Samp::S2x2, RGBConvert::<u8>::new(range.unwrap_or(Range::Limited), matrix)),\n            Colorspace::C420p10 => return Err(\"Y4M with C420p10 is not supported yet\".into()),\n            Colorspace::C420p12 => return Err(\"Y4M with C420p12 is not supported yet\".into()),\n            Colorspace::C420jpeg => (Samp::S2x2, RGBConvert::<u8>::new(range.unwrap_or(Range::Limited), matrix)),\n            Colorspace::C420paldv => (Samp::S2x2, RGBConvert::<u8>::new(range.unwrap_or(Range::Limited), matrix)),\n            Colorspace::C420mpeg2 => (Samp::S2x2, RGBConvert::<u8>::new(range.unwrap_or(Range::Limited), matrix)),\n            Colorspace::C422 => (Samp::S2x1, RGBConvert::<u8>::new(range.unwrap_or(Range::Limited), matrix)),\n            Colorspace::C422p10 => return Err(\"Y4M with C422p10 is not supported yet\".into()),\n            Colorspace::C422p12 => return Err(\"Y4M with C422p12 is not supported yet\".into()),\n            Colorspace::C444 => (Samp::S1x1, RGBConvert::<u8>::new(range.unwrap_or(Range::Limited), matrix)),\n            Colorspace::C444p10 => return Err(\"Y4M with C444p10 is not supported yet\".into()),\n            Colorspace::C444p12 => return Err(\"Y4M with C444p12 is not supported yet\".into()),\n            _ => return Err(format!(\"Y4M uses unsupported color mode {raw_params_str}\").into()),\n        };\n        let conv = conv?;\n        if width == 0 || width > u16::MAX as _ || height == 0 || height > u16::MAX as _ {\n            return Err(\"Video too large\".into());\n        }\n\n        #[cold]\n        fn bad_frame(mode: &str) -> BinResult<()> {\n            Err(format!(\"Bad Y4M frame (using {mode})\").into())\n        }\n\n        let mut idx = 0;\n        let mut presentation_timestamp = 0.0;\n        let mut wanted_pts = 0.0;\n        loop {\n            match self.decoder.read_frame() {\n                Ok(frame) => {\n                    let this_frame_pts = presentation_timestamp / f64::from(self.fps.speed);\n                    presentation_timestamp += frame_time;\n                    if presentation_timestamp < wanted_pts {\n                        continue; // skip a frame\n                    }\n                    wanted_pts += wanted_frame_time;\n\n                    let y = frame.get_y_plane();\n                    if y.is_empty() {\n                        return bad_frame(raw_params_str);\n                    }\n                    let u = frame.get_u_plane();\n                    let v = frame.get_v_plane();\n                    if v.len() != u.len() {\n                        return bad_frame(raw_params_str);\n                    }\n\n                    let mut out = Vec::new();\n                    out.try_reserve(width * height)?;\n                    match samp {\n                        Samp::Mono => todo!(),\n                        Samp::S1x1 => {\n                            if v.len() != y.len() {\n                                return bad_frame(raw_params_str);\n                            }\n\n                            let y = y.chunks_exact(width);\n                            let u = u.chunks_exact(width);\n                            let v = v.chunks_exact(width);\n                            if y.len() != v.len() {\n                                return bad_frame(raw_params_str);\n                            }\n                            for (y, (u, v)) in y.zip(u.zip(v)) {\n                                out.extend(\n                                    y.iter().copied().zip(u.iter().copied().zip(v.iter().copied()))\n                                    .map(|(y, (u, v))| {\n                                        conv.to_rgb(YUV {y, u, v}).with_alpha(255)\n                                    }));\n                            }\n                        },\n                        Samp::S2x1 => {\n                            let y = y.chunks_exact(width);\n                            let u = u.chunks_exact(width.div_ceil(2));\n                            let v = v.chunks_exact(width.div_ceil(2));\n                            if y.len() != v.len() {\n                                return bad_frame(raw_params_str);\n                            }\n                            for (y, (u, v)) in y.zip(u.zip(v)) {\n                                let u = u.iter().copied().flat_map(|x| [x, x]);\n                                let v = v.iter().copied().flat_map(|x| [x, x]);\n                                out.extend(\n                                    y.iter().copied().zip(u.zip(v))\n                                    .map(|(y, (u, v))| {\n                                        conv.to_rgb(YUV {y, u, v}).with_alpha(255)\n                                    }));\n                            }\n                        },\n                        Samp::S2x2 => {\n                            let y = y.chunks_exact(width);\n                            let u = u.chunks_exact(width.div_ceil(2)).flat_map(|r| [r, r]);\n                            let v = v.chunks_exact(width.div_ceil(2)).flat_map(|r| [r, r]);\n                            for (y, (u, v)) in y.zip(u.zip(v)) {\n                                let u = u.iter().copied().flat_map(|x| [x, x]);\n                                let v = v.iter().copied().flat_map(|x| [x, x]);\n                                out.extend(\n                                    y.iter().copied().zip(u.zip(v))\n                                    .map(|(y, (u, v))| {\n                                        conv.to_rgb(YUV {y, u, v}).with_alpha(255)\n                                    }));\n                            }\n                        },\n                    }\n                    if out.len() != width * height {\n                        return bad_frame(raw_params_str);\n                    }\n                    let pixels = ImgVec::new(out, width, height);\n\n                    c.add_frame_rgba(idx, pixels, this_frame_pts)?;\n                    idx += 1;\n                },\n                Err(y4m::Error::EOF) => break,\n                Err(e) => return Err(e.into()),\n            }\n        }\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "gifski-api/src/c_api/c_api_error.rs",
    "content": "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, PartialEq)]\n#[allow(non_camel_case_types)]\n#[allow(clippy::upper_case_acronyms)]\npub enum GifskiError {\n    OK = 0,\n    NULL_ARG,\n    INVALID_STATE,\n    QUANT,\n    GIF,\n    THREAD_LOST,\n    NOT_FOUND,\n    PERMISSION_DENIED,\n    ALREADY_EXISTS,\n    INVALID_INPUT,\n    TIMED_OUT,\n    WRITE_ZERO,\n    INTERRUPTED,\n    UNEXPECTED_EOF,\n    ABORTED,\n    OTHER,\n}\n\nimpl From<GifskiError> for io::Error {\n    #[cold]\n    fn from(g: GifskiError) -> Self {\n        use std::io::ErrorKind as EK;\n        use GifskiError::*;\n        match g {\n            OK => panic!(\"wrong err code\"),\n            NOT_FOUND => EK::NotFound,\n            PERMISSION_DENIED => EK::PermissionDenied,\n            ALREADY_EXISTS => EK::AlreadyExists,\n            INVALID_INPUT => EK::InvalidInput,\n            TIMED_OUT => EK::TimedOut,\n            WRITE_ZERO => EK::WriteZero,\n            INTERRUPTED => EK::Interrupted,\n            UNEXPECTED_EOF => EK::UnexpectedEof,\n            _ => return Self::other(g),\n        }.into()\n    }\n}\n\nimpl From<c_int> for GifskiError {\n    #[cold]\n    fn from(res: c_int) -> Self {\n        use GifskiError::*;\n        match res {\n            x if x == OK as c_int => OK,\n            x if x == NULL_ARG as c_int => NULL_ARG,\n            x if x == INVALID_STATE as c_int => INVALID_STATE,\n            x if x == QUANT as c_int => QUANT,\n            x if x == GIF as c_int => GIF,\n            x if x == THREAD_LOST as c_int => THREAD_LOST,\n            x if x == NOT_FOUND as c_int => NOT_FOUND,\n            x if x == PERMISSION_DENIED as c_int => PERMISSION_DENIED,\n            x if x == ALREADY_EXISTS as c_int => ALREADY_EXISTS,\n            x if x == INVALID_INPUT as c_int => INVALID_INPUT,\n            x if x == TIMED_OUT as c_int => TIMED_OUT,\n            x if x == WRITE_ZERO as c_int => WRITE_ZERO,\n            x if x == INTERRUPTED as c_int => INTERRUPTED,\n            x if x == UNEXPECTED_EOF as c_int => UNEXPECTED_EOF,\n            x if x == ABORTED as c_int => ABORTED,\n            _ => OTHER,\n        }\n    }\n}\n\nimpl From<GifResult<()>> for GifskiError {\n    #[cold]\n    fn from(res: GifResult<()>) -> Self {\n        use crate::error::Error::*;\n        match res {\n            Ok(()) => GifskiError::OK,\n            Err(err) => match err {\n                Quant(_) => GifskiError::QUANT,\n                Pal(_) => GifskiError::GIF,\n                ThreadSend => GifskiError::THREAD_LOST,\n                Io(ref err) => err.kind().into(),\n                Aborted => GifskiError::ABORTED,\n                Gifsicle | Gif(_) => GifskiError::GIF,\n                NoFrames => GifskiError::INVALID_STATE,\n                WrongSize(_) => GifskiError::INVALID_INPUT,\n                PNG(_) => GifskiError::OTHER,\n            },\n        }\n    }\n}\n\nimpl From<io::ErrorKind> for GifskiError {\n    #[cold]\n    fn from(res: io::ErrorKind) -> Self {\n        use std::io::ErrorKind as EK;\n        match res {\n            EK::NotFound => GifskiError::NOT_FOUND,\n            EK::PermissionDenied => GifskiError::PERMISSION_DENIED,\n            EK::AlreadyExists => GifskiError::ALREADY_EXISTS,\n            EK::InvalidInput | EK::InvalidData => GifskiError::INVALID_INPUT,\n            EK::TimedOut => GifskiError::TIMED_OUT,\n            EK::WriteZero => GifskiError::WRITE_ZERO,\n            EK::Interrupted => GifskiError::INTERRUPTED,\n            EK::UnexpectedEof => GifskiError::UNEXPECTED_EOF,\n            _ => GifskiError::OTHER,\n        }\n    }\n}\n\nimpl std::error::Error for GifskiError {}\n\nimpl fmt::Display for GifskiError {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        fmt::Debug::fmt(self, f)\n    }\n}\n"
  },
  {
    "path": "gifski-api/src/c_api.rs",
    "content": "#![allow(clippy::missing_safety_doc)]\n//! How to use from C\n//!\n//! ```c\n//! gifski *g = gifski_new(&(GifskiSettings){\n//!     .quality = 90,\n//! });\n//! gifski_set_file_output(g, \"file.gif\");\n//!\n//! for(int i=0; i < frames; i++) {\n//!      int res = gifski_add_frame_rgba(g, i, width, height, buffer, 5);\n//!      if (res != GIFSKI_OK) break;\n//! }\n//! int res = gifski_finish(g);\n//! if (res != GIFSKI_OK) return;\n//! ```\n//!\n//! It's safe and efficient to call `gifski_add_frame_*` in a loop as fast as you can get frames,\n//! because it blocks and waits until previous frames are written.\n//!\n//!\n//! To cancel processing, make progress callback return 0 and call `gifski_finish()`. The write callback\n//! may still be called between the cancellation and `gifski_finish()` returning.\n//!\n//! To build as a library:\n//!\n//! ```bash\n//! cargo build --release --lib\n//! ```\n//!\n//! it will create `target/release/libgifski.a` (static library)\n//! and `target/release/libgifski.so`/`dylib` or `gifski.dll` (dynamic library)\n//!\n//! Static is recommended.\n//!\n//! To build for iOS:\n//!\n//! ```bash\n//! rustup target add aarch64-apple-ios\n//! cargo build --release --lib --target aarch64-apple-ios\n//! ```\n//!\n//! it will build `target/aarch64-apple-ios/release/libgifski.a` (ignore the warning about cdylib).\n\nuse crate::{Collector, NoProgress, ProgressCallback, ProgressReporter, Repeat, Settings, Writer};\nuse imgref::{Img, ImgVec};\nuse rgb::{RGB8, RGBA8};\nuse std::fs;\nuse std::ffi::{CStr, CString};\nuse std::fs::File;\nuse std::io;\nuse std::io::Write;\nuse std::mem;\nuse std::os::raw::{c_char, c_int, c_void};\nuse std::path::{Path, PathBuf};\nuse std::ptr;\nuse std::slice;\nuse std::thread;\nuse std::sync::{Arc, Mutex};\nmod c_api_error;\nuse self::c_api_error::GifskiError;\nuse std::panic::catch_unwind;\n\n/// Settings for creating a new encoder instance. See `gifski_new`\n#[repr(C)]\n#[derive(Copy, Clone)]\npub struct GifskiSettings {\n    /// Resize to max this width if non-0.\n    pub width: u32,\n    /// Resize to max this height if width is non-0. Note that aspect ratio is not preserved.\n    pub height: u32,\n    /// 1-100, but useful range is 50-100. Recommended to set to 90.\n    pub quality: u8,\n    /// Lower quality, but faster encode.\n    pub fast: bool,\n    /// If negative, looping is disabled. The number of times the sequence is repeated. 0 to loop forever.\n    pub repeat: i16,\n}\n\n#[repr(C)]\n#[derive(Copy, Clone)]\npub struct ARGB8 {\n    pub a: u8,\n    pub r: u8,\n    pub g: u8,\n    pub b: u8,\n}\n\n/// Opaque handle used in methods. Note that the handle pointer is actually `Arc<GifskiHandleInternal>`,\n/// but `Arc::into_raw` is nice enough to point past the counter.\n#[repr(C)]\npub struct GifskiHandle {\n    _opaque: usize,\n}\npub struct GifskiHandleInternal {\n    writer: Mutex<Option<Writer>>,\n    collector: Mutex<Option<Collector>>,\n    progress: Mutex<Option<ProgressCallback>>,\n    error_callback: Mutex<Option<Box<dyn Fn(String) + 'static + Sync + Send>>>,\n    /// Bool set to true when the thread has been set up,\n    /// prevents re-setting of the thread after `finish()`\n    write_thread: Mutex<(bool, Option<thread::JoinHandle<GifskiError>>)>,\n}\n\n/// Call to start the process\n///\n/// See `gifski_add_frame_png_file` and `gifski_end_adding_frames`\n///\n/// Returns a handle for the other functions, or `NULL` on error (if the settings are invalid).\n#[no_mangle]\npub unsafe extern \"C\" fn gifski_new(settings: *const GifskiSettings) -> *const GifskiHandle {\n    let Some(settings) = settings.as_ref() else {\n        return ptr::null_mut();\n    };\n    let s = Settings {\n        width: if settings.width > 0 { Some(settings.width) } else { None },\n        height: if settings.height > 0 { Some(settings.height) } else { None },\n        quality: settings.quality,\n        fast: settings.fast,\n        repeat: if settings.repeat == -1 { Repeat::Finite(0) } else if settings.repeat == 0 { Repeat::Infinite } else { Repeat::Finite(settings.repeat as u16) },\n    };\n\n    if let Ok((collector, writer)) = crate::new(s) {\n        Arc::into_raw(Arc::new(GifskiHandleInternal {\n            writer: Mutex::new(Some(writer)),\n            write_thread: Mutex::new((false, None)),\n            collector: Mutex::new(Some(collector)),\n            progress: Mutex::new(None),\n            error_callback: Mutex::new(None),\n        }))\n        .cast::<GifskiHandle>()\n    } else {\n        ptr::null_mut()\n    }\n}\n\n/// Quality 1-100 of temporal denoising. Lower values reduce motion. Defaults to `settings.quality`.\n///\n/// Only valid immediately after calling `gifski_new`, before any frames are added.\n#[no_mangle]\npub unsafe extern \"C\" fn gifski_set_motion_quality(handle: *mut GifskiHandle, quality: u8) -> GifskiError {\n    let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG };\n\n    if let Ok(Some(w)) = g.writer.lock().as_deref_mut() {\n        #[allow(deprecated)]\n        w.set_motion_quality(quality);\n        GifskiError::OK\n    } else {\n        GifskiError::INVALID_STATE\n    }\n}\n\n/// Quality 1-100 of gifsicle compression. Lower values add noise. Defaults to `settings.quality`.\n///\n/// Has no effect if the `gifsicle` feature hasn't been enabled.\n/// Only valid immediately after calling `gifski_new`, before any frames are added.\n#[no_mangle]\npub unsafe extern \"C\" fn gifski_set_lossy_quality(handle: *mut GifskiHandle, quality: u8) -> GifskiError {\n    let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG };\n\n    if let Ok(Some(w)) = g.writer.lock().as_deref_mut() {\n        #[allow(deprecated)]\n        w.set_lossy_quality(quality);\n        GifskiError::OK\n    } else {\n        GifskiError::INVALID_STATE\n    }\n}\n\n/// If `true`, encoding will be significantly slower, but may look a bit better.\n///\n/// Only valid immediately after calling `gifski_new`, before any frames are added.\n#[no_mangle]\npub unsafe extern \"C\" fn gifski_set_extra_effort(handle: *mut GifskiHandle, extra: bool) -> GifskiError {\n    let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG };\n\n    if let Ok(Some(w)) = g.writer.lock().as_deref_mut() {\n        #[allow(deprecated)]\n        w.set_extra_effort(extra);\n        GifskiError::OK\n    } else {\n        GifskiError::INVALID_STATE\n    }\n}\n\n/// Adds a fixed color that will be kept in the palette at all times.\n///\n/// Only valid immediately after calling `gifski_new`, before any frames are added.\n#[no_mangle]\npub unsafe extern \"C\" fn gifski_add_fixed_color(handle: *mut GifskiHandle, col_r: u8, col_g: u8, col_b: u8) -> GifskiError {\n    let Some(g) = borrow(handle) else {\n        return GifskiError::NULL_ARG;\n    };\n\n    if let Ok(Some(w)) = g.writer.lock().as_deref_mut() {\n        w.add_fixed_color(RGB8::new(col_r, col_g, col_b));\n        GifskiError::OK\n    } else {\n        GifskiError::INVALID_STATE\n    }\n}\n\n/// Adds a frame to the animation. This function is asynchronous.\n///\n/// File path must be valid UTF-8.\n///\n/// `frame_number` orders frames (consecutive numbers starting from 0).\n/// You can add frames in any order, and they will be sorted by their `frame_number`.\n///\n/// Presentation timestamp (PTS) is time in seconds, since start of the file, when this frame is to be displayed.\n/// For a 20fps video it could be `frame_number/20.0`.\n/// Frames with duplicate or out-of-order PTS will be skipped.\n///\n/// The first frame should have PTS=0. If the first frame has PTS > 0, it'll be used as a delay after the last frame.\n///\n/// This function may block and wait until the frame is processed. Make sure to call `gifski_set_write_callback` or `gifski_set_file_output` first to avoid a deadlock.\n///\n/// Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error.\n#[no_mangle]\n#[cfg(feature = \"png\")]\npub unsafe extern \"C\" fn gifski_add_frame_png_file(handle: *const GifskiHandle, frame_number: u32, file_path: *const c_char, presentation_timestamp: f64) -> GifskiError {\n    if file_path.is_null() {\n        return GifskiError::NULL_ARG;\n    }\n    let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG };\n\n    let path = if let Ok(s) = CStr::from_ptr(file_path).to_str() {\n        PathBuf::from(s)\n    } else {\n        return GifskiError::INVALID_INPUT;\n    };\n    if let Ok(Some(c)) = g.collector.lock().as_deref_mut() {\n        c.add_frame_png_file(frame_number as usize, path, presentation_timestamp).into()\n    } else {\n        g.print_error(format!(\"frame {frame_number} can't be added any more, because gifski_end_adding_frames has been called already\"));\n        GifskiError::INVALID_STATE\n    }\n}\n\n/// Pixels is an array width×height×4 bytes large. The array is copied, so you can free/reuse it immediately.\n///\n/// Presentation timestamp (PTS) is time in seconds, since start of the file (at 0), when this frame is to be displayed.\n/// For a 20fps video it could be `frame_number/20.0`.\n/// Frames with duplicate or out-of-order PTS will be skipped.\n///\n/// The first frame should have PTS=0. If the first frame has PTS > 0, it'll be used as a delay after the last frame.\n///\n/// Colors are in sRGB, uncorrelated RGBA, with alpha byte last.\n///\n/// This function may block and wait until the frame is processed. Make sure to call `gifski_set_write_callback` or `gifski_set_file_output` first to avoid a deadlock.\n///\n/// Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error.\n#[no_mangle]\npub unsafe extern \"C\" fn gifski_add_frame_rgba(handle: *const GifskiHandle, frame_number: u32, width: u32, height: u32, pixels: *const RGBA8, presentation_timestamp: f64) -> GifskiError {\n    if pixels.is_null() {\n        return GifskiError::NULL_ARG;\n    }\n    if width == 0 || height == 0 || width > 0xFFFF || height > 0xFFFF {\n        return GifskiError::INVALID_INPUT;\n    }\n    let width = width as usize;\n    let height = height as usize;\n    let pixels = slice::from_raw_parts(pixels, width * height);\n    add_frame_rgba(handle, frame_number, Img::new(pixels.into(), width, height), presentation_timestamp)\n}\n\n/// Same as `gifski_add_frame_rgba`, but with bytes per row arg.\n#[no_mangle]\npub unsafe extern \"C\" fn gifski_add_frame_rgba_stride(handle: *const GifskiHandle, frame_number: u32, width: u32, height: u32, bytes_per_row: u32, pixels: *const RGBA8, presentation_timestamp: f64) -> GifskiError {\n    let (pixels, stride) = match pixels_slice(pixels, width, height, bytes_per_row) {\n        Ok(v) => v,\n        Err(err) => return err,\n    };\n    let img = ImgVec::new_stride(pixels.into(), width as _, height as _, stride);\n    add_frame_rgba(handle, frame_number, img, presentation_timestamp)\n}\n\nunsafe fn pixels_slice<'a, T>(pixels: *const T, width: u32, height: u32, bytes_per_row: u32) -> Result<(&'a [T], usize), GifskiError> {\n    if pixels.is_null() {\n        return Err(GifskiError::NULL_ARG);\n    }\n    let stride = bytes_per_row as usize / mem::size_of::<T>();\n    let width = width as usize;\n    let height = height as usize;\n    if stride < width || width == 0 || height == 0 || width > 0xFFFF || height > 0xFFFF {\n        return Err(GifskiError::INVALID_INPUT);\n    }\n    let pixels = slice::from_raw_parts(pixels, stride * height + width - stride);\n    Ok((pixels, stride))\n}\n\nfn add_frame_rgba(handle: *const GifskiHandle, frame_number: u32, frame: ImgVec<RGBA8>, presentation_timestamp: f64) -> GifskiError {\n    let Some(g) = (unsafe { borrow(handle) }) else { return GifskiError::NULL_ARG };\n\n    if let Ok(Some(c)) = g.collector.lock().as_deref_mut() {\n        c.add_frame_rgba(frame_number as usize, frame, presentation_timestamp).into()\n    } else {\n        g.print_error(format!(\"frame {frame_number} can't be added any more, because gifski_end_adding_frames has been called already\"));\n        GifskiError::INVALID_STATE\n    }\n}\n\n/// Same as `gifski_add_frame_rgba`, except it expects components in ARGB order.\n///\n/// Bytes per row must be multiple of 4 and greater or equal width×4.\n///\n/// Colors are in sRGB, uncorrelated ARGB, with alpha byte first.\n///\n/// `gifski_add_frame_rgba` is preferred over this function.\n#[no_mangle]\npub unsafe extern \"C\" fn gifski_add_frame_argb(handle: *const GifskiHandle, frame_number: u32, width: u32, bytes_per_row: u32, height: u32, pixels: *const ARGB8, presentation_timestamp: f64) -> GifskiError {\n    let (pixels, stride) = match pixels_slice(pixels, width, height, bytes_per_row) {\n        Ok(v) => v,\n        Err(err) => return err,\n    };\n    let width = width as usize;\n    let height = height as usize;\n    let img = ImgVec::new(pixels.chunks(stride).flat_map(|r| r[0..width].iter().map(|p| RGBA8 {\n        r: p.r,\n        g: p.g,\n        b: p.b,\n        a: p.a,\n    })).collect(), width, height);\n    add_frame_rgba(handle, frame_number, img, presentation_timestamp)\n}\n\n/// Same as `gifski_add_frame_rgba`, except it expects RGB components (3 bytes per pixel).\n///\n/// Bytes per row must be multiple of 3 and greater or equal width×3.\n///\n/// Colors are in sRGB, red byte first.\n\n/// This function may block and wait until the frame is processed. Make sure to call `gifski_set_write_callback` first to avoid a deadlock.\n///\n/// `gifski_add_frame_rgba` is preferred over this function.\n#[no_mangle]\npub unsafe extern \"C\" fn gifski_add_frame_rgb(handle: *const GifskiHandle, frame_number: u32, width: u32, bytes_per_row: u32, height: u32, pixels: *const RGB8, presentation_timestamp: f64) -> GifskiError {\n    let (pixels, stride) = match pixels_slice(pixels, width, height, bytes_per_row) {\n        Ok(v) => v,\n        Err(err) => return err,\n    };\n    let width = width as usize;\n    let height = height as usize;\n    let img = ImgVec::new(pixels.chunks(stride).flat_map(|r| r[0..width].iter().map(|&p| p.with_alpha(255))).collect(), width, height);\n    add_frame_rgba(handle, frame_number, img, presentation_timestamp)\n}\n\n/// Get a callback for frame processed, and abort processing if desired.\n///\n/// The callback is called once per input frame,\n/// even if the encoder decides to skip some frames.\n///\n/// It gets arbitrary pointer (`user_data`) as an argument. `user_data` can be `NULL`.\n///\n/// The callback must return `1` to continue processing, or `0` to abort.\n///\n/// The callback must be thread-safe (it will be called from another thread).\n/// It must remain valid at all times, until `gifski_finish` completes.\n///\n/// This function must be called before `gifski_set_file_output()` to take effect.\n#[no_mangle]\npub unsafe extern \"C\" fn gifski_set_progress_callback(handle: *const GifskiHandle, cb: unsafe extern \"C\" fn(*mut c_void) -> c_int, user_data: *mut c_void) -> GifskiError {\n    let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG };\n\n    if g.write_thread.lock().map_or(true, |t| t.0) {\n        g.print_error(\"tried to set progress callback after writing has already started\".into());\n        return GifskiError::INVALID_STATE;\n    }\n    match g.progress.lock() {\n        Ok(mut progress) => {\n            *progress = Some(ProgressCallback::new(cb, user_data));\n            GifskiError::OK\n        },\n        Err(_) => GifskiError::THREAD_LOST,\n    }\n}\n\n/// Get a callback when an error occurs.\n/// This is intended mostly for logging and debugging, not for user interface.\n///\n/// The callback function has the following arguments:\n/// * A `\\0`-terminated C string in UTF-8 encoding. The string is only valid for the duration of the call. Make a copy if you need to keep it.\n/// * An arbitrary pointer (`user_data`). `user_data` can be `NULL`.\n///\n/// The callback must be thread-safe (it will be called from another thread).\n/// It must remain valid at all times, until `gifski_finish` completes.\n///\n/// If the callback is not set, errors will be printed to stderr.\n///\n/// This function must be called before `gifski_set_file_output()` to take effect.\n#[no_mangle]\npub unsafe extern \"C\" fn gifski_set_error_message_callback(handle: *const GifskiHandle, cb: unsafe extern \"C\" fn(*const c_char, *mut c_void), user_data: *mut c_void) -> GifskiError {\n    let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG };\n\n    let user_data = SendableUserData(user_data);\n    match g.error_callback.lock() {\n        Ok(mut error_callback) => {\n            *error_callback = Some(Box::new(move |mut s: String| {\n                s.reserve_exact(1);\n                s.push('\\0');\n                let cstring = CString::from_vec_with_nul(s.into_bytes()).unwrap_or_default();\n                unsafe { cb(cstring.as_ptr(), user_data.clone().0) } // the clone is a no-op, only to force closure to own it\n            }));\n            GifskiError::OK\n        },\n        Err(_) => GifskiError::THREAD_LOST,\n    }\n}\n\n#[derive(Clone)]\nstruct SendableUserData(*mut c_void);\nunsafe impl Send for SendableUserData {}\nunsafe impl Sync for SendableUserData {}\n\n/// Start writing to the `destination`. This has to be called before any frames are added.\n///\n/// This call will not block.\n///\n/// Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error.\n#[no_mangle]\npub unsafe extern \"C\" fn gifski_set_file_output(handle: *const GifskiHandle, destination: *const c_char) -> GifskiError {\n    let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG };\n    catch_unwind(move || {\n        let (file, path) = match prepare_for_file_writing(g, destination) {\n            Ok(res) => res,\n            Err(err) => return err,\n        };\n        gifski_write_thread_start(g, file, Some(path)).err().unwrap_or(GifskiError::OK)\n    })\n    .map_err(move |e| g.print_panic(e)).unwrap_or(GifskiError::THREAD_LOST)\n}\n\nfn prepare_for_file_writing(g: &GifskiHandleInternal, destination: *const c_char) -> Result<(File, PathBuf), GifskiError> {\n    if destination.is_null() {\n        return Err(GifskiError::NULL_ARG);\n    }\n    let path = if let Ok(s) = unsafe { CStr::from_ptr(destination).to_str() } {\n        Path::new(s)\n    } else {\n        return Err(GifskiError::INVALID_INPUT);\n    };\n    let t = g.write_thread.lock().map_err(|_| GifskiError::THREAD_LOST)?;\n    if t.0 {\n        g.print_error(\"tried to start writing for the second time, after it has already started\".into());\n        return Err(GifskiError::INVALID_STATE);\n    }\n    match File::create(path) {\n        Ok(file) => Ok((file, path.into())),\n        Err(err) => Err(err.kind().into()),\n    }\n}\n\nstruct CallbackWriter {\n    cb: unsafe extern \"C\" fn(usize, *const u8, *mut c_void) -> c_int,\n    user_data: *mut c_void,\n}\n\nunsafe impl Send for CallbackWriter {}\n\nimpl io::Write for CallbackWriter {\n    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {\n        match unsafe { (self.cb)(buf.len(), buf.as_ptr(), self.user_data) } {\n            0 => Ok(buf.len()),\n            x => Err(GifskiError::from(x).into()),\n        }\n    }\n\n    fn flush(&mut self) -> io::Result<()> {\n        match unsafe { (self.cb)(0, ptr::null(), self.user_data) } {\n            0 => Ok(()),\n            x => Err(GifskiError::from(x).into()),\n        }\n    }\n}\n\n/// Start writing via callback (any buffer, file, whatever you want). This has to be called before any frames are added.\n/// This call will not block.\n///\n/// The callback function receives 3 arguments:\n///  - size of the buffer to write, in bytes. IT MAY BE ZERO (when it's zero, either do nothing, or flush internal buffers if necessary).\n///  - pointer to the buffer.\n///  - context pointer to arbitrary user data, same as passed in to this function.\n///\n/// The callback should return 0 (`GIFSKI_OK`) on success, and non-zero on error.\n///\n/// The callback function must be thread-safe. It must remain valid at all times, until `gifski_finish` completes.\n///\n/// Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error.\n#[no_mangle]\npub unsafe extern \"C\" fn gifski_set_write_callback(handle: *const GifskiHandle, cb: Option<unsafe extern \"C\" fn(usize, *const u8, *mut c_void) -> c_int>, user_data: *mut c_void) -> GifskiError {\n    let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG };\n    catch_unwind(move || {\n        let Some(cb) = cb else { return GifskiError::NULL_ARG };\n\n        let writer = CallbackWriter { cb, user_data };\n        gifski_write_thread_start(g, writer, None).err().unwrap_or(GifskiError::OK)\n    })\n    .map_err(move |e| g.print_panic(e)).unwrap_or(GifskiError::THREAD_LOST)\n}\n\nfn gifski_write_thread_start<W: 'static +  Write + Send>(g: &GifskiHandleInternal, file: W, path: Option<PathBuf>) -> Result<(), GifskiError> {\n    let mut t = g.write_thread.lock().map_err(|_| GifskiError::THREAD_LOST)?;\n    if t.0 {\n        g.print_error(\"gifski_set_file_output/gifski_set_write_callback has been called already\".into());\n        return Err(GifskiError::INVALID_STATE);\n    }\n    let writer = g.writer.lock().map_err(|_| GifskiError::THREAD_LOST)?.take();\n    let mut user_progress = g.progress.lock().map_err(|_| GifskiError::THREAD_LOST)?.take();\n    let handle = thread::Builder::new().name(\"c-write\".into()).spawn(move || {\n        if let Some(writer) = writer {\n            let progress = user_progress.as_mut().map(|m| m as &mut dyn ProgressReporter);\n            match writer.write(file, progress.unwrap_or(&mut NoProgress {})).into() {\n                res @ (GifskiError::OK | GifskiError::ALREADY_EXISTS) => res,\n                err => {\n                    if let Some(path) = path {\n                        let _ = fs::remove_file(path); // clean up unfinished file\n                    }\n                    err\n                },\n            }\n        } else {\n            eprintln!(\"gifski_set_file_output/gifski_set_write_callback has been called already\");\n            GifskiError::INVALID_STATE\n        }\n    });\n    match handle {\n        Ok(handle) => {\n            *t = (true, Some(handle));\n            Ok(())\n        },\n        Err(_) => Err(GifskiError::THREAD_LOST),\n    }\n}\n\nunsafe fn borrow<'a>(handle: *const GifskiHandle) -> Option<&'a GifskiHandleInternal> {\n    let g = handle.cast::<GifskiHandleInternal>();\n    g.as_ref()\n}\n\n/// The last step:\n///  - stops accepting any more frames (`gifski_add_frame_*` calls are blocked)\n///  - blocks and waits until all already-added frames have finished writing\n///\n/// Returns final status of write operations. Remember to check the return value!\n///\n/// Must always be called, otherwise it will leak memory.\n/// After this call, the handle is freed and can't be used any more.\n///\n/// Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error.\n#[no_mangle]\npub unsafe extern \"C\" fn gifski_finish(g: *const GifskiHandle) -> GifskiError {\n    if g.is_null() {\n        return GifskiError::NULL_ARG;\n    }\n    let g = Arc::from_raw(g.cast::<GifskiHandleInternal>());\n    catch_unwind(|| {\n        match g.collector.lock() {\n            // dropping of the collector (if any) completes writing\n            Ok(mut lock) => *lock = None,\n            Err(_) => {\n                g.print_error(\"warning: collector thread crashed\".into());\n            },\n        }\n\n        let thread = match g.write_thread.lock() {\n            Ok(mut writer) => writer.1.take(),\n            Err(_) => return GifskiError::THREAD_LOST,\n        };\n\n        if let Some(thread) = thread {\n            thread.join().map_err(|e| g.print_panic(e)).unwrap_or(GifskiError::THREAD_LOST)\n        } else {\n            g.print_error(\"warning: gifski_finish called before any output has been set\".into());\n            GifskiError::OK // this will become INVALID_STATE once sync write support is dropped\n        }\n    })\n    .map_err(move |e| g.print_panic(e)).unwrap_or(GifskiError::THREAD_LOST)\n}\n\nimpl GifskiHandleInternal {\n    fn print_error(&self, mut err: String) {\n        if let Ok(Some(cb)) = self.error_callback.lock().as_deref() {\n            cb(err);\n        } else {\n            err.reserve_exact(1);\n            err.push('\\n');\n            let _ = std::io::stderr().write_all(err.as_bytes());\n        }\n    }\n\n    fn print_panic(&self, e: Box<dyn std::any::Any + Send>) {\n        let msg = e.downcast_ref::<String>().map(|s| s.as_str())\n            .or_else(|| e.downcast_ref::<&str>().copied()).unwrap_or(\"unknown panic\");\n        self.print_error(format!(\"writer crashed (this is a bug): {msg}\"));\n    }\n}\n\n#[test]\nfn c_cb() {\n    use rgb::RGB;\n    let g = unsafe {\n        gifski_new(&GifskiSettings {\n            width: 1,\n            height: 1,\n            quality: 100,\n            fast: false,\n            repeat: -1,\n        })\n    };\n    assert!(!g.is_null());\n    let mut write_called = false;\n    unsafe extern \"C\" fn cb(_s: usize, _buf: *const u8, user_data: *mut c_void) -> c_int {\n        let write_called = user_data.cast::<bool>();\n        *write_called = true;\n        0\n    }\n    let mut progress_called = 0u32;\n    unsafe extern \"C\" fn pcb(user_data: *mut c_void) -> c_int {\n        let progress_called = user_data.cast::<u32>();\n        *progress_called += 1;\n        1\n    }\n    unsafe {\n        assert_eq!(GifskiError::OK, gifski_set_progress_callback(g, pcb, ptr::addr_of_mut!(progress_called).cast()));\n        assert_eq!(GifskiError::OK, gifski_set_write_callback(g, Some(cb), ptr::addr_of_mut!(write_called).cast()));\n        assert_eq!(GifskiError::INVALID_STATE, gifski_set_progress_callback(g, pcb, ptr::addr_of_mut!(progress_called).cast()));\n        assert_eq!(GifskiError::OK, gifski_add_frame_rgb(g, 0, 1, 3, 1, &RGB::new(0,0,0), 3.));\n        assert_eq!(GifskiError::OK, gifski_add_frame_rgb(g, 0, 1, 3, 1, &RGB::new(0,0,0), 10.));\n        assert_eq!(GifskiError::OK, gifski_finish(g));\n    }\n    assert!(write_called);\n    assert_eq!(2, progress_called);\n}\n\n#[test]\nfn progress_abort() {\n    use rgb::RGB;\n    let g = unsafe {\n        gifski_new(&GifskiSettings {\n            width: 1,\n            height: 1,\n            quality: 100,\n            fast: false,\n            repeat: -1,\n        })\n    };\n    assert!(!g.is_null());\n    unsafe extern \"C\" fn cb(_size: usize, _buf: *const u8, _user_data: *mut c_void) -> c_int {\n        0\n    }\n    unsafe extern \"C\" fn pcb(_user_data: *mut c_void) -> c_int {\n        0\n    }\n    unsafe {\n        assert_eq!(GifskiError::OK, gifski_set_progress_callback(g, pcb, ptr::null_mut()));\n        assert_eq!(GifskiError::OK, gifski_set_write_callback(g, Some(cb), ptr::null_mut()));\n        assert_eq!(GifskiError::OK, gifski_add_frame_rgb(g, 0, 1, 3, 1, &RGB::new(0, 0, 0), 3.));\n        assert_eq!(GifskiError::OK, gifski_add_frame_rgb(g, 0, 1, 3, 1, &RGB::new(0, 0, 0), 10.));\n        assert_eq!(GifskiError::ABORTED, gifski_finish(g));\n    }\n}\n\n#[test]\nfn cant_write_after_finish() {\n    let g = unsafe { gifski_new(&GifskiSettings {\n        width: 1, height: 1,\n        quality: 100,\n        fast: false,\n        repeat: -1,\n    })};\n    assert!(!g.is_null());\n    unsafe extern \"C\" fn cb(_s: usize, _buf: *const u8, u1: *mut c_void) -> c_int {\n        assert_eq!(u1 as usize, 1);\n        0\n    }\n    unsafe {\n        assert_eq!(GifskiError::OK, gifski_set_write_callback(g, Some(cb), 1 as _));\n        assert_eq!(GifskiError::INVALID_STATE, gifski_finish(g));\n    }\n}\n\n#[test]\nfn c_write_failure_propagated() {\n    use rgb::RGB;\n    let g = unsafe { gifski_new(&GifskiSettings {\n        width: 1, height: 1,\n        quality: 100,\n        fast: false,\n        repeat: -1,\n    })};\n    assert!(!g.is_null());\n    unsafe extern \"C\" fn cb(_s: usize, _buf: *const u8, _user: *mut c_void) -> c_int {\n        GifskiError::WRITE_ZERO as c_int\n    }\n    unsafe {\n        assert_eq!(GifskiError::OK, gifski_set_write_callback(g, Some(cb), ptr::null_mut()));\n        assert_eq!(GifskiError::OK, gifski_add_frame_rgb(g, 0, 1, 3, 1, &RGB::new(0, 0, 0), 5.0));\n        assert_eq!(GifskiError::WRITE_ZERO, gifski_finish(g));\n    }\n}\n\n#[test]\nfn test_error_callback() {\n    let g = unsafe { gifski_new(&GifskiSettings {\n        width: 1, height: 1,\n        quality: 100,\n        fast: false,\n        repeat: -1,\n    })};\n    assert!(!g.is_null());\n    unsafe extern \"C\" fn cb(_s: usize, _buf: *const u8, u1: *mut c_void) -> c_int {\n        assert_eq!(u1 as usize, 1);\n        0\n    }\n    unsafe extern \"C\" fn errcb(msg: *const c_char, user_data: *mut c_void) {\n        let callback_msg = user_data.cast::<Option<String>>();\n        *callback_msg = Some(CStr::from_ptr(msg).to_str().unwrap().to_string());\n    }\n    let mut callback_msg: Option<String> = None;\n    unsafe {\n        assert_eq!(GifskiError::OK, gifski_set_error_message_callback(g, errcb, std::ptr::addr_of_mut!(callback_msg) as _));\n        assert_eq!(GifskiError::OK, gifski_set_write_callback(g, Some(cb), 1 as _));\n        assert_eq!(GifskiError::INVALID_STATE, gifski_set_write_callback(g, Some(cb), 1 as _));\n        assert_eq!(GifskiError::INVALID_STATE, gifski_finish(g));\n        assert_eq!(\"gifski_set_file_output/gifski_set_write_callback has been called already\", callback_msg.unwrap());\n    }\n}\n\n#[test]\nfn cant_write_twice() {\n    let g = unsafe { gifski_new(&GifskiSettings {\n        width: 1, height: 1,\n        quality: 100,\n        fast: false,\n        repeat: -1,\n    })};\n    assert!(!g.is_null());\n    unsafe extern \"C\" fn cb(_s: usize, _buf: *const u8, _user: *mut c_void) -> c_int {\n        GifskiError::WRITE_ZERO as c_int\n    }\n    unsafe {\n        assert_eq!(GifskiError::OK, gifski_set_write_callback(g, Some(cb), ptr::null_mut()));\n        assert_eq!(GifskiError::INVALID_STATE, gifski_set_write_callback(g, Some(cb), ptr::null_mut()));\n    }\n}\n\n#[test]\nfn c_incomplete() {\n    use rgb::RGB;\n    let g = unsafe { gifski_new(&GifskiSettings {\n        width: 0, height: 0,\n        quality: 100,\n        fast: true,\n        repeat: 0,\n    })};\n    assert_eq!(3, mem::size_of::<RGB8>());\n\n    assert!(!g.is_null());\n    unsafe {\n        assert_eq!(GifskiError::NULL_ARG, gifski_add_frame_rgba(g, 0, 1, 1, ptr::null(), 5.0));\n    }\n    extern \"C\" fn cb(_: *mut c_void) -> c_int {\n        1\n    }\n    unsafe {\n        gifski_set_progress_callback(g, cb, ptr::null_mut());\n        assert_eq!(GifskiError::OK, gifski_add_frame_rgba(g, 0, 1, 1, &RGBA8::new(0, 0, 0, 0), 5.0));\n        assert_eq!(GifskiError::OK, gifski_add_frame_rgb(g, 1, 1, 3, 1, &RGB::new(0, 0, 0), 5.0));\n        assert_eq!(GifskiError::OK, gifski_finish(g));\n    }\n}\n"
  },
  {
    "path": "gifski-api/src/collector.rs",
    "content": "//! For adding frames to the encoder\n//!\n//! [`gifski::new()`][crate::new] returns the [`Collector`] that collects animation frames,\n//! and a [`Writer`][crate::Writer] that performs compression and I/O.\n\npub use imgref::ImgVec;\npub use rgb::{RGB8, RGBA8};\n\nuse crate::error::GifResult;\nuse crossbeam_channel::Sender;\n\n#[cfg(feature = \"png\")]\nuse std::path::PathBuf;\n\npub(crate) enum FrameSource {\n    Pixels(ImgVec<RGBA8>),\n    #[cfg(feature = \"png\")]\n    PngData(Vec<u8>),\n    #[cfg(all(feature = \"png\", not(target_arch = \"wasm32\")))]\n    Path(PathBuf),\n}\n\npub(crate) struct InputFrame {\n    /// The pixels to resize and encode\n    pub frame: FrameSource,\n    /// Time in seconds when to display the frame. First frame should start at 0.\n    pub presentation_timestamp: f64,\n    pub frame_index: usize,\n}\n\npub(crate) struct InputFrameResized {\n    /// The pixels to encode\n    pub frame: ImgVec<RGBA8>,\n    /// The same as above, but with smart blur applied (for denoiser)\n    pub frame_blurred: ImgVec<RGB8>,\n    /// Time in seconds when to display the frame. First frame should start at 0.\n    pub presentation_timestamp: f64,\n}\n\n/// Collect frames that will be encoded\n///\n/// Note that writing will finish only when the collector is dropped.\n/// Collect frames on another thread, or call `drop(collector)` before calling `writer.write()`!\npub struct Collector {\n    pub(crate) queue: Sender<InputFrame>,\n}\n\nimpl Collector {\n    /// Frame index starts at 0.\n    ///\n    /// Set each frame (index) only once, but you can set them in any order. However, out-of-order frames\n    /// will be buffered in RAM, and big gaps in frame indices will cause high memory usage.\n    ///\n    /// Presentation timestamp is time in seconds (since file start at 0) when this frame is to be displayed.\n    ///\n    /// If the first frame doesn't start at pts=0, the delay will be used for the last frame.\n    ///\n    /// If this function appears to be stuck after a few frames, it's because [`crate::Writer::write()`] is not running.\n    #[cfg_attr(debug_assertions, track_caller)]\n    pub fn add_frame_rgba(&self, frame_index: usize, frame: ImgVec<RGBA8>, presentation_timestamp: f64) -> GifResult<()> {\n        debug_assert!(frame_index == 0 || presentation_timestamp > 0.);\n        self.queue.send(InputFrame {\n            frame_index,\n            frame: FrameSource::Pixels(frame),\n            presentation_timestamp,\n        })?;\n        Ok(())\n    }\n\n    /// Decode a frame from in-memory PNG-compressed data.\n    ///\n    /// Frame index starts at 0.\n    /// Set each frame (index) only once, but you can set them in any order. However, out-of-order frames\n    /// will be buffered in RAM, and big gaps in frame indices will cause high memory usage.\n    ///\n    /// Presentation timestamp is time in seconds (since file start at 0) when this frame is to be displayed.\n    ///\n    /// If the first frame doesn't start at pts=0, the delay will be used for the last frame.\n    ///\n    /// If this function appears to be stuck after a few frames, it's because [`crate::Writer::write()`] is not running.\n    #[cfg(feature = \"png\")]\n    #[inline]\n    pub fn add_frame_png_data(&self, frame_index: usize, png_data: Vec<u8>, presentation_timestamp: f64) -> GifResult<()> {\n        self.queue.send(InputFrame {\n            frame: FrameSource::PngData(png_data),\n            presentation_timestamp,\n            frame_index,\n        })?;\n        Ok(())\n    }\n\n    /// Read and decode a PNG file from disk.\n    ///\n    /// Frame index starts at 0.\n    /// Set each frame (index) only once, but you can set them in any order.\n    ///\n    /// Presentation timestamp is time in seconds (since file start at 0) when this frame is to be displayed.\n    ///\n    /// If the first frame doesn't start at pts=0, the delay will be used for the last frame.\n    ///\n    /// If this function appears to be stuck after a few frames, it's because [`crate::Writer::write()`] is not running.\n    #[cfg(feature = \"png\")]\n    pub fn add_frame_png_file(&self, frame_index: usize, path: PathBuf, presentation_timestamp: f64) -> GifResult<()> {\n        self.queue.send(InputFrame {\n            frame: FrameSource::Path(path),\n            presentation_timestamp,\n            frame_index,\n        })?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "gifski-api/src/denoise.rs",
    "content": "use std::collections::VecDeque;\nuse crate::PushInCapacity;\npub use imgref::ImgRef;\nuse imgref::ImgVec;\nuse loop9::loop9_img;\nuse rgb::ComponentMap;\nuse rgb::RGB8;\npub use rgb::RGBA8;\n\nconst LOOKAHEAD: usize = 5;\n\n#[derive(Debug, Default, Copy, Clone)]\npub struct Acc {\n    px_blur: [(RGB8, RGB8); LOOKAHEAD],\n    alpha_bits: u8,\n    can_stay_for: u8,\n    stayed_for: u8,\n    /// The last pixel used (currently on screen)\n    bg_set: RGBA8,\n}\n\nimpl Acc {\n    /// Actual pixel + blurred pixel\n    #[inline(always)]\n    pub fn get(&self, idx: usize) -> Option<(RGB8, RGB8)> {\n        if idx >= LOOKAHEAD {\n            debug_assert!(idx < LOOKAHEAD);\n            return None;\n        }\n        if self.alpha_bits & (1 << idx) == 0 {\n            Some(self.px_blur[idx])\n        } else {\n            None\n        }\n    }\n\n    #[inline(always)]\n    pub fn append(&mut self, val: RGBA8, val_blur: RGB8) {\n        for n in 1..LOOKAHEAD {\n            self.px_blur[n - 1] = self.px_blur[n];\n        }\n        self.alpha_bits >>= 1;\n\n        if val.a < 128 {\n            self.alpha_bits |= 1 << (LOOKAHEAD - 1);\n        } else {\n            self.px_blur[LOOKAHEAD - 1] = (val.rgb(), val_blur);\n        }\n    }\n}\n\npub enum Denoised<T> {\n    // Feed more frames\n    NotYet,\n    // No more\n    Done,\n    Frame {\n        frame: ImgVec<RGBA8>,\n        importance_map: ImgVec<u8>,\n        meta: T,\n    },\n}\n\npub struct Denoiser<T> {\n    /// the algo starts outputting on 3rd frame\n    frames: usize,\n    threshold: u32,\n    splat: ImgVec<Acc>,\n    processed: VecDeque<(ImgVec<RGBA8>, ImgVec<u8>)>,\n    metadatas: VecDeque<T>,\n}\n\n#[derive(Debug)]\npub struct WrongSizeError;\n\nimpl<T> Denoiser<T> {\n    #[inline]\n    pub fn new(width: usize, height: usize, quality: u8) -> Result<Self, WrongSizeError> {\n        let area = width.checked_mul(height).ok_or(WrongSizeError)?;\n        let clear = Acc {\n            px_blur: [(RGB8::new(0, 0, 0), RGB8::new(0, 0, 0)); LOOKAHEAD],\n            alpha_bits: (1 << LOOKAHEAD) - 1,\n            bg_set: RGBA8::default(),\n            stayed_for: 0,\n            can_stay_for: 0,\n        };\n        Ok(Self {\n            frames: 0,\n            processed: VecDeque::with_capacity(LOOKAHEAD),\n            metadatas: VecDeque::with_capacity(LOOKAHEAD),\n            threshold: (55 - u32::from(quality) / 2).pow(2),\n            splat: ImgVec::new(vec![clear; area], width, height),\n        })\n    }\n\n    fn quick_append(&mut self, frame: ImgRef<RGBA8>, frame_blurred: ImgRef<RGB8>) {\n        for ((acc, src), src_blur) in self.splat.pixels_mut().zip(frame.pixels()).zip(frame_blurred.pixels()) {\n            acc.append(src, src_blur);\n        }\n    }\n\n    /// Generate last few frames\n    #[inline(never)]\n    pub fn flush(&mut self) {\n        while self.processed.len() < self.metadatas.len() {\n            let mut median1 = Vec::with_capacity(self.splat.width() * self.splat.height());\n            let mut imp_map1 = Vec::with_capacity(self.splat.width() * self.splat.height());\n\n            let odd_frame = self.frames & 1 != 0;\n            for acc in self.splat.pixels_mut() {\n                acc.append(RGBA8::new(0, 0, 0, 0), RGB8::new(0, 0, 0));\n                let (m, i) = acc.next_pixel(self.threshold, odd_frame);\n                median1.push_in_cap(m);\n                imp_map1.push_in_cap(i);\n            }\n\n            // may need to push down first if there were not enough frames to fill the pipeline\n            self.frames += 1;\n            if self.frames >= LOOKAHEAD {\n                let median1 = ImgVec::new(median1, self.splat.width(), self.splat.height());\n                let imp_map1 = ImgVec::new(imp_map1, self.splat.width(), self.splat.height());\n                self.processed.push_front((median1, imp_map1));\n            }\n        }\n    }\n\n    #[cfg(test)]\n    fn push_frame_test(&mut self, frame: ImgRef<RGBA8>, frame_metadata: T) -> Result<(), WrongSizeError> {\n        let frame_blurred = smart_blur(frame);\n        self.push_frame(frame, frame_blurred.as_ref(), frame_metadata)\n    }\n\n    #[inline(never)]\n    pub fn push_frame(&mut self, frame: ImgRef<RGBA8>, frame_blurred: ImgRef<RGB8>, frame_metadata: T) -> Result<(), WrongSizeError> {\n        if frame.width() != self.splat.width() || frame.height() != self.splat.height() {\n            return Err(WrongSizeError);\n        }\n\n        self.metadatas.push_front(frame_metadata);\n\n        self.frames += 1;\n        // Can't output anything yet\n        if self.frames < LOOKAHEAD {\n            self.quick_append(frame, frame_blurred);\n            return Ok(());\n        }\n\n        let mut median = Vec::with_capacity(frame.width() * frame.height());\n        let mut imp_map = Vec::with_capacity(frame.width() * frame.height());\n        let odd_frame = self.frames & 1 != 0;\n        for ((acc, src), src_blur) in self.splat.pixels_mut().zip(frame.pixels()).zip(frame_blurred.pixels()) {\n            acc.append(src, src_blur);\n\n            let (m, i) = acc.next_pixel(self.threshold, odd_frame);\n            median.push_in_cap(m);\n            imp_map.push_in_cap(i);\n        }\n\n        let median = ImgVec::new(median, frame.width(), frame.height());\n        let imp_map = ImgVec::new(imp_map, frame.width(), frame.height());\n        self.processed.push_front((median, imp_map));\n        Ok(())\n    }\n\n    #[inline]\n    pub fn pop(&mut self) -> Denoised<T> {\n        if let Some((frame, importance_map)) = self.processed.pop_back() {\n            let meta = self.metadatas.pop_back().expect(\"meta\");\n            Denoised::Frame { frame, importance_map, meta }\n        } else if !self.metadatas.is_empty() {\n            Denoised::NotYet\n        } else {\n            Denoised::Done\n        }\n    }\n}\n\nimpl Acc {\n    fn next_pixel(&mut self, threshold: u32, odd_frame: bool) -> (RGBA8, u8) {\n        // No previous bg set, so find a new one\n        if let Some((curr, curr_blur)) = self.get(0) {\n            let my_turn = cohort(curr) != odd_frame;\n            let threshold = if my_turn { threshold } else { threshold * 2 };\n            let diff_with_bg = if self.bg_set.a > 0 {\n                let bg = color_diff(self.bg_set.rgb(), curr);\n                let bg_blur = color_diff(self.bg_set.rgb(), curr_blur);\n                if bg < bg_blur { bg } else { (bg + bg_blur) / 2 }\n            } else { 1<<20 };\n\n            if self.stayed_for < self.can_stay_for {\n                // If this is the second, corrective frame, then\n                // give it weight proportional to its staying duration\n                let max = if self.stayed_for > 0 { 0 } else {\n                    [0, 40, 80, 100, 110][self.can_stay_for.min(4) as usize]\n                };\n                // min == 0 may wipe pixels totally clear, so give them at least a second chance,\n                // if quality setting allows\n                let min = match threshold {\n                    0..300 if self.stayed_for < 3 => 1, // q >= 75\n                    300..500 if self.stayed_for < 2 => 1,\n                    400..900 if self.stayed_for < 1 => 1, // q >= 50\n                    _ => 0,\n                };\n                self.stayed_for += 1;\n                return (self.bg_set, pixel_importance(diff_with_bg, threshold, min, max));\n            }\n\n            // if it's still good, keep rolling with it\n            if diff_with_bg < threshold {\n                return (self.bg_set, 0);\n            }\n\n            // See how long this bg can stay\n            let mut stays_frames = 0;\n            for i in 1..LOOKAHEAD {\n                if self.get(i).is_some_and(|(c, blurred)| color_diff(c, curr) < threshold || color_diff(blurred, curr_blur) < threshold) {\n                    stays_frames = i;\n                } else {\n                    break;\n                }\n            }\n\n            // fast path for regular changing pixel\n            if stays_frames == 0 {\n                self.bg_set = curr.with_alpha(255);\n                return (self.bg_set, pixel_importance(diff_with_bg, threshold, 10, 110));\n            }\n            let imp = if stays_frames <= 1 {\n                pixel_importance(diff_with_bg, threshold, 5, 80)\n            } else if stays_frames == 2 {\n                pixel_importance(diff_with_bg, threshold, 15, 190)\n            } else {\n                pixel_importance(diff_with_bg, threshold, 50, 205)\n            };\n\n            // set the new current (bg) color to the median of the frames it matches\n            self.bg_set = get_medians(&self.px_blur, stays_frames).with_alpha(255);\n            // shorten stay-for to use overlapping ranges for smoother transitions\n            self.can_stay_for = (stays_frames as u8).min(LOOKAHEAD as u8 - 1);\n            self.stayed_for = 0;\n            (self.bg_set, imp)\n        } else {\n            // pixels with importance == 0 are totally ignored, but that could skip frames\n            // which need to set background to clear\n            let imp = if self.bg_set.a > 0 {\n                self.bg_set.a = 0;\n                self.can_stay_for = 0;\n                1\n            } else { 0 };\n            (RGBA8::new(0,0,0,0), imp)\n        }\n    }\n}\n\n/// Median of 9 neighboring pixels\nmacro_rules! median_channel {\n    ($top:expr, $mid:expr, $bot:expr, $chan:ident) => {\n        *[\n            if $top.prev.a > 0 { $top.prev.$chan } else { $mid.curr.$chan },\n            if $top.curr.a > 0 { $top.curr.$chan } else { $mid.curr.$chan },\n            if $top.next.a > 0 { $top.next.$chan } else { $mid.curr.$chan },\n            if $mid.prev.a > 0 { $mid.prev.$chan } else { $mid.curr.$chan },\n            $mid.curr.$chan, // if the center pixel is transparent, the result won't be used\n            if $mid.next.a > 0 { $mid.next.$chan } else { $mid.curr.$chan },\n            if $bot.prev.a > 0 { $bot.prev.$chan } else { $mid.curr.$chan },\n            if $bot.curr.a > 0 { $bot.curr.$chan } else { $mid.curr.$chan },\n            if $bot.next.a > 0 { $bot.next.$chan } else { $mid.curr.$chan },\n        ].select_nth_unstable(4).1\n    }\n}\n\n/// Average of 9 neighboring pixels\nmacro_rules! blur_channel {\n    ($top:expr, $mid:expr, $bot:expr, $chan:ident) => {{\n        let mut tmp = 0u16;\n        tmp += u16::from(if $top.prev.a > 0 { $top.prev.$chan } else { $mid.curr.$chan });\n        tmp += u16::from(if $top.curr.a > 0 { $top.curr.$chan } else { $mid.curr.$chan });\n        tmp += u16::from(if $top.next.a > 0 { $top.next.$chan } else { $mid.curr.$chan });\n        tmp += u16::from(if $mid.prev.a > 0 { $mid.prev.$chan } else { $mid.curr.$chan });\n        tmp += u16::from($mid.curr.$chan); // if the center pixel is transparent, the result won't be used\n        tmp += u16::from(if $mid.next.a > 0 { $mid.next.$chan } else { $mid.curr.$chan });\n        tmp += u16::from(if $bot.prev.a > 0 { $bot.prev.$chan } else { $mid.curr.$chan });\n        tmp += u16::from(if $bot.curr.a > 0 { $bot.curr.$chan } else { $mid.curr.$chan });\n        tmp += u16::from(if $bot.next.a > 0 { $bot.next.$chan } else { $mid.curr.$chan });\n        (tmp / 9) as u8\n    }}\n}\n\n#[inline(never)]\npub(crate) fn smart_blur(frame: ImgRef<RGBA8>) -> ImgVec<RGB8> {\n    let mut out = Vec::with_capacity(frame.width() * frame.height());\n    loop9_img(frame, |_, _, top, mid, bot| {\n        out.push_in_cap(if mid.curr.a > 0 {\n            let median_r = median_channel!(top, mid, bot, r);\n            let median_g = median_channel!(top, mid, bot, g);\n            let median_b = median_channel!(top, mid, bot, b);\n\n            let blurred = RGB8::new(median_r, median_g, median_b);\n            if color_diff(mid.curr.rgb(), blurred) < 16 * 16 * 6 {\n                blurred\n            } else {\n                mid.curr.rgb()\n            }\n        } else {\n            RGB8::new(255, 0, 255)\n        });\n    });\n    ImgVec::new(out, frame.width(), frame.height())\n}\n\n#[inline(never)]\npub(crate) fn less_smart_blur(frame: ImgRef<RGBA8>) -> ImgVec<RGB8> {\n    let mut out = Vec::with_capacity(frame.width() * frame.height());\n    loop9_img(frame, |_, _, top, mid, bot| {\n        out.push_in_cap(if mid.curr.a > 0 {\n            let median_r = blur_channel!(top, mid, bot, r);\n            let median_g = blur_channel!(top, mid, bot, g);\n            let median_b = blur_channel!(top, mid, bot, b);\n\n            let blurred = RGB8::new(median_r, median_g, median_b);\n            if color_diff(mid.curr.rgb(), blurred) < 16 * 16 * 6 {\n                blurred\n            } else {\n                mid.curr.rgb()\n            }\n        } else {\n            RGB8::new(255, 0, 255)\n        });\n    });\n    ImgVec::new(out, frame.width(), frame.height())\n}\n\n/// The idea is to split colors into two arbitrary groups, and flip-flop weight between them.\n/// This might help quantization have less unique colors per frame, and catch up in the next frame.\n#[inline(always)]\nfn cohort(color: RGB8) -> bool {\n    (color.r / 2 > color.g) != (color.b > 127)\n}\n\n/// importance = how much it exceeds percetible threshold\n#[inline(always)]\nfn pixel_importance(diff_with_bg: u32, threshold: u32, min: u8, max: u8) -> u8 {\n    debug_assert!((u32::from(min) + u32::from(max)) <= 255);\n    let exceeds = diff_with_bg.saturating_sub(threshold);\n    min + (exceeds.saturating_mul(u32::from(max)) / (threshold.saturating_mul(48))).min(u32::from(max)) as u8\n}\n\n#[inline(always)]\nfn avg8(a: u8, b: u8) -> u8 {\n    ((u16::from(a) + u16::from(b)) / 2) as u8\n}\n\n#[inline(always)]\nfn zip(zip: impl Fn(fn(&(RGB8, RGB8)) -> u8) -> u8) -> RGB8 {\n    RGB8 {\n        r: zip(|px| px.0.r),\n        g: zip(|px| px.0.g),\n        b: zip(|px| px.0.b),\n    }\n}\n\n#[inline(always)]\nfn get_medians(src: &[(RGB8, RGB8); LOOKAHEAD], len_minus_one: usize) -> RGB8 {\n    match len_minus_one {\n        0 => src[0].0,\n        1 => zip(|ch| avg8(ch(&src[0]), ch(&src[1]))),\n        2 => zip(|ch| {\n            let mut tmp: [u8; 3] = std::array::from_fn(|i| ch(&src[i]));\n            tmp.sort_unstable();\n            tmp[1]\n        }),\n        3 => zip(|ch| {\n            let mut tmp: [u8; 3] = std::array::from_fn(|i| ch(&src[i]));\n            tmp.sort_unstable();\n            avg8(tmp[1], tmp[2])\n        }),\n        4 => zip(|ch| {\n            let mut tmp: [u8; 3] = std::array::from_fn(|i| ch(&src[i]));\n            tmp.sort_unstable();\n            tmp[2]\n        }),\n        _ => {\n            debug_assert!(false);\n            src[0].0\n        },\n    }\n}\n\n#[inline]\nfn color_diff(x: RGB8, y: RGB8) -> u32 {\n    let x = x.map(i32::from);\n    let y = y.map(i32::from);\n\n    (x.r - y.r).pow(2) as u32 * 2 +\n    (x.g - y.g).pow(2) as u32 * 3 +\n    (x.b - y.b).pow(2) as u32\n}\n\n#[track_caller]\n#[cfg(test)]\nfn px<T>(f: Denoised<T>) -> (RGBA8, T) {\n    if let Denoised::Frame { frame, meta, .. } = f {\n        (frame.pixels().next().unwrap(), meta)\n    } else { panic!(\"no frame\") }\n}\n\n#[test]\nfn one() {\n    let mut d = Denoiser::new(1, 1, 100).unwrap();\n    let w = RGBA8::new(255, 255, 255, 255);\n    let frame = ImgVec::new(vec![w], 1, 1);\n    let frame_blurred = smart_blur(frame.as_ref());\n\n    d.push_frame(frame.as_ref(), frame_blurred.as_ref(), 0).unwrap();\n    assert!(matches!(d.pop(), Denoised::NotYet));\n    d.flush();\n    assert_eq!(px(d.pop()), (w, 0));\n    assert!(matches!(d.pop(), Denoised::Done));\n}\n\n#[test]\nfn two() {\n    let mut d = Denoiser::new(1,1, 100).unwrap();\n    let w = RGBA8::new(254,253,252,255);\n    let b = RGBA8::new(8,7,0,255);\n    d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), 0).unwrap();\n    d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), 1).unwrap();\n    assert!(matches!(d.pop(), Denoised::NotYet));\n    d.flush();\n    assert_eq!(px(d.pop()), (w, 0));\n    assert_eq!(px(d.pop()), (b, 1));\n    assert!(matches!(d.pop(), Denoised::Done));\n}\n\n#[test]\nfn three() {\n    let mut d = Denoiser::new(1,1, 100).unwrap();\n    let w = RGBA8::new(254,253,252,255);\n    let b = RGBA8::new(8,7,0,255);\n    d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), 0).unwrap();\n    d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), 1).unwrap();\n    d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), 2).unwrap();\n    assert!(matches!(d.pop(), Denoised::NotYet));\n    d.flush();\n    assert_eq!(px(d.pop()), (w, 0));\n    assert_eq!(px(d.pop()), (b, 1));\n    assert_eq!(px(d.pop()), (b, 2));\n    assert!(matches!(d.pop(), Denoised::Done));\n}\n\n#[test]\nfn four() {\n    let mut d = Denoiser::new(1,1, 100).unwrap();\n    let w = RGBA8::new(254,253,252,255);\n    let b = RGBA8::new(8,7,0,255);\n    let t = RGBA8::new(0,0,0,0);\n    d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), 0).unwrap();\n    d.push_frame_test(ImgVec::new(vec![t], 1, 1).as_ref(), 1).unwrap();\n    d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), 2).unwrap();\n    d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), 3).unwrap();\n    assert!(matches!(d.pop(), Denoised::NotYet));\n    d.flush();\n    assert_eq!(px(d.pop()), (w, 0));\n    assert_eq!(px(d.pop()), (t, 1));\n    assert_eq!(px(d.pop()), (b, 2));\n    assert_eq!(px(d.pop()), (w, 3));\n    assert!(matches!(d.pop(), Denoised::Done));\n}\n\n#[test]\nfn five() {\n    let mut d = Denoiser::new(1,1, 100).unwrap();\n    let w = RGBA8::new(254,253,252,255);\n    let b = RGBA8::new(8,7,0,255);\n    let t = RGBA8::new(0,0,0,0);\n    d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), 0).unwrap();\n    d.push_frame_test(ImgVec::new(vec![t], 1, 1).as_ref(), 1).unwrap();\n    d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), 2).unwrap();\n    d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), 3).unwrap();\n    assert!(matches!(d.pop(), Denoised::NotYet));\n    d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), 4).unwrap();\n    assert_eq!(px(d.pop()), (w, 0));\n    d.flush();\n    assert_eq!(px(d.pop()), (t, 1));\n    assert_eq!(px(d.pop()), (b, 2));\n    assert_eq!(px(d.pop()), (b, 3));\n    assert_eq!(px(d.pop()), (w, 4));\n    assert!(matches!(d.pop(), Denoised::Done));\n}\n\n#[test]\nfn six() {\n    let mut d = Denoiser::new(1,1, 100).unwrap();\n    let w = RGBA8::new(254,253,252,255);\n    let b = RGBA8::new(8,7,0,255);\n    let t = RGBA8::new(0,0,0,0);\n    let x = RGBA8::new(4,5,6,255);\n    d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), 0).unwrap();\n    assert!(matches!(d.pop(), Denoised::NotYet));\n    d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), 1).unwrap();\n    assert!(matches!(d.pop(), Denoised::NotYet));\n    d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), 2).unwrap();\n    assert!(matches!(d.pop(), Denoised::NotYet));\n    d.push_frame_test(ImgVec::new(vec![t], 1, 1).as_ref(), 3).unwrap();\n    assert!(matches!(d.pop(), Denoised::NotYet));\n    d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), 4).unwrap();\n    assert_eq!(px(d.pop()), (w, 0));\n    d.push_frame_test(ImgVec::new(vec![x], 1, 1).as_ref(), 5).unwrap();\n    d.flush();\n    assert_eq!(px(d.pop()), (b, 1));\n    assert_eq!(px(d.pop()), (b, 2));\n    assert_eq!(px(d.pop()), (t, 3));\n    assert_eq!(px(d.pop()), (w, 4));\n    assert_eq!(px(d.pop()), (x, 5));\n    assert!(matches!(d.pop(), Denoised::Done));\n}\n\n#[test]\nfn many() {\n    let mut d = Denoiser::new(1,1, 100).unwrap();\n    let w = RGBA8::new(255,254,253,255);\n    let b = RGBA8::new(1,2,3,255);\n    let t = RGBA8::new(0,0,0,0);\n    d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), \"w0\").unwrap();\n    assert!(matches!(d.pop(), Denoised::NotYet));\n    d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), \"w1\").unwrap();\n    assert!(matches!(d.pop(), Denoised::NotYet));\n    d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), \"b2\").unwrap();\n    assert!(matches!(d.pop(), Denoised::NotYet));\n    d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), \"b3\").unwrap();\n    assert!(matches!(d.pop(), Denoised::NotYet));\n    d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), \"b4\").unwrap();\n    assert_eq!(px(d.pop()), (w, \"w0\"));\n    d.push_frame_test(ImgVec::new(vec![t], 1, 1).as_ref(), \"t5\").unwrap();\n    assert_eq!(px(d.pop()), (w, \"w1\"));\n    d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), \"b6\").unwrap();\n    assert_eq!(px(d.pop()), (b, \"b2\"));\n    d.flush();\n    assert_eq!(px(d.pop()), (b, \"b3\"));\n    assert_eq!(px(d.pop()), (b, \"b4\"));\n    assert_eq!(px(d.pop()), (t, \"t5\"));\n    assert_eq!(px(d.pop()), (b, \"b6\"));\n    assert!(matches!(d.pop(), Denoised::Done));\n}\n"
  },
  {
    "path": "gifski-api/src/encoderust.rs",
    "content": "use crate::error::CatResult;\nuse crate::{GIFFrame, Settings, SettingsExt};\nuse rgb::RGB8;\nuse std::cell::Cell;\nuse std::io::Write;\nuse std::iter::repeat;\nuse std::rc::Rc;\n\n#[cfg(feature = \"gifsicle\")]\nuse crate::gifsicle;\n\nstruct CountingWriter<W> {\n    writer: W,\n    written: Rc<Cell<u64>>,\n}\n\nimpl<W: Write> Write for CountingWriter<W> {\n    #[inline(always)]\n    fn write(&mut self, buf: &[u8]) -> Result<usize, std::io::Error> {\n        let len = self.writer.write(buf)?;\n        self.written.set(self.written.get() + len as u64);\n        Ok(len)\n    }\n\n    #[inline(always)]\n    fn flush(&mut self) -> Result<(), std::io::Error> {\n        self.writer.flush()\n    }\n}\n\npub(crate) struct RustEncoder<W: Write> {\n    writer: Option<W>,\n    written: Rc<Cell<u64>>,\n    gif_enc: Option<gif::Encoder<CountingWriter<W>>>,\n}\n\nimpl<W: Write> RustEncoder<W> {\n    pub fn new(writer: W, written: Rc<Cell<u64>>) -> Self {\n        Self {\n            written,\n            writer: Some(writer),\n            gif_enc: None,\n        }\n    }\n}\n\nimpl<W: Write> RustEncoder<W> {\n    #[inline(never)]\n    #[cfg_attr(debug_assertions, track_caller)]\n    pub fn compress_frame(f: GIFFrame, settings: &SettingsExt) -> CatResult<gif::Frame<'static>> {\n        let GIFFrame {left, top, pal, image, dispose, transparent_index} = f;\n\n        let (buffer, width, height) = image.into_contiguous_buf();\n\n        let mut pal_rgb = rgb::bytemuck::cast_slice(&pal).to_vec();\n        // Palette should be power-of-two sized\n        if pal.len() != 256 {\n            let needed_size = 3 * pal.len().max(2).next_power_of_two();\n            pal_rgb.extend(repeat([115, 107, 105, 46, 103, 105, 102]).flatten().take(needed_size - pal_rgb.len()));\n            debug_assert_eq!(needed_size, pal_rgb.len());\n        }\n        let mut frame = gif::Frame {\n            delay: 1, // TBD\n            dispose,\n            transparent: transparent_index,\n            needs_user_input: false,\n            top,\n            left,\n            width: width as u16,\n            height: height as u16,\n            interlaced: false,\n            palette: Some(pal_rgb),\n            buffer: buffer.into(),\n        };\n\n        #[allow(unused)]\n        let loss = settings.gifsicle_loss();\n        #[cfg(feature = \"gifsicle\")]\n        if loss > 0 {\n            Self::compress_gifsicle(&mut frame, loss)?;\n            return Ok(frame);\n        }\n\n        frame.make_lzw_pre_encoded();\n        Ok(frame)\n    }\n\n    #[cfg(feature = \"gifsicle\")]\n    #[inline(never)]\n    fn compress_gifsicle(frame: &mut gif::Frame<'static>, loss: u32) -> CatResult<()> {\n        use crate::Error;\n        use gifsicle::{GiflossyImage, GiflossyWriter};\n\n        let pal = frame.palette.as_ref().ok_or(Error::Gifsicle)?;\n        let g_pal = pal.chunks_exact(3)\n            .map(|c| RGB8 {\n                r: c[0],\n                g: c[1],\n                b: c[2],\n            })\n            .collect::<Vec<_>>();\n\n        let gif_img = GiflossyImage::new(&frame.buffer, frame.width, frame.height, frame.transparent, Some(&g_pal));\n\n        let mut lossy_writer = GiflossyWriter { loss };\n\n        frame.buffer = lossy_writer.write(&gif_img, None)?.into();\n        Ok(())\n    }\n\n    pub fn write_frame(&mut self, mut frame: gif::Frame<'static>, delay: u16, screen_width: u16, screen_height: u16, settings: &Settings) -> CatResult<()> {\n        frame.delay = delay; // the delay wasn't known\n\n        let writer = &mut self.writer;\n        let enc = match self.gif_enc {\n            None => {\n                let w = CountingWriter {\n                    writer: writer.take().ok_or(crate::Error::ThreadSend)?,\n                    written: self.written.clone(),\n                };\n                let mut enc = gif::Encoder::new(w, screen_width, screen_height, &[])?;\n                enc.write_extension(gif::ExtensionData::Repetitions(settings.repeat))?;\n                enc.write_raw_extension(gif::Extension::Comment.into(), &[b\"gif.ski\"])?;\n                self.gif_enc.get_or_insert(enc)\n            },\n            Some(ref mut enc) => enc,\n        };\n\n        enc.write_lzw_pre_encoded_frame(&frame)?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "gifski-api/src/error.rs",
    "content": "use crate::WrongSizeError;\nuse quick_error::quick_error;\nuse std::io;\nuse std::num::TryFromIntError;\n\nquick_error! {\n    #[derive(Debug)]\n    pub enum Error {\n        /// Internal error\n        ThreadSend {\n            display(\"Internal error; unexpectedly aborted\")\n        }\n        Aborted {\n            display(\"aborted\")\n        }\n        Gifsicle {\n            display(\"gifsicle failure\")\n        }\n        Gif(err: gif::EncodingError) {\n            display(\"GIF encoding error: {}\", err)\n        }\n        NoFrames {\n            display(\"Found no usable frames to encode\")\n        }\n        Io(err: io::Error) {\n            from()\n            from(_oom: std::collections::TryReserveError) -> (io::ErrorKind::OutOfMemory.into())\n            display(\"I/O: {}\", err)\n        }\n        PNG(msg: String) {\n            display(\"{}\", msg)\n        }\n        WrongSize(msg: String) {\n            display(\"{}\", msg)\n            from(e: TryFromIntError) -> (e.to_string())\n            from(_e: WrongSizeError) -> (\"wrong size\".to_string())\n            from(e: resize::Error) -> (e.to_string())\n        }\n        Quant(liq: imagequant::liq_error) {\n            from()\n            display(\"pngquant error: {}\", liq)\n        }\n        Pal(gif: gif_dispose::Error) {\n            from()\n            display(\"gif dispose error: {}\", gif)\n        }\n    }\n}\n\n#[doc(hidden)]\npub type CatResult<T, E = Error> = Result<T, E>;\n\n/// Alias for `Result` with gifski's [`Error`]\npub type GifResult<T, E = Error> = Result<T, E>;\n\nimpl From<gif::EncodingError> for Error {\n    #[cold]\n    fn from(err: gif::EncodingError) -> Self {\n        match err {\n            gif::EncodingError::Io(err) => err.into(),\n            other => Self::Gif(other),\n        }\n    }\n}\n\nimpl<T> From<ordered_channel::SendError<T>> for Error {\n    #[cold]\n    fn from(_: ordered_channel::SendError<T>) -> Self {\n        Self::ThreadSend\n    }\n}\n\nimpl From<ordered_channel::RecvError> for Error {\n    #[cold]\n    fn from(_: ordered_channel::RecvError) -> Self {\n        Self::Aborted\n    }\n}\n\nimpl From<Box<dyn std::any::Any + Send>> for Error {\n    #[cold]\n    fn from(_panic: Box<dyn std::any::Any + Send>) -> Self {\n        Self::ThreadSend\n    }\n}\n"
  },
  {
    "path": "gifski-api/src/gifsicle.rs",
    "content": "pub struct GiflossyImage<'data> {\n    img: &'data [u8],\n    width: u16,\n    height: u16,\n    interlace: bool,\n    transparent: Option<u8>,\n    pal: Option<&'data [RGB8]>,\n}\n\nuse rgb::RGB8;\n\nuse crate::Error;\npub type LzwCode = u16;\n\n#[derive(Clone, Copy)]\npub struct GiflossyWriter {\n    pub loss: u32,\n}\n\nstruct CodeTable {\n    pub nodes: Vec<Node>,\n    pub links_used: usize,\n    pub clear_code: LzwCode,\n}\n\ntype NodeId = u16;\n\nstruct Node {\n    pub code: LzwCode,\n    pub suffix: u8,\n    pub children: Vec<NodeId>,\n}\n\ntype RgbDiff = rgb::RGB<i16>;\n\n#[inline]\nfn color_diff(a: RGB8, b: RGB8, a_transparent: bool, b_transparent: bool, dither: RgbDiff) -> u32 {\n    if a_transparent != b_transparent {\n        return (1 << 25) as u32;\n    }\n    if a_transparent {\n        return 0;\n    }\n    let dith =\n         ((i32::from(a.r) - i32::from(b.r) + i32::from(dither.r)) * (i32::from(a.r) - i32::from(b.r) + i32::from(dither.r))\n        + (i32::from(a.g) - i32::from(b.g) + i32::from(dither.g)) * (i32::from(a.g) - i32::from(b.g) + i32::from(dither.g))\n        + (i32::from(a.b) - i32::from(b.b) + i32::from(dither.b)) * (i32::from(a.b) - i32::from(b.b) + i32::from(dither.b))) as u32;\n    let undith =\n         ((i32::from(a.r) - i32::from(b.r) + i32::from(dither.r) / 2) * (i32::from(a.r) - i32::from(b.r) + i32::from(dither.r) / 2)\n        + (i32::from(a.g) - i32::from(b.g) + i32::from(dither.g) / 2) * (i32::from(a.g) - i32::from(b.g) + i32::from(dither.g) / 2)\n        + (i32::from(a.b) - i32::from(b.b) + i32::from(dither.b) / 2) * (i32::from(a.b) - i32::from(b.b) + i32::from(dither.b) / 2)) as u32;\n    if dith < undith {\n        dith\n    } else {\n        undith\n    }\n}\n#[inline]\nfn diffused_difference(\n    a: RGB8,\n    b: RGB8,\n    a_transparent: bool,\n    b_transparent: bool,\n    dither: RgbDiff,\n) -> RgbDiff {\n    if a_transparent || b_transparent {\n        RgbDiff { r: 0, g: 0, b: 0 }\n    } else {\n        RgbDiff {\n            r: (i32::from(a.r) - i32::from(b.r) + i32::from(dither.r) * 3 / 4) as i16,\n            g: (i32::from(a.g) - i32::from(b.g) + i32::from(dither.g) * 3 / 4) as i16,\n            b: (i32::from(a.b) - i32::from(b.b) + i32::from(dither.b) * 3 / 4) as i16,\n        }\n    }\n}\n\nimpl CodeTable {\n    #[inline]\n    fn define(&mut self, work_node_id: NodeId, suffix: u8, next_code: LzwCode) {\n        let id = self.nodes.len() as u16;\n        self.nodes.push(Node {\n            code: next_code,\n            suffix,\n            children: Vec::new(),\n        });\n        self.nodes[work_node_id as usize].children.push(id);\n    }\n\n    #[cold]\n    fn reset(&mut self) {\n        self.links_used = 0;\n        self.nodes.clear();\n        self.nodes.extend((0..usize::from(self.clear_code)).map(|i| Node {\n            code: i as u16,\n            suffix: i as u8,\n            children: Vec::new(),\n        }));\n    }\n}\n\nstruct Lookup<'a> {\n    pub code_table: &'a CodeTable,\n    pub pal: &'a [RGB8],\n    pub image: &'a GiflossyImage<'a>,\n    pub max_diff: u32,\n    pub best_node: NodeId,\n    pub best_pos: usize,\n    pub best_total_diff: u64,\n}\n\nimpl Lookup<'_> {\n    pub fn lossy_node(&mut self, pos: usize, node_id: NodeId, total_diff: u64, dither: RgbDiff) {\n        let Some(px) = self.image.px_at_pos(pos) else {\n            return;\n        };\n        self.code_table.nodes[node_id as usize].children.iter().copied().for_each(|node_id| {\n            self.try_node(\n                pos,\n                node_id,\n                px,\n                dither,\n                total_diff,\n            );\n        });\n    }\n\n    #[inline]\n    fn try_node(\n        &mut self,\n        pos: usize,\n        node_id: NodeId,\n        px: u8,\n        dither: RgbDiff,\n        total_diff: u64,\n    ) {\n        let node = &self.code_table.nodes[node_id as usize];\n        let next_px = node.suffix;\n        let diff = if px == next_px {\n            0\n        } else {\n            color_diff(\n                self.pal[px as usize],\n                self.pal[next_px as usize],\n                Some(px) == self.image.transparent,\n                Some(next_px) == self.image.transparent,\n                dither,\n            )\n        };\n        if diff <= self.max_diff {\n            let new_dither = diffused_difference(\n                self.pal[px as usize],\n                self.pal[next_px as usize],\n                Some(px) == self.image.transparent,\n                Some(next_px) == self.image.transparent,\n                dither,\n            );\n            let new_pos = pos + 1;\n            let new_diff = total_diff + u64::from(diff);\n            if new_pos > self.best_pos || new_pos == self.best_pos && new_diff < self.best_total_diff {\n                self.best_node = node_id;\n                self.best_pos = new_pos;\n                self.best_total_diff = new_diff;\n            }\n            self.lossy_node(new_pos, node_id, new_diff, new_dither);\n        }\n    }\n}\n\nconst RUN_EWMA_SHIFT: usize = 4;\nconst RUN_EWMA_SCALE: usize = 19;\nconst RUN_INV_THRESH: usize = (1 << RUN_EWMA_SCALE) / 3000;\n\nimpl GiflossyWriter {\n    pub fn write(&mut self, image: &GiflossyImage, global_pal: Option<&[RGB8]>) -> Result<Vec<u8>, Error> {\n        let mut buf = Vec::new();\n        buf.try_reserve((image.height as usize * image.width as usize / 4).next_power_of_two())?;\n\n        let mut run = 0;\n        let mut run_ewma = 0;\n        let mut next_code = 0;\n        let pal = image.pal.or(global_pal).unwrap();\n\n        let min_code_size = (pal.len() as u32).max(3).next_power_of_two().trailing_zeros() as u8;\n\n        buf.push(min_code_size);\n        let mut bufpos_bits = 8;\n\n        let mut code_table = CodeTable {\n            clear_code: 1 << u16::from(min_code_size),\n            links_used: 0,\n            nodes: Vec::new(),\n        };\n        code_table.reset();\n\n        let mut cur_code_bits = min_code_size + 1;\n        let mut output_code = code_table.clear_code as LzwCode;\n        let mut clear_bufpos_bits = bufpos_bits;\n        let mut pos = 0;\n        let mut clear_pos = pos;\n        loop {\n            let endpos_bits = bufpos_bits + (cur_code_bits as usize);\n            loop {\n                if bufpos_bits & 7 != 0 {\n                    buf[bufpos_bits / 8] |= (output_code << (bufpos_bits & 7)) as u8;\n                } else {\n                    buf.push((output_code >> (bufpos_bits + (cur_code_bits as usize) - endpos_bits)) as u8);\n                }\n                bufpos_bits = bufpos_bits + 8 - (bufpos_bits & 7);\n                if bufpos_bits >= endpos_bits {\n                    break;\n                }\n            }\n            bufpos_bits = endpos_bits;\n\n            if output_code == code_table.clear_code {\n                cur_code_bits = min_code_size + 1;\n                next_code = (code_table.clear_code + 2) as LzwCode;\n                run_ewma = 1 << RUN_EWMA_SCALE;\n                code_table.reset();\n                clear_bufpos_bits = 0;\n                clear_pos = clear_bufpos_bits;\n            } else {\n                if output_code == (code_table.clear_code + 1) {\n                    break;\n                }\n                if next_code > (1 << cur_code_bits) && cur_code_bits < 12 {\n                    cur_code_bits += 1;\n                }\n                run = (((run as u32) << RUN_EWMA_SCALE) + (1 << (RUN_EWMA_SHIFT - 1) as u32)) as usize;\n                if run < run_ewma {\n                    run_ewma = run_ewma - ((run_ewma - run) >> RUN_EWMA_SHIFT);\n                } else {\n                    run_ewma = run_ewma + ((run - run_ewma) >> RUN_EWMA_SHIFT);\n                }\n            }\n            if let Some(px) = image.px_at_pos(pos) {\n                let mut l = Lookup {\n                    code_table: &code_table,\n                    pal,\n                    image,\n                    max_diff: self.loss,\n                    best_node: u16::from(px),\n                    best_pos: pos + 1,\n                    best_total_diff: 0,\n                };\n                l.lossy_node(pos + 1, u16::from(px), 0, RgbDiff { r: 0, g: 0, b: 0 });\n                run = l.best_pos - pos;\n                pos = l.best_pos;\n                let selected_node = &code_table.nodes[l.best_node as usize];\n                output_code = selected_node.code;\n                if let Some(px) = image.px_at_pos(pos) {\n                    if next_code < 0x1000 {\n                        code_table.define(l.best_node, px, next_code);\n                        next_code += 1;\n                    } else {\n                        next_code = 0x1001;\n                    }\n                    if next_code >= 0x0FFF {\n                        let pixels_left = image.img.len() - pos - 1;\n                        let do_clear = pixels_left != 0\n                            && (run_ewma\n                                < (36 << RUN_EWMA_SCALE) / (min_code_size as usize)\n                                || pixels_left > (0x7FFF_FFFF * 2 + 1) / RUN_INV_THRESH\n                                || run_ewma < pixels_left * RUN_INV_THRESH);\n                        if (do_clear || run < 7) && clear_pos == 0 {\n                            clear_pos = pos - run;\n                            clear_bufpos_bits = bufpos_bits;\n                        } else if !do_clear && run > 50 {\n                            clear_bufpos_bits = 8; // buf contains min code\n                            clear_pos = 0;\n                        }\n                        if do_clear {\n                            output_code = code_table.clear_code;\n                            pos = clear_pos;\n                            bufpos_bits = clear_bufpos_bits;\n                            buf.truncate(bufpos_bits.div_ceil(8));\n                            if buf.len() > bufpos_bits / 8 {\n                                buf[bufpos_bits / 8] &= (1 << (bufpos_bits & 7)) - 1;\n                            }\n                            continue;\n                        }\n                    }\n                    run = (((run as u32) << RUN_EWMA_SCALE) + (1 << (RUN_EWMA_SHIFT - 1) as u32)) as usize;\n                    if run < run_ewma {\n                        run_ewma = run_ewma - ((run_ewma - run) >> RUN_EWMA_SHIFT);\n                    } else {\n                        run_ewma = run_ewma + ((run - run_ewma) >> RUN_EWMA_SHIFT);\n                    }\n                }\n            } else {\n                run = 0;\n                output_code = code_table.clear_code + 1;\n            }\n        }\n        Ok(buf)\n    }\n}\n\nimpl<'a> GiflossyImage<'a> {\n    #[must_use]\n    #[cfg_attr(debug_assertions, track_caller)]\n    pub fn new(\n        img: &'a [u8],\n        width: u16,\n        height: u16,\n        transparent: Option<u8>,\n        pal: Option<&'a [RGB8]>,\n    ) -> Self {\n        assert_eq!(img.len(), width as usize * height as usize);\n        GiflossyImage {\n            img,\n            width,\n            height,\n            interlace: false,\n            transparent,\n            pal,\n        }\n    }\n\n    #[inline]\n    fn px_at_pos(&self, pos: usize) -> Option<u8> {\n        if !self.interlace {\n            self.img.get(pos).copied()\n        } else {\n            let y = pos / self.width as usize;\n            let x = pos - (y * self.width as usize);\n            self.img.get(self.width as usize * interlaced_line(y, self.height as usize) + x).copied()\n        }\n    }\n}\n\nfn interlaced_line(line: usize, height: usize) -> usize {\n    if line > height / 2 {\n        line * 2 - (height | 1)\n    } else if line > height / 4 {\n        return line * 4 - (height & !1 | 2);\n    } else if line > height / 8 {\n        return line * 8 - (height & !3 | 4);\n    } else {\n        return line * 8;\n    }\n}\n"
  },
  {
    "path": "gifski-api/src/lib.rs",
    "content": "/*\n gifski pngquant-based GIF encoder\n © 2017 Kornel Lesiński\n\n This program is free software: you can redistribute it and/or modify\n it under the terms of the GNU Affero General Public License as\n published by the Free Software Foundation, either version 3 of the\n License, or (at your option) any later version.\n\n This program is distributed in the hope that it will be useful,\n but WITHOUT ANY WARRANTY; without even the implied warranty of\n MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n GNU Affero General Public License for more details.\n\n You should have received a copy of the GNU Affero General Public License\n along with this program.  If not, see <https://www.gnu.org/licenses/>.\n*/\n//! gif.ski library allows creation of GIF animations from [arbitrary pixels][ImgVec],\n//! or [PNG files][Collector::add_frame_png_file].\n//!\n//! See the [`new`] function to get started.\n#![doc(html_logo_url = \"https://gif.ski/icon.png\")]\n#![allow(clippy::bool_to_int_with_if)]\n#![allow(clippy::cast_possible_truncation)]\n#![allow(clippy::enum_glob_use)]\n#![allow(clippy::if_not_else)]\n#![allow(clippy::inline_always)]\n#![allow(clippy::match_same_arms)]\n#![allow(clippy::missing_errors_doc)]\n#![allow(clippy::module_name_repetitions)]\n#![allow(clippy::needless_pass_by_value)]\n#![allow(clippy::redundant_closure_for_method_calls)]\n#![allow(clippy::wildcard_imports)]\n\nuse encoderust::RustEncoder;\nuse gif::DisposalMethod;\nuse imagequant::{Attributes, Image, QuantizationResult};\nuse imgref::*;\nuse rgb::*;\n\nmod error;\npub use crate::error::*;\nuse ordered_channel::bounded as ordqueue_new;\nuse ordered_channel::Receiver as OrdQueueIter;\nuse ordered_channel::Sender as OrdQueue;\npub mod progress;\nuse crate::progress::*;\npub mod c_api;\nmod denoise;\nuse crate::denoise::*;\npub mod collector;\nmod encoderust;\n#[doc(inline)]\npub use crate::collector::Collector;\nuse crate::collector::{FrameSource, InputFrame, InputFrameResized};\n\n#[cfg(feature = \"gifsicle\")]\nmod gifsicle;\n\nmod minipool;\n\nuse crossbeam_channel::{Receiver, Sender};\nuse std::cell::Cell;\nuse std::io::prelude::*;\nuse std::num::NonZeroU8;\nuse std::rc::Rc;\nuse std::sync::atomic::Ordering::Relaxed;\nuse std::thread;\n\n/// Number of repetitions\npub type Repeat = gif::Repeat;\n\n/// Encoding settings for the `new()` function\n#[derive(Copy, Clone)]\npub struct Settings {\n    /// Resize to max this width if non-0.\n    pub width: Option<u32>,\n    /// Resize to max this height if width is non-0. Note that aspect ratio is not preserved.\n    pub height: Option<u32>,\n    /// 1-100, but useful range is 50-100. Recommended to set to 100.\n    pub quality: u8,\n    /// Lower quality, but faster encode.\n    pub fast: bool,\n    /// Sets the looping method for the image sequence.\n    pub repeat: Repeat,\n}\n\n#[derive(Copy, Clone)]\n#[non_exhaustive]\nstruct SettingsExt {\n    pub s: Settings,\n    pub max_threads: NonZeroU8,\n    pub extra_effort: bool,\n    pub motion_quality: u8,\n    pub giflossy_quality: u8,\n    pub matte: Option<RGB8>,\n}\n\nimpl Settings {\n    /// quality is used in other places, like gifsicle or frame differences,\n    /// and it's better to lower quality there before ruining quantization\n    pub(crate) fn color_quality(&self) -> u8 {\n        (u16::from(self.quality) * 4 / 3).min(100) as u8\n    }\n\n    /// `add_frame` is going to resize the images to this size.\n    #[must_use]\n    #[inline]\n    pub fn dimensions_for_image(&self, width: usize, height: usize) -> (usize, usize) {\n        dimensions_for_image((width, height), (self.width, self.height))\n    }\n}\n\nimpl SettingsExt {\n    pub(crate) fn gifsicle_loss(&self) -> u32 {\n        if cfg!(feature = \"gifsicle\") && self.giflossy_quality < 100 {\n            ((100. / 5. - f32::from(self.giflossy_quality) / 5.).powf(1.8).ceil() as u32 + 10) * 10\n        } else {\n            0\n        }\n    }\n\n    pub(crate) fn dithering_level(&self) -> f32 {\n        let gifsicle_quality = if cfg!(feature = \"gifsicle\") { self.giflossy_quality } else { 100 };\n        debug_assert!(gifsicle_quality <= 100);\n        // lossy LZW adds its own dithering, so the input could be less nosiy to compensate\n        // but don't change dithering unless gifsicle quality < 90, and don't completely disable it\n        let gifsicle_factor = 0.25 + f32::from(gifsicle_quality) * (1. / 100. * 1. / 0.9 * 0.75);\n\n        (f32::from(self.s.quality) * (1. / 50. * gifsicle_factor) - 1.).clamp(0.2, 1.)\n    }\n}\n\nimpl Default for Settings {\n    #[inline]\n    fn default() -> Self {\n        Self {\n            width: None, height: None,\n            quality: 100,\n            fast: false,\n            repeat: Repeat::Infinite,\n        }\n    }\n}\n\n/// Perform GIF writing\npub struct Writer {\n    /// Input frame decoder results\n    queue_iter: Option<Receiver<InputFrame>>,\n    settings: SettingsExt,\n    /// Colors the caller has specified as fixed (i.e. key colours)\n    /// This can't be in settings because that would cause it to lose Copy.\n    /// Additionally to avoid breaking C API compatibility this has to be mutable there too.\n    fixed_colors: Vec<RGB8>,\n}\n\nstruct GIFFrame {\n    left: u16,\n    top: u16,\n    image: ImgVec<u8>,\n    pal: Vec<RGB8>,\n    dispose: DisposalMethod,\n    transparent_index: Option<u8>,\n}\n\n/// Frame before quantization\nstruct DiffMessage {\n    /// 1..\n    ordinal_frame_number: usize,\n    pts: f64, frame_duration: f64,\n    image: ImgVec<RGBA8>,\n    importance_map: Vec<u8>,\n}\n\nstruct QuantizeMessage {\n    /// 1.. with holes\n    ordinal_frame_number: usize,\n    /// 0.. no holes\n    frame_index: u32,\n    first_frame_has_transparency: bool,\n    image: ImgVec<RGBA8>,\n    importance_map: Vec<u8>,\n    prev_frame_keeps: bool,\n    dispose: gif::DisposalMethod,\n    end_pts: f64,\n    has_next_frame: bool,\n}\n\n/// Frame post quantization, before remap\nstruct RemapMessage {\n    /// 1..\n    ordinal_frame_number: usize,\n    end_pts: f64,\n    dispose: DisposalMethod,\n    liq: Attributes,\n    remap: QuantizationResult,\n    liq_image: Image<'static>,\n    out_buf: Vec<u8>,\n    has_next_frame: bool,\n}\n\n/// Frame post quantization and remap\nstruct FrameMessage {\n    /// 0..\n    frame_index: usize,\n    /// 1..\n    ordinal_frame_number: usize,\n    end_pts: f64,\n    frame: GIFFrame,\n    screen_width: u16,\n    screen_height: u16,\n}\n\n/// Start new encoding on two threads.\n///\n/// Encoding is always multi-threaded, and the `Collector` and `Writer`\n/// must be used on sepate threads.\n///\n/// You feed input frames to the [`Collector`], and ask the [`Writer`] to\n/// start writing the GIF.\n///\n/// If you don't start writing, then adding frames will block forever.\n///\n///\n/// ```rust,no_run\n/// use gifski::*;\n///\n/// let (collector, writer) = gifski::new(Settings::default())?;\n/// std::thread::scope(|t| -> Result<(), Error> {\n///     let frames_thread = t.spawn(move || {\n///         for i in 0..10 {\n///             collector.add_frame_png_file(i, format!(\"frame{i:04}.png\").into(), i as f64 * 0.1)?;\n///         }\n///         drop(collector);\n///         Ok(())\n///     });\n///\n///     writer.write(std::fs::File::create(\"demo.gif\")?, &mut progress::NoProgress {})?;\n///     frames_thread.join().unwrap()\n/// })?;\n/// Ok::<_, Error>(())\n/// ```\n#[inline]\npub fn new(settings: Settings) -> GifResult<(Collector, Writer)> {\n    if settings.quality == 0 || settings.quality > 100 {\n        return Err(Error::WrongSize(\"quality must be 1-100\".into())); // I forgot to add a better error variant\n    }\n    if settings.width.unwrap_or(0) > 1 << 16 || settings.height.unwrap_or(0) > 1 << 16 {\n        return Err(Error::WrongSize(\"image size too large\".into()));\n    }\n\n    let max_threads = thread::available_parallelism().map(|t| t.get().min(255) as u8).unwrap_or(8);\n    let (queue, queue_iter) = crossbeam_channel::bounded(5.min(max_threads.into())); // should be sufficient for denoiser lookahead\n    Ok((\n        Collector {\n            queue,\n        },\n        Writer {\n            queue_iter: Some(queue_iter),\n            settings: SettingsExt {\n                s: settings,\n                max_threads: max_threads.try_into()?,\n                motion_quality: settings.quality,\n                giflossy_quality: settings.quality,\n                extra_effort: false,\n                matte: None,\n            },\n            fixed_colors: Vec::new(),\n        },\n    ))\n}\n\n#[inline(never)]\n#[cfg_attr(debug_assertions, track_caller)]\nfn resized_binary_alpha(image: ImgVec<RGBA8>, width: Option<u32>, height: Option<u32>, matte: Option<RGB8>) -> CatResult<ImgVec<RGBA8>> {\n    let (width, height) = dimensions_for_image((image.width(), image.height()), (width, height));\n\n    let mut image = if width != image.width() || height != image.height() {\n        let tmp = image.as_ref();\n        let (buf, img_width, img_height) = tmp.to_contiguous_buf();\n        assert_eq!(buf.len(), img_width * img_height);\n\n        let mut r = resize::new(img_width, img_height, width, height, resize::Pixel::RGBA8P, resize::Type::Lanczos3)?;\n        let mut dst = vec![RGBA8::new(0, 0, 0, 0); width * height];\n        r.resize(&buf, &mut dst)?;\n        ImgVec::new(dst, width, height)\n    } else {\n        image\n    };\n\n    if let Some(matte) = matte {\n        image.pixels_mut().filter(|px| px.a < 255 && px.a > 0).for_each(move |px| {\n            let alpha = u16::from(px.a);\n            let inv_alpha = 255 - alpha;\n\n            *px = RGBA8 {\n                r: ((u16::from(px.r) * alpha + u16::from(matte.r) * inv_alpha) / 255) as u8,\n                g: ((u16::from(px.g) * alpha + u16::from(matte.g) * inv_alpha) / 255) as u8,\n                b: ((u16::from(px.b) * alpha + u16::from(matte.b) * inv_alpha) / 255) as u8,\n                a: 255,\n            };\n        });\n    } else {\n        dither_image(image.as_mut());\n    }\n\n    Ok(image)\n}\n\n#[allow(clippy::identity_op)]\n#[allow(clippy::erasing_op)]\n#[inline(never)]\nfn dither_image(mut image: ImgRefMut<RGBA8>) {\n    let width = image.width();\n    let height = image.height();\n\n    // dithering of anti-aliased edges can look very fuzzy, so disable it near the edges\n    let mut anti_aliasing = vec![false; width * height];\n    loop9::loop9(image.as_ref(), 0, 0, width, height, |x, y, top, mid, bot| {\n        if mid.curr.a != 255 && mid.curr.a != 0 {\n            fn is_edge(a: u8, b: u8) -> bool {\n                a < 12 && b >= 240 ||\n                b < 12 && a >= 240\n            }\n            if is_edge(top.curr.a, bot.curr.a) ||\n            is_edge(mid.prev.a, mid.next.a) ||\n            is_edge(top.prev.a, bot.next.a) ||\n            is_edge(top.next.a, bot.prev.a) {\n                anti_aliasing[x + y * width] = true;\n            }\n        }\n    });\n\n    // this table is already biased, so that px.a doesn't need to be changed\n    const DITHER: [u8; 64] = [\n     0*2+8,48*2+8,12*2+8,60*2+8, 3*2+8,51*2+8,15*2+8,63*2+8,\n    32*2+8,16*2+8,44*2+8,28*2+8,35*2+8,19*2+8,47*2+8,31*2+8,\n     8*2+8,56*2+8, 4*2+8,52*2+8,11*2+8,59*2+8, 7*2+8,55*2+8,\n    40*2+8,24*2+8,36*2+8,20*2+8,43*2+8,27*2+8,39*2+8,23*2+8,\n     2*2+8,50*2+8,14*2+8,62*2+8, 1*2+8,49*2+8,13*2+8,61*2+8,\n    34*2+8,18*2+8,46*2+8,30*2+8,33*2+8,17*2+8,45*2+8,29*2+8,\n    10*2+8,58*2+8, 6*2+8,54*2+8, 9*2+8,57*2+8, 5*2+8,53*2+8,\n    42*2+8,26*2+8,38*2+8,22*2+8,41*2+8,25*2+8,37*2+8,21*2+8];\n\n    // Make transparency binary\n    for (y, (row, aa)) in image.rows_mut().zip(anti_aliasing.chunks_exact(width)).enumerate() {\n        for (x, (px, aa)) in row.iter_mut().zip(aa.iter().copied()).enumerate() {\n            if px.a < 255 {\n                if aa {\n                    px.a = if px.a < 89 { 0 } else { 255 };\n                } else {\n                    px.a = if px.a < DITHER[(y & 7) * 8 + (x & 7)] { 0 } else { 255 };\n                }\n            }\n        }\n    }\n}\n\n/// `add_frame` is going to resize the image to this size.\n/// The `Option` args are user-specified max width and max height\n#[inline(never)]\nfn dimensions_for_image((img_w, img_h): (usize, usize), resize_to: (Option<u32>, Option<u32>)) -> (usize, usize) {\n    match resize_to {\n        (None, None) => {\n            let factor = ((img_w * img_h + 800 * 600 / 2) as f64 / f64::from(800 * 600)).sqrt().round() as usize;\n            if factor > 1 {\n                (img_w / factor, img_h / factor)\n            } else {\n                (img_w, img_h)\n            }\n        },\n        (Some(w), Some(h)) => {\n            ((w as usize).min(img_w), (h as usize).min(img_h))\n        },\n        (Some(w), None) => {\n            let w = (w as usize).min(img_w);\n            (w, img_h * w / img_w)\n        },\n        (None, Some(h)) => {\n            let h = (h as usize).min(img_h);\n            (img_w * h / img_h, h)\n        },\n    }\n}\n\n#[derive(Copy, Clone)]\nenum LastFrameDuration {\n    FixedOffset(f64),\n    FrameRate(f64),\n}\n\nimpl LastFrameDuration {\n    #[inline]\n    pub fn value(&self) -> f64 {\n        match self {\n            Self::FixedOffset(val) | Self::FrameRate(val) => *val,\n        }\n    }\n\n    #[inline]\n    pub fn shift_every_pts_by(&self) -> f64 {\n        match self {\n            Self::FixedOffset(offset) => *offset,\n            Self::FrameRate(_) => 0.,\n        }\n    }\n}\n\n/// Encode collected frames\nimpl Writer {\n    #[deprecated(note = \"please don't use, it will be in Settings eventually\")]\n    #[doc(hidden)]\n    pub fn set_extra_effort(&mut self, enabled: bool) {\n        self.settings.extra_effort = enabled;\n    }\n\n    #[deprecated(note = \"please don't use, it will be in Settings eventually\")]\n    #[doc(hidden)]\n    pub fn set_motion_quality(&mut self, q: u8) {\n        self.settings.motion_quality = q;\n    }\n\n    #[deprecated(note = \"please don't use, it will be in Settings eventually\")]\n    #[doc(hidden)]\n    pub fn set_lossy_quality(&mut self, q: u8) {\n        self.settings.giflossy_quality = q;\n    }\n\n    /// Adds a fixed color that will be kept in the palette at all times.\n    ///\n    /// This may increase file size, because every frame will use a larger palette.\n    /// Max 255 allowed, because one more is reserved for transparency.\n    pub fn add_fixed_color(&mut self, col: RGB8) {\n        if self.fixed_colors.len() < 255 {\n            self.fixed_colors.push(col);\n        }\n    }\n\n    #[deprecated(note = \"please don't use, it will be in Settings eventually\")]\n    #[doc(hidden)]\n    pub fn set_matte_color(&mut self, col: RGB8) {\n        self.settings.matte = Some(col);\n    }\n\n    /// `importance_map` is computed from previous and next frame.\n    /// Improves quality of pixels visible for longer.\n    /// Avoids wasting palette on pixels identical to the background.\n    ///\n    /// `background` is the previous frame.\n    fn quantize(&self, image: ImgVec<RGBA8>, importance_map: &[u8], first_frame: bool, needs_transparency: bool, prev_frame_keeps: bool) -> CatResult<(Attributes, QuantizationResult, Image<'static>, Vec<u8>)> {\n        let mut liq = Attributes::new();\n        if self.settings.s.fast && !first_frame {\n            liq.set_speed(10)?;\n        } else if self.settings.extra_effort {\n            liq.set_speed(1)?;\n        }\n        let quality = if !first_frame {\n            self.settings.s.color_quality()\n        } else {\n            100 // the first frame is too important to ruin it\n        };\n        liq.set_quality(0, quality)?;\n        if self.settings.s.quality < 50 {\n            let min_colors = 5 + self.fixed_colors.len() as u32;\n            liq.set_max_colors(u32::from(self.settings.s.quality * 2).max(min_colors).next_power_of_two().min(256))?;\n        }\n        let (buf, width, height) = image.into_contiguous_buf();\n        let mut img = liq.new_image(buf, width, height, 0.)?;\n        // only later remapping tracks which area has been damaged by transparency\n        // so for previous-transparent background frame the importance map may be invalid\n        // because there's a transparent hole in the background not taken into account,\n        // and palette may lack colors to fill that hole\n        if first_frame || prev_frame_keeps {\n            img.set_importance_map(importance_map)?;\n        }\n        // first frame may be transparent too, so it's not just for diffs\n        if needs_transparency {\n            img.add_fixed_color(RGBA8::new(0, 0, 0, 0))?;\n        }\n        // user may have colors which need to be preserved and left undithered\n        for color in &self.fixed_colors {\n            img.add_fixed_color(RGBA8::new(color.r, color.g, color.b, 255))?;\n        }\n\n        let mut res = liq.quantize(&mut img)?;\n\n        // GIF only stores power-of-two palette sizes\n        if self.settings.extra_effort {\n            let len = res.palette_len();\n            // it has little impact on compression (128c -> 64c is only 7% smaller)\n            if (len < 128 || len > 220) && len != len.next_power_of_two() {\n                liq.set_max_colors(len.next_power_of_two() as _)?;\n                liq.set_quality(0, 100)?;\n                res = liq.quantize(&mut img)?;\n            }\n        }\n        res.set_dithering_level(self.settings.dithering_level())?;\n\n        let mut out = Vec::new();\n        out.try_reserve_exact(width * height).map_err(imagequant::liq_error::from)?;\n        res.optionally_prepare_for_dithering_with_background_set(&mut img, &mut out.spare_capacity_mut()[..width * height])?;\n\n        Ok((liq, res, img, out))\n    }\n\n    fn remap<'a>(&self, liq: Attributes, mut res: QuantizationResult, mut img: Image<'a>, background: Option<ImgRef<'a, RGBA8>>, mut pal_img: Vec<u8>) -> CatResult<(ImgVec<u8>, Vec<RGBA8>)> {\n        if let Some(bg) = background {\n            img.set_background(Image::new_stride_borrowed(&liq, bg.buf(), bg.width(), bg.height(), bg.stride(), 0.)?)?;\n        }\n\n        let pal = res.remap_into_vec(&mut img, &mut pal_img)?;\n        debug_assert_eq!(img.width() * img.height(), pal_img.len());\n\n        Ok((Img::new(pal_img, img.width(), img.height()), pal))\n    }\n\n    #[inline(never)]\n    fn write_frames(&self, write_queue: Receiver<FrameMessage>, writer: &mut dyn Write, reporter: &mut dyn ProgressReporter) -> CatResult<()> {\n        let (lzw_queue, lzw_recv) = ordqueue_new(2);\n        minipool::new_scope((if self.settings.s.fast || self.settings.gifsicle_loss() > 0 { 3 } else { 1 }).try_into().unwrap(), \"lzw\", move || {\n            let mut pts_in_delay_units = 0_u64;\n\n            let written = Rc::new(Cell::new(0));\n            let mut enc = RustEncoder::new(writer, written.clone());\n\n            let mut n_done = 0;\n            for tmp in lzw_recv {\n                let (end_pts, ordinal_frame_number, frame, screen_width, screen_height): (f64, _, _, _, _) = tmp;\n                // delay=1 doesn't work, and it's too late to drop frames now\n                let delay = ((end_pts * 100_f64).round() as u64)\n                    .saturating_sub(pts_in_delay_units)\n                    .clamp(2, 30000) as u16;\n                pts_in_delay_units += u64::from(delay);\n\n                enc.write_frame(frame, delay, screen_width, screen_height, &self.settings.s)?;\n\n                reporter.written_bytes(written.get());\n\n                // loop to report skipped frames too\n                while n_done < ordinal_frame_number {\n                    n_done += 1;\n                    if !reporter.increase() {\n                        return Err(Error::Aborted);\n                    }\n                }\n            }\n            if n_done == 0 {\n                Err(Error::NoFrames)\n            } else {\n                Ok(())\n            }\n        }, move |abort| {\n            for FrameMessage {frame, frame_index, ordinal_frame_number, end_pts, screen_width, screen_height } in write_queue {\n                if abort.load(Relaxed) {\n                    return Err(Error::Aborted);\n                }\n\n                let frame = RustEncoder::<&mut dyn std::io::Write>::compress_frame(frame, &self.settings)?;\n                lzw_queue.send(frame_index, (end_pts, ordinal_frame_number, frame, screen_width, screen_height))?;\n            }\n            Ok(())\n        })\n    }\n\n    /// Start writing frames. This function will not return until the [`Collector`] is dropped.\n    ///\n    /// `outfile` can be any writer, such as `File` or `&mut Vec`.\n    ///\n    /// `ProgressReporter.increase()` is called each time a new frame is being written.\n    #[inline]\n    pub fn write<W: Write>(mut self, mut writer: W, reporter: &mut dyn ProgressReporter) -> GifResult<()> {\n        let decode_queue_recv = self.queue_iter.take().ok_or(Error::Aborted)?;\n        self.write_inner(decode_queue_recv, &mut writer, reporter)\n    }\n\n    #[inline(never)]\n    fn write_inner(&self, decode_queue_recv: Receiver<InputFrame>, writer: &mut dyn Write, reporter: &mut dyn ProgressReporter) -> CatResult<()> {\n        thread::scope(|s| {\n            let (diff_queue, diff_queue_recv) = ordqueue_new(0);\n            let resize_thread = thread::Builder::new().name(\"resize\".into()).spawn_scoped(s, move || {\n                self.make_resize(decode_queue_recv, diff_queue)\n            })?;\n            let (quant_queue, quant_queue_recv) = crossbeam_channel::bounded(0);\n            let diff_thread = thread::Builder::new().name(\"diff\".into()).spawn_scoped(s, move || {\n                self.make_diffs(diff_queue_recv, quant_queue)\n            })?;\n            let (remap_queue, remap_queue_recv) = ordqueue_new(0);\n            let quant_thread = thread::Builder::new().name(\"quant\".into()).spawn_scoped(s, move || {\n                self.quantize_frames(quant_queue_recv, remap_queue)\n            })?;\n            let (write_queue, write_queue_recv) = crossbeam_channel::bounded(0);\n            let remap_thread = thread::Builder::new().name(\"remap\".into()).spawn_scoped(s, move || {\n                self.remap_frames(remap_queue_recv, write_queue)\n            })?;\n            let res0 = self.write_frames(write_queue_recv, writer, reporter);\n            let res1 = resize_thread.join().map_err(handle_join_error)?;\n            let res2 = diff_thread.join().map_err(handle_join_error)?;\n            let res3 = quant_thread.join().map_err(handle_join_error)?;\n            let res4 = remap_thread.join().map_err(handle_join_error)?;\n            combine_res(combine_res(combine_res(res0, res1), combine_res(res2, res3)), res4)\n        })\n    }\n\n    /// Apply resizing and crate a blurred version for the diff/denoise phase\n    fn make_resize(&self, inputs: Receiver<InputFrame>, diff_queue: OrdQueue<InputFrameResized>) -> CatResult<()> {\n        minipool::new_scope(self.settings.max_threads.min(if self.settings.s.fast || self.settings.extra_effort { 6 } else { 4 }.try_into()?), \"resize\", move || {\n            Ok(())\n        }, move |abort| {\n            for frame in inputs {\n                if abort.load(Relaxed) {\n                    return Err(Error::Aborted);\n                }\n                let image = match frame.frame {\n                    FrameSource::Pixels(image) => image,\n                    #[cfg(feature = \"png\")]\n                    FrameSource::PngData(data) => {\n                        let image = lodepng::decode32(&data)\n                            .map_err(|err| Error::PNG(format!(\"Can't load PNG: {err}\")))?;\n                        Img::new(image.buffer, image.width, image.height)\n                    },\n                    #[cfg(feature = \"png\")]\n                    FrameSource::Path(path) => {\n                        let image = lodepng::decode32_file(&path)\n                            .map_err(|err| Error::PNG(format!(\"Can't load {}: {err}\", path.display())))?;\n                        Img::new(image.buffer, image.width, image.height)\n                    },\n                };\n                let resized = resized_binary_alpha(image, self.settings.s.width, self.settings.s.height, self.settings.matte)?;\n                let frame_blurred = if self.settings.extra_effort { smart_blur(resized.as_ref()) } else { less_smart_blur(resized.as_ref()) };\n                diff_queue.send(frame.frame_index, InputFrameResized {\n                    frame: resized,\n                    frame_blurred,\n                    presentation_timestamp: frame.presentation_timestamp,\n                })?;\n            }\n            Ok(())\n        })\n    }\n\n    /// Find differences between frames, and compute importance maps\n    fn make_diffs(&self, mut inputs: OrdQueueIter<InputFrameResized>, diffs: Sender<DiffMessage>) -> CatResult<()> {\n        let first_frame = inputs.next().ok_or(Error::NoFrames)?;\n\n        let mut last_frame_duration = if first_frame.presentation_timestamp > 1. / 100. {\n            // this is gifski's weird rule that a non-zero first-frame pts\n            // shifts the whole anim and is the delay of the last frame\n            LastFrameDuration::FixedOffset(first_frame.presentation_timestamp)\n        } else {\n            LastFrameDuration::FrameRate(0.)\n        };\n\n        let mut denoiser = Denoiser::new(first_frame.frame.width(), first_frame.frame.height(), self.settings.motion_quality)?;\n\n        let mut ordinal_frame_number = 0;\n        let mut last_frame_pts = 0.;\n        let mut next_frame = Some(first_frame);\n        loop {\n            // NB! There are two interleaved loops here:\n            //  - one to feed the denoiser\n            //  - the other to process denoised frames\n            //\n            // The denoiser buffers five frames, so these two loops process different frames!\n            // But need to be interleaved in one `loop{}` to get frames falling out of denoiser's buffer.\n\n            ////////////////////// Feed denoiser: /////////////////////\n\n            if let Some(InputFrameResized { frame, frame_blurred, presentation_timestamp: raw_pts }) = next_frame {\n                ordinal_frame_number += 1;\n\n                let pts = raw_pts - last_frame_duration.shift_every_pts_by();\n                if let LastFrameDuration::FrameRate(duration) = &mut last_frame_duration {\n                    *duration = pts - last_frame_pts;\n                }\n                last_frame_pts = pts;\n\n                denoiser.push_frame(frame.as_ref(), frame_blurred.as_ref(), (ordinal_frame_number, pts, last_frame_duration)).map_err(|_| {\n                    Error::WrongSize(format!(\"Frame {ordinal_frame_number} has wrong size ({}×{})\", frame.width(), frame.height()))\n                })?;\n            } else {\n                denoiser.flush();\n            }\n\n            ////////////////////// Consume denoised frames /////////////////////\n\n            match denoiser.pop() {\n                Denoised::Done => {\n                    debug_assert!(inputs.next().is_none());\n                    break;\n                },\n                Denoised::NotYet => {},\n                Denoised::Frame { importance_map, frame: image, meta: (ordinal_frame_number, pts, last_frame_duration) } => {\n                    let (importance_map, ..) = importance_map.into_contiguous_buf();\n                    diffs.send(DiffMessage {\n                        importance_map,\n                        ordinal_frame_number,\n                        image,\n                        pts, frame_duration: last_frame_duration.value().max(1. / 100.),\n                    })?;\n                },\n            }\n            next_frame = inputs.next();\n        }\n\n        Ok(())\n    }\n\n    fn quantize_frames(&self, inputs: Receiver<DiffMessage>, remap_queue: OrdQueue<RemapMessage>) -> CatResult<()> {\n        minipool::new_channel(self.settings.max_threads.min(4.try_into()?), \"quant\", move |quant_queue| {\n        let mut inputs = inputs.into_iter();\n        let next_frame = inputs.next().ok_or(Error::NoFrames)?;\n\n        let DiffMessage {image: first_frame, ..} = &next_frame;\n        let first_frame_has_transparency = first_frame.pixels().any(|px| px.a < 128);\n\n        let mut prev_frame_keeps = false;\n        let mut frame_index = 0;\n        let mut importance_map = None;\n        let mut next_frame = Some(next_frame);\n        while let Some(DiffMessage { image, pts, frame_duration, ordinal_frame_number, importance_map: new_importance_map }) = next_frame {\n            next_frame = inputs.next();\n\n            if importance_map.is_none() {\n                importance_map = Some(new_importance_map);\n            }\n\n            let dispose = if let Some(DiffMessage { image: next_image, .. }) = &next_frame {\n                // Skip identical frames\n                if next_image.as_ref() == image.as_ref() {\n                    // this keeps importance_map of the previous frame in the identical-frame series\n                    // (important, because subsequent identical frames have all-zero importance_map and would be dropped too)\n                    continue;\n                }\n\n                // If the next frame becomes transparent, this frame has to clear to bg for it\n                if next_image.pixels().zip(image.pixels()).any(|(next, curr)| next.a < curr.a) {\n                    DisposalMethod::Background\n                } else {\n                    DisposalMethod::Keep\n                }\n            } else if first_frame_has_transparency {\n                // Last frame should reset to background to avoid breaking transparent looped anims\n                DisposalMethod::Background\n            } else {\n                // macOS preview gets Background wrong\n                DisposalMethod::Keep\n            };\n\n            let importance_map = importance_map.take().ok_or(Error::ThreadSend)?; // always set at the beginning\n\n            if !prev_frame_keeps || importance_map.iter().any(|&px| px > 0) {\n                let end_pts = if let Some(&DiffMessage { pts: next_pts, .. }) = next_frame.as_ref() {\n                    next_pts\n                } else {\n                    pts + frame_duration\n                };\n                debug_assert!(end_pts > 0.);\n\n                quant_queue.send(QuantizeMessage {\n                    image,\n                    ordinal_frame_number, frame_index,\n                    first_frame_has_transparency,\n                    importance_map, prev_frame_keeps, dispose, end_pts,\n                    has_next_frame: next_frame.is_some(),\n                })?;\n\n                frame_index += 1;\n                prev_frame_keeps = dispose == DisposalMethod::Keep;\n            }\n        }\n        Ok(())\n        }, move |QuantizeMessage { end_pts, mut image, importance_map, ordinal_frame_number, frame_index, dispose, first_frame_has_transparency, prev_frame_keeps, has_next_frame }| {\n            if prev_frame_keeps {\n                // if denoiser says the background didn't change, then believe it\n                // (except higher quality settings, which try to improve it every time)\n                let bg_keep_likelihood = u32::from(self.settings.s.quality.saturating_sub(80) / 4);\n                if self.settings.s.fast || (self.settings.s.quality < 100 && (frame_index % 5) >= bg_keep_likelihood) {\n                    image.pixels_mut().zip(&importance_map).filter(|&(_, &m)| m == 0).for_each(|(px, _)| *px = RGBA8::new(0,0,0,0));\n                }\n            }\n\n            let needs_transparency = frame_index > 0 || (frame_index == 0 && first_frame_has_transparency);\n            let (liq, remap, liq_image, out_buf) = self.quantize(image, &importance_map, frame_index == 0, needs_transparency, prev_frame_keeps)?;\n\n            Ok(remap_queue.send(frame_index as usize, RemapMessage {\n                ordinal_frame_number,\n                end_pts,\n                dispose,\n                liq, remap,\n                liq_image,\n                out_buf,\n                has_next_frame,\n            })?)\n        })\n    }\n\n    fn remap_frames(&self, mut inputs: OrdQueueIter<RemapMessage>, write_queue: Sender<FrameMessage>) -> CatResult<()> {\n        let mut frame_index = 0;\n        let first_frame = inputs.next().ok_or(Error::NoFrames)?;\n        let mut screen = gif_dispose::Screen::new(first_frame.liq_image.width(), first_frame.liq_image.height(), None);\n\n        #[cfg(debug_assertions)]\n        let mut debug_screen = gif_dispose::Screen::new(first_frame.liq_image.width(), first_frame.liq_image.height(), None);\n\n        let mut next_frame = Some(first_frame);\n        while let Some(RemapMessage {ordinal_frame_number, end_pts, dispose, liq, remap, liq_image, out_buf, has_next_frame}) = next_frame {\n            let pixels = screen.pixels_rgba();\n            let screen_width = pixels.width() as u16;\n            let screen_height = pixels.height() as u16;\n            let mut screen_after_dispose = screen.dispose_only();\n\n            let (mut image8, image8_pal) = {\n                let bg = if frame_index != 0 { Some(screen_after_dispose.pixels_rgba()) } else { None };\n                self.remap(liq, remap, liq_image, bg, out_buf)?\n            };\n\n            let (image8_pal, transparent_index) = transparent_index_from_palette(image8_pal, image8.as_mut());\n\n            #[cfg(debug_assertions)]\n            debug_screen.blit(Some(&image8_pal), dispose, 0, 0, image8.as_ref(), transparent_index)?;\n\n            let (left, top) = if frame_index != 0 && has_next_frame {\n                let (left, top, new_width, new_height) = trim_image(image8.as_ref(), &image8_pal, transparent_index, dispose, screen_after_dispose.pixels_rgba())\n                    .unwrap_or((0, 0, 1, 1));\n                if new_width != image8.width() || new_height != image8.height() {\n                    let new_buf = image8.sub_image(left.into(), top.into(), new_width, new_height).to_contiguous_buf().0.into_owned();\n                    image8 = ImgVec::new(new_buf, new_width, new_height);\n                }\n                (left, top)\n            } else {\n                // must keep first and last frame\n                (0, 0)\n            };\n\n            screen_after_dispose.then_blit(Some(&image8_pal), dispose, left, top, image8.as_ref(), transparent_index)?;\n\n            #[cfg(debug_assertions)]\n            debug_assert!(debug_screen.pixels_rgba() == screen.pixels_rgba(), \"fr {ordinal_frame_number} {left}/{top} {}x{}\", image8.width(), image8.height());\n\n            write_queue.send(FrameMessage {\n                frame_index,\n                ordinal_frame_number,\n                end_pts,\n                screen_width,\n                screen_height,\n                frame: GIFFrame {\n                    left,\n                    top,\n                    image: image8,\n                    pal: image8_pal,\n                    transparent_index,\n                    dispose,\n                },\n            })?;\n            frame_index += 1;\n            next_frame = inputs.next();\n        }\n        Ok(())\n    }\n}\n\nfn transparent_index_from_palette(mut image8_pal: Vec<RGBA8>, mut image8: ImgRefMut<u8>) -> (Vec<RGB8>, Option<u8>) {\n    // Palette may have multiple transparent indices :(\n    let mut transparent_index = None;\n    for (i, p) in image8_pal.iter_mut().enumerate() {\n        if p.a <= 128 {\n            *p = RGBA8::new(71, 80, 76, 0);\n            let new_index = i as u8;\n            if let Some(old_index) = transparent_index {\n                image8.pixels_mut().filter(|px| **px == new_index).for_each(|px| *px = old_index);\n            } else {\n                transparent_index = Some(new_index);\n            }\n        }\n    }\n\n    // Check that palette is fine and has no duplicate transparent indices\n    debug_assert!(image8_pal.iter().enumerate().all(|(idx, color)| {\n        Some(idx as u8) == transparent_index || color.a > 128 || !image8.pixels().any(|px| px == idx as u8)\n    }));\n\n    (image8_pal.into_iter().map(|r| r.rgb()).collect(), transparent_index)\n}\n\n/// When one thread unexpectedly fails, all other threads fail with Aborted, but that Aborted isn't the relevant cause\n#[inline]\nfn combine_res(res1: Result<(), Error>, res2: Result<(), Error>) -> Result<(), Error> {\n    use Error::*;\n    match (res1, res2) {\n        (Err(e), Ok(())) | (Ok(()), Err(e)) => Err(e),\n        (Err(ThreadSend), res) | (res, Err(ThreadSend)) => res,\n        (Err(Aborted), res) | (res, Err(Aborted)) => res,\n        (Err(NoFrames), res) | (res, Err(NoFrames)) => res,\n        (_, res2) => res2,\n    }\n}\n\nfn trim_image(mut image_trimmed: ImgRef<u8>, image8_pal: &[RGB8], transparent_index: Option<u8>, dispose: DisposalMethod, mut screen: ImgRef<RGBA8>) -> Option<(u16, u16, usize, usize)> {\n    debug_assert_eq!(image_trimmed.width(), screen.width());\n    debug_assert_eq!(image_trimmed.height(), screen.height());\n\n    let is_matching_pixel = move |px: u8, bg: RGBA8| -> bool {\n        if Some(px) == transparent_index {\n            if dispose == DisposalMethod::Keep {\n                // if dispose == keep, then transparent pixels do nothing, so they can be cropped out\n                true\n            } else {\n                debug_assert_eq!(dispose, DisposalMethod::Background);\n                // if disposing to background, then transparent pixels paint transparency, so bg has to actually be transparent to match\n                bg.a == 0\n            }\n        } else {\n            let Some(pal_px) = image8_pal.get(px as usize) else {\n                debug_assert!(false, \"{px} > {}\", image8_pal.len());\n                return false;\n            };\n            pal_px.with_alpha(255) == bg\n        }\n    };\n\n    let bottom = image_trimmed.rows().zip(screen.rows()).rev()\n        .take_while(|(img_row, screen_row)| {\n            img_row.iter().copied().zip(screen_row.iter().copied())\n                .all(|(px, bg)| is_matching_pixel(px, bg))\n        })\n        .count();\n\n    if bottom > 0 {\n        if bottom == image_trimmed.height() {\n            return None;\n        }\n        image_trimmed = image_trimmed.sub_image(0, 0, image_trimmed.width(), image_trimmed.height() - bottom);\n        screen = screen.sub_image(0, 0, screen.width(), screen.height() - bottom);\n    }\n\n    let top = image_trimmed.rows().zip(screen.rows())\n        .take_while(|(img_row, screen_row)| {\n            img_row.iter().copied().zip(screen_row.iter().copied())\n                .all(|(px, bg)| is_matching_pixel(px, bg))\n        })\n        .count();\n\n    if top > 0 {\n        debug_assert_ne!(image_trimmed.height(), top);\n        image_trimmed = image_trimmed.sub_image(0, top, image_trimmed.width(), image_trimmed.height() - top);\n        screen = screen.sub_image(0, top, screen.width(), screen.height() - top);\n    }\n\n    let left = (0..image_trimmed.width() - 1)\n        .take_while(|&x| {\n            (0..image_trimmed.height()).all(|y| {\n                let px = image_trimmed[(x, y)];\n                is_matching_pixel(px, screen[(x, y)])\n            })\n        }).count();\n    if left > 0 {\n        debug_assert!(image_trimmed.width() > left);\n        image_trimmed = image_trimmed.sub_image(left, 0, image_trimmed.width() - left, image_trimmed.height());\n        screen = screen.sub_image(left, 0, screen.width() - left, screen.height());\n    }\n    debug_assert_eq!(image_trimmed.width(), screen.width());\n    debug_assert_eq!(image_trimmed.height(), screen.height());\n\n    let right = (1..image_trimmed.width()).rev()\n        .take_while(|&x| {\n            (0..image_trimmed.height()).all(|y| {\n                let px = image_trimmed[(x, y)];\n                is_matching_pixel(px, screen[(x, y)])\n            })\n        }).count();\n    if right > 0 {\n        debug_assert!(image_trimmed.width() > right);\n        image_trimmed = image_trimmed.sub_image(0, 0, image_trimmed.width() - right, image_trimmed.height());\n    }\n\n    Some((left as _, top as _, image_trimmed.width(), image_trimmed.height()))\n}\n\ntrait PushInCapacity<T> {\n    fn push_in_cap(&mut self, val: T);\n}\n\nimpl<T> PushInCapacity<T> for Vec<T> {\n    #[inline(always)]\n    #[cfg_attr(debug_assertions, track_caller)]\n    fn push_in_cap(&mut self, val: T) {\n        debug_assert!(self.capacity() != self.len());\n        if self.capacity() != self.len() {\n            self.push(val);\n        }\n    }\n}\n\n#[cold]\nfn handle_join_error(err: Box<dyn std::any::Any + Send>) -> Error {\n    let msg = err.downcast_ref::<String>().map(|s| s.as_str())\n    .or_else(|| err.downcast_ref::<&str>().copied()).unwrap_or(\"unknown panic\");\n    eprintln!(\"thread crashed (this is a bug): {msg}\");\n    Error::ThreadSend\n}\n\n#[test]\nfn sendable() {\n    fn is_send<T: Send>() {}\n    is_send::<Collector>();\n    is_send::<Writer>();\n}\n"
  },
  {
    "path": "gifski-api/src/minipool.rs",
    "content": "use crate::Error;\nuse crossbeam_channel::Sender;\nuse std::num::NonZeroU8;\nuse std::panic::catch_unwind;\nuse std::sync::atomic::{AtomicBool, Ordering::Relaxed};\n\n#[inline]\npub fn new_channel<P, C, M, R>(num_threads: NonZeroU8, name: &str, producer: P, mut consumer: C) -> Result<R, Error> where\n    M: Send,\n    C: Clone + Send + FnMut(M) -> Result<(), Error> + std::panic::UnwindSafe,\n    P: FnOnce(Sender<M>) -> Result<R, Error>,\n{\n    let (s, r) = crossbeam_channel::bounded(2);\n    new_scope(num_threads, name, move || producer(s),\n        move |should_abort| {\n            for m in r {\n                if should_abort.load(Relaxed) {\n                    break;\n                }\n                consumer(m)?;\n            }\n            Ok(())\n        })\n}\n\npub fn new_scope<P, C, R>(num_threads: NonZeroU8, name: &str, waiter: P, consumer: C) -> Result<R, Error> where\n    C: Clone + Send + FnOnce(&AtomicBool) -> Result<(), Error> + std::panic::UnwindSafe,\n    P: FnOnce() -> Result<R, Error>,\n{\n    let failed = &AtomicBool::new(false);\n    std::thread::scope(move |scope| {\n        let thread = move || {\n            catch_unwind(move || consumer(failed))\n                .map_err(|_| Error::ThreadSend).and_then(|x| x)\n                .map_err(|e| {\n                    failed.store(true, Relaxed);\n                    e\n                })\n        };\n        let handles = std::iter::repeat(thread).enumerate()\n            .take(num_threads.get().into())\n            .map(move |(n, thread)| {\n                std::thread::Builder::new().name(format!(\"{name}{n}\")).spawn_scoped(scope, thread)\n            })\n            .collect::<Result<Vec<_>, _>>()\n            .map_err(move |_| {\n                failed.store(true, Relaxed);\n                Error::ThreadSend\n            })?;\n\n        let res = waiter().map_err(|e| {\n            failed.store(true, Relaxed);\n            e\n        });\n        handles.into_iter().try_for_each(|h| h.join().map_err(|_| Error::ThreadSend)?)?;\n        res\n    })\n}\n"
  },
  {
    "path": "gifski-api/src/progress.rs",
    "content": "//! For tracking conversion progress and aborting early\n\n#[cfg(feature = \"pbr\")]\n#[doc(hidden)]\n#[deprecated(note = \"The pbr dependency is no longer exposed. Please use a newtype pattern and write your own trait impl for it\")]\npub use pbr::ProgressBar;\n\nuse std::os::raw::{c_int, c_void};\n\n/// A trait that is used to report progress to some consumer.\npub trait ProgressReporter: Send {\n    /// Called after each frame has been written.\n    ///\n    /// This method may return `false` to abort processing.\n    fn increase(&mut self) -> bool;\n\n    /// File size so far\n    fn written_bytes(&mut self, _current_file_size_in_bytes: u64) {}\n\n    /// Not used :(\n    /// Writing is done when `Writer::write()` call returns\n    fn done(&mut self, _msg: &str) {}\n}\n\n/// No-op progress reporter\npub struct NoProgress {}\n\n/// For C\npub struct ProgressCallback {\n    callback: unsafe extern \"C\" fn(*mut c_void) -> c_int,\n    arg: *mut c_void,\n}\n\nunsafe impl Send for ProgressCallback {}\n\nimpl ProgressCallback {\n    pub fn new(callback: unsafe extern \"C\" fn(*mut c_void) -> c_int, arg: *mut c_void) -> Self {\n        Self { callback, arg }\n    }\n}\n\nimpl ProgressReporter for NoProgress {\n    fn increase(&mut self) -> bool {\n        true\n    }\n\n    fn done(&mut self, _msg: &str) {}\n}\n\nimpl ProgressReporter for ProgressCallback {\n    fn increase(&mut self) -> bool {\n        unsafe { (self.callback)(self.arg) == 1 }\n    }\n\n    fn done(&mut self, _msg: &str) {}\n}\n\n/// Implement the progress reporter trait for a progress bar,\n/// to make it usable for frame processing reporting.\n#[cfg(feature = \"pbr\")]\nimpl<T> ProgressReporter for ProgressBar<T> where T: std::io::Write + Send {\n    fn increase(&mut self) -> bool {\n        self.inc();\n        true\n    }\n\n    fn done(&mut self, msg: &str) {\n        self.finish_print(msg);\n    }\n}\n"
  },
  {
    "path": "license",
    "content": "MIT License\n\nCopyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)\nCopyright (c) Kornel Lesiński <kornel@pngquant.org> (https://gif.ski)\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n"
  },
  {
    "path": "maintaining.md",
    "content": "## Maintaining\n\n### Testing system services\n\nFirst, we need to install the app:\n1. Archive the app.\n2. Once you have your archive in the Organizer window, right-click it, and click **Show in Finder**.\n3. Right-click again, now on the latest `Gifski_DATE_.xcarchive`, and click **Show Package Contents**.\n4. Open `/Products/Applications` and move `Gifski.app` to your `Applications` directory.\n\nThen, we need to check if our system has the latest service installed:\n1. In your terminal, enter the command:\n```bash\n/System/Library/CoreServices/pbs -dump | grep Gifski.app\n```\n2. If you see `NSBundlePath = \"/Applications/Gifski.app”` - you're good to go.\n3. If you don't see the line above, try updating the cache:\n```bash\n/System/Library/CoreServices/pbs -update\n```\n\n### Troubleshooting system services\n\nSometimes the service doesn't work and it's really hard to understand why without any tools. You can use a debug flag on the instance of `Finder` app and see the logs it dumps:\n\n```bash\n/System/Library/CoreServices/Finder.app/Contents/MacOS/Finder -NSDebugServices com.sindresorhus.Gifski\n```\n\n### Video rotation handling\n\nVideos can have a `preferredTransform` that rotates the raw frames (e.g., portrait videos filmed on phones). There are two coordinate spaces:\n\n1. **Natural space**: Raw frame dimensions (`naturalSize`), unrotated (e.g., 1920x1080)\n2. **Preferred space**: How the user sees the video after rotation (e.g., 1080x1920 for portrait)\n\nIn this app:\n- UI dimensions (`metadata.dimensions`) are in **preferred space** (already rotated)\n- Crop rect from UI is defined in **preferred space**\n- `AVAssetImageGenerator` with `appliesPreferredTrackTransform = true` returns images in **preferred space**\n- Preview manually applies transform, so images are also in **preferred space**\n- `AVComposition` layer instructions operate in **natural space** (must transform crop back)\n\nWhen cropping images: Apply crop directly (images are pre-rotated).\nWhen exporting video: Transform crop from preferred → natural space first.\n"
  },
  {
    "path": "readme.md",
    "content": "<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 videos to high-quality GIFs on your Mac</b>\n\t</p>\n\t<br>\n\t<br>\n\t<br>\n</div>\n\nThis is a macOS app for the [`gifski` encoder](https://gif.ski), which converts videos to GIF animations using [`pngquant`](https://pngquant.org)'s fancy features for efficient cross-frame palettes and temporal dithering. It produces animated GIFs that use thousands of colors per frame and up to 50 FPS (useful for showing off design work on Dribbble).\n\nYou can also produce smaller lower quality GIFs when needed with the “Quality” slider, thanks to [`gifsicle`](https://github.com/kohler/gifsicle).\n\nGifski supports all the video formats that macOS supports (`.mp4` or `.mov` with H264, HEVC, ProRes, etc). The [QuickTime Animation format](https://en.wikipedia.org/wiki/QuickTime_Animation) is not supported. Use [ProRes 4444 XQ](https://en.wikipedia.org/wiki/Apple_ProRes) instead. It's more efficient, more widely supported, and like QuickTime Animation, it also supports alpha channel.\n\nGifski has a bunch of settings like changing dimensions, speed, frame rate, quality, looping, and more.\n\n## Download\n\n[![](https://sindresorhus.com/assets/download-on-app-store-badge.svg)](https://apps.apple.com/app/id1351639930)\n\nRequires macOS 14 or later.\n\n**Older versions**\n\n- [2.23.0](https://github.com/sindresorhus/Gifski/releases/download/v2.23.0/Gifski.2.23.0.-.macOS.13.zip) for macOS 13+\n- [2.22.3](https://github.com/sindresorhus/Gifski/releases/download/v2.22.3/Gifski.2.22.3.-.macOS.12.zip) for macOS 12+\n- [2.21.2](https://github.com/sindresorhus/Gifski/releases/download/v2.21.2/Gifski.2.21.2.-.macOS.11.zip) for macOS 11+\n- [2.20.2](https://github.com/sindresorhus/Gifski/releases/download/v2.20.2/Gifski.2.20.2.-.macOS.10.15.zip) for macOS 10.15+\n- [2.16.0](https://github.com/sindresorhus/Gifski/releases/download/v2.16.0/Gifski.2.16.0.-.macOS.10.14.zip) for macOS 10.14+\n- [2.4.0](https://github.com/sindresorhus/Gifski/files/3991913/Gifski.2.4.0.-.High.Sierra.zip) for macOS 10.13+\n\n**Non-App Store version**\n\nA special version for users that cannot access the App Store. It won't receive automatic updates. I will update it here once a year.\n\n[Download](https://github.com/sindresorhus/meta/files/13539147/Gifski-2.23.0-1692807940.zip) *(2.23.0 · macOS 13+)*\n\n## Features\n\n### Share extension\n\nGifski includes a share extension that lets you share videos to Gifski. Just select Gifski from the Share menu of any macOS app.\n\n> Tip: You can share a macOS screen recording with Gifski by clicking on the thumbnail that pops up once you are done recording and selecting “Share” from there.\n\n### System service\n\nGifski includes a [system service](https://www.computerworld.com/article/2476298/os-x-a-quick-guide-to-services-on-your-mac.html) that lets you quickly convert a video to GIF from the **Services** menu in any app that provides a compatible video file.\n\n### Bounce (yo-yo) GIF playback\n\nGifski includes the option to create GIFs that bounce back and forth between forward and backward playback. This is a similar effect to the bounce effect in [iOS's Live Photo effects](https://support.apple.com/en-us/HT207310). This option doubles the number of frames in the GIF so the file size will double as well.\n\n<!-- ### Batch conversion\n\nYou can use the Shortcuts app to do batch conversions or any kind of automated GIF generation. Look for the “Convert Video to Animated GIF” action in the Shortcuts app. -->\n\n## Tips\n\n#### Quickly copy or save the GIF\n\nAfter converting, press <kbd>Command+C</kbd> to copy the GIF or <kbd>Command+S</kbd> to save it.\n\n#### Change GIF dimensions with the keyboard\n\n<img src=\"https://user-images.githubusercontent.com/170270/59964494-b8519f00-952b-11e9-8d16-47c8bc103a61.gif\" width=\"226\" height=\"80\" align=\"right\">\n\nIn the width/height input fields in the editor view, press the arrow up/down keys to change the value by 1. Hold the Option key meanwhile to change it by 10.\n\n## Screenshots\n\n<img src=\"Stuff/screenshot1.jpg\" width=\"720\" height=\"450\">\n<img src=\"Stuff/screenshot2.jpg\" width=\"720\" height=\"450\">\n<img src=\"Stuff/screenshot3.jpg\" width=\"720\" height=\"450\">\n<img src=\"Stuff/screenshot4.jpg\" width=\"720\" height=\"450\">\n\n## Building from source\n\nTo build the app in Xcode, you need to have [Rust](https://www.rust-lang.org) installed first:\n\n```sh\ncurl https://sh.rustup.rs -sSf | sh\nbrew install SwiftLint\nxcode-select --install\n```\n\n## Tips\n\n## Quick Action shortcut\n\nConvert videos to GIFs directly from Finder using the built-in [Quick Action](https://support.apple.com/en-mz/guide/mac-help/mchl97ff9142/mac) shortcut. It works without opening Gifski, and you can create multiple shortcuts with different settings, such as quality, dimensions, or looping, to match your workflow.\n\n[Download shortcut](https://www.icloud.com/shortcuts/8a00497b180742139474d5470857d699)\n\n**Requires the [TestFlight version](https://testflight.apple.com/join/iCyHNNIA) of Gifski**\n\n## FAQ\n\n#### The generated GIFs are huge!\n\nThe GIF image format is very space inefficient. It works best with short video clips. Try reducing the dimensions, FPS, or quality.\n\n#### Why are 60 FPS and higher not supported?\n\nBrowsers throttle frame rates above 50 FPS, playing them at 10 FPS. [Read more](https://github.com/sindresorhus/Gifski/issues/161#issuecomment-552547771).\n\n#### How can I convert a sequence of PNG images to a GIF?\n\nInstall [FFmpeg](https://www.ffmpeg.org/) (with Homebrew: `brew install ffmpeg`) and then run this command:\n\n```\nTMPFILE=\"$(mktemp /tmp/XXXXXXXXXXX).mov\"; \\\n\tffmpeg -f image2 -framerate 30 -i image_%06d.png -c:v prores_ks -profile:v 5 \"$TMPFILE\" \\\n\t&& open -a Gifski \"$TMPFILE\"\n```\n\nEnsure the images are named in the format `image_000001.png` and adjust the `-framerate` accordingly.\n\n[*Command explanation.*](https://avpres.net/FFmpeg/sq_ProRes.html)\n\n#### How can I run multiple conversions at the same time?\n\nThis is unfortunately not supported in the app itself, but you can do it from the Shortcuts app using the shortcut action that comes with the app.\n\nIf you know how to run a terminal command, you could also run `open -na Gifski` multiple times to open multiple instances of Gifski, where each instance can convert a separate video. You should not have the editor view open in multiple instances though, as changing the quality, for example, will change it in all the instances.\n\n#### Is it possible to convert from WebM?\n\nGifski supports the video formats macOS supports, which does not include WebM.\n\nYou can convert your video to MP4 first with [this app](https://apps.apple.com/app/id1518836004).\n\n#### Can I contribute localizations?\n\nWe don't plan to localize the app.\n\n#### Can you support Windows and Linux?\n\nNo, but there's a [cross-platform command-line tool](https://github.com/ImageOptim/gifski) available.\n\n#### [More FAQs…](https://sindresorhus.com/apps/faq)\n\n## Press\n\n- [Five Mac Apps Worth Checking Out - September 2019 - MacRumors](https://www.macrumors.com/2019/09/04/five-mac-apps-sept-2019/)\n\n## Built with\n\n- [gifski library](https://github.com/ImageOptim/gifski) - High-quality GIF encoder\n- [Defaults](https://github.com/sindresorhus/Defaults) - Swifty and modern UserDefaults\n- [DockProgress](https://github.com/sindresorhus/DockProgress) - Show progress in your app's Dock icon\n\n## Maintainers\n\n- [Sindre Sorhus](https://github.com/sindresorhus)\n- [Kornel Lesiński](https://github.com/kornelski)\n\n## Related\n\n- [Sindre's apps](https://sindresorhus.com/apps)\n\n## License\n\nMIT (the Mac app) + [gifski library license](https://github.com/ImageOptim/gifski/blob/master/LICENSE)\n"
  }
]