Gifski
Gifski copied to clipboard
implemented GIF preview feature issue #136
Hi! I implemented the gif preview features as requested in issue #136 Please watch this short video for a demonstration:
https://github.com/user-attachments/assets/0b02212b-29f7-43d9-91f3-ec74a0ab9e05
Here is what the feature does:
- [x] adds the Show Preview Texture toggle
- [x] Renders the current frame right away
- [x] Starts rendering the whole thing for real.
- [x] Once it is all rendered you can preview the render with the play button
- [x] Allows for scrubbing to render one frame at the scrubbed position.
- [x] Implemented in SwiftUI and Combine Looking at the issue #136 this should fulfill all of your major requirements.
I will submit this to issueHunt for the ongoing bounty, but any extra tips (you can use GitHub Sponsor page) would be very welcome!
IssueHunt Summary
Referenced issues
This pull request has been submitted to:
- The spinner besides the preview is too big.
- Don't need the "Preview" text on the video. The checkbox makes it clear enough.
- The "Show PReview" checkbox doesn't look that good.
I ended up with empty preview many times:
Test video:
https://github.com/user-attachments/assets/b372bbeb-4554-4537-9343-dec8cfd25349
Changes
As usually here is a new demo video:
https://github.com/user-attachments/assets/eda28e2b-52f0-4cb9-a74d-556206e6ba19
- [x] The spinner besides the preview is too big.
- [x] Don't need the "Preview" text on the video. The checkbox makes it clear enough.
- [x] Improve Visual for the "Show Preview" checkbox
- [x] FIX: I ended up with empty preview many times:
- fixed
- [x] Don't hide PlayButton, just disable
- [x] Implement reusable getter/setter to hide/show the PlayButton on Trimmer
- [x] Preview Logic OUtside of trimming AVPlayer
- All the logic has been moved out of trimmingAVplayer and moved into the PreviewViewState
- [x] Don't hard-wrap, playback rate
- [x] Instead of PeriodicTimeObserver, Add a AVPlayer extension that wraps addPeriodicTimeObserver in a asyncstream and use that instead. It would make it easier to use. It could even accept a Duration instead, for improved usability.
- Moved the peridicTimerObserver to a new stream focued on when the trimer scrubs to a new time
- [x] /// Instead of /**
- [x] Use Combine. for playerRateObserer (m)
- [x] PreviewGenerator to Observable
- [x] Cleanup PreviewGenerator
- Much better now, switched form my old-timey thread loop to an AsyncStream. It has been completely reworked
I found some more things that needed to be fixed while I was at it:
- [x] Fix not having enough frames for a preview
- [x] Fix trimmed video callback happening on loopback, bounce, or play button press
- [x] Fix video playback not syncing up with preview
- [x] Fix scrubbing preview not pausing the gif (have to pause the gif because there are no native gif pausing)
One last thing:
While testing the preview, I noticed a bug where it would flash the wrong frame. I confirmed that this is not a problem with preview, it is a problem with the export itself, if you convert the video it has the same results, the same bug, you can see it here:
https://github.com/user-attachments/assets/3bc49c05-fd2f-47c0-abf3-6e1508a3725f
- In preview, if I pause in the middle and resume, it starts from the beginning, not the paused position.
- The "Show preview" button should be a toggle with a SF Symbol icon, just like with crop.
- It should also be in the menu bar menu, like with crop.
- When the drag the position handle (the red line), I often see the huge progress indicator show, then hide, then show. It flickers.
- When it has converted the whole video to a GIF and I play and then pause, it still shows the huge progress indicator momentarily. It should not show.
- If I click "Show preview", let it convert the whole video to GIF, then press "Hide preview", and then "Show preview" again, it re-renders the whole GIF. Nothing changed, so it should be able to reuse the existing GIF. A user may want to be able to quickly toggle back and forth to compare quality with the original.
- The code still needs a lot of minor cleanup. Don't hard-wrap code comments.
- Would be great if you could go over the code and try to simplify it. It's becoming quite complex.
While testing the preview, I noticed a bug where it would flash the wrong frame. I confirmed that this is not a problem with preview, it is a problem with the export itself, if you convert the video it has the same results, the same bug, you can see it here:
Seems like a bug in the "Bounce" logic.
- In preview, if I pause in the middle and resume, it starts from the beginning, not the paused position.
- The preview outputs a GIF, which is directly passed to a NSImage and there are no GIF playback controls. All I can do is start at beginning or stop. The only solution to this is to make a custom GIF SwiftUI player, perhaps with
CGAnimateImageDataWithBlock(_:_:_:). I will probably take this approach.
- When it has converted the whole video to a GIF and I play and then pause, it still shows the huge progress indicator momentarily. It should not show.
- When you Pause it takes a moment to generate the preview image at the current pause time. If I switch to a custom GIF player, this shouldn't be a problem anymore.
The preview outputs a GIF, which is directly passed to a NSImage and there are no GIF playback controls. All I can do is start at beginning or stop. The only solution to this is to make a custom GIF SwiftUI player, perhaps with CGAnimateImageDataWithBlock(::_:). I will probably take this approach.
Wouldn't it be easier to make a video out of the GIF frames? Then you could reuse everything we already have since it's just a video.
The preview outputs a GIF, which is directly passed to a NSImage and there are no GIF playback controls. All I can do is start at beginning or stop. The only solution to this is to make a custom GIF SwiftUI player, perhaps with CGAnimateImageDataWithBlock(::_:). I will probably take this approach.
Wouldn't it be easier to make a video out of the GIF frames? Then you could reuse everything we already have since it's just a video.
I actually did try that earlier but I ran into 2 problems:
- Re-encoding the gif increased the time to preview by at least 2x.
- If I used a lossless format to preserve the preview exactly, the memory usage ballooned.
- If I used a lossy format, how does the user know if a gif preview looks bad due to compression artifacts from the lossy preview or due to the my gif quality?
My video branch is here although it's not complete (doesn't have all the fixes from last time)
edit: It also doesn't actually run right now, it still has some bugs to be ironed out.
I'll just try both, starting with the video, and if I can't solve my problems, I'll move on to the custom GIF player.
I uploaded the commit with the video player, I would say it's in progress, but not finished.
Overall, I have mixed feelings about it.
On one hand, it does simplify things quite a bit, I was able to simplify most of the code into pure functions and I moved almost everything out of TrimmingAVPlayer, and there is no "PreviewState", "PreviewGenerator", or "LatestItemStream".
edit 04/21: I think I have a solution to all these problems. I will upload it in the next couple of days.
On the other, AVPlayerView is generally glitchy and hard to work with. I've made some good progress and squashed the major bugs, but I'm sure you will have no problem finding more.
For example:
- When the preview generates, and we switch the AVAssets to the TrimmingAVPlayer, it causes a flash of (unrelated) video content to the screen. For the sake of toggling on and off the preview, I got around this by moving the preview view to a second track in a AVMutableComposition, and creating two separate AVMutableVideoComposition that you can switch at runtime. So that you can toggle the preview on/off, the asset stays the same, it's just changes videoComposition.
I had hoped I could do something similar for the EditScreen overall. Instead of swapping in and out AVAssets to the TrimmingAVPlayer, I could have one AVMutableComposition and I could swap in and out the tracks as you update the preview. But, I couldn't get this to work reliably, I had assumed you could just
track.removeTimeRange(...)
track.insertTimeRange(updatedPreview)
but I just end up with blank screen. And everything else I tried all needed an updated AVPlayerItem, which just got me back to where I started.
edit on 04/21:
Reading through the docs, I think the issue might be that AVPlayerItem expects an immutable snapshot of the composition. If I want to modify it, I should first make a copy pass that to the player Item
Last but not least, when you export a preview of a smaller size, it surrounds the entire rest of the video with a black background. This background, as far as I can tell, is baked into the AVPlayer. Nothing I have tried has gotten rid of it. There are only two solutions that I can think of:
- Create a video of the checkerboard pattern, calculated to the exact height and width needed to blend into the background and add another track below all the previous tracks, and place it there.
- Give up having the preview and the regular video in one AVMutableComposition, but then we have the AVAsset swapping problem mentioned above. (we would also have to introduce emptyRanges into the track so the trimmer view would match up between preview and normal mode, and I'm not sure if this would also cause a black box to appear over the background)
Took longer than expected, but I got it working such that the preview is incredibly smooth.
Overview of changes
First, here is what the code does:
- As soon as you enter edit screen you can press the preview button in the toolbar to instantly show a preview of the current frame.
- This works in pause and play mode, but in play mode it will preform slowly**, until..
- The complete video (of just the trim range) is converted to GIF, henceforth I will call this a FullPreview.
- Once the video is converted to GIF, it will play smoothly in the preview.
- At all times, you can toggle the preview on and off to compare the original video with the GIF.
- After you change quality you can see the change reflected in the preview (speed is limited by Gifski at the moment), and it will generate a new FullPreview.
- This works with all settings such as speed, trim range, frame rate, and size.
** I'm debating if I should disable the play button like I used to. One one hand it might be jarring to see a choppy preview at first, on the other it's nice to have a fast limited frame rate preview. One of your the original ideas for this was to render about 5 frames spread out to give a good idea of the video, and I think this implementation does that.
Implementation
The new code is conceptually very simple, there are just two main parts
- The preview is implemented by an avvideocompositing,
PreviewVideoCompositorthis subclass is responsible for rendering the preview as well as the original video. A custom compositor may seem like overkill, but it is the only way to get smooth playback of the GIF, and to handle some corner cases with the AVPlayer. When a FullPreview is available, it will use that, otherwise it will convert the video to a GIF using Gifski on the fly. - The generation of new FullPreview is handled by
createPreviewStream. Which returns a function you can call to start a new full preview and an AsyncStream which emits events when the preview on progress and when it is finished.
Code flow:
EditScreenstarts the stream withcreateFullPreviewStream, and setups of the compositor withPreviewableComposition. Previewable compositor is a AVMutableComposition with 2 tracks. One for the original video from the asset, and the second for the full preview.PreviewableCompositionalso has a custom compositor,PreviewVideoCompositor, which is responsible for rendering the preview.- The
PreviewVideoCompositornow works, and in the absence of a FullPreview will render each video frame on the fly. I added a simple function toGIFGenerator,convertOneFrame, to do this on the fly rendering. Depending on the video size and quality, this can be slow. So we still need the FullPreview. - On any setting change, the
EditScreenwillrequestFullNewPreviewto request a new FullPreview.For each new setting it checks if it needs to re-render the GIF, and if so it will convert the video to a GIF, then convert that GIF to a video as an AVAsset. - Once created, the stream will emit an event containing the new FullPreview, at which point the
EditScreenwill will update thePreviewableCompositionby inserting the new FullPreviewAVAssetinto the second track. - Rinse and repeat.
Misc.
- I put a lot of effort into making the code as simple and clean as possible, but it did balloon up a bit. This is mostly because AVPlayer has a lot of quirks that I had to fix to give the best possible user experience. So if you see something that looks odd, there is probably a reason for it. I tried to document most things I would consider odd, with the exact reason why I did it that way.
- When compositing a preview smaller than the source video, I just blur the original and put it behind the preview. This is easy to change, so we can make it whatever we want.
- I made sure not to hard wrap any comments this time :)
Great to see you managed to find a solution. From some quick testing, it seems to work.
I am a little worried about all the code though. The logic has gotten significantly more complicated now, which increases the chances of crashes and bugs.
I noticed some color difference now in non-preview mode.
This PR:
Main branch:
Will review in detail later.
Great to see you managed to find a solution. From some quick testing, it seems to work.
I am a little worried about all the code though. The logic has gotten significantly more complicated now, which increases the chances of crashes and bugs.
I feel that. As I mentioned above, AVPlayer had many subtle bugs that had to workaround to get something that worked well. I simplified as much as possible, but not simpler. Although it is a lot as code, I tried as much as possible to keep everything modeled as pure functions (ie modular) so that we don't have a big pile of state to debug and maintain. I'm trying to keep each part as self-contained as possible.
I noticed some color difference now in non-preview mode.
Good catch. It looks like a color space issue. I literally memcpy the pixel data, so it must be a buffer attribute. I will look into this.
The problem was exactly what I thought it was. An easy 3 line fix.
For this commit I followed the same cleanup rules from the crop video branch:
- [x] Asked ChatGPT for bug fixes/ code simplification for all changes
- [x] Looked at the diff from main to find any leftovers / style issues
- [x] Removed unnecessary returns from switch statements
I would also feed ChatGPT all the preview code and ask it to find memory issues. It's easy to get things wrong with low-level code like pixel buffers.
When compositing a preview smaller than the source video, I just blur the original and put it behind the preview. This is easy to change, so we can make it whatever we want.
It's a bit distracting. Would be better to just use the checkerboard like in the non-preview.
- [x] Merge the main branch (with the crop video changes)
- [x] use
@Bindableinstead ofbinding(for:) - [x] Feed ChatGPT all the preview code and ask it to find memory issues. It's easy to get things wrong with low-level code like pixel buffers
- ChatGPT said the code is robust.
- [x] use checkerboard background during preview
- [x] Use
originalFrame.buf.propagateAttachments(to: outputFrame.buf)to copy attachments - [x] Remove inouts from readWritable and so on.
- [x]
let planes = planeCount == 0 ? [nil] : Array(0..<planeCount).map { Optional($0) }->for planeIndex in 0..<max(1, planeCount) - [x] Instead of
LockedPixelBufferclasses refactor to use closures - [x]
PixelBufferByteCopierto free function
Preview and Crop together
- [ ] allow preview of cropped video I haven't implemented this yet, because I have a couple of different ideas for how to do it. Here are the two main ones I thought of:
- Allow preview and crop at the same time, but don't show a preview of the crop. In other words render out the entire frame of the video, only cropping it when you actually export it. This way you can adjust the crop while doing a preview of the video.
Upsides
- Can see things like quality, speed, and framerate, while cropping.
Downsides
- You don't see a preview of the crop
- May take longer to render the preview if doing a long video
- Hide the crop controls while previewing, but show the cropped video in the preview.
Upsides
- Can see the cropped video while previewing
- faster
Downsides
- This hides the crop controls while previewing, so if you turn on the crop while you preview, crop might appear to not work. (this could be fixed by showing a tooltip saying "Crop is disabled while previewing" or something like that)
- Let the user choose between the two options, by replacing the preview and crop toolbar items with a menu that changes the current mode.
- Normal mode
- Crop mode
- Preview mode
- Edit crop and Preview mod
I know you will have an opinion on this, so I wanted to get your thoughts before I implement it.
I don't remember why we didn't do it initially, but maybe we could make the crop persistent, so even if the user exits the crop edit mode (by clicking the crop icon again), the crop is still applied and we still show the black overlay indicating where the crop is, just not the white crop rect handles? The user can simple go back to crop mode and reset the crop not crop the video. I think this may be more ideal behavior.
If we do the above, the situation with preview would be easier, because if the user clicks the preview button, we would just exit crop edit mode.
Do we actually need Metal for this? I asked ChatGPT and it said we could solve it with CoreImage, and it seems to work ok: https://github.com/sindresorhus/Gifski/commit/5a56a7cc3bbc426807f303c72aa75ff63dbdb75a
Do we actually need Metal for this? I asked ChatGPT and it said we could solve it with CoreImage, and it seems to work ok: 5a56a7c
I had a solution using CoreImage that I tried before moving to metal, but it really suffered from performance issues. But, maybe I was doing something wrong! I will compare this commit to my old branch and see if there is a performant way to use CoreImage to do this.
Do we actually need Metal for this? I asked ChatGPT and it said we could solve it with CoreImage, and it seems to work ok: 5a56a7c
I had a solution using CoreImage that I tried before moving to metal, but it really suffered from performance issues. But, maybe I was doing something wrong! I will compare this commit to my old branch and see if there is a performant way to use CoreImage to do this.
I just tested it, and Metal is still about 4-5x less CPU usage (50% compared to 200%) than the CoreImage code. But, the important thing is that they are both realtime (no frame drops) for fullHD. So, if you wanted to make the codebase simpler I could see switching to pure CoreImage.
I don't remember why we didn't do it initially, but maybe we could make the crop persistent, so even if the user exits the crop edit mode (by clicking the crop icon again), the crop is still applied and we still show the black overlay indicating where the crop is, just not the white crop rect handles? The user can simple go back to crop mode and reset the crop not crop the video. I think this may be more ideal behavior.
If we do the above, the situation with preview would be easier, because if the user clicks the preview button, we would just exit crop edit mode.
Now that crop edit mode and preview mode are mutually exclusive, should the toolbar items in the top right corner be a Menu or a Picker instead of two separate buttons?
Pros
- It would be immediately obvious that you can only enter one of those two modes at a time
- No surprises, like being surprised that the crop editor goes away when you hit preview button and vice-versa
- The toolbar is getting a bit crowded now, so this would make more space.
Cons
- Might make discoverability a little harder, as you don't immediately see the other two buttons
Now that crop edit mode and preview mode are mutually exclusive, should the toolbar items in the top right corner be a Menu or a Picker instead of two separate buttons?
No. I would prefer if they are just two buttons, for discoverability and simplicity.
So, if you wanted to make the codebase simpler I could see switching to pure CoreImage.
I would prefer to use CoreImage if it has no other downsides than being less efficient.
- [x] move to CoreImage
- [x] make crop persistent, and make it work with the preview
- [x] add and use
CVPixelBufferhelper extension - [x]
CVPixelBuffer.copy(to:)errors instead ofBoolreturn value - [x]
LockedPixelBufferPlane->CVPixelBuffer.LockedPlane - [x]
makeANewBufferThatThisCanCopyTo->makeCompatibleBuffer - [x]
withLocked->withLockedBaseAddress+withLockedPlanes - [x] use async version of
assetWriter.finishWriting() - [x] Throw on error in
createAVAssetFromGIFinstead of skipping framess - [x]
AsyncStreamto checked continuation in createAVAssetFromGIF - [x] Check
pixelBufferAdaptor.appendreturn value - [x]
createAVAssetFromGIFAsyncStreamtoSendable boxedvalues
Other bugs
- [x] Fixed a bug where if you rapidly press the crop edit button multiple times, sometimes it would hide the trimmer view when it shouldn't.
outputCropRect
I need to change the preview when the outputCropRect changes, Simple enough, right? I tried to do this with the following code:
.onChange(of: outputCropRect) {
//This is literally left blank
}
Then the the crop edit controls no longer work, they more a tiny bit then get "stuck".
see video:
https://github.com/user-attachments/assets/d02fffae-94a0-4d13-96e0-f3dde16b80e8
I discovered what is happening is that whenever the body of the view is re-evaluated it must end up changing the NSHostingView of the CropOverlayView causing the dragGesture to stop working. The problem is that if you observe outputCropRect in any way in the view hierarchy, it causes swiftUI to re-evaluate, and we lose all drag control. The NSHostingView needs to be stable during the drag gesture, otherwise it will stop working. In other words, the NSHostingView needs to be lifted up above the hierarchy in order to be stable I achieve this stability via two steps:
- By moving from passing an
AnyViewto theTrimmingAVPlayerto passing aNSViewdirectly. This way I can directly compare theNSViewand see if it is the same as the previous one, so I won't have to remove it from its superview (breaking the drag gesture) - I need the overlay to be stable (not change on re-evaluation of the view hierarchy). I do this by lifting up the
EditScreenview (and storing the actual view in_EditScreen). This way when the inner state (_EditScreen) changes SwiftUI will not re-evaluate the outer view, and thus theNSHostingViewwill not change.
- [x] After switching to
CoreImagethere was a Colorspace issue (preview was slightly brighter than it should be). This adjusts the colorspace so that it is correct.
I'm seeing some preview frames being lighter than others. Notice the grass. Also some quick flicker when showing the bird. This is not visible if I generate the GIF and look at it.
https://github.com/user-attachments/assets/de7fd0b7-ab50-41d8-b898-9c535125250b
I also encountered a crash. I cannot consistently reproduce, but I enabled the preview, changed dimensions to 480 or something, and then let it run for a couple of loops, and then pause/play a couple of times, and it crashed:
#0 0x00000001008cb894 in std::__1::__hash_table<std::__1::__hash_value_type<long, qos_info_t>, std::__1::__unordered_map_hasher<long, std::__1::__hash_value_type<long, qos_info_t>, std::__1::hash<long>, std::__1::equal_to<long>, true>, std::__1::__unordered_map_equal<long, std::__1::__hash_value_type<long, qos_info_t>, std::__1::equal_to<long>, std::__1::hash<long>, true>, std::__1::allocator<std::__1::__hash_value_type<long, qos_info_t>>>::__emplace_unique_key_args<long, std::__1::piecewise_construct_t const&, std::__1::tuple<long const&>, std::__1::tuple<>> ()
#1 0x00000001008cb42c in qosWaiterSignallerInvariantCheck ()
#2 0x00000001008c5a7c in interposed_dispatch_group_wait ()
#3 0x000000019a92dee4 in invocation function for block in CI::Context::recursive_render(CI::TileTask*, CI::roiKey const&, CI::Node*, bool) ()
#4 0x0000000100ef8514 in _dispatch_call_block_and_release ()
#5 0x0000000100f152dc in _dispatch_client_callout ()
#6 0x0000000100f01594 in _dispatch_lane_serial_drain ()
#7 0x0000000100f0246c in _dispatch_lane_invoke ()
#8 0x0000000100f0ffbc in _dispatch_root_queue_drain_deferred_wlh ()
#9 0x0000000100f0f414 in _dispatch_workloop_worker_thread ()
#10 0x00000001006137a4 in _pthread_wqthread ()
Enqueued from com.apple.root.user-initiated-qos.cooperative (Thread 255) Queue : com.apple.root.user-initiated-qos.cooperative (serial)
#0 0x0000000100efe5ac in dispatch_async ()
#1 0x000000019a92d820 in CI::Context::recursive_render ()
#2 0x000000019a71def0 in CI::Context::render ()
#3 0x000000019a988ccc in invocation function for block in CI::image_render_to_surface(CI::Context*, CI::Image*, CGRect, __IOSurface*, CI::RenderDestination const*) ()
#4 0x000000019a98c888 in CI::recursive_tile ()
#5 0x000000019a9872c4 in CI::tile_node_graph ()
#6 0x000000019a9882bc in CI::image_render_to_surface ()
#7 0x000000019a71807c in -[CIContext(CIRenderDestination) _startTaskToRender:toDestination:forPrepareRender:forClear:error:] ()
#8 0x000000019a717554 in -[CIContext(CIRenderDestination) startTaskToRender:fromRect:toDestination:atPoint:error:] ()
#9 0x000000019a784040 in -[CIContext render:toCVPixelBuffer:bounds:colorSpace:] ()
#10 0x0000000101c5c404 in static PreviewRenderer.renderPreview(previewImage:outputFrame:previewCheckerboardParams:) at /Users/sindresorhus/dev/oss/Gifski/Gifski/Preview/PreviewRenderer.swift:62
#11 0x000000027734f454 in swift::runJobInEstablishedExecutorContext ()
#12 0x00000002773509b4 in swift_job_runImpl ()
#13 0x0000000100f0e30c in _dispatch_root_queue_drain ()
#14 0x0000000100f0ee2c in _dispatch_worker_thread2 ()
#15 0x0000000100613768 in _pthread_wqthread ()
#16 0x000000010061af48 in start_wqthread ()
Fixture video:
https://github.com/user-attachments/assets/37b826ad-5d57-49f2-92d0-16f4eab358d7
I also encountered a crash while it was just playing (size 128). It crashed at: https://github.com/sindresorhus/Gifski/pull/329/files#diff-8d1d6a777c6075916ce9e67873409a0d7dd75e4eb51f1e062e1a89ac2908ec7bR62
Crash:
Thread 9: EXC_BAD_ACCESS (code=1, address=0xf06c8f32f4d8) A bad access to memory terminated the process.
Thread 9 Queue : com.apple.root.user-initiated-qos.cooperative (concurrent)
#0 0x0000000102ab6f34 in std::__1::__hash_table<std::__1::__hash_value_type<long, qos_info_t>, std::__1::__unordered_map_hasher<long, std::__1::__hash_value_type<long, qos_info_t>, std::__1::hash<long>, std::__1::equal_to<long>, true>, std::__1::__unordered_map_equal<long, std::__1::__hash_value_type<long, qos_info_t>, std::__1::equal_to<long>, std::__1::hash<long>, true>, std::__1::allocator<std::__1::__hash_value_type<long, qos_info_t>>>::find<long> ()
#1 0x0000000102aba880 in findPrimitiveInfoNoAssert ()
#2 0x0000000102ab5a34 in interposed_dispatch_group_wait ()
#3 0x000000019a995150 in CI::RenderTask::waitUntilCompleted ()
#4 0x000000019a723710 in -[CIRenderTask waitUntilCompletedAndReturnError:] ()
#5 0x000000019a78405c in -[CIContext render:toCVPixelBuffer:bounds:colorSpace:] ()
#6 0x0000000103e4c404 in static PreviewRenderer.renderPreview(previewImage:outputFrame:previewCheckerboardParams:) at /Users/sindresorhus/dev/oss/Gifski/Gifski/Preview/PreviewRenderer.swift:62
#7 0x0000000103e4c690 in static PreviewRenderer.renderPreview(previewFrame:outputFrame:previewCheckerboardParams:) at /Users/sindresorhus/dev/oss/Gifski/Gifski/Preview/PreviewRenderer.swift:32
#8 0x0000000103ea993c in PreviewVideoCompositor.render(fullPreviewFrame:originalFrame:outputFrame:compositionTime:) at /Users/sindresorhus/dev/oss/Gifski/Gifski/Preview/PreviewVideoCompositor.swift:97
#9 0x0000000103ea798c in closure #1 in PreviewVideoCompositor.startRequest(_:) at /Users/sindresorhus/dev/oss/Gifski/Gifski/Preview/PreviewVideoCompositor.swift:38
#10 0x0000000103eae8dc in partial apply for closure #1 in PreviewVideoCompositor.startRequest(_:) ()
#11 0x0000000103d848e4 in thunk for @escaping @isolated(any) @callee_guaranteed @async () -> (@out A) ()
#12 0x0000000103e1e558 in partial apply for thunk for @escaping @isolated(any) @callee_guaranteed @async () -> (@out A) ()
The top frame is: