Automated plug-in audio/video capturing (incl OpenGL)

Since quite a few people have shown interest in seeing how I’ve setup an automated audio/video JUCE plug-in capturing system, I’ve started putting together some documentation. I haven’t finished writing it all out, but it’s been weeks since I mentioned it so wanted to at least share what I have so far.

The missing piece is mostly the Python based orchestration. This is where I store the actual animation data for my projects. So far, this documentation contains only the C++ side of things. Python also handles passing the raw output to ffmpeg for encoding.

I’ll try to update this thread when I’m a little less frantically working on other things. Since I’ve had to strip out much of my project-specific code, there could be some errors in the transcription. Apologies for that, but perhaps this can evolve into an example JUCE project or something you can integrate into your project with fewer manual steps.

Live examples of the results:

https://apu.software/meter/
https://apu.software/compressor/

JUCE plug-in audio/video capturing

This document is intended to provide an overview on how to build an automated audio/video capture system for your JUCE project(s).

Overview

The approach taken in this document is to extend a standalone application with additional command-line parameters. These parameters will be used to enable capture mode and provide a set of parameter automations, as well as provide the audio/video input/output filenames. For each block of audio, the output of the audio processor’s processBlock is written to disk. Latency compensation is applied to keep the output audio aligned with the video. The number of samples written per block is used to determine when to capture a frame of video from the processor’s editor.

The resulting output of the standalone plug-in is a set of numbered .png files and an output audio file. Note that these png files are lossless and the output audio file is float 32-bit direct from processBlock. This provides a master encoding which can then be used to encode various individual segments and/or file formats and/or encoding configurations. This document uses ffmpeg command-line for encoding.

isCapture

For the purposes of this demo, I’ve implemented a simple isCapture() function which checks the command-line for capture enable. You can use this technique or whatever other method you use for feature flags or compile-time options.


static inline bool isCapture()
{
    static const bool cachedResult = hasBoolParam("--capture");
    return cachedResult;
}

StandaloneFilterApp

In order to orchestrate the capture process, you’ll need to hook in at the StandaloneFilterApp level. During the initialization step you’ll just need to create an instance of the capture class. This class will perform the command-line processing, parameter automation and file I/O. You can create an empty “PluginCapture” class in your project, obviously renaming as appropriate. Give it an empty run function which we’ll implement later.

As you can see, we just create an instance of the capture orchestration class, run and then trigger quit when it’s done. During capturing, this will replace the normal standalone filter processor and editor. The normal behavior of your standalone app won’t be effected.

void PluginStandaloneFilterApp::initialise(const String& commandLine)
{
    if (isCapture()) {
        PluginCapture pluginCapture;
        pluginCapture.run();
        MessageManager::callAsync([this] { JUCEApplicationBase::quit(); });
        return;
    }

PluginCapture

The PluginCapture class should inherit from the plugin’s audio processor as well as DocumentWindow. This gives us an instance of the audio processor as well as a window to use for containing and capturing the processor’s editor. The below implementation of PluginCapture.h provides a minimal example which you’ll need to tailor to your own style of parameters usage. In particular, you’ll need some way of enumerating your parameters and identifying them by name. This is what will enable parameter automation via CLI.

class PluginCapture : public PluginAudioProcessor, public juce::DocumentWindow
{
public:
    PluginCapture();
    ~PluginCapture();

    void run();

private:
    // read audio file into an audio buffer
    bool readFile(const char* inputPath, const char* outputPath);

    // perform parameter automation
    void processParameters(float elapsed);

    std::map<int, float> m_defaultFloat; //!< Initial float parameter values
    std::map<int, int> m_defaultInt;     //!< Initial int parameter values
    std::map<int, bool> m_defaultBool;   //!< Initial bool parameter values

    AudioFormatManager m_formatManager;    //!< Format manager used for reading audio files
    AudioFormatReader* m_reader = nullptr; //!< Format reader used for parsing audio file formats
    AudioFormatWriter* m_writer = nullptr; //!< Format writer used for writing audio file formats

    ArgumentList m_args; //!< parsed command-line arguments

    std::string m_inputPath;  //!< input file path
    std::string m_outputPath; //!< output file path

    PluginAudioProcessorEditor* m_editor = nullptr; //!< Editor instance for this audio processor

    AudioBuffer<float> m_srcBuffer; // Source buffer from the most recently read file
    AudioBuffer<float> m_outBuffer; // Output buffer from the most recently filtered file

    //
    // Parameter automation time ranges
    //

    struct AutomationRange
    {
        float rangeBeg = 0.0f;
        float rangeEnd = 0.0f;
        float targetValue = INFINITY;
    };

    typedef std::list<AutomationRange> AutomationRangeList;

    std::array<AutomationRangeList, APP_PARAM_FLOAT_COUNT> m_floatParamRanges; //!< time ranges for float parameters
    std::array<AutomationRangeList, APP_PARAM_INT_COUNT> m_intParamRanges;     //!< time ranges for int parameters
    std::array<AutomationRangeList, APP_PARAM_BOOL_COUNT> m_boolParamRanges;   //!< time ranges for bool parameters
};

The implementation inside PluginCapture.cpp will also require some customization specific to your project. This code assumes 44100.0 samplerate for the input content and output content. You can deal with samplerate conversion on your own if you want! But obviously at least update this code to reflect the samplerate of your input content.


#include "PluginCapture.h"

PluginCapture::PluginCapture()
  : PluginAudioProcessor(),
    DocumentWindow("Plugin Project", juce::Desktop::getInstance().getDefaultLookAndFeel().findColour(juce::ResizableWindow::backgroundColourId), 0),
    m_args("", JUCEApplicationBase::getCommandLineParameterArray())
{
    // setup processor
    setCurrentSampleRate(44100.0f);

    // register audio formats so we can open input files
    m_formatManager.registerBasicFormats();

    // helper function for adding to parameters list
    auto addParameter = [&](String& parameter, AutomationRangeList& rangeList) {
        AutomationRange range;
        StringArray parts;
        parts.addTokens(parameter, "=,", "\"");
        range.targetValue = parts[1] != "inf" ? parts[1].getFloatValue() : INFINITY;
        range.rangeBeg = Time::fromISO8601("1970-01-01T" + parts[2]).toMilliseconds() / 1000.0f;
        range.rangeEnd = Time::fromISO8601("1970-01-01T" + parts[3]).toMilliseconds() / 1000.0f;
        rangeList.push_back(range);
    };

    // add command-line parameters to queue
    auto parameters = JUCEApplicationBase::getCommandLineParameterArray();
    for (auto& parameter : parameters) {
        // explicitly scan for each plugin float parameter
        for (int pluginParamFloat = 0; pluginParamFloat < APP_PARAM_FLOAT_COUNT; ++pluginParamFloat) {
            String paramStr = String("--") + String(PluginParams::getFloatParamID(pluginParamFloat));
            String equalStr = paramStr + String("=");
            if (parameter.startsWith(equalStr))
                addParameter(parameter, m_floatParamRanges[pluginParamFloat]);
        }

        // explicitly scan for each plugin int parameter
        for (int pluginParamInt = 0; pluginParamInt < APP_PARAM_INT_COUNT; ++pluginParamInt) {
            String paramStr = String("--") + String(PluginParams::getIntParamID(pluginParamInt));
            String equalStr = paramStr + String("=");
            if (parameter.startsWith(equalStr))
                addParameter(parameter, m_intParamRanges[pluginParamInt]);
        }

        // explicitly scan for each plugin bool parameter
        for (int pluginParamBool = 0; pluginParamBool < APP_PARAM_BOOL_COUNT; ++pluginParamBool) {
            String paramStr = String("--") + String(PluginParams::getBoolParamID(pluginParamBool));
            String equalStr = paramStr + String("=");
            if (parameter.startsWith(equalStr))
                addParameter(parameter, m_boolParamRanges[pluginParamBool]);
        }
    }

    // input/output paths
    m_inputPath = m_args.containsOption("--inputPath") ? m_args.getValueForOption("--inputPath").toStdString() : "";
    m_outputPath = m_args.containsOption("--outputPath") ? m_args.getValueForOption("--outputPath").toStdString() : "";
}

PluginCapture::~PluginCapture()
{
    if (m_editor)
        delete m_editor;

    if (m_reader)
        delete m_reader;

    if (m_writer)
        delete m_writer;
}

void PluginCapture::run()
{
    m_editor = static_cast<PluginAudioProcessorEditor*>(createEditor());

    // process initial parameters
    processParameters(0.0f);

    // add command-line parameters to queue
    for (int pluginParamFloat = 0; pluginParamFloat < APP_PARAM_FLOAT_COUNT; ++pluginParamFloat)
        m_defaultFloat[pluginParamFloat] = Plugin::getFloat(pluginParamFloat);

    // explicitly scan for each plugin int parameter
    for (int pluginParamInt = 0; pluginParamInt < APP_PARAM_INT_COUNT; ++pluginParamInt)
        m_defaultInt[pluginParamInt] = Plugin::getInt(pluginParamInt);

    // explicitly scan for each plugin bool parameter
    for (int pluginParamBool = 0; pluginParamBool < APP_PARAM_BOOL_COUNT; ++pluginParamBool)
        m_defaultBool[pluginParamBool] = Plugin::getBool(pluginParamBool);

    setResizable(false, false);
    setResizeLimits(PluginGlobals::defaultEditorWidth, PluginGlobals::defaultEditorHeight, INT_MAX, INT_MAX);
    setBounds(50, 50, PluginGlobals::defaultEditorWidth, PluginGlobals::defaultEditorHeight);
    setContentNonOwned(m_editor, false);
    setContentComponentSize(PluginGlobals::defaultEditorWidth, PluginGlobals::defaultEditorHeight);
    setVisible(true);

    readFile(m_inputPath.c_str(), m_outputPath.c_str());
}

bool PluginCapture::readFile(const char* inputPath, const char* outputPath)
{
    //
    // Create read/writer
    //

    {
        // cleanup previous reader, if exists
        if (m_reader)
            delete m_reader;

        // try creating reader for the specified input path
        m_reader = m_formatManager.createReaderFor(File(inputPath));
        if (m_reader == nullptr)
            return false;

        // cleanup previous writer, if exists
        if (m_writer)
            delete m_writer;

        // create the file itself
        File wavFile(outputPath);

        // remove existing file (if there is one)
        wavFile.deleteFile();

        // try creating writer for the specified output path
        WavAudioFormat format;
        m_writer = format.createWriterFor(new FileOutputStream(wavFile), m_reader->sampleRate, m_reader->numChannels, m_reader->bitsPerSample, {}, 0);
        if (m_writer == nullptr)
            return false;
    }

    const int lengthInSamples = static_cast<int>(m_reader->lengthInSamples);
    const int inputChannelCount = static_cast<int>(m_reader->numChannels);

    // initialize plugin processor
    Plugin::setCurrentSampleRate(static_cast<float>(m_reader->sampleRate));
    Plugin::updatePlayback(m_reader->sampleRate, getBlocksize(), m_reader->numChannels, m_reader->numChannels);
    Plugin::updateLatency(m_reader->numChannels, m_reader->numChannels, m_reader->sampleRate);

    // note: fast learn has zero latency and pass-through output
    const int latencyInSamples = Plugin::getLatency();

    //
    // Silence is appended to input buffer, equal to latency sample count, to flush output
    //
    // inpBuffer := [0][1][2][3][s]
    // outBuffer := [x][0][1][2][3]
    //

    // read the audio buffer (padding to LUFS block size)
    m_srcBuffer = AudioBuffer<float>(inputChannelCount, latencyInSamples + lengthInSamples);
    m_srcBuffer.applyGain(0.0f);
    m_reader->read(&m_srcBuffer, 0, lengthInSamples, 0, true, true);
    m_srcBuffer.setSize(m_srcBuffer.getNumChannels(), roundUp(m_srcBuffer.getNumSamples(), getBlocksize()), true, true);

    // prepare output buffer (copy input)
    m_outBuffer.makeCopyOf(m_srcBuffer);

    // deliver block(s) of samples to processBlock
    const int samplesPerBlock = getBlocksize();
    const int sampleCount = m_srcBuffer.getNumSamples();

    // prepare audio processor
    setPlayConfigDetails(inputChannelCount, inputChannelCount, m_reader->sampleRate, samplesPerBlock);
    prepareToPlay(m_reader->sampleRate, samplesPerBlock);

    // empty MIDI buffer for calling processBlock
    MidiBuffer midiBuffer;

    AudioBuffer<float> processBlockBuffer(inputChannelCount, samplesPerBlock);
    for (int sampleIndex = 0; sampleIndex < sampleCount; sampleIndex += samplesPerBlock) {
        for (int channel = 0; channel < static_cast<int>(inputChannelCount); ++channel)
            processBlockBuffer.copyFrom(channel, 0, m_srcBuffer.getReadPointer(channel, sampleIndex), samplesPerBlock);
        processParameters(static_cast<float>(sampleIndex) / static_cast<float>(m_reader->sampleRate));
        processBlock(processBlockBuffer, midiBuffer);
        for (int channel = 0; channel < static_cast<int>(inputChannelCount); ++channel)
            m_outBuffer.copyFrom(channel, sampleIndex, processBlockBuffer.getReadPointer(channel), samplesPerBlock);
    }

    //
    // Peak limiter
    //

    {
        juce::dsp::ProcessSpec processSpec;
        processSpec.maximumBlockSize = m_outBuffer.getNumSamples();
        processSpec.numChannels = m_outBuffer.getNumChannels();
        processSpec.sampleRate = m_reader->sampleRate;

        auto block = juce::dsp::AudioBlock<float>(m_outBuffer);
        juce::dsp::ProcessContextReplacing<float> processContext(block);

        juce::dsp::Limiter<float> limiter;
        limiter.prepare(processSpec);
        limiter.setThreshold(0.0f);
        limiter.process(processContext);
    }

    //
    // Validate no overshoots
    //

    for (int channel = 0; channel < m_outBuffer.getNumChannels(); ++channel) {
        for (int sampleIndex = 0; sampleIndex < lengthInSamples; ++sampleIndex) {
            const float sample = m_outBuffer.getSample(channel, sampleIndex);
            jassert(abs(sample) <= 1.0f);
        }
    }

    m_writer->writeFromAudioSampleBuffer(m_outBuffer, latencyInSamples, lengthInSamples);
    m_writer->flush();

    return true;
}

void PluginCapture::processParameters(float elapsed)
{
    // helper function for more natural automation
    auto clampedSigmoid = [](float x) { return x >= 1.0f ? 1.0f : 1.0f / (1.0f + std::exp(-10.0f * (x - 0.5f))); };
    // helper function to apply automation
    auto automate = [](float defaultValue, float targetValue, float proportion) {
        if (proportion <= 0.0f)
            return defaultValue;
        if (proportion >= 1.0f)
            return targetValue;
        return defaultValue * (1.0f - proportion) + targetValue * proportion;
    };

    // check for float parameters within range
    for (int pluginParamFloat = 0; pluginParamFloat < APP_PARAM_FLOAT_COUNT; ++pluginParamFloat) {
        AutomationRangeList& rangeList = m_floatParamRanges[pluginParamFloat];
        for (auto& range : rangeList) {
            if (elapsed < range.rangeBeg)
                continue;
            // handle parameter automation
            if (!isinf(range.targetValue)) {
                const float duration = range.rangeEnd - range.rangeBeg;
                const float proportion = duration > 0.0f ? clampedSigmoid((elapsed - range.rangeBeg) / duration) : 1.0f;
                const float defaultValue = m_defaultFloat[pluginParamFloat];
                const float animationValue = automate(defaultValue, range.targetValue, proportion);
                setFloat(pluginParamFloat, animationValue);
            }
            // handle tab index automation
            else {
                // you can use inf values to trigger special logic here, for example focusing the tab in your ui
                // updateTabIndexFloat(pluginParamFloat);
            }
        }
        // expire items and update defaults
        for (auto iter = rangeList.begin(); iter != rangeList.end();) {
            if (elapsed > iter->rangeEnd) {
                m_defaultFloat[pluginParamFloat] = getFloat(pluginParamFloat);
                iter = rangeList.erase(iter);
            }
            else {
                ++iter;
            }
        }
    }

    // explicitly scan for each plugin int parameter
    for (int pluginParamInt = 0; pluginParamInt < APP_PARAM_INT_COUNT; ++pluginParamInt) {
        AutomationRangeList& rangeList = m_intParamRanges[pluginParamInt];
        for (auto& range : rangeList) {
            if (elapsed < range.rangeBeg)
                continue;
            // handle parameter automation
            if (!isinf(range.targetValue)) {
                const float duration = range.rangeEnd - range.rangeBeg;
                const float proportion = clampedSigmoid((elapsed - range.rangeBeg) / duration);
                const float defaultValue = static_cast<float>(m_defaultInt[pluginParamInt]);
                const float animationValue = automate(defaultValue, range.targetValue, proportion);
                setInt(pluginParamInt, static_cast<int>(animationValue));
            }
            // handle tab index automation
            else {
                // you can use inf values to trigger special logic here, for example focusing a tab in your ui
                // updateTabIndexInt(pluginParamInt);
            }
        }
        // expire items and update defaults
        for (auto iter = rangeList.begin(); iter != rangeList.end();) {
            if (elapsed > iter->rangeEnd) {
                m_defaultInt[pluginParamInt] = getInt(pluginParamInt);
                iter = rangeList.erase(iter);
            }
            else {
                ++iter;
            }
        }
    }

    // explicitly scan for each plugin bool parameter
    for (int pluginParamBool = 0; pluginParamBool < APP_PARAM_BOOL_COUNT; ++pluginParamBool) {
        AutomationRangeList& rangeList = m_boolParamRanges[pluginParamBool];
        for (auto& range : rangeList) {
            if (elapsed < range.rangeBeg)
                continue;
            // handle parameter automation
            if (!isinf(range.targetValue)) {
                const float duration = range.rangeEnd - range.rangeBeg;
                const float proportion = clampedSigmoid((elapsed - range.rangeBeg) / duration);
                const float defaultValue = m_defaultBool[pluginParamBool] ? 1.0f : 0.0f;
                const float animationValue = automate(defaultValue, range.targetValue, proportion);
                setBool(pluginParamBool, !!static_cast<int>(animationValue));
            }
            // handle tab index automation
            else {
                // you can use inf values to trigger special logic here, for example focusing a tab in your ui
                // updateTabIndexBool(pluginParamBool);
            }
        }
        // expire items and update defaults
        for (auto iter = rangeList.begin(); iter != rangeList.end();) {
            if (elapsed > iter->rangeEnd) {
                m_defaultBool[pluginParamBool] = getBool(pluginParamBool);
                iter = rangeList.erase(iter);
            }
            else {
                ++iter;
            }
        }
    }
}

PluginAudioProcessor.h

Inside the plugin processor itself, we can store some additional context which will be used during the capture process. In this example, there’s a little helper class for writing the image frames and sample/frame counters which will be used to properly sync the audio and video capturing. The image writer helper class also manages asynchronous tasks so frames can be encoded to PNG with thread parallelism. You can modify the magic number “15” below to match the system you’re using for capturing (number of hardware threads minus one, leaving one for the main thread).

This block of code below goes inside PluginAudioProcess.h:

    //
    // Audio/video capture resources
    //

    uint64_t m_capturedSamples = 0;
    uint32_t m_capturedFrames = 0;

    class ImageFrameWriter
    {
    public:
        ImageFrameWriter(const File& outputDirectory, const String& fileNamePrefix) : outputDirectory(outputDirectory), fileNamePrefix(fileNamePrefix), nextFrameIndex(1) {}
        ~ImageFrameWriter()
        {
            // wait for all futures to finish executing
            for (auto& frameFuture : frameFutures)
                frameFuture.wait();
        }

        void addFrame(const Image& frame)
        {
            // detach from any previous reference
            Image frameCopy(frame);
            frameCopy.duplicateIfShared();

            // lambda to write a single image to .png filename
            auto frameFunc = [this](Image frame, String fileName) {
                File outputFile(outputDirectory.getChildFile(fileName));
                FileOutputStream outputStream(outputFile);

                PNGImageFormat imageFormat;
                imageFormat.writeImageToStream(frame, outputStream);
            };

            // generate filename
            String fileName = "tmp." + fileNamePrefix + "-" + String(nextFrameIndex++) + ".png";

            // queue the future
            frameFutures.push_back(std::async(std::launch::async, frameFunc, frameCopy, fileName));

            // limit parallelism
            while (frameFutures.size() >= 15) {
                frameFutures.front().wait();
                frameFutures.pop_front();
            }
        }

    private:
        std::list<std::future<void>> frameFutures;
        File outputDirectory;
        String fileNamePrefix;
        int nextFrameIndex;
    };

    std::unique_ptr<ImageFrameWriter> m_imageWriter;

PluginAudioProcessor::processBlock

Somewhere in your audio processor, you’ll want to create an instance of the image writer. It’s fine to just toss it somewhere toward the top of processBlock. Main thing is, you want to make sure this helper class is prepared by the first audio block. Here’s an example, note you’ll need to configure a directory that makes sense on your machine. You’re going to want lots of hard disk space available on this drive.

    // create audio output file for capturing
    if (isCapture() && !m_imageWriter) {
        // create the image writer
        File outputDirectory("F:\\APU-Temp");
        m_imageWriter.reset(new ImageFrameWriter(outputDirectory, getCaptureName()));
    }

Later in processBlock, after you’ve performed your audio processing and have filled the output buffer, we’ll perform the audio capture and check if it’s time to capture the next video frame as well. The code below will require some plugin specific logic, but basically you need access to your editor and the processBlock output buffer.

Note that we’re checking the editor’s OpenGL context. You can skip this, or replace it with your own logic, if you’re not using OpenGL.

    // if enabled and appropriate, perform capturing for this block
    if (isCapture() && m_editors.size()) {
        auto& editor = *m_editors.begin();
        if (editor->getContext().isAttached()) {
            m_capturedSamples += outBuffer.getNumSamples();
            auto videoIsBehind = [&]() {
                const uint64_t frameSamples = static_cast<uint64_t>(getSampleRate() * m_capturedFrames / 60.0f);
                return m_capturedSamples >= frameSamples;
            };
            while (videoIsBehind()) {
                juce::Image* frame = editor->captureFrame();
                jassert(frame);
                m_imageWriter->addFrame(*frame);
                m_capturedFrames++;
            }
        }
    }

PluginAudioProcessor::getMillisecondCounter

This is an important step, and you may need to do additional work in order to properly manage time within your plugin during capture. The basic idea is, you’ll want to simulate the passage of time accurate while capturing, otherwise you’ll inevitably drop frames and your animated graphics will look like garbage. So, you need a function which abstracts time across capture vs normal plugin operation. Here’s an example:



uint32_t PluginAudioProcessor::getMillisecondCounter() const
{
    if (isCapture())
        return static_cast<uint32_t>((m_capturedSamples / getCurrentSampleRate()) * 1000.0f);

    // normal plug-in timing, replace with your own
    return Base::getMillisecondCounter();
}

PluginAudioProcessorEditor.h

The editor will need a way to signal to the OpenGL renderer that it’s time to capture a frame. Simple atomic variable works:

    std::atomic<bool> m_capturePending = false; //!< OpenGL capture frame signal

PluginAudioProcessorEditor::setupOpenGL

We’ll also need to setup our capturing resources. Note there is some additional logic to accommodate the standalone application border.


        if (isCapture()) {
            auto documentWindow = findParentComponentOfClass<DocumentWindow>();
            auto borderSize = documentWindow->getContentComponentBorder();
            const int captureHeight = m_processor.renderHeight + borderSize.getTopAndBottom();
            const int captureWidth = m_processor.renderWidth + borderSize.getLeftAndRight();
            m_glCapture.reset(new juce::Image(Image::ARGB, captureWidth, captureHeight, true));
            m_capturePixels.resize(captureWidth * captureHeight * 4);
        }

PluginAudioProcessorEditor::captureFrame

This function signals the OpenGL thread to capture a single frame and then waits:


juce::Image* LoudnessAudioProcessorEditor::captureFrame()
{
    if (m_openGLContext.isAttached()) {
        // draw opengl
        m_capturePending = true;
        while (m_capturePending)
            MessageManager::getInstance()->runDispatchLoopUntil(0);

        // return capture image
        return m_glCapture.get();
    }

    return nullptr;
}

PluginAudioProcessorEditor::renderOpenGL

Here’s an example how to capture the OpenGL pixels:


    auto performCapture = [&](juce::Image* renderTarget) {
        // skip if capturing is disabled
        if (!capturing || !m_capturePending)
            return;

        const MessageManagerLock mmLock;

        // retrieve document window up the heirarchy
        auto documentWindow = m_component->findParentComponentOfClass<DocumentWindow>();

        // capture DocumentWindow title bar
        {
            Graphics graphics(*m_glCapture);
            documentWindow->paint(graphics);
        }

        // paint document window children (title bar buttons)
        for (auto* child : documentWindow->getChildren()) {
            Graphics graphics(*m_glCapture);
            graphics.setOrigin(child->getPosition());
            child->paint(graphics);
        }

        auto borderSize = documentWindow->getContentComponentBorder();

        const int offsetX = borderSize.getLeft();
        const int offsetY = borderSize.getTop();

        // copy render target bitmap into capture texture
        {
            Image::BitmapData bmpData(*m_glCapture, 0, 0, m_glCapture->getWidth(), m_glCapture->getHeight());
            Image::BitmapData bmpDataFrom(*renderTarget, 0, 0, m_processor.renderWidth, m_processor.renderHeight);

            // copy from render target to capture bitmap, removing alpha channel
            for (int y = 0; y < m_processor.renderHeight; ++y) {
                uint8_t* imageLine = bmpData.getLinePointer(y + offsetY) + offsetX * 4;
                uint8_t* imageLineFrom = bmpDataFrom.getLinePointer(y);
                for (int x = 0; x < m_processor.renderWidth; ++x) {
                    imageLine[x * 4 + 0] = imageLineFrom[x * 4 + 0];
                    imageLine[x * 4 + 1] = imageLineFrom[x * 4 + 1];
                    imageLine[x * 4 + 2] = imageLineFrom[x * 4 + 2];
                    imageLine[x * 4 + 3] = 0xFF;
                }
            }
        }

        // capture editor window body
        {
            Image subImage = m_glCapture->getClippedImage(juce::Rectangle<int>(offsetX, offsetY, m_processor.renderWidth, m_processor.renderHeight));
            Graphics graphics(subImage);
            m_component->paintEntireComponent(graphics, true);
        }

        // signal completion if we were capturing
        m_capturePending = false;
    };
1 Like