Is there a way to save the live brush preview of a given brush preset as an image?

In summary, I want to save the area inside the orange box as an image, and I want to do this for each *.kpp file in the Resources folders.

From what I gather, all the files I’m looking for are either in the “./paintoppresets” folder of the Resources folder or the “./painttoppresets/” folder in any one of the of the “*.bundle” zip files. I wrote this script to invoke on any of the .kpp files in any of the folders mentioned above.

import json

from PIL import Image
from bs4 import BeautifulSoup

# https://docs.krita.org/en/reference_manual/resource_management/paintoppresets.html#structure

FILENAME = "brush_file.kpp"

img = Image.open(FILENAME)

with open("brush_file.xml", mode="w") as wfile:
    raw_xml = img.info["preset"]
    soup = BeautifulSoup(raw_xml, "html.parser")
    with open("brush_file.xml") as rfile:
        print(soup.prettify(), file=wfile)

I get the following output:

<preset name="v)_Texture_Impressionism" paintopid="paintbrush">
 <param name="ColorSource/Type" type="string"/>
 <![CDATA[plain]]>
 <param name="CompositeOp" type="string"/>
 <![CDATA[normal]]>
 <param name="DarkenSensor" type="string"/>
 <![CDATA[<!DOCTYPE params> <params id="pressure"> <curve>0,0;1,1;</curve> </params> ]]>
 <param name="DarkenUseCurve" type="internal"/>
 true
 <param name="DarkenUseSameCurve" type="internal"/>
 true
 <param name="DarkenValue" type="internal"/>
 1
 <param name="DarkencurveMode" type="internal"/>
 0
 <param name="EraserMode" type="internal"/>
 false
 <param name="FlowSensor" type="string"/>
 <![CDATA[<!DOCTYPE params> <params id="pressure"> <curve>0,0.528384;1,1;</curve> </params> ]]>
 <param name="FlowUseCurve" type="internal"/>
 true
 <param name="FlowUseSameCurve" type="internal"/>
 true
 <param name="FlowValue" type="internal"/>
 1
 <param name="FlowcurveMode" type="internal"/>
 0
 <param name="HorizontalMirrorEnabled" type="internal"/>
 true
 <param name="KisPrecisionOption/AutoPrecisionEnabled" type="internal"/>
 false
 <param name="KisPrecisionOption/DeltaValue" type="internal"/>
 15
 <param name="KisPrecisionOption/SizeToStartFrom" type="internal"/>
 0
 <param name="KisPrecisionOption/precisionLevel" type="internal"/>
 1
 <param name="MaskingBrush/Enabled" type="internal"/>
 false
 <param name="MaskingBrush/MaskingCompositeOp" type="string"/>
 <![CDATA[multiply]]>
 <param name="MaskingBrush/MasterSizeCoeff" type="internal"/>
 0.24339832945674428
 <param name="MaskingBrush/Preset/FlowSensor" type="string"/>
 <![CDATA[<!DOCTYPE params> <params id="pressure"/> ]]>
 <param name="MaskingBrush/Preset/FlowUseCurve" type="internal"/>
 true
 <param name="MaskingBrush/Preset/FlowUseSameCurve" type="internal"/>
 true
 <param name="MaskingBrush/Preset/FlowValue" type="internal"/>
 1
 <param name="MaskingBrush/Preset/FlowcurveMode" type="internal"/>
 0
 <param name="MaskingBrush/Preset/HorizontalMirrorEnabled" type="internal"/>
 false
 <param name="MaskingBrush/Preset/MirrorSensor" type="string"/>
 <![CDATA[<!DOCTYPE params> <params id="pressure"/> ]]>
 <param name="MaskingBrush/Preset/MirrorUseCurve" type="internal"/>
 true
 <param name="MaskingBrush/Preset/MirrorUseSameCurve" type="internal"/>
 true
 <param name="MaskingBrush/Preset/MirrorValue" type="internal"/>
 1
 <param name="MaskingBrush/Preset/MirrorcurveMode" type="internal"/>
 0
 <param name="MaskingBrush/Preset/OpacitySensor" type="string"/>
 <![CDATA[<!DOCTYPE params> <params id="pressure"/> ]]>
 <param name="MaskingBrush/Preset/OpacityUseCurve" type="internal"/>
 true
 <param name="MaskingBrush/Preset/OpacityUseSameCurve" type="internal"/>
 true
 <param name="MaskingBrush/Preset/OpacityValue" type="internal"/>
 1
 <param name="MaskingBrush/Preset/OpacitycurveMode" type="internal"/>
 0
 <param name="MaskingBrush/Preset/PressureMirror" type="internal"/>
 false
 <param name="MaskingBrush/Preset/PressureRatio" type="internal"/>
 false
 <param name="MaskingBrush/Preset/PressureRotation" type="internal"/>
 false
 <param name="MaskingBrush/Preset/PressureScatter" type="internal"/>
 false
 <param name="MaskingBrush/Preset/PressureSize" type="internal"/>
 false
 <param name="MaskingBrush/Preset/RatioSensor" type="string"/>
 <![CDATA[<!DOCTYPE params> <params id="pressure"/> ]]>
 <param name="MaskingBrush/Preset/RatioUseCurve" type="internal"/>
 true
 <param name="MaskingBrush/Preset/RatioUseSameCurve" type="internal"/>
 true
 <param name="MaskingBrush/Preset/RatioValue" type="internal"/>
 1
 <param name="MaskingBrush/Preset/RatiocurveMode" type="internal"/>
 0
 <param name="MaskingBrush/Preset/RotationSensor" type="string"/>
 <![CDATA[<!DOCTYPE params> <params id="pressure"/> ]]>
 <param name="MaskingBrush/Preset/RotationUseCurve" type="internal"/>
 true
 <param name="MaskingBrush/Preset/RotationUseSameCurve" type="internal"/>
 true
 <param name="MaskingBrush/Preset/RotationValue" type="internal"/>
 1
 <param name="MaskingBrush/Preset/RotationcurveMode" type="internal"/>
 0
 <param name="MaskingBrush/Preset/ScatterSensor" type="string"/>
 <![CDATA[<!DOCTYPE params> <params id="pressure"/> ]]>
 <param name="MaskingBrush/Preset/ScatterUseCurve" type="internal"/>
 true
 <param name="MaskingBrush/Preset/ScatterUseSameCurve" type="internal"/>
 true
 <param name="MaskingBrush/Preset/ScatterValue" type="internal"/>
 5
 <param name="MaskingBrush/Preset/ScattercurveMode" type="internal"/>
 0
 <param name="MaskingBrush/Preset/Scattering/AxisX" type="internal"/>
 true
 <param name="MaskingBrush/Preset/Scattering/AxisY" type="internal"/>
 true
 <param name="MaskingBrush/Preset/SizeSensor" type="string"/>
 <![CDATA[<!DOCTYPE params> <params id="pressure"/> ]]>
 <param name="MaskingBrush/Preset/SizeUseCurve" type="internal"/>
 true
 <param name="MaskingBrush/Preset/SizeUseSameCurve" type="internal"/>
 true
 <param name="MaskingBrush/Preset/SizeValue" type="internal"/>
 1
 <param name="MaskingBrush/Preset/SizecurveMode" type="internal"/>
 0
 <param name="MaskingBrush/Preset/VerticalMirrorEnabled" type="internal"/>
 false
 <param name="MaskingBrush/Preset/brush_definition" type="string"/>
 <![CDATA[<Brush type="auto_brush" useAutoSpacing="0" autoSpacingCoeff="1" angle="0" spacing="0.1" BrushVersion="2" density="1" randomness="0"> <MaskGenerator ratio="1" vfade="0.5" spikes="2" type="circle" hfade="0.5" antialiasEdges="0" diameter="19.4719" id="default"/> </Brush> ]]>
 <param name="MaskingBrush/Preset/requiredBrushFile" type="string"/>
 <![CDATA[ ]]>
 <param name="MaskingBrush/Preset/requiredBrushFilesList" type="string"/>
 <![CDATA[ ]]>
 <param name="MaskingBrush/UseMasterSize" type="internal"/>
 true
 <param name="MirrorSensor" type="string"/>
 <![CDATA[<!DOCTYPE params> <params id="fuzzy"> <curve>0,0;1,1;</curve> </params> ]]>
 <param name="MirrorUseCurve" type="internal"/>
 true
 <param name="MirrorUseSameCurve" type="internal"/>
 true
 <param name="MirrorValue" type="internal"/>
 1
 <param name="MirrorcurveMode" type="internal"/>
 0
 <param name="MixSensor" type="string"/>
 <![CDATA[<!DOCTYPE params> <params id="pressure"> <curve>0,0;1,1;</curve> </params> ]]>
 <param name="MixUseCurve" type="internal"/>
 true
 <param name="MixUseSameCurve" type="internal"/>
 true
 <param name="MixValue" type="internal"/>
 1
 <param name="MixcurveMode" type="internal"/>
 0
 <param name="OpacitySensor" type="string"/>
 <![CDATA[<!DOCTYPE params> <params id="pressure"> <curve>0,0;1,1;</curve> </params> ]]>
 <param name="OpacityUseCurve" type="internal"/>
 false
 <param name="OpacityUseSameCurve" type="internal"/>
 true
 <param name="OpacityValue" type="internal"/>
 1
 <param name="OpacitycurveMode" type="internal"/>
 0
 <param name="PaintOpAction" type="internal"/>
 2
 <param name="PaintOpSettings/ignoreSpacing" type="internal"/>
 false
 <param name="PaintOpSettings/isAirbrushing" type="internal"/>
 false
 <param name="PaintOpSettings/rate" type="internal"/>
 20
 <param name="PaintOpSettings/updateSpacingBetweenDabs" type="internal"/>
 false
 <param name="PressureDarken" type="internal"/>
 false
 <param name="PressureMirror" type="internal"/>
 true
 <param name="PressureMix" type="internal"/>
 false
 <param name="PressureRate" type="internal"/>
 false
 <param name="PressureRatio" type="internal"/>
 false
 <param name="PressureRotation" type="internal"/>
 true
 <param name="PressureScatter" type="internal"/>
 true
 <param name="PressureSharpness" type="internal"/>
 false
 <param name="PressureSize" type="internal"/>
 true
 <param name="PressureSoftness" type="internal"/>
 false
 <param name="PressureSpacing" type="internal"/>
 false
 <param name="PressureTexture/Strength/" type="internal"/>
 true
 <param name="Pressureh" type="internal"/>
 true
 <param name="Pressures" type="internal"/>
 false
 <param name="Pressurev" type="internal"/>
 true
 <param name="RateSensor" type="string"/>
 <![CDATA[<!DOCTYPE params> <params id="pressure"/> ]]>
 <param name="RateUseCurve" type="internal"/>
 true
 <param name="RateUseSameCurve" type="internal"/>
 true
 <param name="RateValue" type="internal"/>
 1
 <param name="RatecurveMode" type="internal"/>
 0
 <param name="RatioSensor" type="string"/>
 <![CDATA[<!DOCTYPE params> <params id="pressure"/> ]]>
 <param name="RatioUseCurve" type="internal"/>
 true
 <param name="RatioUseSameCurve" type="internal"/>
 true
 <param name="RatioValue" type="internal"/>
 1
 <param name="RatiocurveMode" type="internal"/>
 0
 <param name="RotationSensor" type="string"/>
 <![CDATA[<!DOCTYPE params> <params fanCornersStep="30" fanCornersEnabled="1" lockedAngleMode="0" angleOffset="91" id="drawingangle"> <curve>0,0;1,1;</curve> </params> ]]>
 <param name="RotationUseCurve" type="internal"/>
 true
 <param name="RotationUseSameCurve" type="internal"/>
 true
 <param name="RotationValue" type="internal"/>
 1
 <param name="RotationcurveMode" type="internal"/>
 0
 <param name="ScatterSensor" type="string"/>
 <![CDATA[<!DOCTYPE params> <params id="pressure"/> ]]>
 <param name="ScatterUseCurve" type="internal"/>
 true
 <param name="ScatterUseSameCurve" type="internal"/>
 true
 <param name="ScatterValue" type="internal"/>
 2.18
 <param name="ScattercurveMode" type="internal"/>
 0
 <param name="Scattering/AxisX" type="internal"/>
 true
 <param name="Scattering/AxisY" type="internal"/>
 true
 <param name="Sharpness/threshold" type="internal"/>
 4
 <param name="SharpnessSensor" type="string"/>
 <![CDATA[<!DOCTYPE params> <params id="pressure"/> ]]>
 <param name="SharpnessUseCurve" type="internal"/>
 true
 <param name="SharpnessUseSameCurve" type="internal"/>
 true
 <param name="SharpnessValue" type="internal"/>
 1
 <param name="SharpnesscurveMode" type="internal"/>
 0
 <param name="SizeSensor" type="string"/>
 <![CDATA[<!DOCTYPE params> <params id="sensorslist"> <ChildSensor id="fuzzy"> <curve>0,0.436308;0.323671,0.497487;1,1;</curve> </ChildSensor> <ChildSensor id="pressure"> <curve>0,0.436308;0.323671,0.497487;1,1;</curve> </ChildSensor> </params> ]]>
 <param name="SizeUseCurve" type="internal"/>
 true
 <param name="SizeUseSameCurve" type="internal"/>
 true
 <param name="SizeValue" type="internal"/>
 1
 <param name="SizecurveMode" type="internal"/>
 0
 <param name="SoftnessSensor" type="string"/>
 <![CDATA[<!DOCTYPE params> <params id="pressure"/> ]]>
 <param name="SoftnessUseCurve" type="internal"/>
 true
 <param name="SoftnessUseSameCurve" type="internal"/>
 true
 <param name="SoftnessValue" type="internal"/>
 1
 <param name="SoftnesscurveMode" type="internal"/>
 0
 <param name="Spacing/Isotropic" type="internal"/>
 false
 <param name="SpacingSensor" type="string"/>
 <![CDATA[<!DOCTYPE params> <params id="pressure"/> ]]>
 <param name="SpacingUseCurve" type="internal"/>
 true
 <param name="SpacingUseSameCurve" type="internal"/>
 true
 <param name="SpacingValue" type="internal"/>
 1
 <param name="SpacingcurveMode" type="internal"/>
 0
 <param name="Texture/Pattern/Enabled" type="internal"/>
 false
 <param name="VerticalMirrorEnabled" type="internal"/>
 true
 <param name="brush_definition" type="string"/>
 <![CDATA[<Brush scale="0.3125" type="gbr_brush" useAutoSpacing="1" autoSpacingCoeff="0.11" ColorAsMask="0" angle="0" spacing="0.1" BrushVersion="2" filename="impressionism_brush.gih"/> ]]>
 <param name="hSensor" type="string"/>
 <![CDATA[<!DOCTYPE params> <params id="fuzzy"> <curve>0,0.497816;1,0.591495;</curve> </params> ]]>
 <param name="hUseCurve" type="internal"/>
 true
 <param name="hUseSameCurve" type="internal"/>
 true
 <param name="hValue" type="internal"/>
 1
 <param name="hcurveMode" type="internal"/>
 0
 <param name="lodUserAllowed" type="internal"/>
 false
 <param name="paintop" type="string"/>
 <![CDATA[paintbrush]]>
 <param name="requiredBrushFile" type="string"/>
 <![CDATA[impressionism_brush.gih]]>
 <param name="requiredBrushFilesList" type="string"/>
 <![CDATA[impressionism_brush.gih]]>
 <param name="sSensor" type="string"/>
 <![CDATA[<!DOCTYPE params> <params id="pressure"/> ]]>
 <param name="sUseCurve" type="internal"/>
 true
 <param name="sUseSameCurve" type="internal"/>
 true
 <param name="sValue" type="internal"/>
 1
 <param name="scurveMode" type="internal"/>
 0
 <param name="vSensor" type="string"/>
 <![CDATA[<!DOCTYPE params> <params id="fuzzy"> <curve>0,0.480349;1,0.616445;</curve> </params> ]]>
 <param name="vUseCurve" type="internal"/>
 true
 <param name="vUseSameCurve" type="internal"/>
 true
 <param name="vValue" type="internal"/>
 1
 <param name="vcurveMode" type="internal"/>
 0
