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
- isCapture
- StandaloneFilterApp
- PluginCapture
- PluginAudioProcessor.h
- PluginAudioProcessor::processBlock
- PluginAudioProcessor::getMillisecondCounter
- PluginAudioProcessorEditor.h
- PluginAudioProcessorEditor::setupOpenGL
- PluginAudioProcessorEditor::captureFrame
- PluginAudioProcessorEditor::renderOpenGL
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;
};
