├── .gitignore ├── .swiftpm └── xcode │ └── xcshareddata │ └── xcschemes │ ├── VideoComposer.xcscheme │ ├── encodeformastodon-Package.xcscheme │ └── encodeformastodon.xcscheme ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── VideoComposer │ └── VideoComposer.swift └── encodeformastodon │ ├── EncodeForMastodonCommand.swift │ └── Resources │ └── Info.plist └── example.png /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | __MACOSX 3 | *.pbxuser 4 | !default.pbxuser 5 | *.mode1v3 6 | !default.mode1v3 7 | *.mode2v3 8 | !default.mode2v3 9 | *.perspectivev3 10 | !default.perspectivev3 11 | *.xcworkspace 12 | !default.xcworkspace 13 | xcuserdata 14 | profile 15 | *.moved-aside 16 | DerivedData 17 | .idea/ 18 | Crashlytics.sh 19 | generatechangelog.sh 20 | Pods/ 21 | Carthage 22 | Provisioning 23 | Crashlytics.sh 24 | Sharing.h 25 | Tests/Private 26 | Design/Icon 27 | Build/ 28 | MockServer/ -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/VideoComposer.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/encodeformastodon-Package.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 44 | 45 | 46 | 47 | 57 | 58 | 64 | 65 | 66 | 67 | 73 | 74 | 80 | 81 | 82 | 83 | 85 | 86 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/encodeformastodon.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 44 | 46 | 52 | 53 | 54 | 55 | 58 | 59 | 60 | 61 | 65 | 66 | 67 | 68 | 74 | 76 | 82 | 83 | 84 | 85 | 87 | 88 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-argument-parser", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/apple/swift-argument-parser.git", 7 | "state" : { 8 | "revision" : "fddd1c00396eed152c45a46bea9f47b98e59301d", 9 | "version" : "1.2.0" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "encodeformastodon", 8 | platforms: [.macOS(.v12)], 9 | products: [ 10 | .executable( 11 | name: "encodeformastodon", 12 | targets: ["encodeformastodon"]), 13 | .library( 14 | name: "VideoComposer", 15 | targets: ["VideoComposer"]), 16 | ], 17 | dependencies: [ 18 | .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0") 19 | ], 20 | targets: [ 21 | .target(name: "VideoComposer"), 22 | .executableTarget( 23 | name: "encodeformastodon", 24 | dependencies: [ 25 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 26 | .target(name: "VideoComposer"), 27 | ], 28 | linkerSettings: [ 29 | .unsafeFlags(["-sectcreate", 30 | "__TEXT", 31 | "__info_plist", 32 | "Sources/encodeformastodon/Resources/Info.plist"]) 33 | ]), 34 | ] 35 | ) 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # encodeformastodon 2 | 3 | Simple command-line tool for macOS that encodes videos in a format suitable for publishing to Mastodon. Runs on macOS 12 or later. 4 | 5 | I wrote this tool because upon uploading [a vertical video to my Mastodon account](https://mastodon.social/@_inside/109540102047492623), I noticed that it didn't convert the video properly, resulting in a distorted video. 6 | This tool is a temporary workaround until Mastodon gets better support for video uploads. It also serves as a simple example of how to use AVFoundation for this sort of video manipulation. 7 | 8 | All it does is resize the video to fit in a 1920x1080 resolution, pillar-boxing if needed. 9 | 10 | [A notarized build installer is available in the releases page](https://github.com/insidegui/encodeformastodon/releases/latest). 11 | 12 | ``` 13 | OVERVIEW: Encodes and resizes any input video in a format suitable for 14 | publishing to Mastodon. 15 | 16 | USAGE: encodeformastodon 17 | 18 | ARGUMENTS: 19 | Path to the video file that will be encoded 20 | 21 | OPTIONS: 22 | -h, --help Show help information. 23 | ``` 24 | 25 | You can see a before/after example in the image below: 26 | 27 | ![example](./example.png) -------------------------------------------------------------------------------- /Sources/VideoComposer/VideoComposer.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import AVFoundation 3 | import CoreImage 4 | import CoreImage.CIFilterBuiltins 5 | 6 | public final class VideoComposer { 7 | 8 | public static func resizeVideo(with asset: AVAsset, in renderSize: CGSize) async throws -> AVMutableVideoComposition { 9 | let videoTracks = try await asset.loadTracks(withMediaType: .video) 10 | guard let videoTrack = videoTracks.first else { 11 | throw CocoaError(.coderValueNotFound, userInfo: [NSLocalizedDescriptionKey: "Couldn't find video track in input file"]) 12 | } 13 | 14 | /// We'll need the size of the video track in order to reposition it within the rendered video's bounds. 15 | let videoSize = try await videoTrack.load(.naturalSize) 16 | 17 | /// Figure out the pixels per point resolution of the machine, since the `NSImage` below 18 | /// will be using it as a multiplier, but we actually need the correct size in pixels. 19 | let bgScale = NSScreen.main?.backingScaleFactor ?? 1 20 | 21 | /// Create a black background image to be composed behind the video in the rendered output. 22 | let background = NSImage(size: NSSize(width: renderSize.width / bgScale, height: renderSize.height / bgScale), flipped: true) { rect in 23 | NSColor.black.setFill() 24 | rect.fill() 25 | return true 26 | } 27 | 28 | /// Get the corresponding `CGImage` from the background image, which we'll use to initialize a `CIImage`. 29 | guard let backgroundImage = background.cgImage(forProposedRect: nil, context: nil, hints: nil) else { 30 | throw CocoaError(.coderReadCorrupt, userInfo: [NSLocalizedDescriptionKey: "Unable to create black background image"]) 31 | } 32 | 33 | /// Create a composition based on the input video asset that uses a CoreImage filter pipeline to render each frame. 34 | let composition = AVMutableVideoComposition(asset: asset, applyingCIFiltersWithHandler: { request in 35 | /// Grab the input video frame from the render request. 36 | let videoFrame = request.sourceImage 37 | /// Create a `CIImage` for the black background. 38 | let background = CIImage(cgImage: backgroundImage) 39 | 40 | /// Create an affine transform filter for centering the rendered video frame within the background, creating a pillar box effect. 41 | guard let transformFilter = CIFilter(name: "CIAffineTransform") else { 42 | request.finish(with: CocoaError(.coderInvalidValue, userInfo: [NSLocalizedDescriptionKey: "Couldn't get CIAffineTransform"])) 43 | return 44 | } 45 | 46 | transformFilter.setDefaults() 47 | transformFilter.setValue(videoFrame, forKey: kCIInputImageKey) 48 | 49 | /// Calculate the amount of translation needed in order to center the video within the rendered output. 50 | let transform = CGAffineTransform( 51 | translationX: request.renderSize.width / 2 - videoSize.width / 2, 52 | y: request.renderSize.height / 2 - videoSize.height / 2 53 | ) 54 | transformFilter.setValue(transform, forKey: kCIInputTransformKey) 55 | 56 | /// Grab the transformed image. 57 | guard let transformedImage = transformFilter.outputImage else { 58 | request.finish(with: CocoaError(.coderInvalidValue, userInfo: [NSLocalizedDescriptionKey: "Couldn't transform image"])) 59 | return 60 | } 61 | 62 | /// Compose the video frame on top of the black background image. 63 | let compositeFilter = CIFilter.sourceAtopCompositing() 64 | compositeFilter.backgroundImage = background 65 | compositeFilter.inputImage = transformedImage 66 | 67 | /// Grab the final rendered video frame and feed it into the request for this frame. 68 | guard let outputImage = compositeFilter.outputImage else { 69 | request.finish(with: CocoaError(.coderInvalidValue, userInfo: [NSLocalizedDescriptionKey: "Couldn't get output image"])) 70 | return 71 | } 72 | 73 | request.finish(with: outputImage.clampedToExtent(), context: nil) 74 | }) 75 | 76 | /// Ensure the composition will render at the specified size (currently 1920x1080). 77 | composition.renderSize = renderSize 78 | 79 | return composition 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /Sources/encodeformastodon/EncodeForMastodonCommand.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import ArgumentParser 3 | import AVFoundation 4 | import CoreImage 5 | import CoreImage.CIFilterBuiltins 6 | import VideoComposer 7 | 8 | private var progressTimer = Timer.publish(every: 0.1, on: .main, in: .common) 9 | 10 | @main 11 | struct EncodeForMastodonCommand: AsyncParsableCommand { 12 | 13 | static let configuration = CommandConfiguration( 14 | commandName: "encodeformastodon", 15 | abstract: "Encodes and resizes any input video in a format suitable for publishing to Mastodon." 16 | ) 17 | 18 | @Argument(help: "Path to the video file that will be encoded") 19 | var path: String 20 | 21 | var renderSize: CGSize { CGSize(width: 1920, height: 1080) } 22 | 23 | func run() async throws { 24 | let inputURL = URL(fileURLWithPath: path) 25 | guard FileManager.default.fileExists(atPath: inputURL.path) else { 26 | throw ValidationError("Input file doesn't exist at \(inputURL.path)") 27 | } 28 | 29 | let filename = inputURL.deletingPathExtension().lastPathComponent 30 | let outputURL = inputURL 31 | .deletingLastPathComponent() 32 | .appendingPathComponent(filename + "-Mastodon") 33 | .appendingPathExtension("mov") 34 | 35 | if FileManager.default.fileExists(atPath: outputURL.path) { 36 | try FileManager.default.removeItem(at: outputURL) 37 | } 38 | 39 | let asset = AVAsset(url: inputURL) 40 | 41 | let composition = try await VideoComposer.resizeVideo(with: asset, in: renderSize) 42 | 43 | /// Create the export session and configure it with the composition, output URL and video file format. 44 | guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset1920x1080) else { 45 | throw CocoaError(.coderValueNotFound, userInfo: [NSLocalizedDescriptionKey: "Failed to create export session"]) 46 | } 47 | 48 | session.videoComposition = composition 49 | session.outputURL = outputURL 50 | session.outputFileType = .mov 51 | 52 | fputs("Exporting…", stderr) 53 | 54 | /// Detach a new task to stream the export session's progress to the terminal. 55 | Task.detached { 56 | await showProgress(for: session) 57 | } 58 | 59 | /// Actually begin exporting. 60 | /// This will block until the export has finished or failed. 61 | await session.export() 62 | 63 | /// Check for errors within the export session. 64 | if let error = session.error { 65 | throw CocoaError(.coderInvalidValue, userInfo: [NSLocalizedDescriptionKey: "Export session failed", NSUnderlyingErrorKey: error]) 66 | } 67 | 68 | print("") 69 | print("Done!") 70 | } 71 | 72 | private func showProgress(for session: AVAssetExportSession) async { 73 | for await _ in progressTimer.autoconnect().values { 74 | let message = String(format: "Exporting… %02d%%", Int(session.progress * 100)) 75 | 76 | if canPrintEscapeCodes { 77 | /// Clear line. 78 | fputs("\u{001B}[2K", stderr) 79 | /// Move cursor to beginning of the line. 80 | fputs("\u{001B}[G", stderr) 81 | fputs(message, stderr) 82 | } else { 83 | fputs(message + "\n", stderr) 84 | } 85 | 86 | if session.progress >= 1 { break } 87 | } 88 | } 89 | 90 | private var canPrintEscapeCodes: Bool { !isRunningInXcode } 91 | 92 | /// Set environment variable `XCODE` to `1` to prevent escape codes from being used by the command, 93 | /// since Xcode's console can't handle them. 94 | private var isRunningInXcode: Bool { 95 | #if DEBUG 96 | return ProcessInfo.processInfo.environment["XCODE"] == "1" 97 | #else 98 | return false 99 | #endif 100 | } 101 | 102 | } 103 | -------------------------------------------------------------------------------- /Sources/encodeformastodon/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleIdentifier 6 | codes.rambo.encodeformastodon 7 | CFBundleShortVersionString 8 | 0.2 9 | CFBundleVersion 10 | 3 11 | CFBundleName 12 | encodeformastodon 13 | CFBundleExecutable 14 | encodeformastodon 15 | CFBundleDisplayName 16 | encodeformastodon 17 | CFBundleDevelopmentRegion 18 | en 19 | CFBundleInfoDictionaryVersion 20 | 6.0 21 | 22 | 23 | -------------------------------------------------------------------------------- /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/encodeformastodon/d175d74e5652d17f9792c510f6af5c8c63855750/example.png --------------------------------------------------------------------------------