</preset>

According to this article in the official Krita documentation, this XML output is for Krita’s brush engines to parse. I couldn’t read C++ code, so I skipped trying to parse the code in the “./libs/image/brushengine/” of the source code. I went on to take a gander at the Krita-Python interface, which is documented briefly here, but I still couldn’t find much information on what I’m trying to do. Additionally, the links in the Krita API section are broken, so I didn’t have much luck there either.

:slight_smile: Hello @eclair_de_xii, and welcome to the forum!

The API is found here:

For your other questions, you need to wait for users with the needed knowledge.

Michelist

You could look into the merge request of this feature request. Maybe you find what you are looking for.

1 Like

I think brush presets only contain the thumbnail as base64 png, not 100%.

It should be possible to grab the live preview image. If you’ve not seen the plugin developer tools check them out.

  1. Get the main window (qmwin = Krita.instance().activeWindow().qwindow())
  2. find brush preset editor w = qmwin.findchild(QWidget, 'KisPaintOpPresetsEditor')
  3. inside that widget find the live preview widget 'liveBrushPreviewView'
  4. do w.grab() to get a QPixmap. That can be turned into QImage and then converted into PIL image?

Both brush editor and live preview widgets have unique object names which is convenient, I would use w.findChild(QWidget, objectName). You have to import both QWidget and QGraphicsView for use in the findChild function.

