This guide is available for both Python and Rust.
To create a plug-in for camlet, you’ll first need to create a new
directory for your plug-in in ~/.config/camlet/plugins,
e.g.
cd ~/.config/camlet/plugins
mkdir example-plugin-name
cd example-plugin-nameAnd create a file called about.txt to store the name and
description of your plug-in:
Example plug-in name
This is an example plug-in which does something.
Then install the camlet package in a new virtual environment
python -m venv venv
source venv/bin/activate
pip install camletNow, here’s a simple source file that does the bare minimum to get a working plug-in:
#!./venv/bin/python3
import camlet
class Plugin(camlet.Plugin):
def __init__(self, command: camlet.CommandInit):
# You can also report an error with init.error(...) if
# something goes wrong while initializing.
command.ok()
if __name__ == '__main__':
camlet.run(Plugin)Put this in run.py, then run
chmod +x run.pyto make the file executable.
You now have a plug-in which does nothing! You can try running camlet, and it’ll be the same as before, except you’ll see your plugin’s name listed in the “Plug-ins” section of the menu. You can try enabling and disabling it, not that it’ll do anything.
camlet watches plug-in executable files for changes, so it’ll automatically reload the plug-in whenever you save the python file.
The general model of camlet’s plug-in system is that camlet sends plug-ins commands when something happens. Certain commands also allow sending information back to camlet about what it should do in response. You can see the full list of commands here. In the following sections, we’ll handle and respond to commands in various ways, to make plug-ins that do something instead of nothing.
First, let’s make a plug-in which opens photos whenever they’re taken in the user’s default image viewer.
To do this, we listen for the photo command which
indicates that a photo will be taken, and make note of the path where it
will be saved. In theory, an error could occur, such as the user’s disk
being full or another plug-in cancelling the photo, so then we need to
wait for _photo_ok command which indicates that the photo
was successfully saved to disk. Here’s the code:
#!./venv/bin/python3
import camlet, subprocess
class Plugin(camlet.Plugin):
def __init__(self, command: camlet.CommandInit):
command.ok()
self.processes = []
def handle_photo(self, command: camlet.CommandPhoto):
# Save the path to the photo
self.photo_path = command.path()
def handle_photo_ok(self, command):
# Clean up zombie processes
self.processes = [process for process in self.processes
if process.poll() is None]
# Open it with xdg-open (uses default application).
self.processes.append(subprocess.Popen(
['xdg-open', self.photo_path]))
if __name__ == '__main__':
camlet.run(Plugin)Unfortunately we have to keep track of a list of running photo viewer
processes, so that we can properly clean up zombie
processes, so it’s not as simple as it could be. Every time we get
the _photo_ok command, we also look at old photo viewer
processes to see if they’re done running. Otherwise Linux will keep
around some of their data around indefinitely because it thinks we care
what happened to them. To be honest, though, you could leave that part
out and it probably wouldn’t matter unless the user decides to take a
hundred thousand photos without closing camlet.
Next, let’s look at a more sophisticated example: adding a shortcut to camlet for taking a “burst” of photos. We’ll start by hardcoding the B key to take five photos at ½ second intervals:
#!./venv/bin/python3
import camlet, time
class Plugin(camlet.Plugin):
def __init__(self, command: camlet.CommandInit):
self.burst_photos_left = 0
self.last_photo_time = 0
command.ok()
def handle_key_down(self, command: camlet.CommandKeyDown):
if command.name() == 'B':
# B key was pressed - start burst capture
self.burst_photos_left = 5
def handle_frame(self, command: camlet.CommandFrame):
# Stop burst if we're in video mode.
if not command.is_photo_mode():
self.burst_photos_left = 0
# If a burst is going on, and it's been long enough
# since the last photo,
if self.burst_photos_left and \
time.time() - self.last_photo_time > 0.5:
# Take a photo
command.take_photo()
# Decrease number of photos left to take
self.burst_photos_left -= 1
# Record the time
self.last_photo_time = time.time()
if __name__ == '__main__':
camlet.run(Plugin)Try it out!
Note the use of the frame command, which runs every
frame camlet is open, which is used in this case to check if enough time
has passed to take a new photo. Many things can only be done in response
to a frame command—it’s kind of the “kitchen sink”
command—and taking photos is one of them.
Well, it’s nice, but what if someone wants to use a different key? Or
take a different number of photos? That’s the perfect job for plug-in
settings. We’ll define the settings in the initializer, then we receive
the settings one at a time through the _setting command,
first when camlet starts up, then again whenever they’re changed.
Everything else is handled by camlet.
#!./venv/bin/python3
import camlet, time
class Plugin(camlet.Plugin):
def __init__(self, command: camlet.CommandInit):
self.burst_photos_left = 0
self.last_photo_time = 0
# Define keyboard shortcut setting with default
# value of "B".
command.define_keyboard_setting('shortcut',
'Keyboard shortcut', 'B')
# Define number setting with minimum 2, maximum 30,
# step 1, default 5.
command.define_number_setting('count',
'Number of photos to take', 2, 30, 1, 5)
# Define number setting with minimum 0.1, maximum 5,
# step 0.1, default 0.5
command.define_number_setting('interval',
'Time between photos', 0.1, 5, 0.1, 0.5)
command.ok()
def handle_setting(self, command: camlet.CommandSetting):
setting = command.id()
if setting == 'shortcut':
self.shortcut = command.value()
elif setting == 'count':
self.count = command.value_number()
elif setting == 'interval':
self.interval = command.value_number()
def handle_key_down(self, command: camlet.CommandKeyDown):
if command.combo() == self.shortcut:
self.burst_photos_left = self.count
def handle_frame(self, command: camlet.CommandFrame):
if not command.is_photo_mode():
self.burst_photos_left = 0
if self.burst_photos_left and \
time.time() - self.last_photo_time > self.interval:
command.take_photo()
self.burst_photos_left -= 1
self.last_photo_time = time.time()
if __name__ == '__main__':
camlet.run(Plugin)Note that the ID of the setting (e.g. shortcut) is
different from its name (e.g. Keyboard shortcut). You can
use a shorter name for the ID, and you can change the displayed name of
the setting without resetting its value for everyone. We define a
“keyboard” setting for the keyboard shortcut, and a number setting for
the number of photos and the interval between photos. Note that we are
now using the combo value of the _key_down
command. This includes both the name of the key and any modifiers (Ctrl,
Shift, etc.) that are being held. So, if the user chooses
Ctrl+B as the shortcut for example, it’ll be handled
automatically.
Try it out by going to the main menu and selecting “Plug-ins”, then finding your plug-in on the list. You should now be able to enter its settings menu and change the settings.
Now let’s look at maybe one of the most exciting features of camlet
plug-ins: shaders. Shaders describe a function for transforming input
data to output data on the GPU. In the case of camlet shaders, we’re
transforming the pixels in the picture taken by the camera into pixels
that will be displayed to the user. camlet shaders are written in the OpenGL
Shading Language (GLSL). One of the simplest things we can do is
convert the picture to grayscale, so let’s try that. First, create a
file called shader.glsl in your plug-in’s root
directory:
// Use GLSL version 130 (aka 3.00). You can use later versions
// to access new features, but they may not work on older devices.
#version 130
// Define a uniform for the picture passed into the shader.
// A uniform is a variable that's the same across all inputs (pixels).
uniform sampler2D camlet_frame;
// This is the input to the shader: the coordinate of the pixel.
// It's a vec2, which is a vector of 2 floating-point numbers.
// (0,0) is the bottom-left of the picture and (1,1) is the top-right.
in vec2 camlet_pos;
// This is the output of the shader: the color to paint the pixel.
// It's a vec3, which is a vector of 3 floating-point numbers,
// the red, green, and blue values of the pixel.
out vec3 camlet_color;
void main() {
// Access the pixel in the input picture.
// Note that texture returns a vec4 because OpenGL textures are
// allowed to have up to 4 components per pixel. But this texture
// only uses 3 components (RGB), so don't look at the fourth.
vec4 input_pixel = texture(camlet_frame, camlet_pos);
// Output a color which is a weighted average of the red (x),
// green (y), and blue (z) components of the input pixel. The
// weights roughly match how bright these colors appear to the
// human eye, for maximal accuracy.
camlet_color = vec3(0.21 * input_pixel.x
+ 0.72 * input_pixel.y
+ 0.07 * input_pixel.z);
// ↑ here's a little GLSL trick: vec3(float) will create a
// vec3 with all components equal to the float.
}Note that the names camlet_frame,
camlet_pos, camlet_color, and
main are all important here — you can’t change them!
(If you’ve never seen GLSL before, there are lots of resources for learning about it online, including the OpenGL cheat sheet—scroll down a bit to get to the GLSL stuff—and this 20-minute video tutorial by Inigo Quilez. Also there are reference pages for all GLSL functions in the OpenGL refpages.)
Now we have to tell camlet to use our shader. We can do this with the following plug-in code
#!./venv/bin/python3
import camlet
class Plugin(camlet.Plugin):
def __init__(self, init: camlet.CommandInit):
init.ok()
def handle_frame(self, frame: camlet.CommandFrame):
frame.set_camera_shader(
camlet.Shader.from_file('shader.glsl'))
if __name__ == '__main__':
camlet.run(Plugin)We’re setting it as a camera shader. There are also overlay shaders, which apply to the whole window, including letterboxing, and aren’t included in photos or videos (these are useful for displaying textual information, etc.).
Note that since we’re creating the shader from a file path, this
means that the shader code will be read from the file every single
frame. This is convenient for development, since the shader will
automatically update when the file is changed, but for releasing it to
the world, you may want to read the file once in the initializer, then
create a shader from a string instead when setting it, using
camlet.Shader(code), to lower the resource usage of the
plug-in.
Here’s a little example that’s actually the inspiration behind camlet’s whole plug-in system. I wanted a way of superimposing the last photo that was taken over the camera feed, for making stop-motion animations.
This will give us an opportunity to look at some more ways of controlling shaders in camlet plug-ins, namely custom uniforms, texture grabbing, and texture binding.
First, let’s put the shader code in shader.glsl as before:
#version 130
uniform sampler2D camlet_frame;
// Texture of last photo that was taken
uniform sampler2D last_photo;
// User-controlled opacity at which we should display the last photo.
uniform float opacity;
in vec2 camlet_pos;
// You can declare camlet_color as a vec3 or vec4. We use vec4
// here for convenience. The last component is ignored.
out vec4 camlet_color;
void main() {
// Color of the pixel coming from the camera.
vec4 input_pixel = texture(camlet_frame, camlet_pos);
// Color of the same pixel in the last photo that was taken.
vec4 last_photo_pixel = texture(last_photo, camlet_pos);
// GLSL "mix" function creates a mix of two values.
// If the third argument is 0, the first argument is returned.
// If it's 1, the second argument is returned.
// If it's something between 0 and 1, a corresponding
// mix of the first and second arguments is returned.
camlet_color = mix(input_pixel, last_photo_pixel, opacity);
}And here’s the plug-in code:
#!./venv/bin/python3
import camlet
class Plugin(camlet.Plugin):
def __init__(self, init: camlet.CommandInit):
self.last_photo = 0
self.old_photo = 0
self.just_took_photo = False
# Setting for the opacity of the superimposed photo.
init.define_number_setting('opacity',
'Superimposition opacity',
0.1, 0.9, 0.1, 0.5)
init.ok()
def handle_setting(self, setting: camlet.CommandSetting):
if setting.id() == 'opacity':
self.opacity = setting.value_number()
def handle_frame(self, frame: camlet.CommandFrame):
if self.just_took_photo:
# Tell camlet to save the camera's picture as a texture.
frame.grab_camera()
self.just_took_photo = False
# Tell camlet we don't need this old photo anymore.
if self.old_photo:
frame.release(self.old_photo)
self.old_photo = 0
if self.last_photo:
shader = camlet.Shader.from_file('shader.glsl')
# Bind the texture to a numbered texture slot.
# Shaders can use up to 31 different textures,
# with slots numbered 1-31.
shader.bind_textures({1: self.last_photo})
# Tell our shader that the texture is in slot 1.
shader.set_uniform_int('last_photo', 1)
# Tell our shader what opacity it should use for the
# superimposition.
shader.set_uniform_float('opacity', self.opacity)
frame.set_camera_shader(shader)
def handle_grabbed(self, grabbed: camlet.CommandGrabbed):
# If we've grabbed a photo before, we want to delete its
# texture. That's only possible in the frame command - just
# store it away for now.
if self.last_photo:
self.old_photo = self.last_photo
# Store the new photo.
self.last_photo = grabbed.texture()
def handle_photo(self, photo: camlet.CommandPhoto):
# Temporarily disable the shader while taking the photo.
photo.set_camera_shader(None)
def handle_photo_ok(self, command):
self.just_took_photo = True
if __name__ == '__main__':
camlet.run(Plugin)In the frame command, we ask camlet to grab the camera feed
if a photo was taken, so that we can use it in our shader. Then we
release old textures we aren’t using anymore — it’s important
to do this, otherwise your video memory will keep filling up with old
textures until you quit camlet. Next, if a photo has been taken, we set
up our shader, by binding the photo texture to a slot, so that
we can use it in our shader, then assigning the value of the
last_photo uniform to the index of the slot, and lastly
assigning the value of the opacity uniform according to the
user’s preference.
Lastly, we’ll look at something more complicated: a plug-in that lets you crop the camera feed, complete with a crosshair indicator of where the crop will start, a box displaying the area that the frame will be cropped down to, and the coordinates of the crop in the bottom-left corner.
For this, we’ll need a few new features of camlet plug-ins:
To get started, download
crosshair.png,
and put it in your plug-in’s directory.
We’ll start by looking at the camera shader, which we’ll save to
shader.glsl as before. This shader does three things:
crop_pos is set
to something with a non-negative x coordinate, and it’ll display a box
where the crop will take place.texture_pos) so that the
frame is cropped accordingly.#version 130
uniform sampler2D camlet_frame;
// "crosshair" image to display at mouse position
uniform sampler2D crosshair;
// mouse position in 0-1 coordinates.
uniform vec2 mouse_pos;
// first crop corner in 0-1 coordinates.
uniform vec2 crop_pos;
// picture_width / picture_height
uniform float aspect_ratio;
// Scale/offset to use to crop the incoming camera feed
uniform vec2 frame_scale;
uniform vec2 frame_offset;
in vec2 camlet_pos;
out vec4 camlet_color;
// Gives the signed distance to a box centered at p with "radius" b
// Negative = inside the box, positive = outside the box
// https://iquilezles.org/articles/distfunctions2d/
float sd_box(vec2 p, vec2 b)
{
vec2 d = abs(p)-b;
return length(max(d,0.0)) + min(max(d.x,d.y),0.0);
}
void main() {
// Compute the cropped texture coordinate
vec2 texture_pos = camlet_pos * frame_scale + frame_offset;
vec4 input_pixel = texture(camlet_frame, texture_pos);
// Vector which you can multiply coordinates by to fix the
// fact that the width/height of the frame aren't necessarily
// the same, and we don't want things to be stretched.
vec2 fix_aspect = vec2(aspect_ratio, 1.);
vec4 final_color = input_pixel;
if (crop_pos.x < 0.0) {
// Draw crosshair image.
vec2 crosshair_coord =
(camlet_pos - mouse_pos) * 40.0
* fix_aspect + 0.5;
// If we're at a coordinate where we should draw the crosshair,
if (min(crosshair_coord.x, crosshair_coord.y) >= 0.0
&& max(crosshair_coord.x, crosshair_coord.y) <= 1.0) {
// Sample the crosshair texture
vec4 crosshair_pixel = texture(crosshair, crosshair_coord);
// Mix it with the camera feed according to the
// alpha (opacity) of the crosshair pixel.
final_color = mix(final_color, crosshair_pixel, crosshair_pixel.w);
}
} else {
// Draw the crop box.
vec2 crop_center = (mouse_pos + crop_pos) * 0.5;
vec2 crop_radius = abs(mouse_pos - crop_pos) * 0.5;
// Compute signed distance to crop box, to determine
// whether we're inside, outside, or on the border.
float box_dist = sd_box(
(camlet_pos - crop_center) * fix_aspect,
crop_radius * fix_aspect);
if (box_dist < 0.0) {
// (Inside the crop box)
} else if (box_dist < 0.004) {
// Draw a border around the crop box.
final_color = vec4(0.0);
} else {
// Darken everything outside of the crop box.
final_color *= 0.6;
}
}
camlet_color = final_color;
}Next, let’s look at the overlay shader, which we’ll save to
overlay.glsl. This shader’s sole function is to draw the
text with the coordinates in the bottom-left corner of the screen.
#version 130
uniform sampler2D camlet_frame;
uniform sampler2D text;
uniform vec2 window_size;
in vec2 camlet_pos;
out vec4 camlet_color;
void main() {
vec4 input_pixel = texture(camlet_frame, camlet_pos);
// Compute size of text, in the same coordinate system
// as camlet_pos.
vec2 text_size = vec2(textureSize(text, 0)) / window_size;
// If we're in the bottom-left part of the screen,
if (camlet_pos.x < text_size.x &&
camlet_pos.y < text_size.y) {
// Get the color of the text at this point
float text_pixel = texture(text,camlet_pos / text_size).x;
// Blend it with the incoming color. We first multiply
// the incoming color by 0.5 so that the text gets a
// semi-transparent black background.
camlet_color = mix(input_pixel * 0.5,
vec4(1.0),
text_pixel);
} else {
// Otherwise, don't do anything.
camlet_color = input_pixel;
}
}Lastly, the plug-in code itself, which puts everything together and handles user input:
#!./venv/bin/python3
import camlet
class Plugin(camlet.Plugin):
def __init__(self, init: camlet.CommandInit):
# Texture for the little crosshair icon
self.crosshair = 0
# Texture for the informational text
self.text = 0
# Position of first point of crop, if we're halfway
# through a crop.
self.crop_pos = None
# Current crop (x, y, width, height)
# The width/height will be clamped down to the
# camera width/height in set_camera_size.
self.crop = [0, 0, 1000000, 1000000]
# picture width/height according to last frame command
self.picture_size = None
# camera width/height according to last frame command
self.camera_size = None
# List of textures to release
self.release = []
# Define a setting that allows the user to select between
# "yes" and "no" with a default of "yes"
init.define_select_setting('show_dimensions',
'Display crop dimensions', ['yes', 'no'], 'yes')
init.ok()
def convert_picture_to_camera(self, x, y):
'''Convert from picture coordinates, where (0,0) is the top-left
and (picture_width,picture_height) is the bottom-right of the
displayed camera feed, to camera coordinates, where (0,0)
is the top-left and (camera_width,camera_height) is the bottom-right
of the uncropped camera feed.'''
x01 = max(0, min(1, x / self.picture_size[0]))
y01 = max(0, min(1, y / self.picture_size[1]))
return [
int(self.crop[0] + self.crop[2] * x01),
int(self.crop[1] + self.crop[3] * y01)
]
def convert_camera_to_01(self, coord):
'''Convert camera coordinates (described above) to 0-1 OpenGL texture
coordinates - (0,0) = bottom-left, (1,1) = top-right of the displayed
camera feed.'''
[x, y] = coord
return [
(x - self.crop[0]) / self.crop[2],
1 - (y - self.crop[1]) / self.crop[3]
]
def crop_second_point(self, mouse_pos):
'''Get the second crop corner given the mouse position and the
first crop corner in self.crop_pos. This is to stop the crop
from having a different aspect ratio from the camera and thus
making the picture stretched.'''
dx = mouse_pos[0] - self.crop_pos[0]
aspect = self.camera_size[1] / self.camera_size[0]
dy = abs(dx) * aspect
if mouse_pos[1] < self.crop_pos[1]:
dy = -dy
if self.crop_pos[1] + dy < 0:
dy = -self.crop_pos[1]
dx = (abs(dy) if dx >= 0 else -abs(dy)) / aspect
if self.crop_pos[1] + dy >= self.camera_size[1]:
dy = self.camera_size[1] - self.crop_pos[1]
dx = (abs(dy) if dx >= 0 else -abs(dy)) / aspect
dx = int(dx)
dy = int(dy)
# prevent division by 0
if dx == 0: dx = 1
if dy == 0: dy = 1
return [
self.crop_pos[0] + dx,
self.crop_pos[1] + dy]
def set_camera_size(self, w, h):
'''Set the resolution of the camera, and adjust stored coordinates
as needed to fit inside.'''
if self.camera_size and \
self.crop == [0, 0] + self.camera_size:
# Special case: if the user switches to a higher-resolution
# camera, don't crop down to the old resolution
self.crop = [0, 0, w, h]
self.camera_size = [w, h]
if self.crop[0] + self.crop[2] > w:
self.crop[2] = w - self.crop[0]
if self.crop[1] + self.crop[3] > h:
self.crop[3] = h - self.crop[1]
if self.crop_pos:
self.crop_pos[0] = min(self.crop_pos[0], w)
self.crop_pos[1] = min(self.crop_pos[1], h)
def handle_frame(self, frame: camlet.CommandFrame):
# Release old textures we're not using anymore
frame.release(*self.release)
self.release = []
if frame.menu_open():
# Cancel crop if the menu is currently open
self.crop_pos = None
picture_width = frame.picture_width()
picture_height = frame.picture_height()
self.picture_size = [picture_width, picture_height]
self.set_camera_size(
frame.camera_width(),
frame.camera_height())
mouse_pos = self.convert_picture_to_camera(
frame.picture_mouse_x(),
frame.picture_mouse_y())
if self.crop_pos is not None:
# Set mouse_pos to the second coordinate of the crop,
# which may be different from the actual mouse position
# since we need to adjust for the camera's aspect ratio.
mouse_pos = self.crop_second_point(mouse_pos)
if not self.crosshair:
# Upload texture from image file to OpenGL. This can
# then be used in shaders.
frame.upload_texture('crosshair.png')
else:
shader = camlet.Shader.from_file('shader.glsl')
shader.bind_textures({1: self.crosshair})
shader.set_uniform_int('crosshair', 1)
# Give our shader information about the
# current and prospective crop.
shader.set_uniform_float('aspect_ratio',
picture_width / picture_height)
shader.set_uniform_float('frame_scale', [
self.crop[2] / self.camera_size[0],
self.crop[3] / self.camera_size[1]
])
crop_right = self.crop[1] + self.crop[3]
shader.set_uniform_float('frame_offset', [
self.crop[0] / self.camera_size[0],
1 - (crop_right / self.camera_size[1])
])
shader.set_uniform_float('crop_pos',
self.convert_camera_to_01(self.crop_pos)
if self.crop_pos else [-100, -100])
shader.set_uniform_float('mouse_pos',
self.convert_camera_to_01(mouse_pos))
frame.set_camera_shader(shader)
if self.crop_pos is not None:
width = abs(mouse_pos[0] - self.crop_pos[0])
height = abs(mouse_pos[1] - self.crop_pos[1])
text = f'x: {mouse_pos[0]}, y: {mouse_pos[1]},'\
f' width: {width}, height: {height}'
else:
text = f'x: {mouse_pos[0]}, y: {mouse_pos[1]}'
# If the show_dimensions setting is set to "yes", display
# some text with the coordinates of the prospective crop.
if self.show_dimensions:
frame.render_text(text)
if not self.text: return
overlay = camlet.Shader.from_file('overlay.glsl')
overlay.bind_textures({1: self.text})
overlay.set_uniform_int('text', 1)
overlay.set_uniform_float('window_size',
(frame.window_width(),
frame.window_height()))
frame.set_overlay_shader(overlay)
else:
frame.set_overlay_shader(None)
def handle_setting(self, setting: camlet.CommandSetting):
if setting.id() == 'show_dimensions':
self.show_dimensions = setting.value() == 'yes'
def handle_grabbed(self, grabbed: camlet.CommandGrabbed):
if grabbed.source() == 'text':
# Text was rendered
if self.text: self.release.append(self.text)
self.text = grabbed.texture()
elif grabbed.source() == 'upload':
# Crosshair image was uploaded
self.crosshair = grabbed.texture()
def handle_mouse_down(self, command: camlet.CommandMouseDown):
if command.button() == 'R':
# If the right mouse button is pressed,
# reset everything.
self.crop_pos = None
self.crop = [0, 0] + self.camera_size
if command.button() != 'L':
# only handle left clicks from here on
return
mouse_pos = self.convert_picture_to_camera(
command.picture_x(), command.picture_y())
if self.crop_pos is None:
# Set first crop corner.
self.crop_pos = mouse_pos
else:
# Set second crop corner, and crop.
mouse_pos = self.crop_second_point(mouse_pos)
crop_p0 = [
min(mouse_pos[0], self.crop_pos[0]),
min(mouse_pos[1], self.crop_pos[1])]
crop_p1 = [
max(mouse_pos[0], self.crop_pos[0]),
max(mouse_pos[1], self.crop_pos[1])]
self.crop = [crop_p0[0], crop_p0[1],
crop_p1[0] - crop_p0[0],
crop_p1[1] - crop_p0[1]]
self.crop_pos = None
if __name__ == '__main__':
camlet.run(Plugin)For more information about what you can do with plug-ins, check out the documentation for the camlet plug-in library here. You can also check out the command reference for more detailed information, or if you want to make bindings for another programming language. Have fun!