Skip to content

Instantly share code, notes, and snippets.

@g-l-i-t-c-h-o-r-s-e
Last active November 25, 2025 02:48
Show Gist options
  • Select an option

  • Save g-l-i-t-c-h-o-r-s-e/537353cf1e39315defef9c32b23a0c40 to your computer and use it in GitHub Desktop.

Select an option

Save g-l-i-t-c-h-o-r-s-e/537353cf1e39315defef9c32b23a0c40 to your computer and use it in GitHub Desktop.

Revisions

  1. g-l-i-t-c-h-o-r-s-e revised this gist Nov 25, 2025. 1 changed file with 42 additions and 1 deletion.
    43 changes: 42 additions & 1 deletion FFExportScenePlugIn.m
    Original file line number Diff line number Diff line change
    @@ -80,6 +80,46 @@ @interface FFExportScenePlugIn : QCPlugIn

    @end

    static SystemSoundID gCustomSoundID = 0;

    static void PlayNamedSystemSound(NSString *name)
    {
    if (!name || name.length == 0) {
    AudioServicesPlayAlertSound(kSystemSoundID_UserPreferredAlert);
    return;
    }

    // Try system sounds first: /System/Library/Sounds/<name>.aiff
    NSString *sysPath = [NSString stringWithFormat:@"/System/Library/Sounds/%@.aiff", name];
    NSString *userPath = [NSString stringWithFormat:@"%@/Library/Sounds/%@.aiff",
    NSHomeDirectory(), name];

    NSString *path = nil;
    if ([[NSFileManager defaultManager] fileExistsAtPath:sysPath]) {
    path = sysPath;
    } else if ([[NSFileManager defaultManager] fileExistsAtPath:userPath]) {
    path = userPath;
    }

    if (!path) {
    // Fallback to user’s alert if the named sound doesn’t exist
    AudioServicesPlayAlertSound(kSystemSoundID_UserPreferredAlert);
    return;
    }

    if (gCustomSoundID) {
    AudioServicesDisposeSystemSoundID(gCustomSoundID);
    gCustomSoundID = 0;
    }

    NSURL *url = [NSURL fileURLWithPath:path];
    if (AudioServicesCreateSystemSoundID((__bridge CFURLRef)url, &gCustomSoundID) == kAudioServicesNoError) {
    AudioServicesPlaySystemSound(gCustomSoundID);
    } else {
    AudioServicesPlayAlertSound(kSystemSoundID_UserPreferredAlert);
    }
    }

    @implementation FFExportScenePlugIn
    {
    // FFmpeg state
    @@ -739,7 +779,8 @@ - (void)_stopEncoding
    if (playSound) {
    dispatch_async(dispatch_get_main_queue(), ^{
    // Play the user's preferred alert sound (independent of AppKit / NSBeep / NSApp)
    AudioServicesPlayAlertSound(kSystemSoundID_UserPreferredAlert);
    // AudioServicesPlayAlertSound(kSystemSoundID_UserPreferredAlert);
    PlayNamedSystemSound(@"Glass"); // or @"Basso", @"Funk", @"Ping", etc.

    // Optional: also drop a notification entry (sound usually already played above)
    NSUserNotification *note = [[NSUserNotification alloc] init];
  2. g-l-i-t-c-h-o-r-s-e revised this gist Nov 25, 2025. 1 changed file with 1 addition and 0 deletions.
    1 change: 1 addition & 0 deletions build-ffexport-scene.sh
    Original file line number Diff line number Diff line change
    @@ -58,6 +58,7 @@ COMMON_LIBS=(
    -framework Quartz
    -framework OpenGL
    -framework CoreGraphics
    -framework AudioToolbox
    )

    echo "Compiling x86_64 (FFmpeg scene export)…"
  3. g-l-i-t-c-h-o-r-s-e revised this gist Nov 25, 2025. 1 changed file with 103 additions and 54 deletions.
    157 changes: 103 additions & 54 deletions FFExportScenePlugIn.m
    Original file line number Diff line number Diff line change
    @@ -7,13 +7,18 @@
    //
    // Inputs:
    // Output Path (string)
    // • If this is a directory (e.g. "/Users/blah/Desktop/" or "/Users/blah/Desktop"),
    // the filename is auto-filled as "$DATE-$TIME.mp4", e.g. "20251124-153012.mp4".
    // • If this is a filename containing "%", e.g. "/Users/blah/Desktop/filename%.mp4",
    // "%" is replaced with "$DATE-$TIME".
    // Record (bool toggle; start/stop & finalize)
    // Pause (bool toggle; pause encoding, keep file open)
    // Duration (sec) (number; 0 = unlimited; based on encoded frames / FPS)
    // FPS (number; default 30)
    // Limit to FPS (bool; when ON, capture at most FPS frames/sec; uses QC time for gating)
    // Codec Options (string; e.g. "-c:v libx264 -g 120 -bf 3 -s 1280x720 -preset veryfast -crf 18 -pix_fmt yuv444p")
    // Use QC Time PTS (bool; OFF = frame count PTS, ON = QC time based PTS)
    // Play Done Sound (bool; when ON, play system alert sound when render finishes)
    //
    // Notes:
    // • Uses QC's OpenGL context directly (like SyphonServer's "OpenGL Scene" mode).
    @@ -30,14 +35,15 @@
    // Playback follows QC's time progression (real-time style).
    //
    // Link with:
    // -framework Foundation -framework Quartz -framework OpenGL -framework CoreGraphics
    // -framework Foundation -framework Quartz -framework OpenGL -framework CoreGraphics -framework AudioToolbox
    // FFmpeg 4.4.x: avformat,avcodec,avutil,swscale

    #import <Quartz/Quartz.h>
    #import <CoreGraphics/CoreGraphics.h>
    #import <OpenGL/OpenGL.h>
    #import <OpenGL/gl.h>
    #import <OpenGL/CGLMacro.h>
    #import <AudioToolbox/AudioToolbox.h>

    #include <math.h>
    #include <string.h>
    @@ -69,7 +75,8 @@ @interface FFExportScenePlugIn : QCPlugIn
    @property(assign) double inputFPS;
    @property(assign) BOOL inputLimitFPS;
    @property(assign) NSString *inputCodecOptions;
    @property(assign) BOOL inputUseTimePTS; // NEW: toggle QC-time vs frame-count PTS
    @property(assign) BOOL inputUseTimePTS; // toggle QC-time vs frame-count PTS
    @property(assign) BOOL inputPlayDoneSound; // play notification when done

    @end

    @@ -118,11 +125,13 @@ @implementation FFExportScenePlugIn

    BOOL _finalizing; // true while trailing/cleanup is running on encodeQueue

    // Mode toggle
    // Mode toggles
    BOOL _useTimePTS; // copy of inputUseTimePTS, latched at start of recording
    BOOL _playDoneSound; // last value of inputPlayDoneSound
    }

    @dynamic inputOutputPath, inputRecord, inputPause, inputDuration, inputFPS, inputLimitFPS, inputCodecOptions, inputUseTimePTS;
    @dynamic inputOutputPath, inputRecord, inputPause, inputDuration, inputFPS,
    inputLimitFPS, inputCodecOptions, inputUseTimePTS, inputPlayDoneSound;

    + (NSDictionary *)attributes
    {
    @@ -135,31 +144,50 @@ + (NSDictionary *)attributes
    + (NSDictionary *)attributesForPropertyPortWithKey:(NSString *)key
    {
    if ([key isEqualToString:@"inputOutputPath"])
    return @{ QCPortAttributeNameKey: @"Output Path", QCPortAttributeTypeKey: QCPortTypeString, QCPortAttributeDefaultValueKey: @"" };
    return @{ QCPortAttributeNameKey: @"Output Path",
    QCPortAttributeTypeKey: QCPortTypeString,
    QCPortAttributeDefaultValueKey: @"" };

    if ([key isEqualToString:@"inputRecord"])
    return @{ QCPortAttributeNameKey: @"Record", QCPortAttributeTypeKey: QCPortTypeBoolean, QCPortAttributeDefaultValueKey: @0.0 };
    return @{ QCPortAttributeNameKey: @"Record",
    QCPortAttributeTypeKey: QCPortTypeBoolean,
    QCPortAttributeDefaultValueKey: @0.0 };

    if ([key isEqualToString:@"inputPause"])
    return @{ QCPortAttributeNameKey: @"Pause", QCPortAttributeTypeKey: QCPortTypeBoolean, QCPortAttributeDefaultValueKey: @0.0 };
    return @{ QCPortAttributeNameKey: @"Pause",
    QCPortAttributeTypeKey: QCPortTypeBoolean,
    QCPortAttributeDefaultValueKey: @0.0 };

    if ([key isEqualToString:@"inputDuration"])
    return @{ QCPortAttributeNameKey: @"Duration (sec)", QCPortAttributeTypeKey: QCPortTypeNumber, QCPortAttributeDefaultValueKey: @0.0 };
    return @{ QCPortAttributeNameKey: @"Duration (sec)",
    QCPortAttributeTypeKey: QCPortTypeNumber,
    QCPortAttributeDefaultValueKey: @0.0 };

    if ([key isEqualToString:@"inputFPS"])
    return @{ QCPortAttributeNameKey: @"FPS", QCPortAttributeTypeKey: QCPortTypeNumber, QCPortAttributeDefaultValueKey: @30.0 };
    return @{ QCPortAttributeNameKey: @"FPS",
    QCPortAttributeTypeKey: QCPortTypeNumber,
    QCPortAttributeDefaultValueKey: @30.0 };

    if ([key isEqualToString:@"inputLimitFPS"])
    return @{ QCPortAttributeNameKey: @"Limit to FPS", QCPortAttributeTypeKey: QCPortTypeBoolean, QCPortAttributeDefaultValueKey: @1.0 };
    return @{ QCPortAttributeNameKey: @"Limit to FPS",
    QCPortAttributeTypeKey: QCPortTypeBoolean,
    QCPortAttributeDefaultValueKey: @1.0 };

    if ([key isEqualToString:@"inputCodecOptions"])
    return @{ QCPortAttributeNameKey: @"Codec Options", QCPortAttributeTypeKey: QCPortTypeString, QCPortAttributeDefaultValueKey: @"" };
    return @{ QCPortAttributeNameKey: @"Codec Options",
    QCPortAttributeTypeKey: QCPortTypeString,
    QCPortAttributeDefaultValueKey: @"" };

    if ([key isEqualToString:@"inputUseTimePTS"])
    return @{ QCPortAttributeNameKey: @"Use QC Time PTS",
    QCPortAttributeTypeKey: QCPortTypeBoolean,
    QCPortAttributeDefaultValueKey: @0.0 }; // default: frame-count PTS

    if ([key isEqualToString:@"inputPlayDoneSound"])
    return @{ QCPortAttributeNameKey: @"Play Done Sound",
    QCPortAttributeTypeKey: QCPortTypeBoolean,
    QCPortAttributeDefaultValueKey: @0.0 };

    return nil;
    }

    @@ -172,7 +200,8 @@ + (NSArray *)sortedPropertyPortKeys
    @"inputDuration",
    @"inputFPS",
    @"inputLimitFPS",
    @"inputUseTimePTS", // NEW
    @"inputUseTimePTS",
    @"inputPlayDoneSound",
    @"inputCodecOptions"
    ];
    }
    @@ -226,6 +255,7 @@ - (id)init
    _finalizing = NO;

    _useTimePTS = NO;
    _playDoneSound = NO;
    }
    return self;
    }
    @@ -690,6 +720,7 @@ - (void)_stopEncoding

    _finalizing = YES;
    int64_t totalFrames = _frameCount;
    BOOL playSound = _playDoneSound; // snapshot for this stop

    dispatch_async(_encodeQueue, ^{
    @autoreleasepool {
    @@ -704,6 +735,20 @@ - (void)_stopEncoding
    _scheduledFrames = 0;
    atomic_store(&_inFlightFrames, 0);
    _finalizing = NO;

    if (playSound) {
    dispatch_async(dispatch_get_main_queue(), ^{
    // Play the user's preferred alert sound (independent of AppKit / NSBeep / NSApp)
    AudioServicesPlayAlertSound(kSystemSoundID_UserPreferredAlert);

    // Optional: also drop a notification entry (sound usually already played above)
    NSUserNotification *note = [[NSUserNotification alloc] init];
    note.title = @"FFExport Scene";
    note.informativeText = @"Video export finished.";
    note.soundName = nil; // sound already played
    [[NSUserNotificationCenter defaultUserNotificationCenter] deliverNotification:note];
    });
    }
    }
    });
    }
    @@ -904,53 +949,57 @@ - (BOOL)execute:(id<QCPlugInContext>)context
    NSString *codecOpts = self.inputCodecOptions;
    if (!codecOpts || (id)codecOpts == [NSNull null]) codecOpts = @"";

    BOOL recVal = self.inputRecord;
    BOOL pauseVal = self.inputPause;
    BOOL limitFPSVal = self.inputLimitFPS;
    BOOL useTimePTS = self.inputUseTimePTS;
    BOOL recVal = self.inputRecord;
    BOOL pauseVal = self.inputPause;
    BOOL limitFPSVal = self.inputLimitFPS;
    BOOL useTimePTS = self.inputUseTimePTS;
    BOOL playDoneSound = self.inputPlayDoneSound;

    // Latch sound toggle each tick so _stopEncoding sees last user value
    _playDoneSound = playDoneSound;

    BOOL recEdgeOn = (recVal && !_prevRecord);
    BOOL recEdgeOff = (!recVal && _prevRecord);
    _prevRecord = recVal;

    // Start recording on Record rising edge
    if (recEdgeOn && !_isRecording && !_finalizing) {
    // Resolve directory-only paths and "%" placeholders to a concrete filename
    NSString *resolvedPath = [self _resolvedOutputPathFromInputPath:path];
    if (resolvedPath.length == 0) {
    NSLog(@"[FFExportScene] No valid output path specified.");
    } else {
    CGLContextObj cgl_ctx = [context CGLContextObj];
    (void)cgl_ctx;

    GLint viewport[4] = {0,0,0,0};
    glGetIntegerv(GL_VIEWPORT, viewport);
    int w = viewport[2];
    int h = viewport[3];

    if (w > 0 && h > 0) {
    // Latch mode at start
    _useTimePTS = useTimePTS;

    if ([self _startEncodingWithSourceWidth:w
    sourceHeight:h
    fps:fpsVal
    path:resolvedPath
    options:codecOpts]) {

    _isRecording = YES;
    _durationLimit = durVal;
    _recordStartTime = time;
    _lastTime = time;
    _nextCaptureTime = time; // for LimitFPS gating
    } else {
    NSLog(@"[FFExportScene] Failed to start encoding for path: %@", resolvedPath);
    }
    } else {
    NSLog(@"[FFExportScene] Viewport size is zero; cannot start recording.");
    }
    }
    }
    // Start recording on Record rising edge
    if (recEdgeOn && !_isRecording && !_finalizing) {
    // Resolve directory-only paths and "%" placeholders to a concrete filename
    NSString *resolvedPath = [self _resolvedOutputPathFromInputPath:path];
    if (resolvedPath.length == 0) {
    NSLog(@"[FFExportScene] No valid output path specified.");
    } else {
    CGLContextObj cgl_ctx = [context CGLContextObj];
    (void)cgl_ctx;

    GLint viewport[4] = {0,0,0,0};
    glGetIntegerv(GL_VIEWPORT, viewport);
    int w = viewport[2];
    int h = viewport[3];

    if (w > 0 && h > 0) {
    // Latch PTS mode at start
    _useTimePTS = useTimePTS;

    if ([self _startEncodingWithSourceWidth:w
    sourceHeight:h
    fps:fpsVal
    path:resolvedPath
    options:codecOpts]) {

    _isRecording = YES;
    _durationLimit = durVal;
    _recordStartTime = time;
    _lastTime = time;
    _nextCaptureTime = time; // for LimitFPS gating
    } else {
    NSLog(@"[FFExportScene] Failed to start encoding for path: %@", resolvedPath);
    }
    } else {
    NSLog(@"[FFExportScene] Viewport size is zero; cannot start recording.");
    }
    }
    }

    // Duration auto-stop (based on encoded timeline = scheduledFrames / FPS)
    if (_isRecording && _durationLimit > 0.0 && _fps > 0.0 && _scheduledFrames > 0) {
  4. g-l-i-t-c-h-o-r-s-e revised this gist Nov 25, 2025. 1 changed file with 106 additions and 33 deletions.
    139 changes: 106 additions & 33 deletions FFExportScenePlugIn.m
    Original file line number Diff line number Diff line change
    @@ -16,7 +16,7 @@
    // Use QC Time PTS (bool; OFF = frame count PTS, ON = QC time based PTS)
    //
    // Notes:
    // • Uses QC's OpenGL context directly
    // • Uses QC's OpenGL context directly (like SyphonServer's "OpenGL Scene" mode).
    // • Scene size comes from the current GL viewport.
    // • Each frame (when capturing):
    // - QC renders the scene into its back buffer.
    @@ -708,6 +708,73 @@ - (void)_stopEncoding
    });
    }

    // --------------------------------------------------
    // Output path helper (date/time auto-filename)
    // --------------------------------------------------

    - (NSString *)_dateTimeStampString
    {
    NSDateFormatter *df = [[NSDateFormatter alloc] init];
    // $DATE-$TIME style, filesystem-safe (no ':' etc.)
    [df setDateFormat:@"yyyyMMdd-HHmmss"];
    return [df stringFromDate:[NSDate date]];
    }

    - (NSString *)_resolvedOutputPathFromInputPath:(NSString *)rawPath
    {
    if (!rawPath || (id)rawPath == [NSNull null] || rawPath.length == 0) {
    return @"";
    }

    // Handle common "file://..." inputs
    if ([rawPath hasPrefix:@"file://"]) {
    NSURL *url = [NSURL URLWithString:rawPath];
    if (url.path.length > 0) {
    rawPath = url.path;
    }
    }

    // Expand tilde, e.g. "~/Desktop"
    rawPath = [rawPath stringByExpandingTildeInPath];

    NSString *path = [rawPath copy];
    BOOL endsWithSlash = [path hasSuffix:@"/"];
    NSString *stamp = [self _dateTimeStampString];

    NSString *last = [path lastPathComponent];

    // Case 2: "/users/blah/Desktop/filename%.mp4" -> replace % with date/time
    if (last.length > 0 && [last containsString:@"%"]) {
    NSString *dir = [path stringByDeletingLastPathComponent];
    NSString *newName = [last stringByReplacingOccurrencesOfString:@"%" withString:stamp];

    if (dir.length > 0) {
    path = [dir stringByAppendingPathComponent:newName];
    } else {
    path = newName; // relative filename
    }
    } else {
    // Case 1: directory-only input -> auto filename "$DATE-$TIME.mp4"
    if (endsWithSlash) {
    // "/users/blah/Desktop/" -> "/users/blah/Desktop/<stamp>.mp4"
    NSString *filename = [NSString stringWithFormat:@"%@.mp4", stamp];
    path = [path stringByAppendingPathComponent:filename];
    } else {
    // No trailing slash: if there's no extension, treat as directory path
    NSString *ext = [last pathExtension];
    if (ext.length == 0) {
    // "/users/blah/Desktop" -> "/users/blah/Desktop/<stamp>.mp4"
    NSString *dir = path;
    NSString *filename = [NSString stringWithFormat:@"%@.mp4", stamp];
    path = [dir stringByAppendingPathComponent:filename];
    }
    // If there *is* an extension, we leave it as-is.
    }
    }

    return path;
    }

    // --------------------------------------------------
    // Capture from QC's OpenGL context (viewport)
    // --------------------------------------------------
    @@ -846,38 +913,44 @@ - (BOOL)execute:(id<QCPlugInContext>)context
    BOOL recEdgeOff = (!recVal && _prevRecord);
    _prevRecord = recVal;

    // Start recording on Record rising edge
    if (recEdgeOn && !_isRecording && !_finalizing && path.length > 0) {
    CGLContextObj cgl_ctx = [context CGLContextObj];
    (void)cgl_ctx;

    GLint viewport[4] = {0,0,0,0};
    glGetIntegerv(GL_VIEWPORT, viewport);
    int w = viewport[2];
    int h = viewport[3];

    if (w > 0 && h > 0) {
    // Latch mode at start
    _useTimePTS = useTimePTS;

    if ([self _startEncodingWithSourceWidth:w
    sourceHeight:h
    fps:fpsVal
    path:path
    options:codecOpts]) {

    _isRecording = YES;
    _durationLimit = durVal;
    _recordStartTime = time;
    _lastTime = time;
    _nextCaptureTime = time; // for LimitFPS gating
    } else {
    NSLog(@"[FFExportScene] Failed to start encoding for path: %@", path);
    }
    } else {
    NSLog(@"[FFExportScene] Viewport size is zero; cannot start recording.");
    }
    }
    // Start recording on Record rising edge
    if (recEdgeOn && !_isRecording && !_finalizing) {
    // Resolve directory-only paths and "%" placeholders to a concrete filename
    NSString *resolvedPath = [self _resolvedOutputPathFromInputPath:path];
    if (resolvedPath.length == 0) {
    NSLog(@"[FFExportScene] No valid output path specified.");
    } else {
    CGLContextObj cgl_ctx = [context CGLContextObj];
    (void)cgl_ctx;

    GLint viewport[4] = {0,0,0,0};
    glGetIntegerv(GL_VIEWPORT, viewport);
    int w = viewport[2];
    int h = viewport[3];

    if (w > 0 && h > 0) {
    // Latch mode at start
    _useTimePTS = useTimePTS;

    if ([self _startEncodingWithSourceWidth:w
    sourceHeight:h
    fps:fpsVal
    path:resolvedPath
    options:codecOpts]) {

    _isRecording = YES;
    _durationLimit = durVal;
    _recordStartTime = time;
    _lastTime = time;
    _nextCaptureTime = time; // for LimitFPS gating
    } else {
    NSLog(@"[FFExportScene] Failed to start encoding for path: %@", resolvedPath);
    }
    } else {
    NSLog(@"[FFExportScene] Viewport size is zero; cannot start recording.");
    }
    }
    }

    // Duration auto-stop (based on encoded timeline = scheduledFrames / FPS)
    if (_isRecording && _durationLimit > 0.0 && _fps > 0.0 && _scheduledFrames > 0) {
  5. g-l-i-t-c-h-o-r-s-e revised this gist Nov 25, 2025. 1 changed file with 155 additions and 150 deletions.
    305 changes: 155 additions & 150 deletions FFExportScenePlugIn.m
    Original file line number Diff line number Diff line change
    @@ -1,4 +1,8 @@
    // FFExportScenePlugIn.m — FFmpeg OpenGL scene exporter (CONSUMER) for Quartz Composer (Mojave/ARC, 64-bit)
    // VERSION: Direct glReadPixels from QC's OpenGL context, SyphonServer-style viewport usage,
    // with selectable PTS: either frame-count-based (offline-style) or QC-time-based.
    // Vertical flip is done on the encode queue, not on the QC thread.
    //
    // Place this patch in the top layer; it captures the rendered OpenGL scene below it.
    //
    // Inputs:
    @@ -7,16 +11,23 @@
    // Pause (bool toggle; pause encoding, keep file open)
    // Duration (sec) (number; 0 = unlimited; based on encoded frames / FPS)
    // FPS (number; default 30)
    // Limit to FPS (bool; when ON, capture at most FPS frames/sec; PTS still monotonic frame counter)
    // Limit to FPS (bool; when ON, capture at most FPS frames/sec; uses QC time for gating)
    // Codec Options (string; e.g. "-c:v libx264 -g 120 -bf 3 -s 1280x720 -preset veryfast -crf 18 -pix_fmt yuv444p")
    // Use QC Time PTS (bool; OFF = frame count PTS, ON = QC time based PTS)
    //
    // Notes:
    // • For a pure, no-conversion path, you can use something like:
    // -c:v ffv1 -pix_fmt bgra -level 3 -coder 1
    // (we bypass swscale if encoder pix_fmt == BGRA and size is unchanged)
    // • CPU-heavy codecs are encoded on a serial GCD queue; QC thread never blocks.
    // If the encoder can’t keep up, frames are dropped only after a sizable backlog,
    // instead of immediately, so 60 fps exports stay much smoother.
    // • Uses QC's OpenGL context directly
    // • Scene size comes from the current GL viewport.
    // • Each frame (when capturing):
    // - QC renders the scene into its back buffer.
    // - We glReadPixels() the viewport into a scratch buffer.
    // - We memcpy() that into a heap buffer and push it to the encode queue.
    // - On the encode queue we flip vertically and feed the data to FFmpeg.
    // • PTS mode:
    // - Frame-count mode (default): pts = 0,1,2,... with time_base = 1/FPS.
    // Playback always at requested FPS; real-time duration may differ if QC is slow.
    // - QC-time mode: pts ≈ (time - start) * FPS, with time_base = 1/FPS.
    // Playback follows QC's time progression (real-time style).
    //
    // Link with:
    // -framework Foundation -framework Quartz -framework OpenGL -framework CoreGraphics
    @@ -58,6 +69,7 @@ @interface FFExportScenePlugIn : QCPlugIn
    @property(assign) double inputFPS;
    @property(assign) BOOL inputLimitFPS;
    @property(assign) NSString *inputCodecOptions;
    @property(assign) BOOL inputUseTimePTS; // NEW: toggle QC-time vs frame-count PTS

    @end

    @@ -78,26 +90,23 @@ @implementation FFExportScenePlugIn
    AVRational _timeBase;
    double _fps;

    int64_t _nextPTS; // strictly monotonic frame counter (always used)
    int64_t _nextPTS; // last PTS (frame index or QC-time-derived ticks)
    int64_t _frameCount; // encoded frames (encoder thread)

    // Recording state
    BOOL _isRecording;
    BOOL _prevRecord;

    NSTimeInterval _recordStartTime;
    NSTimeInterval _lastTime;
    double _timeAccum; // accumulated QC time for FPS stepping (no-limit mode)
    NSTimeInterval _recordStartTime; // QC time at start (for QC-time PTS & gating)
    NSTimeInterval _lastTime; // last QC time (for FPS gating)

    double _durationLimit; // seconds (0 = unlimited)
    double _durationLimit; // seconds (0 = unlimited)
    double _nextCaptureTime; // QC time of next allowed capture when Limit to FPS is ON

    // Capture buffers (BGRA top-down)
    // Capture scratch buffer (BGRA, bottom-up as glReadPixels gives it)
    uint8_t *_captureBuf;
    size_t _captureBufSize;

    uint8_t *_rowTmp;
    size_t _rowTmpSize;

    CGColorSpaceRef _cs;

    // Async encoding
    @@ -109,17 +118,17 @@ @implementation FFExportScenePlugIn

    BOOL _finalizing; // true while trailing/cleanup is running on encodeQueue

    // FPS limiting schedule
    double _nextCaptureTime; // next QC time at which we are allowed to capture when Limit to FPS is ON
    // Mode toggle
    BOOL _useTimePTS; // copy of inputUseTimePTS, latched at start of recording
    }

    @dynamic inputOutputPath, inputRecord, inputPause, inputDuration, inputFPS, inputLimitFPS, inputCodecOptions;
    @dynamic inputOutputPath, inputRecord, inputPause, inputDuration, inputFPS, inputLimitFPS, inputCodecOptions, inputUseTimePTS;

    + (NSDictionary *)attributes
    {
    return @{
    QCPlugInAttributeNameKey: @"FFExport Scene (x86_64)",
    QCPlugInAttributeDescriptionKey: @"FFmpeg-based exporter that captures the OpenGL scene below it.\nPlace this as the top layer. Uses Record/Pause/Duration/FPS/Limit/Codec Options like FFExport."
    QCPlugInAttributeDescriptionKey: @"FFmpeg-based exporter that captures the OpenGL scene below it.\nPlace this as the top layer. Capture is from QC's OpenGL viewport; PTS can be frame-count-based or QC-time-based."
    };
    }

    @@ -146,6 +155,11 @@ + (NSDictionary *)attributesForPropertyPortWithKey:(NSString *)key
    if ([key isEqualToString:@"inputCodecOptions"])
    return @{ QCPortAttributeNameKey: @"Codec Options", QCPortAttributeTypeKey: QCPortTypeString, QCPortAttributeDefaultValueKey: @"" };

    if ([key isEqualToString:@"inputUseTimePTS"])
    return @{ QCPortAttributeNameKey: @"Use QC Time PTS",
    QCPortAttributeTypeKey: QCPortTypeBoolean,
    QCPortAttributeDefaultValueKey: @0.0 }; // default: frame-count PTS

    return nil;
    }

    @@ -158,11 +172,13 @@ + (NSArray *)sortedPropertyPortKeys
    @"inputDuration",
    @"inputFPS",
    @"inputLimitFPS",
    @"inputUseTimePTS", // NEW
    @"inputCodecOptions"
    ];
    }

    + (QCPlugInExecutionMode)executionMode { return kQCPlugInExecutionModeConsumer; }
    // We still use Idle; QC time is used for gating and (optionally) PTS.
    + (QCPlugInTimeMode) timeMode { return kQCPlugInTimeModeIdle; }
    + (BOOL)allowsSubpatches { return NO; }

    @@ -196,33 +212,31 @@ - (id)init
    _prevRecord = NO;
    _recordStartTime = 0.0;
    _lastTime = 0.0;
    _timeAccum = 0.0;
    _durationLimit = 0.0;
    _nextCaptureTime = 0.0;

    _captureBuf = NULL;
    _captureBufSize = 0;
    _rowTmp = NULL;
    _rowTmpSize = 0;

    _encodeQueue = dispatch_queue_create("com.yourdomain.FFExportScene.encode", DISPATCH_QUEUE_SERIAL);
    _scheduledFrames = 0;
    atomic_store(&_inFlightFrames, 0);

    _directBGRAPath = NO;
    _finalizing = NO;
    _nextCaptureTime = 0.0;

    _useTimePTS = NO;
    }
    return self;
    }

    - (void)dealloc
    {
    [self _stopEncoding]; // async finalize/cleanup
    [self _stopEncoding];

    if (_cs) { CFRelease(_cs); _cs = NULL; }

    if (_captureBuf) { free(_captureBuf); _captureBuf = NULL; _captureBufSize = 0; }
    if (_rowTmp) { free(_rowTmp); _rowTmp = NULL; _rowTmpSize = 0; }
    }

    - (BOOL)startExecution:(id<QCPlugInContext>)context
    @@ -233,6 +247,7 @@ - (BOOL)startExecution:(id<QCPlugInContext>)context

    - (void)stopExecution:(id<QCPlugInContext>)context
    {
    (void)context;
    [self _stopEncoding];
    }

    @@ -278,13 +293,6 @@ static void _parse_resolution(NSString *val, int *encW, int *encH)
    }
    }

    // Parse codec options string into:
    // - codecName (c:v / codec:v)
    // - gop size (g)
    // - max B-frames (bf)
    // - encode size (s)
    // - pixel format (pix_fmt / pixel_format)
    // - generic AVDictionary options (preset, tune, crf, etc.)
    - (void)_parseCodecOptionsString:(NSString *)opts
    codecName:(NSString * __strong *)outCodecName
    gopPtr:(int *)outGop
    @@ -326,45 +334,39 @@ - (void)_parseCodecOptionsString:(NSString *)opts
    plainKey = [plainKey substringToIndex:plainKey.length - 2];
    }

    // Codec name (-c:v / -codec:v)
    if (([key isEqualToString:@"c:v"] || [key isEqualToString:@"codec:v"]) && val) {
    if (outCodecName) *outCodecName = val;
    i++;
    continue;
    }

    // GOP size (-g)
    if ([plainKey isEqualToString:@"g"] && val && outGop) {
    *outGop = [val intValue];
    i++;
    continue;
    }

    // Max B-frames (-bf)
    if ([plainKey isEqualToString:@"bf"] && val && outBF) {
    *outBF = [val intValue];
    i++;
    continue;
    }

    // Encode size (-s WxH)
    if ([plainKey isEqualToString:@"s"] && val && outEncW && outEncH) {
    _parse_resolution(val, outEncW, outEncH);
    i++;
    continue;
    }

    // Pixel format (-pix_fmt / -pixel_format)
    if (([plainKey isEqualToString:@"pix_fmt"] || [plainKey isEqualToString:@"pixel_format"]) && val && outPixFmt) {
    enum AVPixelFormat pf = av_get_pix_fmt([val UTF8String]);
    if (pf != AV_PIX_FMT_NONE) {
    *outPixFmt = pf;
    }
    i++;
    continue; // handled, don't add to dict
    continue;
    }

    // Everything else goes into AVDictionary
    if (val) {
    av_dict_set(&d, [plainKey UTF8String], [val UTF8String], 0);
    i++;
    @@ -423,6 +425,8 @@ - (BOOL)_startEncodingWithSourceWidth:(int)srcW

    int fpsInt = (int)llround(fps);
    if (fpsInt < 1) fpsInt = 1;

    // Time base is always 1/FPS; we just change how we compute PTS.
    _timeBase = (AVRational){1, fpsInt};

    _nextPTS = 0;
    @@ -459,7 +463,6 @@ - (BOOL)_startEncodingWithSourceWidth:(int)srcW
    return NO;
    }

    // Default pixel format: use encoder's first supported if none specified; fallback to yuv420p.
    if (pixFmt == AV_PIX_FMT_NONE) {
    if (codec->pix_fmts) {
    pixFmt = codec->pix_fmts[0];
    @@ -485,19 +488,21 @@ - (BOOL)_startEncodingWithSourceWidth:(int)srcW
    _venc->width = encW;
    _venc->height = encH;
    _venc->pix_fmt = pixFmt;
    _venc->time_base = _timeBase;

    _venc->time_base = _timeBase;
    _vstream->time_base = _timeBase;
    _venc->framerate = (AVRational){ fpsInt, 1 };
    _venc->framerate = (AVRational){ fpsInt, 1 };
    _vstream->avg_frame_rate = (AVRational){ fpsInt, 1 };
    _vstream->r_frame_rate = (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; can be overridden by options
    _venc->bit_rate = 8 * 1000 * 1000;

    if (ofmt->flags & AVFMT_GLOBALHEADER) {
    _venc->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
    }

    // Color metadata (mostly relevant for YUV)
    if (_venc->pix_fmt == AV_PIX_FMT_YUV420P ||
    _venc->pix_fmt == AV_PIX_FMT_YUV422P ||
    _venc->pix_fmt == AV_PIX_FMT_YUV444P ||
    @@ -506,9 +511,9 @@ - (BOOL)_startEncodingWithSourceWidth:(int)srcW
    _venc->color_primaries = AVCOL_PRI_BT709;
    _venc->color_trc = AVCOL_TRC_BT709;
    _venc->colorspace = AVCOL_SPC_BT709;
    _venc->color_range = AVCOL_RANGE_MPEG; // studio
    _venc->color_range = AVCOL_RANGE_MPEG;
    } else {
    _venc->color_range = AVCOL_RANGE_JPEG; // full for RGB-ish
    _venc->color_range = AVCOL_RANGE_JPEG;
    }

    if (_venc->priv_data) {
    @@ -550,7 +555,6 @@ - (BOOL)_startEncodingWithSourceWidth:(int)srcW
    return NO;
    }

    // Decide if we can bypass swscale and copy BGRA directly
    _directBGRAPath = (_venc->pix_fmt == AV_PIX_FMT_BGRA &&
    _srcWidth == _width &&
    _srcHeight == _height);
    @@ -564,14 +568,15 @@ - (BOOL)_startEncodingWithSourceWidth:(int)srcW
    _sws = NULL;
    }

    NSLog(@"[FFExportScene] Recording started: %s (%dx%d -> %dx%d @ %.3f fps, pix_fmt=%d, directBGRA=%d)",
    filename, _srcWidth, _srcHeight, encW, encH, _fps, (int)_venc->pix_fmt, (int)_directBGRAPath);
    NSLog(@"[FFExportScene] Recording started: %s (%dx%d -> %dx%d @ %.3f fps, pix_fmt=%d, directBGRA=%d, useTimePTS=%d)",
    filename, _srcWidth, _srcHeight, encW, encH, _fps,
    (int)_venc->pix_fmt, (int)_directBGRAPath, (int)_useTimePTS);

    return YES;
    }

    // Called only on the encode queue.
    - (BOOL)_encodeFrameWithBGRA_locked:(const uint8_t *)src
    - (BOOL)_encodeFrameWithBGRA_locked:(uint8_t *)src
    rowBytes:(int)rowBytes
    pts:(int64_t)pts
    {
    @@ -580,7 +585,6 @@ - (BOOL)_encodeFrameWithBGRA_locked:(const uint8_t *)src
    if (av_frame_make_writable(_frame) < 0) return NO;

    if (_directBGRAPath) {
    // Pure copy: BGRA -> BGRA, same size, no swscale, no color conversion.
    uint8_t *dst = _frame->data[0];
    int dstRB = _frame->linesize[0];
    int copyRB = rowBytes;
    @@ -590,7 +594,6 @@ - (BOOL)_encodeFrameWithBGRA_locked:(const uint8_t *)src
    memcpy(dst + (size_t)y * dstRB, src + (size_t)y * rowBytes, (size_t)copyRB);
    }
    } else {
    // Use swscale for RGB->YUV or scaling conversions.
    const uint8_t *srcSlice[4] = { src, NULL, NULL, NULL };
    int srcStride[4] = { rowBytes, 0, 0, 0 };

    @@ -619,6 +622,7 @@ - (BOOL)_encodeFrameWithBGRA_locked:(const uint8_t *)src

    pkt->stream_index = _vstream->index;
    av_packet_rescale_ts(pkt, _venc->time_base, _vstream->time_base);
    pkt->duration = 1;

    ret = av_interleaved_write_frame(_fmt, pkt);
    av_packet_unref(pkt);
    @@ -653,14 +657,15 @@ - (void)_flushEncoder_locked

    pkt->stream_index = _vstream->index;
    av_packet_rescale_ts(pkt, _venc->time_base, _vstream->time_base);
    pkt->duration = 1;
    av_interleaved_write_frame(_fmt, pkt);
    av_packet_unref(pkt);
    }

    av_packet_free(&pkt);
    }

    // Public stop: async — QC thread no longer blocks, so no visible jitter.
    // Public stop: async — QC thread no longer blocks.
    - (void)_stopEncoding
    {
    if (!_fmt && !_venc) {
    @@ -672,7 +677,6 @@ - (void)_stopEncoding
    _isRecording = NO;

    if (!_encodeQueue) {
    // Fallback: finalize synchronously (shouldn’t happen in practice).
    if (wasRecording && _fmt && _venc) {
    [self _flushEncoder_locked];
    av_write_trailer(_fmt);
    @@ -704,41 +708,17 @@ - (void)_stopEncoding
    });
    }

    // Enqueue a frame for encoding; called from QC thread.
    - (void)_enqueueBGRAForEncoding:(uint8_t *)data
    rowBytes:(int)rowBytes
    pts:(int64_t)pts
    {
    if (!_fmt || !_venc || !_encodeQueue) {
    free(data);
    return;
    }

    atomic_fetch_add(&_inFlightFrames, 1);

    dispatch_async(_encodeQueue, ^{
    @autoreleasepool {
    [self _encodeFrameWithBGRA_locked:data rowBytes:rowBytes pts:pts];
    free(data);
    atomic_fetch_sub(&_inFlightFrames, 1);
    }
    });
    }
    // --------------------------------------------------
    // Capture from QC's OpenGL context (viewport)
    // --------------------------------------------------
    //
    // Capture the current OpenGL scene via glReadPixels into a scratch buffer,
    // then heap-copy that buffer and send it to the encode queue.
    // PTS is either frame-count-based or QC-time-based, depending on _useTimePTS.

    // Capture the current OpenGL scene and schedule it for encoding.
    // Returns YES if we actually scheduled a frame, NO if dropped/failed.
    - (BOOL)_captureSceneAtTime:(NSTimeInterval)time
    context:(id<QCPlugInContext>)context
    maxBacklog:(int)maxBacklog
    {
    (void)time; // PTS now comes purely from the frame counter.

    int inFlight = atomic_load(&_inFlightFrames);
    if (inFlight >= maxBacklog) {
    // Encoder is behind: drop this logical frame
    return NO;
    }

    CGLContextObj cgl_ctx = [context CGLContextObj];
    (void)cgl_ctx;

    @@ -747,53 +727,93 @@ - (BOOL)_captureSceneAtTime:(NSTimeInterval)time
    int w = viewport[2];
    int h = viewport[3];

    if (w == _srcWidth && h == _srcHeight && w > 0 && h > 0) {
    size_t rowBytes = (size_t)_srcWidth * 4;
    size_t needed = rowBytes * (size_t)_srcHeight;
    if (w <= 0 || h <= 0) return NO;

    if (_captureBufSize < needed) {
    _captureBuf = (uint8_t*)realloc(_captureBuf, needed);
    _captureBufSize = needed;
    }
    if (_rowTmpSize < rowBytes) {
    _rowTmp = (uint8_t*)realloc(_rowTmp, rowBytes);
    _rowTmpSize = rowBytes;
    }
    if (_srcWidth == 0 || _srcHeight == 0) {
    _srcWidth = w;
    _srcHeight = h;
    }

    // Read scene as BGRA from QC's framebuffer
    glPixelStorei(GL_PACK_ALIGNMENT, 4);
    glReadPixels(viewport[0], viewport[1], w, h,
    GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV,
    _captureBuf);

    // Vertical flip: OpenGL is bottom-up; encoder expects top-down.
    uint8_t *top = _captureBuf;
    uint8_t *bottom = _captureBuf + (size_t)(_srcHeight - 1) * rowBytes;
    for (int y = 0; y < _srcHeight / 2; ++y) {
    memcpy(_rowTmp, top, rowBytes);
    memcpy(top, bottom, rowBytes);
    memcpy(bottom, _rowTmp, rowBytes);
    top += rowBytes;
    bottom -= rowBytes;
    }
    // If viewport changes mid-record, you could handle it here; for now we require it to stay constant.
    if (w != _srcWidth || h != _srcHeight) {
    // NSLog(@"[FFExportScene] Viewport changed during recording (%dx%d -> %dx%d), ignoring frame.", _srcWidth, _srcHeight, w, h);
    return NO;
    }

    uint8_t *copy = (uint8_t*)malloc(needed);
    if (!copy) return NO;
    memcpy(copy, _captureBuf, needed);
    size_t rowBytes = (size_t)_srcWidth * 4;
    size_t needed = rowBytes * (size_t)_srcHeight;

    // Monotonic PTS: simple frame counter (prevents non-monotonic DTS warnings)
    int64_t pts = _nextPTS++;
    _scheduledFrames++;
    [self _enqueueBGRAForEncoding:copy rowBytes:(int)rowBytes pts:pts];
    if (_captureBufSize < needed) {
    _captureBuf = (uint8_t *)realloc(_captureBuf, needed);
    _captureBufSize = needed;
    }

    return YES;
    glPixelStorei(GL_PACK_ALIGNMENT, 4);
    glReadPixels(viewport[0], viewport[1],
    _srcWidth, _srcHeight,
    GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV,
    _captureBuf);

    // Heap copy for encode queue
    uint8_t *copy = (uint8_t *)malloc(needed);
    if (!copy) return NO;
    memcpy(copy, _captureBuf, needed);

    // Compute PTS according to mode
    int64_t pts;
    if (_useTimePTS) {
    // QC-time-based: map (time - start) * FPS to PTS, enforce monotonic increase
    double rel = time - _recordStartTime;
    if (rel < 0.0) rel = 0.0;
    double exactFrame = rel * _fps; // _fps > 0 by construction
    int64_t candidate = (int64_t)llround(exactFrame);
    if (candidate <= _nextPTS) {
    candidate = _nextPTS + 1;
    }
    pts = candidate;
    _nextPTS = candidate;
    } else {
    // Pure frame count
    pts = _nextPTS++;
    }

    return NO;
    _scheduledFrames++;

    atomic_fetch_add(&_inFlightFrames, 1);
    int64_t ptsCopy = pts;
    size_t rowBytesCopy = rowBytes;

    dispatch_async(_encodeQueue, ^{
    @autoreleasepool {
    // Flip vertically on the encode queue (top-down for FFmpeg).
    uint8_t *buf = copy;
    size_t rb = rowBytesCopy;
    uint8_t *rowTmp = (uint8_t *)malloc(rb);
    if (rowTmp) {
    uint8_t *top = buf;
    uint8_t *bottom = buf + (size_t)(_srcHeight - 1) * rb;
    for (int y = 0; y < _srcHeight / 2; ++y) {
    memcpy(rowTmp, top, rb);
    memcpy(top, bottom, rb);
    memcpy(bottom, rowTmp, rb);
    top += rb;
    bottom -= rb;
    }
    free(rowTmp);
    }

    [self _encodeFrameWithBGRA_locked:buf rowBytes:(int)rb pts:ptsCopy];
    free(buf);

    atomic_fetch_sub(&_inFlightFrames, 1);
    }
    });

    return YES;
    }

    // --------------------------------------------------
    // Execute (capture OpenGL scene)
    // Execute (drive recording state machine)
    // --------------------------------------------------

    - (BOOL)execute:(id<QCPlugInContext>)context
    @@ -802,6 +822,8 @@ - (BOOL)execute:(id<QCPlugInContext>)context
    {
    @autoreleasepool {

    (void)arguments;

    NSString *path = self.inputOutputPath;
    if (!path || (id)path == [NSNull null]) path = @"";

    @@ -818,6 +840,7 @@ - (BOOL)execute:(id<QCPlugInContext>)context
    BOOL recVal = self.inputRecord;
    BOOL pauseVal = self.inputPause;
    BOOL limitFPSVal = self.inputLimitFPS;
    BOOL useTimePTS = self.inputUseTimePTS;

    BOOL recEdgeOn = (recVal && !_prevRecord);
    BOOL recEdgeOff = (!recVal && _prevRecord);
    @@ -834,17 +857,20 @@ - (BOOL)execute:(id<QCPlugInContext>)context
    int h = viewport[3];

    if (w > 0 && h > 0) {
    // Latch mode at start
    _useTimePTS = useTimePTS;

    if ([self _startEncodingWithSourceWidth:w
    sourceHeight:h
    fps:fpsVal
    path:path
    options:codecOpts]) {

    _isRecording = YES;
    _durationLimit = durVal;
    _recordStartTime = time;
    _lastTime = time;
    _timeAccum = 0.0;
    _nextCaptureTime = time; // first capture can happen immediately in limited mode
    _nextCaptureTime = time; // for LimitFPS gating
    } else {
    NSLog(@"[FFExportScene] Failed to start encoding for path: %@", path);
    }
    @@ -866,54 +892,33 @@ - (BOOL)execute:(id<QCPlugInContext>)context
    [self _stopEncoding];
    }

    // Encode frames while recording and NOT paused
    // Capture frames while recording and NOT paused
    if (_isRecording) {
    double dt = time - _lastTime;
    if (dt < 0.0) dt = 0.0;
    _lastTime = time;

    if (!pauseVal) {
    // Effective FPS for scheduling; use actual encoder fps if available, otherwise input fpsVal.
    double effFPS = (_fps > 0.0 ? _fps : fpsVal);
    if (effFPS <= 0.0) effFPS = 30.0;
    double frameInterval = 1.0 / effFPS;

    // Dynamic backlog: ~1.5s of frames, clamped 12..120
    int maxBacklog = (int)llround(effFPS * 1.5);
    if (maxBacklog < 12) maxBacklog = 12;
    if (maxBacklog > 120) maxBacklog = 120;

    if (limitFPSVal) {
    // Limit capture to at most FPS, one capture per QC tick; no "catch-up" loops.
    if (_nextCaptureTime <= 0.0) {
    _nextCaptureTime = time;
    }

    if (time >= _nextCaptureTime) {
    (void)[self _captureSceneAtTime:time
    context:context
    maxBacklog:maxBacklog];
    (void)[self _captureSceneAtTime:time context:context];

    _nextCaptureTime += frameInterval;

    // If we fell far behind (e.g. massive stutter), keep us close to "now"
    if (_nextCaptureTime < time) {
    _nextCaptureTime = time + frameInterval;
    }
    }
    } else {
    // Free-running path: attempt to maintain nominal FPS using accumulated QC time.
    _timeAccum += dt;
    int maxFramesThisTick = 4;
    int framesScheduled = 0;

    while (_timeAccum >= frameInterval && framesScheduled < maxFramesThisTick) {
    (void)[self _captureSceneAtTime:time
    context:context
    maxBacklog:maxBacklog];
    _timeAccum -= frameInterval;
    framesScheduled++;
    }
    // Unlimited mode: capture once per QC tick; PTS mode decides how it plays back.
    (void)[self _captureSceneAtTime:time context:context];
    }
    }
    }
  6. g-l-i-t-c-h-o-r-s-e created this gist Nov 24, 2025.
    925 changes: 925 additions & 0 deletions FFExportScenePlugIn.m
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,925 @@
    // FFExportScenePlugIn.m — FFmpeg OpenGL scene exporter (CONSUMER) for Quartz Composer (Mojave/ARC, 64-bit)
    // Place this patch in the top layer; it captures the rendered OpenGL scene below it.
    //
    // Inputs:
    // Output Path (string)
    // Record (bool toggle; start/stop & finalize)
    // Pause (bool toggle; pause encoding, keep file open)
    // Duration (sec) (number; 0 = unlimited; based on encoded frames / FPS)
    // FPS (number; default 30)
    // Limit to FPS (bool; when ON, capture at most FPS frames/sec; PTS still monotonic frame counter)
    // Codec Options (string; e.g. "-c:v libx264 -g 120 -bf 3 -s 1280x720 -preset veryfast -crf 18 -pix_fmt yuv444p")
    //
    // Notes:
    // • For a pure, no-conversion path, you can use something like:
    // -c:v ffv1 -pix_fmt bgra -level 3 -coder 1
    // (we bypass swscale if encoder pix_fmt == BGRA and size is unchanged)
    // • CPU-heavy codecs are encoded on a serial GCD queue; QC thread never blocks.
    // If the encoder can’t keep up, frames are dropped only after a sizable backlog,
    // instead of immediately, so 60 fps exports stay much smoother.
    //
    // 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>
    #import <OpenGL/OpenGL.h>
    #import <OpenGL/gl.h>
    #import <OpenGL/CGLMacro.h>

    #include <math.h>
    #include <string.h>
    #include <stdatomic.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 <libavutil/dict.h>
    #include <libavutil/pixdesc.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 FFExportScenePlugIn : QCPlugIn

    @property(assign) NSString *inputOutputPath;
    @property(assign) BOOL inputRecord;
    @property(assign) BOOL inputPause;
    @property(assign) double inputDuration;
    @property(assign) double inputFPS;
    @property(assign) BOOL inputLimitFPS;
    @property(assign) NSString *inputCodecOptions;

    @end

    @implementation FFExportScenePlugIn
    {
    // FFmpeg state
    AVFormatContext *_fmt;
    AVStream *_vstream;
    AVCodecContext *_venc;
    struct SwsContext *_sws;
    AVFrame *_frame;

    int _width; // encoded width
    int _height; // encoded height
    int _srcWidth; // source (scene) width
    int _srcHeight; // source (scene) height

    AVRational _timeBase;
    double _fps;

    int64_t _nextPTS; // strictly monotonic frame counter (always used)
    int64_t _frameCount; // encoded frames (encoder thread)

    // Recording state
    BOOL _isRecording;
    BOOL _prevRecord;

    NSTimeInterval _recordStartTime;
    NSTimeInterval _lastTime;
    double _timeAccum; // accumulated QC time for FPS stepping (no-limit mode)

    double _durationLimit; // seconds (0 = unlimited)

    // Capture buffers (BGRA top-down)
    uint8_t *_captureBuf;
    size_t _captureBufSize;

    uint8_t *_rowTmp;
    size_t _rowTmpSize;

    CGColorSpaceRef _cs;

    // Async encoding
    dispatch_queue_t _encodeQueue;
    int64_t _scheduledFrames; // frames we’ve queued for encoding
    _Atomic int _inFlightFrames; // backlog of frames currently in the encode queue

    BOOL _directBGRAPath; // encoder pix_fmt == BGRA && no scale => bypass swscale

    BOOL _finalizing; // true while trailing/cleanup is running on encodeQueue

    // FPS limiting schedule
    double _nextCaptureTime; // next QC time at which we are allowed to capture when Limit to FPS is ON
    }

    @dynamic inputOutputPath, inputRecord, inputPause, inputDuration, inputFPS, inputLimitFPS, inputCodecOptions;

    + (NSDictionary *)attributes
    {
    return @{
    QCPlugInAttributeNameKey: @"FFExport Scene (x86_64)",
    QCPlugInAttributeDescriptionKey: @"FFmpeg-based exporter that captures the OpenGL scene below it.\nPlace this as the top layer. Uses Record/Pause/Duration/FPS/Limit/Codec Options like FFExport."
    };
    }

    + (NSDictionary *)attributesForPropertyPortWithKey:(NSString *)key
    {
    if ([key isEqualToString:@"inputOutputPath"])
    return @{ QCPortAttributeNameKey: @"Output Path", QCPortAttributeTypeKey: QCPortTypeString, 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)", QCPortAttributeTypeKey: QCPortTypeNumber, QCPortAttributeDefaultValueKey: @0.0 };

    if ([key isEqualToString:@"inputFPS"])
    return @{ QCPortAttributeNameKey: @"FPS", QCPortAttributeTypeKey: QCPortTypeNumber, QCPortAttributeDefaultValueKey: @30.0 };

    if ([key isEqualToString:@"inputLimitFPS"])
    return @{ QCPortAttributeNameKey: @"Limit to FPS", QCPortAttributeTypeKey: QCPortTypeBoolean, QCPortAttributeDefaultValueKey: @1.0 };

    if ([key isEqualToString:@"inputCodecOptions"])
    return @{ QCPortAttributeNameKey: @"Codec Options", QCPortAttributeTypeKey: QCPortTypeString, QCPortAttributeDefaultValueKey: @"" };

    return nil;
    }

    + (NSArray *)sortedPropertyPortKeys
    {
    return @[
    @"inputOutputPath",
    @"inputRecord",
    @"inputPause",
    @"inputDuration",
    @"inputFPS",
    @"inputLimitFPS",
    @"inputCodecOptions"
    ];
    }

    + (QCPlugInExecutionMode)executionMode { return kQCPlugInExecutionModeConsumer; }
    + (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;

    _captureBuf = NULL;
    _captureBufSize = 0;
    _rowTmp = NULL;
    _rowTmpSize = 0;

    _encodeQueue = dispatch_queue_create("com.yourdomain.FFExportScene.encode", DISPATCH_QUEUE_SERIAL);
    _scheduledFrames = 0;
    atomic_store(&_inFlightFrames, 0);

    _directBGRAPath = NO;
    _finalizing = NO;
    _nextCaptureTime = 0.0;
    }
    return self;
    }

    - (void)dealloc
    {
    [self _stopEncoding]; // async finalize/cleanup

    if (_cs) { CFRelease(_cs); _cs = NULL; }

    if (_captureBuf) { free(_captureBuf); _captureBuf = NULL; _captureBufSize = 0; }
    if (_rowTmp) { free(_rowTmp); _rowTmp = NULL; _rowTmpSize = 0; }
    }

    - (BOOL)startExecution:(id<QCPlugInContext>)context
    {
    av_log_set_level(AV_LOG_ERROR);
    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;
    _directBGRAPath = NO;
    }

    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)
    // - pixel format (pix_fmt / pixel_format)
    // - generic AVDictionary options (preset, tune, crf, etc.)
    - (void)_parseCodecOptionsString:(NSString *)opts
    codecName:(NSString * __strong *)outCodecName
    gopPtr:(int *)outGop
    bfPtr:(int *)outBF
    encWidth:(int *)outEncW
    encHeight:(int *)outEncH
    pixFmt:(enum AVPixelFormat *)outPixFmt
    codecOptions:(AVDictionary **)outDict
    {
    if (outCodecName) *outCodecName = nil;
    if (outGop) *outGop = -1;
    if (outBF) *outBF = -1;
    if (outPixFmt) *outPixFmt = AV_PIX_FMT_NONE;

    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;

    NSString *plainKey = key;
    if ([plainKey hasSuffix:@":v"]) {
    plainKey = [plainKey substringToIndex:plainKey.length - 2];
    }

    // Codec name (-c:v / -codec:v)
    if (([key isEqualToString:@"c:v"] || [key isEqualToString:@"codec:v"]) && val) {
    if (outCodecName) *outCodecName = val;
    i++;
    continue;
    }

    // GOP size (-g)
    if ([plainKey isEqualToString:@"g"] && val && outGop) {
    *outGop = [val intValue];
    i++;
    continue;
    }

    // Max B-frames (-bf)
    if ([plainKey isEqualToString:@"bf"] && val && outBF) {
    *outBF = [val intValue];
    i++;
    continue;
    }

    // Encode size (-s WxH)
    if ([plainKey isEqualToString:@"s"] && val && outEncW && outEncH) {
    _parse_resolution(val, outEncW, outEncH);
    i++;
    continue;
    }

    // Pixel format (-pix_fmt / -pixel_format)
    if (([plainKey isEqualToString:@"pix_fmt"] || [plainKey isEqualToString:@"pixel_format"]) && val && outPixFmt) {
    enum AVPixelFormat pf = av_get_pix_fmt([val UTF8String]);
    if (pf != AV_PIX_FMT_NONE) {
    *outPixFmt = pf;
    }
    i++;
    continue; // handled, don't add to dict
    }

    // Everything else goes into AVDictionary
    if (val) {
    av_dict_set(&d, [plainKey UTF8String], [val UTF8String], 0);
    i++;
    }
    }

    if (outDict) *outDict = d;
    }

    // srcW/srcH = OpenGL viewport size; -s can override encode size, -pix_fmt overrides pixel format.
    - (BOOL)_startEncodingWithSourceWidth:(int)srcW
    sourceHeight:(int)srcH
    fps:(double)fps
    path:(NSString *)path
    options:(NSString *)optString
    {
    if (_finalizing) {
    NSLog(@"[FFExportScene] Still finalizing previous recording; ignoring new start.");
    return NO;
    }

    [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;
    enum AVPixelFormat pixFmt = AV_PIX_FMT_NONE;

    [self _parseCodecOptionsString:optString
    codecName:&codecName
    gopPtr:&gopSize
    bfPtr:&maxBF
    encWidth:&encW
    encHeight:&encH
    pixFmt:&pixFmt
    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;
    _scheduledFrames = 0;
    atomic_store(&_inFlightFrames, 0);

    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 FALSE;
    }
    ofmt = _fmt->oformat;

    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;
    }

    // Default pixel format: use encoder's first supported if none specified; fallback to yuv420p.
    if (pixFmt == AV_PIX_FMT_NONE) {
    if (codec->pix_fmts) {
    pixFmt = codec->pix_fmts[0];
    } else {
    pixFmt = AV_PIX_FMT_YUV420P;
    }
    }

    _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 = pixFmt;
    _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; can be overridden by options

    if (ofmt->flags & AVFMT_GLOBALHEADER) {
    _venc->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
    }

    // Color metadata (mostly relevant for YUV)
    if (_venc->pix_fmt == AV_PIX_FMT_YUV420P ||
    _venc->pix_fmt == AV_PIX_FMT_YUV422P ||
    _venc->pix_fmt == AV_PIX_FMT_YUV444P ||
    _venc->pix_fmt == AV_PIX_FMT_YUV420P10LE ||
    _venc->pix_fmt == AV_PIX_FMT_YUV444P10LE) {
    _venc->color_primaries = AVCOL_PRI_BT709;
    _venc->color_trc = AVCOL_TRC_BT709;
    _venc->colorspace = AVCOL_SPC_BT709;
    _venc->color_range = AVCOL_RANGE_MPEG; // studio
    } else {
    _venc->color_range = AVCOL_RANGE_JPEG; // full for RGB-ish
    }

    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);
    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;
    }

    // Decide if we can bypass swscale and copy BGRA directly
    _directBGRAPath = (_venc->pix_fmt == AV_PIX_FMT_BGRA &&
    _srcWidth == _width &&
    _srcHeight == _height);

    if (!_directBGRAPath) {
    _sws = sws_getContext(_srcWidth, _srcHeight, AV_PIX_FMT_BGRA,
    encW, encH, _venc->pix_fmt,
    SWS_BICUBIC, NULL, NULL, NULL);
    if (!_sws) return NO;
    } else {
    _sws = NULL;
    }

    NSLog(@"[FFExportScene] Recording started: %s (%dx%d -> %dx%d @ %.3f fps, pix_fmt=%d, directBGRA=%d)",
    filename, _srcWidth, _srcHeight, encW, encH, _fps, (int)_venc->pix_fmt, (int)_directBGRAPath);

    return YES;
    }

    // Called only on the encode queue.
    - (BOOL)_encodeFrameWithBGRA_locked:(const uint8_t *)src
    rowBytes:(int)rowBytes
    pts:(int64_t)pts
    {
    if (!_fmt || !_venc || !_frame) return NO;

    if (av_frame_make_writable(_frame) < 0) return NO;

    if (_directBGRAPath) {
    // Pure copy: BGRA -> BGRA, same size, no swscale, no color conversion.
    uint8_t *dst = _frame->data[0];
    int dstRB = _frame->linesize[0];
    int copyRB = rowBytes;
    if (copyRB > dstRB) copyRB = dstRB;

    for (int y = 0; y < _srcHeight; ++y) {
    memcpy(dst + (size_t)y * dstRB, src + (size_t)y * rowBytes, (size_t)copyRB);
    }
    } else {
    // Use swscale for RGB->YUV or scaling conversions.
    const uint8_t *srcSlice[4] = { src, NULL, NULL, NULL };
    int srcStride[4] = { rowBytes, 0, 0, 0 };

    if (!_sws) return NO;

    sws_scale(_sws, srcSlice, srcStride, 0, _srcHeight,
    _frame->data, _frame->linesize);
    }

    _frame->pts = pts;

    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;
    }

    // Called on encode queue
    - (void)_flushEncoder_locked
    {
    if (!_fmt || !_venc) return;

    int ret = avcodec_send_frame(_venc, NULL);
    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);
    }

    // Public stop: async — QC thread no longer blocks, so no visible jitter.
    - (void)_stopEncoding
    {
    if (!_fmt && !_venc) {
    _isRecording = NO;
    return;
    }

    BOOL wasRecording = _isRecording;
    _isRecording = NO;

    if (!_encodeQueue) {
    // Fallback: finalize synchronously (shouldn’t happen in practice).
    if (wasRecording && _fmt && _venc) {
    [self _flushEncoder_locked];
    av_write_trailer(_fmt);
    }
    [self _cleanupFFmpeg];
    _frameCount = 0;
    _scheduledFrames = 0;
    atomic_store(&_inFlightFrames, 0);
    return;
    }

    _finalizing = YES;
    int64_t totalFrames = _frameCount;

    dispatch_async(_encodeQueue, ^{
    @autoreleasepool {
    if (wasRecording && _fmt && _venc) {
    [self _flushEncoder_locked];
    av_write_trailer(_fmt);
    }
    NSLog(@"[FFExportScene] Recording stopped. Encoded frames: %lld", (long long)totalFrames);

    [self _cleanupFFmpeg];
    _frameCount = 0;
    _scheduledFrames = 0;
    atomic_store(&_inFlightFrames, 0);
    _finalizing = NO;
    }
    });
    }

    // Enqueue a frame for encoding; called from QC thread.
    - (void)_enqueueBGRAForEncoding:(uint8_t *)data
    rowBytes:(int)rowBytes
    pts:(int64_t)pts
    {
    if (!_fmt || !_venc || !_encodeQueue) {
    free(data);
    return;
    }

    atomic_fetch_add(&_inFlightFrames, 1);

    dispatch_async(_encodeQueue, ^{
    @autoreleasepool {
    [self _encodeFrameWithBGRA_locked:data rowBytes:rowBytes pts:pts];
    free(data);
    atomic_fetch_sub(&_inFlightFrames, 1);
    }
    });
    }

    // Capture the current OpenGL scene and schedule it for encoding.
    // Returns YES if we actually scheduled a frame, NO if dropped/failed.
    - (BOOL)_captureSceneAtTime:(NSTimeInterval)time
    context:(id<QCPlugInContext>)context
    maxBacklog:(int)maxBacklog
    {
    (void)time; // PTS now comes purely from the frame counter.

    int inFlight = atomic_load(&_inFlightFrames);
    if (inFlight >= maxBacklog) {
    // Encoder is behind: drop this logical frame
    return NO;
    }

    CGLContextObj cgl_ctx = [context CGLContextObj];
    (void)cgl_ctx;

    GLint viewport[4] = {0,0,0,0};
    glGetIntegerv(GL_VIEWPORT, viewport);
    int w = viewport[2];
    int h = viewport[3];

    if (w == _srcWidth && h == _srcHeight && w > 0 && h > 0) {
    size_t rowBytes = (size_t)_srcWidth * 4;
    size_t needed = rowBytes * (size_t)_srcHeight;

    if (_captureBufSize < needed) {
    _captureBuf = (uint8_t*)realloc(_captureBuf, needed);
    _captureBufSize = needed;
    }
    if (_rowTmpSize < rowBytes) {
    _rowTmp = (uint8_t*)realloc(_rowTmp, rowBytes);
    _rowTmpSize = rowBytes;
    }

    // Read scene as BGRA from QC's framebuffer
    glPixelStorei(GL_PACK_ALIGNMENT, 4);
    glReadPixels(viewport[0], viewport[1], w, h,
    GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV,
    _captureBuf);

    // Vertical flip: OpenGL is bottom-up; encoder expects top-down.
    uint8_t *top = _captureBuf;
    uint8_t *bottom = _captureBuf + (size_t)(_srcHeight - 1) * rowBytes;
    for (int y = 0; y < _srcHeight / 2; ++y) {
    memcpy(_rowTmp, top, rowBytes);
    memcpy(top, bottom, rowBytes);
    memcpy(bottom, _rowTmp, rowBytes);
    top += rowBytes;
    bottom -= rowBytes;
    }

    uint8_t *copy = (uint8_t*)malloc(needed);
    if (!copy) return NO;
    memcpy(copy, _captureBuf, needed);

    // Monotonic PTS: simple frame counter (prevents non-monotonic DTS warnings)
    int64_t pts = _nextPTS++;
    _scheduledFrames++;
    [self _enqueueBGRAForEncoding:copy rowBytes:(int)rowBytes pts:pts];

    return YES;
    }

    return NO;
    }

    // --------------------------------------------------
    // Execute (capture OpenGL scene)
    // --------------------------------------------------

    - (BOOL)execute:(id<QCPlugInContext>)context
    atTime:(NSTimeInterval)time
    withArguments:(NSDictionary *)arguments
    {
    @autoreleasepool {

    NSString *path = self.inputOutputPath;
    if (!path || (id)path == [NSNull null]) path = @"";

    double fpsVal = self.inputFPS;
    if (fpsVal <= 0.0) fpsVal = 30.0;
    fpsVal = _clamp(fpsVal, 1.0, 240.0);

    double durVal = self.inputDuration;
    if (durVal < 0.0) durVal = 0.0;

    NSString *codecOpts = self.inputCodecOptions;
    if (!codecOpts || (id)codecOpts == [NSNull null]) codecOpts = @"";

    BOOL recVal = self.inputRecord;
    BOOL pauseVal = self.inputPause;
    BOOL limitFPSVal = self.inputLimitFPS;

    BOOL recEdgeOn = (recVal && !_prevRecord);
    BOOL recEdgeOff = (!recVal && _prevRecord);
    _prevRecord = recVal;

    // Start recording on Record rising edge
    if (recEdgeOn && !_isRecording && !_finalizing && path.length > 0) {
    CGLContextObj cgl_ctx = [context CGLContextObj];
    (void)cgl_ctx;

    GLint viewport[4] = {0,0,0,0};
    glGetIntegerv(GL_VIEWPORT, viewport);
    int w = viewport[2];
    int h = viewport[3];

    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;
    _nextCaptureTime = time; // first capture can happen immediately in limited mode
    } else {
    NSLog(@"[FFExportScene] Failed to start encoding for path: %@", path);
    }
    } else {
    NSLog(@"[FFExportScene] Viewport size is zero; cannot start recording.");
    }
    }

    // Duration auto-stop (based on encoded timeline = scheduledFrames / FPS)
    if (_isRecording && _durationLimit > 0.0 && _fps > 0.0 && _scheduledFrames > 0) {
    double recordedSecs = (double)_scheduledFrames / _fps;
    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) {
    double dt = time - _lastTime;
    if (dt < 0.0) dt = 0.0;
    _lastTime = time;

    if (!pauseVal) {
    // Effective FPS for scheduling; use actual encoder fps if available, otherwise input fpsVal.
    double effFPS = (_fps > 0.0 ? _fps : fpsVal);
    if (effFPS <= 0.0) effFPS = 30.0;
    double frameInterval = 1.0 / effFPS;

    // Dynamic backlog: ~1.5s of frames, clamped 12..120
    int maxBacklog = (int)llround(effFPS * 1.5);
    if (maxBacklog < 12) maxBacklog = 12;
    if (maxBacklog > 120) maxBacklog = 120;

    if (limitFPSVal) {
    // Limit capture to at most FPS, one capture per QC tick; no "catch-up" loops.
    if (_nextCaptureTime <= 0.0) {
    _nextCaptureTime = time;
    }

    if (time >= _nextCaptureTime) {
    (void)[self _captureSceneAtTime:time
    context:context
    maxBacklog:maxBacklog];

    _nextCaptureTime += frameInterval;

    // If we fell far behind (e.g. massive stutter), keep us close to "now"
    if (_nextCaptureTime < time) {
    _nextCaptureTime = time + frameInterval;
    }
    }
    } else {
    // Free-running path: attempt to maintain nominal FPS using accumulated QC time.
    _timeAccum += dt;
    int maxFramesThisTick = 4;
    int framesScheduled = 0;

    while (_timeAccum >= frameInterval && framesScheduled < maxFramesThisTick) {
    (void)[self _captureSceneAtTime:time
    context:context
    maxBacklog:maxBacklog];
    _timeAccum -= frameInterval;
    framesScheduled++;
    }
    }
    }
    }

    return YES;
    }
    }

    @end
    215 changes: 215 additions & 0 deletions build-ffexport-scene.sh
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,215 @@
    #!/usr/bin/env bash
    set -euo pipefail
    shopt -s nullglob

    # --- config (SCENE CONSUMER PLUGIN) ---
    NAME="FFExportScene"
    CLASS="FFExportScenePlugIn"
    SRC="${SRC:-${CLASS}.m}"
    PLUG="$NAME.plugin"
    OUT="$(pwd)/build-manual-scene"
    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 scene 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 (NOTE: separate identifier just for the scene plug-in)
    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 Scene (x86_64)'."