Iterate over all presets, set current brush each time, and grab the preview for each? Hope that helps.

No, brush presets, or *.KPP files, are PNG files containing metadata describing the settings of that preset and defining how it will work. Simply rename any *.KPP to *.PNG and double-click it to see.

Michelist

1 Like

Yes you are correct, thank you Michelist. I was confused with the output of Preset.toXML() which can contain brush tip images.

1 Like

I’ve chosen to follow this approach. But I… sort of got stuck again. This is what I’ve written so far.

import os

from krita import *

ALL_PRESETS = Application.resources("preset")

def get_preset(name):
    return ALL_PRESETS[name]

def save_preset(name, preset):
    k = Krita.instance()
    window = k.activeWindow() 
    qmwin = window.qwindow()
    presets_editor = qmwin.findChild(QWidget, "KisPaintOpPresetsEditor")
    brush_previewer = presets_editor.findChild(QGraphicsView, "liveBrushPreviewView")
    presets_chooser = PresetChooser(brush_previewer)
    presets_chooser.setCurrentPreset(preset)
    pixmap = presets_chooser.grab()
    pixmap.save(os.path.join(".", "Desktop", "presets", name + '.png'))

if __name__ == "__main__":
    name = "b) Airbrush Soft"
    preset = get_preset(name)
    save_preset(name, preset)

My output is as follows; it isn’t exactly what I wanted.

b) Airbrush Soft

2 Likes

Great progress! I think only issue is wrong object is being grabbed.

pixmap = presets_chooser.grab()

should be

pixmap = brush_previewer.grab()

minimal test (change folder to what you want)

import os
from krita import *
def func():
    k = Krita.instance()
    window = k.activeWindow() 
    qmwin = window.qwindow()
    presets_editor = qmwin.findChild(QWidget, "KisPaintOpPresetsEditor")
    brush_previewer = presets_editor.findChild(QGraphicsView, "liveBrushPreviewView")

    pixmap = brush_previewer.grab()
    fp = os.path.join(R"C:\krita\brush", "test" + '.png')
    pixmap.save(fp)

func()

test

1 Like

This is so helpful.

im doing update on my own plugin and maybe i should add this for a particular layout. :face_with_hand_over_mouth:
so many possibilities.

1 Like

Did you have a document open while you ran that code? One would probably need to manually invoke Application.createDocument and Window.addView in case they didn’t.

Also, I feel like there should be at least one invocation of View().setCurrentBrushPreset in that code.

I’m still getting a blank preview using this code after opening up a new dummy document.

b) Airbrush Soft

import os
from krita import *

# initialize Krita
K = Krita.instance()

# get all presets
ALL_PRESETS = K.resources("preset")

# get preset for testing
name = "b) Airbrush Soft"
preset = ALL_PRESETS[name]

# open document
active_window = K.activeWindow()
active_window.activeView().setCurrentBrushPreset(preset) # TODO: Figure out where this goes.

# locate brush previewer
qmwin = active_window.qwindow()
presets_editor = qmwin.findChild(QWidget, "KisPaintOpPresetsEditor")
brush_previewer = presets_editor.findChild(QGraphicsView, "liveBrushPreviewView")

# extract and save
pixmap = brush_previewer.grab()
filename = os.path.join(".", "Desktop", "presets", name + '.png')
pixmap.save(filename)


I did have a document open when I tested it, but it shouldn’t make a difference since the brush editor is separate to documents/views.

I just double checked by testing it with document closed (welcome screen visible) with brush editor open, and then brush editor closed. I also tried your script (path changed) and it still saves the live preview.

It saved whichever brush was last selected (a soft round brush), for the moment I don’t think it matters unless the current brush somehow doesn’t render anything which would be unfortunate. I tried with a few other brushes and they all get saved correctly.

test
test

Are you sure the path is valid? I see you’re on Linux and I’m not very familiar that. On windows the cwd (.) is C:\Program Files\Krita (x64) and requires admin permission to write there.


Ok I closed Krita, opened it again, new document, and didn’t open the brush editor, then ran the script.

b) Airbrush Soft

Oh. I think the problem here was that I had to open the Brush Editor first before running my script.

The path I provided is definitely valid, besides. It points to my Desktop directory, which is directly above my HOME directory.

1 Like

That being said, though the problem I presented in the opening post has been solved, I still have another:

import os
from krita import *

# initialize Krita
K = Krita.instance()
ACTIVE_WINDOW = K.activeWindow()

def save_preset(name, preset, directory):
    # locate brush previewer
    qmwin = ACTIVE_WINDOW.qwindow()
    presets_editor = qmwin.findChild(QWidget, "KisPaintOpPresetsEditor")
    brush_previewer = presets_editor.findChild(QGraphicsView, "liveBrushPreviewView")
    # extract and save
    pixmap = brush_previewer.grab()
    filename = os.path.join(directory, name + '.png')
    return pixmap.save(filename)

if __name__ == "__main__":
    directory = "Desktop/presets"
    # get all presets
    ALL_PRESETS = K.resources("preset")
    # save live-previews of nine presets
    maximum = 10
    presets = set()
    for preset_no, (name, preset) in enumerate(ALL_PRESETS.items(), start=1):
        if preset_no == maximum:
            break
        ACTIVE_WINDOW.activeView().setCurrentBrushPreset(preset)
        presets.add(ACTIVE_WINDOW.activeView().currentBrushPreset().name())
        save_preset(name, preset, directory)
    print(len(presets)) # output should imply that this is equal to 1, but it's 9.

This code generates the same exact image for the first nine files of the preset-resources in Krita, despite the fact that nine distinct presets have been set to the active view, as evidenced by the cardinality of the presets accumulator set.

Thanks so much for your help and patience, besides, in helping me figure this out.

1 Like

You’re very welcome.

I got the same result, it’s likely switching between brushes too fast for each preview to be displayed or generated in time.

If we give a reasonable delay between switching brushes it has time to update the live previews. The yield statements are in milliseconds.

import os
from krita import *

def async_runner(func):
    """Decorator to automatically handle function execution with pauses."""
    import functools
    @functools.wraps(func)
    def wrapper():
        generator = func()
        def step():
            try:
                delay = int(next(generator))
                QTimer.singleShot(delay, step)  # Schedule next step
            except StopIteration:
                print('--- DELAYED EXECUTION COMPLETE ---')
        step()  # Start execution
    return wrapper

@async_runner
def func():

    # initialize Krita
    K = Krita.instance()
    ACTIVE_WINDOW = K.activeWindow()

    def save_preset(name, preset, directory):
        # locate brush previewer
        qmwin = ACTIVE_WINDOW.qwindow()
        presets_editor = qmwin.findChild(QWidget, "KisPaintOpPresetsEditor")
        brush_previewer = presets_editor.findChild(QGraphicsView, "liveBrushPreviewView")
        # extract and save
        pixmap = brush_previewer.grab()
        filename = os.path.join(directory, name + '.png')
        return pixmap.save(filename)


    # directory = "Desktop/presets"
    directory = R"C:\krita\brush\batch"
    # get all presets
    ALL_PRESETS = K.resources("preset")
    # save live-previews of nine presets
    maximum = 10
    presets = set()
    for preset_no, (name, preset) in enumerate(ALL_PRESETS.items(), start=1):
        if preset_no == maximum:
            break
        yield 50
        ACTIVE_WINDOW.activeView().setCurrentBrushPreset(preset)
        yield 50
        presets.add(ACTIVE_WINDOW.activeView().currentBrushPreset().name())
        save_preset(name, preset, directory)
    print(len(presets)) # output should imply that this is equal to 1, but it's 9.

    # yield 0
func()

2 Likes

Again, thanks so much; you’re amazing. Another thing I had to do in order to get this to work was to actually keep the Brush Editor open while the code was running.

1 Like