Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b0e2ca9
implemented export modified video (without audio for now)
mmulet Jun 27, 2025
862ee4f
add app group back in
mmulet Jun 27, 2025
23d08fd
export audio, plus fixes
mmulet Jun 27, 2025
5487cf6
implemented requested changes
mmulet Jul 7, 2025
cc2c721
Update Utilities.swift
sindresorhus Jul 8, 2025
f41ebc5
implemented requested changes
mmulet Jul 8, 2025
51f8990
Merge remote-tracking branch 'origin/main' into export_video2
mmulet Jul 8, 2025
0853bcc
Merge branch 'export_video2' of https://github.com/mmulet/Gifski into…
mmulet Jul 8, 2025
5ff5f76
added the warning on export if the video has audio
mmulet Jul 8, 2025
3a4124c
fix bug where it stop exports if you start when another alert was active
mmulet Jul 9, 2025
3bfb5de
more simplified
mmulet Jul 9, 2025
2cf84de
Update EditScreen.swift
sindresorhus Jul 20, 2025
8fa4535
implemtned requested changes
mmulet Jul 21, 2025
2640165
Show progress only when video is longer than 20 seconds
mmulet Aug 11, 2025
b17c902
move Export Modified Video State out of Export Modified video
mmulet Aug 12, 2025
dfc2e49
move audioWarning out of ExportModifiedVideoState
mmulet Aug 12, 2025
2117c67
implemented requested changes
mmulet Aug 18, 2025
d2e2928
added more support for prefferredTransform
mmulet Aug 19, 2025
7907b7c
Update App.swift
sindresorhus Sep 29, 2025
1bc794c
Merge branch 'main' of https://github.com/sindresorhus/Gifski into ex…
mmulet Oct 4, 2025
4053aac
Update Gifski/Components/TrimmingAVPlayer.swift
mmulet Oct 4, 2025
e9804d6
part 1 of implementing requested changes
mmulet Oct 4, 2025
bc7e2bb
Merge branch 'export_video2' of https://github.com/mmulet/Gifski into…
mmulet Oct 4, 2025
c0b54f2
Fix speed slider bug
mmulet Oct 5, 2025
8bc78da
simplify
mmulet Oct 5, 2025
4dea67b
style fixes
mmulet Oct 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Gifski.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
objects = {

/* Begin PBXBuildFile section */
0CBD7F2E2E0F044C00E2C5E4 /* ExportModifiedVideo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBD7F2D2E0F044C00E2C5E4 /* ExportModifiedVideo.swift */; };
0E7925202329BDBE00058B94 /* ShareController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E79251F2329BDBE00058B94 /* ShareController.swift */; };
0E7925282329BDBE00058B94 /* Share Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0E79251B2329BDBE00058B94 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
8588EB0D22A424B800030A59 /* ResizableDimensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8588EB0C22A424B800030A59 /* ResizableDimensions.swift */; };
Expand Down Expand Up @@ -77,6 +78,7 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
0CBD7F2D2E0F044C00E2C5E4 /* ExportModifiedVideo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportModifiedVideo.swift; sourceTree = "<group>"; };
0E79251B2329BDBE00058B94 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
0E79251F2329BDBE00058B94 /* ShareController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = ShareController.swift; sourceTree = "<group>"; usesTabs = 1; };
0E7925242329BDBE00058B94 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
Expand Down Expand Up @@ -223,6 +225,7 @@
E33552EE2ACAC3190023AAE9 /* MainScreen.swift */,
E33552F22ACAC5D80023AAE9 /* StartScreen.swift */,
E37F68E32ACADA40007F1A7F /* EditScreen.swift */,
0CBD7F2D2E0F044C00E2C5E4 /* ExportModifiedVideo.swift */,
E37F68E12ACAD9F1007F1A7F /* ConversionScreen.swift */,
E37F68DF2ACAD9D1007F1A7F /* CompletedScreen.swift */,
E3908B7326754568000723A7 /* EstimatedFileSize.swift */,
Expand Down Expand Up @@ -429,6 +432,7 @@
E37F68E42ACADA40007F1A7F /* EditScreen.swift in Sources */,
E3961F802AC9F2A700708EB7 /* Intents.swift in Sources */,
E30C8EEF29387E7A002E053F /* Gifski.swift in Sources */,
0CBD7F2E2E0F044C00E2C5E4 /* ExportModifiedVideo.swift in Sources */,
E3FC365C2377FA0000CF7C59 /* Shared.swift in Sources */,
E37F68E02ACAD9D1007F1A7F /* CompletedScreen.swift in Sources */,
E33552EF2ACAC3190023AAE9 /* MainScreen.swift in Sources */,
Expand Down
7 changes: 7 additions & 0 deletions Gifski/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ struct AppMain: App {
.keyboardShortcut("o")
.disabled(appState.isConverting)
}
CommandGroup(replacing: .importExport) {
Button("Export as Video…", systemImage: "square.and.arrow.up") {
appState.onExportAsVideo?()
}
.keyboardShortcut("e")
.disabled(appState.onExportAsVideo == nil)
}
CommandGroup(replacing: .textEditing) {
Toggle("Preview", isOn: appState.toggleMode(mode: .preview))
.keyboardShortcut("p", modifiers: [.command, .shift])
Expand Down
2 changes: 2 additions & 0 deletions Gifski/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ final class AppState {
mode == .editCrop
}

var onExportAsVideo: (() -> Void)?

/**
Provides a binding for a toggle button to access a certain mode.

Expand Down
30 changes: 27 additions & 3 deletions Gifski/Components/TrimmingAVPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ final class TrimmingAVPlayerViewController: NSViewController {
private let controlsStyle: AVPlayerViewControlsStyle
private let timeRangeDidChange: ((ClosedRange<Double>) -> Void)?
private var cancellables = Set<AnyCancellable>()
private var currentItemDurationRange: ClosedRange<Double>?

fileprivate var overlay: NSView? {
didSet {
Expand Down Expand Up @@ -272,9 +273,7 @@ final class TrimmingAVPlayerViewController: NSViewController {

playerView.setupTrimmingObserver()

if let durationRange = $0.durationRange {
timeRangeDidChange?(durationRange)
}
onNewDurationRange(durationRange: $0.durationRange)

// This is here as it needs to be refreshed when the current item changes.
playerView.observeTrimmedTimeRange { [weak self] timeRange in
Expand All @@ -284,6 +283,31 @@ final class TrimmingAVPlayerViewController: NSViewController {
}
.store(in: &cancellables)
}
func onNewDurationRange(durationRange newItemDurationRange: ClosedRange<Double>?) {
guard let newItemDurationRange else {
currentItemDurationRange = nil
return
}
defer {
currentItemDurationRange = newItemDurationRange
}
guard let timeRange,
let currentItemDurationRange else {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code style. Applies everywhere.

Suggested change
guard let timeRange,
let currentItemDurationRange else {
guard
let timeRange,
let currentItemDurationRange
else {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry about this one. I was a bit confused because the alignment did look good on both, xcode and vscode (see below). But, I turned on whitespace and it looks like it was a interplay between tabs and spaces that the Github file viewer doesn't like. I'll switch everything over to pure tabs.

Screenshot 2025-10-04 at 12 26 51 PM Screenshot 2025-10-04 at 12 29 07 PM

timeRangeDidChange?(newItemDurationRange)
return
}
// convert timeRange from oldItemDurationRange to new duration range
// necessary for when the video changes speed.
let speed: Double = {
guard currentItemDurationRange.length != 0 else {
return 1.0
}
return newItemDurationRange.length / currentItemDurationRange.length
}()
let newTimeRange = (timeRange - currentItemDurationRange.lowerBound) * speed + newItemDurationRange.lowerBound
self.timeRange = newTimeRange
timeRangeDidChange?(newTimeRange)
}
}

final class TrimmingAVPlayerView: AVPlayerView {
Expand Down
27 changes: 18 additions & 9 deletions Gifski/Crop/CropSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import AVKit

protocol CropSettings {
var dimensions: (width: Int, height: Int)? { get }
var trackPreferredTransform: CGAffineTransform? { get }
var crop: CropRect? { get }
}

Expand All @@ -15,27 +16,35 @@ extension CropSettings {
If the rect parameter defines an area that is not in the image, it returns nil: https://developer.apple.com/documentation/coregraphics/cgimage/1454683-cropping
*/
func croppedImage(image: CGImage) -> CGImage? {
guard let crop else {
guard crop != nil else {
return image
}
let transformedCrop = unormalziedCropFor(sizeInPreferredTransformationSpace: .init(width: image.width, height: image.height))
return image.cropping(to: transformedCrop)
}

return image.cropping(to: crop.unnormalize(forDimensions: (image.width, image.height)))
func unormalziedCropFor(sizeInPreferredTransformationSpace prefferedSize: CGSize) -> CGRect {
let cropRect = crop ?? .initialCropRect
guard let trackPreferredTransform else {
return cropRect.unnormalize(forDimensions: prefferedSize)
}
let origninalSize = CGRect(origin: .zero, size: prefferedSize)
.applying(trackPreferredTransform.inverted()).size
let originalCropSize = cropRect.unnormalize(forDimensions: origninalSize)
return originalCropSize.applying(trackPreferredTransform)
}

var croppedOutputDimensions: (width: Int, height: Int)? {
guard let crop else {
guard crop != nil else {
return dimensions
}

guard let dimensions else {
return nil
}

let cropInPixels = crop.unnormalize(forDimensions: dimensions)

return (
cropInPixels.width.toIntAndClampingIfNeeded,
cropInPixels.height.toIntAndClampingIfNeeded
)
let outputDimensions = unormalziedCropFor(sizeInPreferredTransformationSpace: .init(width: dimensions.width, height: dimensions.height))
return (outputDimensions.width.toIntAndClampingIfNeeded,
outputDimensions.height.toIntAndClampingIfNeeded)
}
}
63 changes: 62 additions & 1 deletion Gifski/EditScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ private struct _EditScreen: View {
@State private var url: URL
@State private var asset: AVAsset
@State private var modifiedAsset: AVAsset
@State private var modifiedAssetTimeRange: CMTimeRange?
@State private var metadata: AVAsset.VideoMetadata
@State private var estimatedFileSizeModel = EstimatedFileSizeModel()
@State private var timeRange: ClosedRange<Double>?
Expand All @@ -54,6 +55,8 @@ private struct _EditScreen: View {
@State private var fullPreviewDebouncer = Debouncer(delay: .milliseconds(200))

@Binding private var outputCropRect: CropRect
@State private var exportModifiedVideoState = ExportModifiedVideoState.idle
@State private var isExportModifiedVideoAudioWarningPresented = false
private var overlay: NSView
private let fullPreviewStream: FullPreviewStream

Expand All @@ -79,6 +82,11 @@ private struct _EditScreen: View {
trimmingAVPlayer
controls
bottomBar
ExportModifiedVideoView(
state: $exportModifiedVideoState,
sourceURL: url,
isAudioWarningPresented: $isExportModifiedVideoAudioWarningPresented
)
}
.background(.ultraThickMaterial)
.navigationTitle(url.lastPathComponent)
Expand Down Expand Up @@ -160,6 +168,19 @@ private struct _EditScreen: View {
.opacity(shouldShow ? 1 : 0)
.onAppear {
setUp()
appState.onExportAsVideo = onExportAsVideo
}
.onDisappear {
appState.onExportAsVideo = nil

switch exportModifiedVideoState {
case .idle:
break
case .exporting(let task, _):
task.cancel()
case .finished(let url):
try? FileManager.default.removeItem(at: url)
}
}
.task {
try? await Task.sleep(for: .seconds(0.3))
Expand All @@ -175,6 +196,44 @@ private struct _EditScreen: View {
}
}

private func onExportAsVideo() {
switch exportModifiedVideoState {
case .idle:
break
case .exporting, .finished:
// If another alert (like bounce warning) occurs when you activate this callback, the `fileExporter` modifier won't show and the state will be stuck on `.finished`. By reassigning the state this will force a swiftUI draw and bring up the file exporter.
exportModifiedVideoState = exportModifiedVideoState
return
}

if metadata.hasAudio {
SSApp.runOnce(identifier: "audioTrackExportWarning") {
isExportModifiedVideoAudioWarningPresented = true
}
}

exportModifiedVideoState = .exporting(
Task {
do {
let outputURL = try await exportModifiedVideo(conversion: conversionSettings)
try await MainActor.run {
try Task.checkCancellation()
exportModifiedVideoState = .finished(outputURL)
}
} catch {
if Task.isCancelled || error.isCancelled {
return
}
await MainActor.run {
exportModifiedVideoState = .idle
appState.error = error
}
}
},
videoIsOverTwentySeconds: conversionSettings.gifDuration(assetTimeRange: modifiedAssetTimeRange) > .seconds(20)
)
}

private func updatePreviewOnSettingsChange() {
guard appState.mode != .editCrop else {
return
Expand Down Expand Up @@ -203,6 +262,7 @@ private struct _EditScreen: View {

let changedSpeedAsset = try await asset.firstVideoTrack?.extractToNewAssetAndChangeSpeed(to: Defaults[.outputSpeed]) ?? modifiedAsset
modifiedAsset = try await PreviewableComposition(extractPreviewableCompositionFrom: changedSpeedAsset)
modifiedAssetTimeRange = try await changedSpeedAsset.firstVideoTrack?.load(.timeRange)

estimatedFileSizeModel.updateEstimate()
updatePreviewOnSettingsChange()
Expand Down Expand Up @@ -317,7 +377,8 @@ private struct _EditScreen: View {
return .forever
}(),
bounce: bounceGIF,
crop: outputCropRect
crop: outputCropRect,
trackPreferredTransform: metadata.trackPreferredTransform
)
}

Expand Down
Loading