Created
November 24, 2025 19:26
-
-
Save g-l-i-t-c-h-o-r-s-e/3d58e3779a6aef79c719d0ee40e07617 to your computer and use it in GitHub Desktop.
Export QC Image as Video with FFmpeg in Quartz Composer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| shopt -s nullglob | |
| # --- config --- | |
| NAME="FFExport" | |
| CLASS="FFExportPlugIn" | |
| SRC="${SRC:-${CLASS}.m}" | |
| PLUG="$NAME.plugin" | |
| OUT="$(pwd)/build-manual" | |
| INST="$HOME/Library/Graphics/Quartz Composer Plug-Ins" | |
| XCODE_APP="${XCODE_APP:-/Applications/Xcode_9.4.1.app}" | |
| DEV="$XCODE_APP/Contents/Developer" | |
| SDKDIR="$DEV/Platforms/MacOSX.platform/Developer/SDKs" | |
| SDK="${SDK:-}" | |
| if [[ -z "${SDK}" ]]; then | |
| if [[ -d "$SDKDIR/MacOSX10.14.sdk" ]]; then SDK="$SDKDIR/MacOSX10.14.sdk" | |
| elif [[ -d "$SDKDIR/MacOSX10.13.sdk" ]]; then SDK="$SDKDIR/MacOSX10.13.sdk" | |
| else SDK="$(xcrun --sdk macosx --show-sdk-path 2>/dev/null || true)" | |
| fi | |
| fi | |
| [[ -d "$DEV" ]] || { echo "Xcode not found: $XCODE_APP"; exit 1; } | |
| [[ -f "$SRC" ]] || { echo "Source not found: $SRC"; exit 1; } | |
| [[ -n "$SDK" && -d "$SDK" ]] || { echo "macOS SDK not found."; exit 1; } | |
| export DEVELOPER_DIR="$DEV" | |
| # --- FFmpeg via MacPorts pkg-config --- | |
| PKGCFG="/opt/local/bin/pkg-config" | |
| [[ -x "$PKGCFG" ]] || { echo "pkg-config not found at $PKGCFG (install via MacPorts)"; exit 1; } | |
| PKG_LIBS=(libavformat libavcodec libavutil libswscale) | |
| CFLAGS_FFMPEG="$("$PKGCFG" --cflags "${PKG_LIBS[@]}")" | |
| LIBS_FFMPEG="$("$PKGCFG" --libs "${PKG_LIBS[@]}")" | |
| echo "Using SDK: $SDK" | |
| rm -rf "$OUT" | |
| mkdir -p "$OUT/x86_64" "$OUT/universal/$PLUG/Contents/MacOS" "$OUT/universal/$PLUG/Contents/Frameworks" | |
| FRAMEWORKS="$OUT/universal/$PLUG/Contents/Frameworks" | |
| if [[ -d "$INST/$PLUG" ]]; then | |
| echo "Removing installed $INST/$PLUG" | |
| rm -rf "$INST/$PLUG" | |
| fi | |
| COMMON_CFLAGS=( | |
| -bundle -fobjc-arc -fobjc-link-runtime | |
| -isysroot "$SDK" | |
| -mmacosx-version-min=10.9 | |
| -I . | |
| -I /opt/local/include | |
| ) | |
| COMMON_LIBS=( | |
| -framework Foundation | |
| -framework Quartz | |
| -framework OpenGL | |
| -framework CoreGraphics | |
| ) | |
| echo "Compiling x86_64 (FFmpeg export)…" | |
| clang -arch x86_64 \ | |
| "${COMMON_CFLAGS[@]}" \ | |
| $CFLAGS_FFMPEG \ | |
| "$SRC" \ | |
| "${COMMON_LIBS[@]}" \ | |
| $LIBS_FFMPEG \ | |
| -o "$OUT/x86_64/$NAME" | |
| # Layout bundle | |
| cp -a "$OUT/x86_64/$NAME" "$OUT/universal/$PLUG/Contents/MacOS/$NAME" | |
| # Info.plist | |
| cat >"$OUT/universal/$PLUG/Contents/Info.plist" <<PLIST | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| <plist version="1.0"><dict> | |
| <key>CFBundleDevelopmentRegion</key> <string>English</string> | |
| <key>CFBundleExecutable</key> <string>${NAME}</string> | |
| <key>CFBundleIdentifier</key> <string>com.yourdomain.${NAME}</string> | |
| <key>CFBundleInfoDictionaryVersion</key> <string>6.0</string> | |
| <key>CFBundleName</key> <string>${NAME}</string> | |
| <key>CFBundlePackageType</key> <string>BNDL</string> | |
| <key>CFBundleShortVersionString</key> <string>1.0</string> | |
| <key>CFBundleSupportedPlatforms</key> <array><string>MacOSX</string></array> | |
| <key>CFBundleVersion</key> <string>1</string> | |
| <key>QCPlugInClasses</key> | |
| <array> | |
| <string>${CLASS}</string> | |
| </array> | |
| <key>NSPrincipalClass</key> <string>QCPlugIn</string> | |
| </dict></plist> | |
| PLIST | |
| # --- helpers for embedding FFmpeg dylibs from /opt/local/lib --- | |
| mk_short_symlink_if_needed() { | |
| local base="$1" | |
| if [[ "$base" =~ ^(lib[^.]+)\.([0-9]+)\.[0-9.]+\.dylib$ ]]; then | |
| local short="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.dylib" | |
| if [[ ! -e "$FRAMEWORKS/$short" ]]; then | |
| ( cd "$FRAMEWORKS" && ln -s "$base" "$short" ) | |
| fi | |
| fi | |
| } | |
| list_opt_local_deps() { | |
| otool -L "$1" | awk '$1 ~ /^\/opt\/local\/lib\// {print $1}' | |
| } | |
| copy_and_rewrite() { | |
| local src="$1"; [[ "$src" == /opt/local/lib/* ]] || return 0 | |
| local base dest; base="$(basename "$src")"; dest="$FRAMEWORKS/$base" | |
| if [[ ! -f "$dest" ]]; then | |
| echo " → Copy $base" | |
| rsync -aL "$src" "$dest" | |
| chmod u+w "$dest" | |
| install_name_tool -id "@loader_path/$base" "$dest" | |
| mk_short_symlink_if_needed "$base" | |
| while IFS= read -r dep; do | |
| local depbase; depbase="$(basename "$dep")" | |
| copy_and_rewrite "$dep" | |
| install_name_tool -change "$dep" "@loader_path/$depbase" "$dest" | |
| done < <(list_opt_local_deps "$dest") | |
| fi | |
| } | |
| seed_from_otool() { | |
| local bin="$1" | |
| while IFS= read -r path; do copy_and_rewrite "$path"; done < <( | |
| otool -L "$bin" | awk '$1 ~ /^\/opt\/local\/lib\/lib(avformat|avcodec|avutil|swscale).*\.dylib$/ {print $1}' | |
| ) | |
| } | |
| seed_from_pkgconfig() { | |
| for pc in "${PKG_LIBS[@]}"; do | |
| local libdir; libdir="$("$PKGCFG" --variable=libdir "$pc" 2>/dev/null || echo /opt/local/lib)" | |
| local cand | |
| for cand in "$libdir/${pc}".*.dylib "$libdir/${pc}.dylib"; do | |
| [[ -f "$cand" ]] && { copy_and_rewrite "$cand"; break; } | |
| done | |
| done | |
| } | |
| final_full_sweep() { | |
| for lib in "$FRAMEWORKS"/*.dylib; do | |
| while IFS= read -r dep; do | |
| local depbase; depbase="$(basename "$dep")" | |
| copy_and_rewrite "$dep" | |
| install_name_tool -change "$dep" "@loader_path/$depbase" "$lib" | |
| done < <(list_opt_local_deps "$lib") | |
| done | |
| } | |
| echo "Embedding FFmpeg dylibs…" | |
| BIN="$OUT/universal/$PLUG/Contents/MacOS/$NAME" | |
| seed_from_otool "$BIN" | |
| if ! compgen -G "$FRAMEWORKS/*.dylib" >/dev/null; then | |
| seed_from_pkgconfig | |
| fi | |
| while IFS= read -r dep; do | |
| base="$(basename "$dep")" | |
| if [[ ! -e "$FRAMEWORKS/$base" ]]; then | |
| stem="${base%.dylib}" | |
| stem="${stem%.*}" | |
| match=( "$FRAMEWORKS/$stem".*.dylib ) | |
| if [[ -e "${match[0]}" ]]; then | |
| mk_short_symlink_if_needed "$(basename "${match[0]}")" | |
| else | |
| copy_and_rewrite "$dep" | |
| fi | |
| fi | |
| install_name_tool -change "$dep" "@loader_path/../Frameworks/$base" "$BIN" | |
| done < <(list_opt_local_deps "$BIN") | |
| final_full_sweep | |
| echo "Codesigning bundled libs…" | |
| for lib in "$FRAMEWORKS"/*.dylib; do | |
| codesign --force -s - "$lib" >/dev/null || true | |
| done | |
| codesign --force -s - "$OUT/universal/$PLUG" >/dev/null || true | |
| echo "Installing to: $INST" | |
| mkdir -p "$INST" | |
| rsync -a "$OUT/universal/$PLUG" "$INST/" | |
| echo "Verifying install…" | |
| IBIN="$INST/$PLUG/Contents/MacOS/$NAME" | |
| leaks=0 | |
| if otool -L "$IBIN" | awk '$1 ~ /^\/opt\/local\/lib\//' | grep -q .; then | |
| echo "❌ main binary still references /opt/local/lib:" | |
| otool -L "$IBIN" | awk '$1 ~ /^\/opt\/local\/lib\// {print " " $1}' | |
| leaks=1 | |
| fi | |
| for lib in "$INST/$PLUG/Contents/Frameworks/"*.dylib; do | |
| if otool -L "$lib" | awk '$1 ~ /^\/opt\/local\/lib\//' | grep -q .; then | |
| echo "❌ $(basename "$lib") still references /opt/local/lib:" | |
| otool -L "$lib" | awk '$1 ~ /^\/opt\/local\/lib\// {print " " $1}' | |
| leaks=1 | |
| fi | |
| done | |
| if [[ $leaks -ne 0 ]]; then | |
| echo "Fixup failed; see above offending paths." | |
| exit 1 | |
| fi | |
| echo "Installed: $INST/$PLUG" | |
| echo "Embedded libs:" | |
| ls -1 "$INST/$PLUG/Contents/Frameworks" || true | |
| echo "Relaunch Quartz Composer and look for 'FFExport (x86_64)'." | |
| VUILD |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // FFExportPlugIn.m — FFmpeg video exporter for Quartz Composer (Mojave/ARC, 64-bit) | |
| // Inputs: | |
| // Image (image) | |
| // Output Path (string) | |
| // Record (bool toggle; start/stop & finalize) | |
| // Pause (bool toggle; pause encoding, keep file open) | |
| // Duration (sec) (number; 0 = unlimited, counts encoded time only) | |
| // FPS (number; default 30) | |
| // Codec Options (string; e.g. "-c:v libx264 -g 120 -bf 3 -s 1280x720 -preset veryfast -crf 18") | |
| // | |
| // Outputs: | |
| // Is Recording (bool) | |
| // Recorded Seconds (number) | |
| // Recorded Frames (number) | |
| // | |
| // Link with: | |
| // -framework Foundation -framework Quartz -framework OpenGL -framework CoreGraphics | |
| // FFmpeg 4.4.x: avformat,avcodec,avutil,swscale | |
| #import <Quartz/Quartz.h> | |
| #import <CoreGraphics/CoreGraphics.h> | |
| #include <math.h> | |
| #include <string.h> | |
| #ifdef __cplusplus | |
| extern "C" { | |
| #endif | |
| #include <libavformat/avformat.h> | |
| #include <libavcodec/avcodec.h> | |
| #include <libavutil/avutil.h> | |
| #include <libavutil/opt.h> | |
| #include <libavutil/imgutils.h> | |
| #include <libswscale/swscale.h> | |
| #ifdef __cplusplus | |
| } | |
| #endif | |
| static inline double _clamp(double v,double lo,double hi){ return v<lo?lo:(v>hi?hi:v); } | |
| @interface FFExportPlugIn : QCPlugIn | |
| @property(nonatomic, retain) id<QCPlugInInputImageSource> inputImage; | |
| @property(nonatomic, copy) NSString *inputOutputPath; | |
| @property(nonatomic, assign) BOOL inputRecord; | |
| @property(nonatomic, assign) BOOL inputPause; | |
| @property(nonatomic, assign) double inputDuration; | |
| @property(nonatomic, assign) double inputFPS; | |
| @property(nonatomic, copy) NSString *inputCodecOptions; | |
| @property(nonatomic, assign) double outputIsRecording; | |
| @property(nonatomic, assign) double outputRecordedSeconds; | |
| @property(nonatomic, assign) double outputRecordedFrames; | |
| @end | |
| @implementation FFExportPlugIn | |
| { | |
| // FFmpeg state | |
| AVFormatContext *_fmt; | |
| AVStream *_vstream; | |
| AVCodecContext *_venc; | |
| struct SwsContext *_sws; | |
| AVFrame *_frame; | |
| int _width; // encoded width | |
| int _height; // encoded height | |
| int _srcWidth; | |
| int _srcHeight; | |
| AVRational _timeBase; | |
| double _fps; | |
| int64_t _nextPTS; | |
| int64_t _frameCount; | |
| // QC-time recording state | |
| BOOL _isRecording; | |
| BOOL _prevRecord; | |
| NSTimeInterval _recordStartTime; | |
| NSTimeInterval _lastTime; | |
| double _timeAccum; // seconds accumulated for FPS stepping | |
| double _durationLimit; // seconds (0 = unlimited, based on encoded time) | |
| CGColorSpaceRef _cs; | |
| } | |
| @dynamic inputImage, inputOutputPath, inputRecord, inputPause, inputDuration, inputFPS, inputCodecOptions; | |
| @dynamic outputIsRecording, outputRecordedSeconds, outputRecordedFrames; | |
| + (NSDictionary*)attributes | |
| { | |
| return @{ | |
| QCPlugInAttributeNameKey: @"FFExport (x86_64)", | |
| QCPlugInAttributeDescriptionKey: @"FFmpeg-based video exporter.\nFeed an Image, path, set FPS and toggle Record.\nPause keeps file open but stops encoding frames.\nCodec Options allows ffmpeg-style flags like \"-c:v libx264 -g 120 -bf 3 -s 1280x720\".", | |
| }; | |
| } | |
| + (NSDictionary*)attributesForPropertyPortWithKey:(NSString*)key | |
| { | |
| if ([key isEqualToString:@"inputImage"]) | |
| return @{ QCPortAttributeNameKey: @"Image", QCPortAttributeTypeKey: QCPortTypeImage }; | |
| if ([key isEqualToString:@"inputOutputPath"]) | |
| return @{ QCPortAttributeNameKey: @"Output Path", QCPortAttributeDefaultValueKey: @"" }; | |
| if ([key isEqualToString:@"inputRecord"]) | |
| return @{ QCPortAttributeNameKey: @"Record", QCPortAttributeTypeKey: QCPortTypeBoolean, QCPortAttributeDefaultValueKey: @0.0 }; | |
| if ([key isEqualToString:@"inputPause"]) | |
| return @{ QCPortAttributeNameKey: @"Pause", QCPortAttributeTypeKey: QCPortTypeBoolean, QCPortAttributeDefaultValueKey: @0.0 }; | |
| if ([key isEqualToString:@"inputDuration"]) | |
| return @{ QCPortAttributeNameKey: @"Duration (sec)", QCPortAttributeDefaultValueKey: @0.0 }; | |
| if ([key isEqualToString:@"inputFPS"]) | |
| return @{ QCPortAttributeNameKey: @"FPS", QCPortAttributeDefaultValueKey: @30.0 }; | |
| if ([key isEqualToString:@"inputCodecOptions"]) | |
| return @{ QCPortAttributeNameKey: @"Codec Options", QCPortAttributeDefaultValueKey: @"" }; | |
| if ([key isEqualToString:@"outputIsRecording"]) | |
| return @{ QCPortAttributeNameKey: @"Is Recording" }; | |
| if ([key isEqualToString:@"outputRecordedSeconds"]) | |
| return @{ QCPortAttributeNameKey: @"Recorded Seconds" }; | |
| if ([key isEqualToString:@"outputRecordedFrames"]) | |
| return @{ QCPortAttributeNameKey: @"Recorded Frames" }; | |
| return nil; | |
| } | |
| + (NSArray*)sortedPropertyPortKeys | |
| { | |
| return @[ | |
| @"inputImage", | |
| @"inputOutputPath", | |
| @"inputRecord", | |
| @"inputPause", | |
| @"inputDuration", | |
| @"inputFPS", | |
| @"inputCodecOptions", | |
| @"outputIsRecording", | |
| @"outputRecordedSeconds", | |
| @"outputRecordedFrames", | |
| ]; | |
| } | |
| + (QCPlugInExecutionMode)executionMode { return kQCPlugInExecutionModeProcessor; } | |
| + (QCPlugInTimeMode) timeMode { return kQCPlugInTimeModeIdle; } | |
| + (BOOL)allowsSubpatches { return NO; } | |
| // -------------------------------------------------- | |
| // Lifecycle | |
| // -------------------------------------------------- | |
| - (id)init | |
| { | |
| if ((self = [super init])) { | |
| #ifdef kCGColorSpaceSRGB | |
| _cs = CGColorSpaceCreateWithName(kCGColorSpaceSRGB); | |
| #else | |
| _cs = CGColorSpaceCreateDeviceRGB(); | |
| #endif | |
| _fmt = NULL; | |
| _vstream = NULL; | |
| _venc = NULL; | |
| _sws = NULL; | |
| _frame = NULL; | |
| _width = _height = 0; | |
| _srcWidth = _srcHeight = 0; | |
| _fps = 30.0; | |
| _timeBase = (AVRational){1,30}; | |
| _nextPTS = 0; | |
| _frameCount = 0; | |
| _isRecording = NO; | |
| _prevRecord = NO; | |
| _recordStartTime = 0.0; | |
| _lastTime = 0.0; | |
| _timeAccum = 0.0; | |
| _durationLimit = 0.0; | |
| } | |
| return self; | |
| } | |
| - (void)dealloc | |
| { | |
| [self _stopEncoding]; | |
| if (_cs) { CFRelease(_cs); _cs = NULL; } | |
| } | |
| // -------------------------------------------------- | |
| // QC start/stop | |
| // -------------------------------------------------- | |
| - (BOOL)startExecution:(id<QCPlugInContext>)context | |
| { | |
| [self setValue:@0.0 forOutputKey:@"outputIsRecording"]; | |
| [self setValue:@0.0 forOutputKey:@"outputRecordedSeconds"]; | |
| [self setValue:@0.0 forOutputKey:@"outputRecordedFrames"]; | |
| return YES; | |
| } | |
| - (void)stopExecution:(id<QCPlugInContext>)context | |
| { | |
| [self _stopEncoding]; | |
| } | |
| // -------------------------------------------------- | |
| // FFmpeg helpers | |
| // -------------------------------------------------- | |
| - (void)_cleanupFFmpeg | |
| { | |
| if (_venc) { | |
| avcodec_free_context(&_venc); | |
| _venc = NULL; | |
| } | |
| if (_fmt) { | |
| if (!(_fmt->oformat->flags & AVFMT_NOFILE) && _fmt->pb) { | |
| avio_closep(&_fmt->pb); | |
| } | |
| avformat_free_context(_fmt); | |
| _fmt = NULL; | |
| } | |
| if (_sws) { | |
| sws_freeContext(_sws); | |
| _sws = NULL; | |
| } | |
| if (_frame) { | |
| av_frame_free(&_frame); | |
| _frame = NULL; | |
| } | |
| _vstream = NULL; | |
| } | |
| // Parse "WxH" resolution string into encW/encH if possible. | |
| static void _parse_resolution(NSString *val, int *encW, int *encH) | |
| { | |
| if (!val || [val length] == 0) return; | |
| NSArray *parts = [val componentsSeparatedByString:@"x"]; | |
| if ([parts count] != 2) return; | |
| int w = [parts[0] intValue]; | |
| int h = [parts[1] intValue]; | |
| if (w > 0 && h > 0) { | |
| *encW = w; | |
| *encH = h; | |
| } | |
| } | |
| // Parse codec options string into: | |
| // - codecName (c:v / codec:v) | |
| // - gop size (g) | |
| // - max B-frames (bf) | |
| // - encode size (s) | |
| // - generic AVDictionary options (preset, tune, crf, profile, etc.) | |
| - (void)_parseCodecOptionsString:(NSString *)opts | |
| codecName:(NSString * __strong *)outCodecName | |
| gopPtr:(int *)outGop | |
| bfPtr:(int *)outBF | |
| encWidth:(int *)outEncW | |
| encHeight:(int *)outEncH | |
| codecOptions:(AVDictionary **)outDict | |
| { | |
| if (outCodecName) *outCodecName = nil; | |
| if (outGop) *outGop = -1; | |
| if (outBF) *outBF = -1; | |
| AVDictionary *d = NULL; | |
| if (!opts || (id)opts == [NSNull null] || [opts length] == 0) { | |
| if (outDict) *outDict = NULL; | |
| return; | |
| } | |
| NSCharacterSet *ws = [NSCharacterSet whitespaceAndNewlineCharacterSet]; | |
| NSArray<NSString*> *tokens = [opts componentsSeparatedByCharactersInSet:ws]; | |
| NSMutableArray<NSString*> *clean = [NSMutableArray arrayWithCapacity:[tokens count]]; | |
| for (NSString *t in tokens) { | |
| if ([t length] > 0) [clean addObject:t]; | |
| } | |
| for (NSUInteger i = 0; i < [clean count]; ++i) { | |
| NSString *tok = clean[i]; | |
| if (![tok hasPrefix:@"-"]) continue; | |
| NSString *key = [tok substringFromIndex:1]; | |
| NSString *val = (i + 1 < [clean count]) ? clean[i+1] : nil; | |
| // Normalize :v suffix (foo:v -> foo) for generic options | |
| NSString *plainKey = key; | |
| if ([plainKey hasSuffix:@":v"]) { | |
| plainKey = [plainKey substringToIndex:plainKey.length - 2]; | |
| } | |
| // Special cases | |
| if (([key isEqualToString:@"c:v"] || [key isEqualToString:@"codec:v"]) && val) { | |
| if (outCodecName) *outCodecName = val; | |
| i++; // consume value | |
| continue; | |
| } | |
| if ([plainKey isEqualToString:@"g"] && val && outGop) { | |
| *outGop = [val intValue]; | |
| i++; | |
| continue; | |
| } | |
| if ([plainKey isEqualToString:@"bf"] && val && outBF) { | |
| *outBF = [val intValue]; | |
| i++; | |
| continue; | |
| } | |
| if ([plainKey isEqualToString:@"s"] && val && outEncW && outEncH) { | |
| _parse_resolution(val, outEncW, outEncH); | |
| i++; | |
| continue; | |
| } | |
| // Generic codec options -> AVDictionary | |
| if (val) { | |
| av_dict_set(&d, [plainKey UTF8String], [val UTF8String], 0); | |
| i++; | |
| } | |
| } | |
| if (outDict) *outDict = d; | |
| } | |
| // srcW/srcH = QC image size; -s may override encode size. | |
| - (BOOL)_startEncodingWithSourceWidth:(int)srcW | |
| sourceHeight:(int)srcH | |
| fps:(double)fps | |
| path:(NSString *)path | |
| options:(NSString *)optString | |
| { | |
| [self _cleanupFFmpeg]; | |
| if (srcW <= 0 || srcH <= 0) return NO; | |
| if (fps <= 0.0) fps = 30.0; | |
| _srcWidth = srcW; | |
| _srcHeight = srcH; | |
| _fps = fps; | |
| int encW = srcW; | |
| int encH = srcH; | |
| NSString *codecName = nil; | |
| int gopSize = -1; | |
| int maxBF = -1; | |
| AVDictionary *codecOpts = NULL; | |
| [self _parseCodecOptionsString:optString | |
| codecName:&codecName | |
| gopPtr:&gopSize | |
| bfPtr:&maxBF | |
| encWidth:&encW | |
| encHeight:&encH | |
| codecOptions:&codecOpts]; | |
| if (encW <= 0 || encH <= 0) { | |
| encW = srcW; | |
| encH = srcH; | |
| } | |
| _width = encW; | |
| _height = encH; | |
| int fpsInt = (int)llround(fps); | |
| if (fpsInt < 1) fpsInt = 1; | |
| _timeBase = (AVRational){1, fpsInt}; | |
| _nextPTS = 0; | |
| _frameCount = 0; | |
| // Resolve file:// if present | |
| NSString *realPath = path; | |
| if ([realPath hasPrefix:@"file://"]) { | |
| realPath = [[NSURL URLWithString:realPath] path]; | |
| } | |
| const char *filename = [realPath fileSystemRepresentation]; | |
| AVOutputFormat *ofmt = NULL; | |
| avformat_alloc_output_context2(&_fmt, NULL, NULL, filename); | |
| if (!_fmt) { | |
| avformat_alloc_output_context2(&_fmt, NULL, "mp4", filename); | |
| } | |
| if (!_fmt) { | |
| if (codecOpts) av_dict_free(&codecOpts); | |
| return NO; | |
| } | |
| ofmt = _fmt->oformat; | |
| // Choose codec: from c:v if provided, otherwise H.264 | |
| const AVCodec *codec = NULL; | |
| if (codecName && [codecName length] > 0) { | |
| codec = avcodec_find_encoder_by_name([codecName UTF8String]); | |
| } | |
| if (!codec) { | |
| codec = avcodec_find_encoder(AV_CODEC_ID_H264); | |
| } | |
| if (!codec) { | |
| if (codecOpts) av_dict_free(&codecOpts); | |
| return NO; | |
| } | |
| _vstream = avformat_new_stream(_fmt, codec); | |
| if (!_vstream) { | |
| if (codecOpts) av_dict_free(&codecOpts); | |
| return NO; | |
| } | |
| _vstream->id = _fmt->nb_streams - 1; | |
| _venc = avcodec_alloc_context3(codec); | |
| if (!_venc) { | |
| if (codecOpts) av_dict_free(&codecOpts); | |
| return NO; | |
| } | |
| _venc->codec_id = codec->id; | |
| _venc->width = encW; | |
| _venc->height = encH; | |
| _venc->pix_fmt = AV_PIX_FMT_YUV420P; | |
| _venc->time_base = _timeBase; | |
| _vstream->time_base = _timeBase; | |
| _venc->framerate = (AVRational){ fpsInt, 1 }; | |
| _venc->gop_size = (gopSize > 0 ? gopSize : fpsInt); | |
| _venc->max_b_frames = (maxBF >= 0 ? maxBF : 2); | |
| _venc->bit_rate = 8 * 1000 * 1000; // default 8 Mbps; can be overridden via codecOptions if codec supports it | |
| if (ofmt->flags & AVFMT_GLOBALHEADER) { | |
| _venc->flags |= AV_CODEC_FLAG_GLOBAL_HEADER; | |
| } | |
| // Only set default preset/tune if user didn't override them. | |
| if (_venc->priv_data) { | |
| if (!av_dict_get(codecOpts, "preset", NULL, 0)) { | |
| av_dict_set(&codecOpts, "preset", "medium", 0); | |
| } | |
| if (!av_dict_get(codecOpts, "tune", NULL, 0)) { | |
| av_dict_set(&codecOpts, "tune", "animation", 0); | |
| } | |
| } | |
| if (avcodec_open2(_venc, codec, &codecOpts) < 0) { | |
| if (codecOpts) av_dict_free(&codecOpts); // any leftovers | |
| return NO; | |
| } | |
| if (codecOpts) av_dict_free(&codecOpts); | |
| if (avcodec_parameters_from_context(_vstream->codecpar, _venc) < 0) { | |
| return NO; | |
| } | |
| if (!(ofmt->flags & AVFMT_NOFILE)) { | |
| if (avio_open(&_fmt->pb, filename, AVIO_FLAG_WRITE) < 0) { | |
| return NO; | |
| } | |
| } | |
| if (avformat_write_header(_fmt, NULL) < 0) { | |
| return NO; | |
| } | |
| _frame = av_frame_alloc(); | |
| if (!_frame) return NO; | |
| _frame->format = _venc->pix_fmt; | |
| _frame->width = _venc->width; | |
| _frame->height = _venc->height; | |
| if (av_frame_get_buffer(_frame, 32) < 0) { | |
| return NO; | |
| } | |
| // Swscale from QC's BGRA source size -> encoded size | |
| _sws = sws_getContext(_srcWidth, _srcHeight, AV_PIX_FMT_BGRA, | |
| encW, encH, _venc->pix_fmt, | |
| SWS_BICUBIC, NULL, NULL, NULL); | |
| if (!_sws) return NO; | |
| return YES; | |
| } | |
| - (BOOL)_encodeFrameWithBGRA:(const uint8_t *)src | |
| rowBytes:(int)rowBytes | |
| { | |
| if (!_fmt || !_venc || !_frame || !_sws) return NO; | |
| if (av_frame_make_writable(_frame) < 0) return NO; | |
| const uint8_t *srcSlice[4] = { src, NULL, NULL, NULL }; | |
| int srcStride[4] = { rowBytes, 0, 0, 0 }; | |
| sws_scale(_sws, srcSlice, srcStride, 0, _srcHeight, | |
| _frame->data, _frame->linesize); | |
| _frame->pts = _nextPTS++; | |
| int ret = avcodec_send_frame(_venc, _frame); | |
| if (ret < 0) return NO; | |
| AVPacket *pkt = av_packet_alloc(); | |
| if (!pkt) return NO; | |
| for (;;) { | |
| ret = avcodec_receive_packet(_venc, pkt); | |
| if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) { | |
| break; | |
| } else if (ret < 0) { | |
| av_packet_free(&pkt); | |
| return NO; | |
| } | |
| pkt->stream_index = _vstream->index; | |
| av_packet_rescale_ts(pkt, _venc->time_base, _vstream->time_base); | |
| ret = av_interleaved_write_frame(_fmt, pkt); | |
| av_packet_unref(pkt); | |
| if (ret < 0) { | |
| av_packet_free(&pkt); | |
| return NO; | |
| } | |
| } | |
| av_packet_free(&pkt); | |
| _frameCount++; | |
| return YES; | |
| } | |
| - (void)_flushEncoder | |
| { | |
| if (!_fmt || !_venc) return; | |
| int ret = avcodec_send_frame(_venc, NULL); // flush | |
| if (ret < 0) { | |
| return; | |
| } | |
| AVPacket *pkt = av_packet_alloc(); | |
| if (!pkt) return; | |
| for (;;) { | |
| ret = avcodec_receive_packet(_venc, pkt); | |
| if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) break; | |
| if (ret < 0) break; | |
| pkt->stream_index = _vstream->index; | |
| av_packet_rescale_ts(pkt, _venc->time_base, _vstream->time_base); | |
| av_interleaved_write_frame(_fmt, pkt); | |
| av_packet_unref(pkt); | |
| } | |
| av_packet_free(&pkt); | |
| } | |
| - (void)_stopEncoding | |
| { | |
| if (_isRecording) { | |
| [self _flushEncoder]; | |
| if (_fmt) { | |
| av_write_trailer(_fmt); | |
| } | |
| } | |
| [self _cleanupFFmpeg]; | |
| _isRecording = NO; | |
| } | |
| // -------------------------------------------------- | |
| // Execute | |
| // -------------------------------------------------- | |
| - (BOOL)execute:(id<QCPlugInContext>)context | |
| atTime:(NSTimeInterval)time | |
| withArguments:(NSDictionary*)arguments | |
| { | |
| @autoreleasepool { | |
| id<QCPlugInInputImageSource> imgSrc = [self valueForInputKey:@"inputImage"]; | |
| NSString *path = [self valueForInputKey:@"inputOutputPath"]; | |
| if (!path || (id)path == [NSNull null]) path = @""; | |
| double fpsVal = [[self valueForInputKey:@"inputFPS"] doubleValue]; | |
| if (fpsVal <= 0.0) fpsVal = 30.0; | |
| fpsVal = _clamp(fpsVal, 1.0, 240.0); | |
| double durVal = [[self valueForInputKey:@"inputDuration"] doubleValue]; | |
| if (durVal < 0.0) durVal = 0.0; | |
| NSString *codecOpts = [self valueForInputKey:@"inputCodecOptions"]; | |
| if (!codecOpts || (id)codecOpts == [NSNull null]) codecOpts = @""; | |
| BOOL recVal = ([[self valueForInputKey:@"inputRecord"] doubleValue] >= 0.5); | |
| BOOL pauseVal = ([[self valueForInputKey:@"inputPause"] doubleValue] >= 0.5); | |
| BOOL recEdgeOn = (recVal && !_prevRecord); | |
| BOOL recEdgeOff = (!recVal && _prevRecord); | |
| _prevRecord = recVal; | |
| // Start recording on Record rising edge | |
| if (recEdgeOn && !_isRecording && imgSrc && path.length > 0) { | |
| CGRect bounds = [imgSrc imageBounds]; | |
| int w = (int)CGRectGetWidth(bounds); | |
| int h = (int)CGRectGetHeight(bounds); | |
| if (w > 0 && h > 0) { | |
| if ([self _startEncodingWithSourceWidth:w | |
| sourceHeight:h | |
| fps:fpsVal | |
| path:path | |
| options:codecOpts]) { | |
| _isRecording = YES; | |
| _durationLimit = durVal; | |
| _recordStartTime = time; | |
| _lastTime = time; | |
| _timeAccum = 0.0; | |
| } | |
| } | |
| } | |
| // Recorded seconds based on encoded frames (pause-safe) | |
| double recordedSecs = 0.0; | |
| if (_fps > 0.0) recordedSecs = (double)_frameCount / _fps; | |
| // Duration auto-stop based on encoded length only | |
| if (_isRecording && _durationLimit > 0.0) { | |
| if (recordedSecs >= _durationLimit) { | |
| [self _stopEncoding]; | |
| } | |
| } | |
| // Stop & finalize when Record is untoggled | |
| if (_isRecording && recEdgeOff) { | |
| [self _stopEncoding]; | |
| } | |
| // Encode frames while recording and NOT paused | |
| if (_isRecording && imgSrc) { | |
| double dt = time - _lastTime; | |
| if (dt < 0.0) dt = 0.0; | |
| _lastTime = time; | |
| if (!pauseVal) { | |
| _timeAccum += dt; | |
| double frameInterval = 1.0 / _fps; | |
| CGRect bounds = [imgSrc imageBounds]; | |
| if ([imgSrc lockBufferRepresentationWithPixelFormat:QCPlugInPixelFormatBGRA8 | |
| colorSpace:_cs | |
| forBounds:bounds]) { | |
| const void *base = [imgSrc bufferBaseAddress]; | |
| size_t rowBytes = [imgSrc bufferBytesPerRow]; | |
| while (_timeAccum >= frameInterval) { | |
| [self _encodeFrameWithBGRA:(const uint8_t *)base | |
| rowBytes:(int)rowBytes]; | |
| _timeAccum -= frameInterval; | |
| } | |
| [imgSrc unlockBufferRepresentation]; | |
| } | |
| if (_fps > 0.0) recordedSecs = (double)_frameCount / _fps; | |
| } | |
| } | |
| [self setValue:@(_isRecording ? 1.0 : 0.0) forOutputKey:@"outputIsRecording"]; | |
| [self setValue:@(recordedSecs) forOutputKey:@"outputRecordedSeconds"]; | |
| [self setValue:@((double)_frameCount) forOutputKey:@"outputRecordedFrames"]; | |
| return YES; | |
| } | |
| } | |
| @end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment