├── .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 | 
--------------------------------------------------------------------------------
/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
--------------------------------------------------------------------------------