diff --git a/README.md b/README.md
new file mode 100644
index 0000000..29ab37f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,162 @@
+
+
Pigment
+
+
+
+Color grading with code.
+
+
+## Table of Contents
+
+- [Supported Platforms](#supported-platforms)
+- [Usage](#usage)
+- [Shaderlib](#shaderlib)
+- [Commandline interface](#commandline-interface)
+- [Known Issues](#known-issues)
+
+
+## Supported Platforms
+
+- Linux
+
+You can find the latest release ...
+
+- [here (remotebranch.eu)](https://remotebranch.eu/ChaoticByte/Pigment/releases/latest)
+
+
+## Usage
+
+With Pigment, you are processing images with GDShaders. This brings almost endless opportunities to create unique art.
+If you want to learn GDShader, take a look at the [Godot docs](https://docs.godotengine.org/en/stable/tutorials/shaders/).
+
+**To get started, use the project template (see the Releases section of this repo) and open it in Godot.**
+
+The template includes many examples. You can use them as a starting-point to write your own stuff.
+
+Besides the regular GDShader stuff, Pigment has so-called directives. Those allow to further control the behaviour of the application. **The most important directive is `//!load` to load an image.**
+
+
+### Load TEXTURE using the `//!load` directive
+
+```glsl
+//!load
+```
+
+The main image file will be read and available as the sampler2D `TEXTURE`.
+
+
+#### Load additional images
+
+```glsl
+//!load+
+
+uniform sampler2D ;
+```
+
+Have a look at the `place_texture.gdshader` example.
+
+
+### Have multiple steps with `//!steps n`
+
+You can apply your shaderfile multiple times. At every additional step, `TEXTURE` is the result of the previous step. This can be used to chain effects that cannot be easily chained otherwise.
+
+To query the current step index, a `STEP` uniform is automatically injected. If `steps` is set to `0`, your shader won't be applied at all.
+
+Example:
+
+```glsl
+//!load ...
+//!steps 5
+
+uniform int STEP;
+uniform int STEPS;
+
+void fragment() {
+ if (STEP == 0) {
+ ...
+ } else if (STEP == 1) {
+ ...
+ } else if (STEP == STEPS-1) {
+ ...
+ }
+}
+```
+
+
+## Shaderlib
+
+This repo comes with a (still small) shader library including pre-written functions and more.
+Have a look at the `shaderlib` folder.
+
+Here is an example:
+
+```glsl
+shader_type canvas_item;
+
+#include "./shaderlib/oklab.gdshaderinc"
+
+//!load ./images/swamp.jpg
+
+void fragment() {
+ vec4 oklab = rgb2oklab(COLOR);
+ vec4 oklch = oklab2oklch(oklab);
+ oklch.z -= 2.0;
+ COLOR = oklab2rgb(oklch2oklab(oklch));
+}
+```
+
+
+## Commandline interface
+
+You can run Pigment from the commandline or scripts.
+
+> Note: Headless mode is not supported. Using the commandline interface still opens a window.
+
+
+### Usage
+
+```
+~ Pigment CLI ~
+-================-
+
+Usage:
+
+./Pigment
+
+Commands:
+
+ help
+
+ | Shows this help text.
+
+ apply --shader PATH --input PATH --output PATH
+
+ | Applies a shader file.
+
+ --shader PATH The path to the shader
+ --input PATH The path to the input image.
+ Passing a folder activates batch mode.
+ --output PATH Where to write the resulting image to.
+ In batch mode, this must be a folder.
+
+```
+
+
+### Batch Mode
+
+Since version v8.0, you can pass a directory to `--input` and `--output`. This will process all images in the input directory and write the output to the output directory.
+
+> Note: You *can* use this feature for video frames, but it will take a loooong time.
+
+
+#### Examples
+
+```
+./Pigment apply --shader ./examples/oklab.gdshader --input ~/Pictures/test.png --output ./output.png
+```
+
+
+## Known Issues
+
+- screen scaling is unsupported; Using screen scaling could lead to an either blurry UI, or no scaling at all -> see #45
+- commandline interface: `--headless` is not supported
diff --git a/build-template/Containerfile b/build-template/Containerfile
new file mode 100644
index 0000000..93e3a52
--- /dev/null
+++ b/build-template/Containerfile
@@ -0,0 +1,21 @@
+
+MAINTAINER ChaoticByte
+
+# Using Ubuntu 20.04
+FROM docker.io/ubuntu:focal AS os-base
+
+# https://docs.godotengine.org/en/stable/contributing/development/compiling/compiling_for_linuxbsd.html
+
+RUN apt-get update
+RUN DEBIAN_FRONTEND=noninteractive apt-get install -yq python3-pip git build-essential pkg-config libx11-dev libxcursor-dev libxinerama-dev libgl1-mesa-dev libglu1-mesa-dev libasound2-dev libpulse-dev libudev-dev libxi-dev libxrandr-dev libwayland-dev
+RUN pip3 install --system scons
+
+FROM os-base AS clone-src
+
+RUN git clone https://github.com/godotengine/godot.git -b 4.5-stable /godot-src
+
+FROM clone-src
+
+WORKDIR /godot-src
+ENTRYPOINT scons platform=linuxbsd target=template_release lto=full optimize=size disable_3d=yes module_text_server_adv_enabled=no module_text_server_fb_enabled=yes module_basis_universal_enabled=no module_csg_enabled=no module_enet_enabled=no module_gridmap_enabled=no module_jsonrpc_enabled=no module_mbedtls_enabled=no module_meshoptimizer_enabled=no module_minimp3_enabled=no module_mobile_vr_enabled=no module_msdfgen_enabled=no module_multiplayer_enabled=no module_navigation_enabled=no module_ogg_enabled=no module_openxr_enabled=no module_raycast_enabled=no module_squish_enabled=no module_theora_enabled=no module_upnp_enabled=no module_vhacd_enabled=no module_vorbis_enabled=no module_webrtc_enabled=no module_websocket_enabled=no module_webxr_enabled=no arch=x86_64 && strip bin/godot.linuxbsd.template_release.x86_64
+
diff --git a/build-template/build.sh b/build-template/build.sh
new file mode 100755
index 0000000..c31f837
--- /dev/null
+++ b/build-template/build.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+
+set -e
+
+function log {
+ echo -e "\033[1;36m***** $@\033[0m"
+}
+
+log
+log "Pigment - Godot Build Template Builder"
+log
+
+cd $(dirname $0)
+log Switched to $(pwd)
+
+tmpsuffix=$(date +%s%N)
+image_name=pigment-godot-template-builder
+container_name=${image_name}-${tmpsuffix}
+output_file=godot.linuxbsd.template_release.x86_64
+
+log Building image ${image_name} ...
+buildah build -t ${image_name}
+log Building godot build template with container ${container_name} ...
+podman run --name ${container_name} localhost/${image_name}:latest
+log Copying ${output_file} from container to $(realpath ./${output_file})
+podman cp ${container_name}:/godot-src/bin/${output_file} ./${output_file}
+log Removing container ${container_name}
+podman container rm ${container_name}
+log Done :D
+
diff --git a/dist.sh b/dist.sh
new file mode 100755
index 0000000..40d9045
--- /dev/null
+++ b/dist.sh
@@ -0,0 +1,30 @@
+set -e
+
+function log {
+ echo -e "\033[1;36m***** $@ *****\033[0m"
+}
+
+mkdir -p dist
+
+log Building application
+
+VERSION="$(godot --headless --no-header -s tools/get_version.gd)"
+
+godot --headless --export-release "Linux/X11" "dist/Pigment-${VERSION}.x86_64"
+
+log Packing shaderlib
+
+ZIP_PATH_SHADERLIB=$(realpath "dist/Pigment-${VERSION}_shaderlib.zip")
+
+zip -r "${ZIP_PATH_SHADERLIB}" shaderlib/
+
+log Packing project template
+
+ZIP_PATH_PROJECT_TEMPLATE=$(realpath "dist/Pigment-${VERSION}_project_template.zip")
+
+rm -f "${ZIP_PATH_PROJECT_TEMPLATE}"
+(
+ cd examples/
+ mv project.godot_ project.godot && trap "mv project.godot project.godot_" EXIT
+ zip -r "${ZIP_PATH_PROJECT_TEMPLATE}" *
+)
diff --git a/examples/0_empty.tscn b/examples/0_empty.tscn
new file mode 100644
index 0000000..5fa71b9
--- /dev/null
+++ b/examples/0_empty.tscn
@@ -0,0 +1,3 @@
+[gd_scene format=3 uid="uid://db2rhq8rwv5wo"]
+
+[node name="Node" type="Node"]
diff --git a/examples/greyscale.gdshader b/examples/greyscale.gdshader
new file mode 100644
index 0000000..d7f4c1f
--- /dev/null
+++ b/examples/greyscale.gdshader
@@ -0,0 +1,8 @@
+shader_type canvas_item;
+
+void fragment() {
+ float v = (COLOR.r + COLOR.g + COLOR.b) / 3.0;
+ COLOR.r = v;
+ COLOR.g = v;
+ COLOR.b = v;
+}
diff --git a/examples/greyscale.gdshader.uid b/examples/greyscale.gdshader.uid
new file mode 100644
index 0000000..b6e1bbd
--- /dev/null
+++ b/examples/greyscale.gdshader.uid
@@ -0,0 +1 @@
+uid://dvarqolt6es27
diff --git a/examples/hsv.gdshader b/examples/hsv.gdshader
new file mode 100644
index 0000000..20b2547
--- /dev/null
+++ b/examples/hsv.gdshader
@@ -0,0 +1,10 @@
+shader_type canvas_item;
+
+#include "./shaderlib/hsv.gdshaderinc"
+
+void fragment() {
+ vec4 hsv = rgb2hsv(COLOR);
+ hsv.xyz += vec3(0.65, .42-(hsv.y*.3), -.125);
+ hsv.xyz *= vec3(1.0, 1.0, 1.25);
+ COLOR = hsv2rgb(hsv);
+}
diff --git a/examples/hsv.gdshader.uid b/examples/hsv.gdshader.uid
new file mode 100644
index 0000000..d943067
--- /dev/null
+++ b/examples/hsv.gdshader.uid
@@ -0,0 +1 @@
+uid://gd23hu7ro148
diff --git a/examples/images/CREDITS.md b/examples/images/CREDITS.md
new file mode 100644
index 0000000..4b7dfd5
--- /dev/null
+++ b/examples/images/CREDITS.md
@@ -0,0 +1,5 @@
+
+# Example Images
+
+- swamp.jpg by [clfr21 on Pixabay](https://pixabay.com/de/users/clfr21-6530007/)
+- mountain.jpg by [Phghvvcftyyufj on Pixabay](https://pixabay.com/users/phghvvcftyyufj-12646982)
diff --git a/examples/images/mountain.jpg b/examples/images/mountain.jpg
new file mode 100644
index 0000000..591c06d
Binary files /dev/null and b/examples/images/mountain.jpg differ
diff --git a/examples/images/mountain.jpg.import b/examples/images/mountain.jpg.import
new file mode 100644
index 0000000..9288ecc
--- /dev/null
+++ b/examples/images/mountain.jpg.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ben72llmopgaj"
+path="res://.godot/imported/mountain.jpg-c1b7de1e6557b826bc6f9324027e11af.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://examples/images/mountain.jpg"
+dest_files=["res://.godot/imported/mountain.jpg-c1b7de1e6557b826bc6f9324027e11af.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/examples/images/swamp.jpg b/examples/images/swamp.jpg
new file mode 100644
index 0000000..d7bdf3c
Binary files /dev/null and b/examples/images/swamp.jpg differ
diff --git a/examples/images/swamp.jpg.import b/examples/images/swamp.jpg.import
new file mode 100644
index 0000000..50879f2
--- /dev/null
+++ b/examples/images/swamp.jpg.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ckjb0agn5btv7"
+path="res://.godot/imported/swamp.jpg-1dfdcd52a5ef03d42a82a7f06acefa98.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://examples/images/swamp.jpg"
+dest_files=["res://.godot/imported/swamp.jpg-1dfdcd52a5ef03d42a82a7f06acefa98.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/examples/lowpass.gdshader b/examples/lowpass.gdshader
new file mode 100644
index 0000000..04d1dec
--- /dev/null
+++ b/examples/lowpass.gdshader
@@ -0,0 +1,9 @@
+shader_type canvas_item;
+
+const float threshold = 0.6;
+
+void fragment() {
+ vec4 tex = texture(TEXTURE , UV);
+ COLOR.rgb = min(tex.rgb, vec3(threshold));
+ COLOR.a = tex.a;
+}
diff --git a/examples/lowpass.gdshader.uid b/examples/lowpass.gdshader.uid
new file mode 100644
index 0000000..80b19c7
--- /dev/null
+++ b/examples/lowpass.gdshader.uid
@@ -0,0 +1 @@
+uid://dn02xsjm1kok8
diff --git a/examples/oklab.gdshader b/examples/oklab.gdshader
new file mode 100644
index 0000000..b01c285
--- /dev/null
+++ b/examples/oklab.gdshader
@@ -0,0 +1,10 @@
+shader_type canvas_item;
+
+#include "./shaderlib/oklab.gdshaderinc"
+
+void fragment() {
+ vec4 oklab = rgb2oklab(COLOR);
+ vec4 oklch = oklab2oklch(oklab);
+ oklch.z -= 2.0;
+ COLOR = oklab2rgb(oklch2oklab(oklch));
+}
diff --git a/examples/oklab.gdshader.uid b/examples/oklab.gdshader.uid
new file mode 100644
index 0000000..87cef6b
--- /dev/null
+++ b/examples/oklab.gdshader.uid
@@ -0,0 +1 @@
+uid://cu37y8lc0x83
diff --git a/examples/project.godot_ b/examples/project.godot_
new file mode 100644
index 0000000..5fc8c10
--- /dev/null
+++ b/examples/project.godot_
@@ -0,0 +1,15 @@
+; Engine configuration file.
+; It's best edited using the editor UI and not directly,
+; since the parameters that go here are not all obvious.
+;
+; Format:
+; [section] ; section goes between []
+; param=value ; assign values to parameters
+
+config_version=5
+
+[application]
+
+config/name="Pigment Project"
+run/main_scene="res://0_empty.tscn"
+config/features=PackedStringArray("4.4", "Forward Plus")
diff --git a/examples/shaderlib b/examples/shaderlib
new file mode 120000
index 0000000..dedec01
--- /dev/null
+++ b/examples/shaderlib
@@ -0,0 +1 @@
+../shaderlib
\ No newline at end of file
diff --git a/export_presets.cfg b/export_presets.cfg
new file mode 100644
index 0000000..de1503f
--- /dev/null
+++ b/export_presets.cfg
@@ -0,0 +1,45 @@
+[preset.0]
+
+name="Linux/X11"
+platform="Linux"
+runnable=true
+advanced_options=false
+dedicated_server=false
+custom_features=""
+export_filter="all_resources"
+include_filter=""
+exclude_filter="screenshot.png, examples/*, shaderlib/*, tools/*, build-template/*"
+export_path="dist/Pigment.x86_64"
+patches=PackedStringArray()
+encryption_include_filters=""
+encryption_exclude_filters=""
+seed=0
+encrypt_pck=false
+encrypt_directory=false
+script_export_mode=2
+
+[preset.0.options]
+
+custom_template/debug=""
+custom_template/release="./build-template/godot.linuxbsd.template_release.x86_64"
+debug/export_console_wrapper=1
+binary_format/embed_pck=true
+texture_format/s3tc_bptc=true
+texture_format/etc2_astc=false
+binary_format/architecture="x86_64"
+ssh_remote_deploy/enabled=false
+ssh_remote_deploy/host="user@host_ip"
+ssh_remote_deploy/port="22"
+ssh_remote_deploy/extra_args_ssh=""
+ssh_remote_deploy/extra_args_scp=""
+ssh_remote_deploy/run_script="#!/usr/bin/env bash
+export DISPLAY=:0
+unzip -o -q \"{temp_dir}/{archive_name}\" -d \"{temp_dir}\"
+\"{temp_dir}/{exe_name}\" {cmd_args}"
+ssh_remote_deploy/cleanup_script="#!/usr/bin/env bash
+kill $(pgrep -x -f \"{temp_dir}/{exe_name} {cmd_args}\")
+rm -rf \"{temp_dir}\""
+texture_format/bptc=true
+texture_format/s3tc=true
+texture_format/etc=false
+texture_format/etc2=false
diff --git a/project.godot b/project.godot
new file mode 100644
index 0000000..a47c8be
--- /dev/null
+++ b/project.godot
@@ -0,0 +1,72 @@
+; Engine configuration file.
+; It's best edited using the editor UI and not directly,
+; since the parameters that go here are not all obvious.
+;
+; Format:
+; [section] ; section goes between []
+; param=value ; assign values to parameters
+
+config_version=5
+
+[application]
+
+config/name="Pigment"
+config/version="v1.0"
+run/main_scene="res://src/scenes/main.tscn"
+config/features=PackedStringArray("4.5", "Mobile")
+config/icon="res://src/assets/icon.png"
+
+[autoload]
+
+Filesystem="*res://src/Filesystem.gd"
+
+[display]
+
+window/size/viewport_width=640
+window/size/viewport_height=672
+window/energy_saving/keep_screen_on=false
+window/subwindows/embed_subwindows=false
+
+[editor_plugins]
+
+enabled=PackedStringArray()
+
+[input]
+
+zoom_out={
+"deadzone": 0.5,
+"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":16,"position":Vector2(244, 15),"global_position":Vector2(248, 56),"factor":1.0,"button_index":5,"canceled":false,"pressed":true,"double_click":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":true,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":45,"physical_keycode":0,"key_label":0,"unicode":45,"location":0,"echo":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":true,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194435,"key_label":0,"unicode":45,"location":0,"echo":false,"script":null)
+]
+}
+zoom_in={
+"deadzone": 0.5,
+"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":8,"position":Vector2(270, 19),"global_position":Vector2(274, 60),"factor":1.0,"button_index":4,"canceled":false,"pressed":true,"double_click":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":true,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":43,"physical_keycode":0,"key_label":0,"unicode":43,"location":0,"echo":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":true,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194437,"key_label":0,"unicode":43,"location":0,"echo":false,"script":null)
+]
+}
+drag={
+"deadzone": 0.5,
+"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":1,"position":Vector2(183, 23),"global_position":Vector2(187, 64),"factor":1.0,"button_index":1,"canceled":false,"pressed":true,"double_click":false,"script":null)
+]
+}
+apply_shader={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194336,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+]
+}
+save_shader={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":true,"meta_pressed":false,"pressed":false,"keycode":83,"physical_keycode":0,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
+]
+}
+
+[rendering]
+
+renderer/rendering_method="mobile"
+textures/vram_compression/import_etc2_astc=true
+textures/lossless_compression/force_png=true
+shader_compiler/shader_cache/enabled=false
+environment/defaults/default_clear_color=Color(0.501961, 0.501961, 0.501961, 1)
diff --git a/screenshot.png b/screenshot.png
new file mode 100644
index 0000000..ce0ac8a
Binary files /dev/null and b/screenshot.png differ
diff --git a/shaderlib/common.gdshaderinc b/shaderlib/common.gdshaderinc
new file mode 100644
index 0000000..7ee57f8
--- /dev/null
+++ b/shaderlib/common.gdshaderinc
@@ -0,0 +1,5 @@
+
+// inefficient cuberoot function
+float cbrt(float x) {
+ return pow(x, 1.0/3.0);
+}
diff --git a/shaderlib/common.gdshaderinc.uid b/shaderlib/common.gdshaderinc.uid
new file mode 100644
index 0000000..f43430d
--- /dev/null
+++ b/shaderlib/common.gdshaderinc.uid
@@ -0,0 +1 @@
+uid://764b6ekchgb8
diff --git a/shaderlib/hsv.gdshaderinc b/shaderlib/hsv.gdshaderinc
new file mode 100644
index 0000000..2a7834d
--- /dev/null
+++ b/shaderlib/hsv.gdshaderinc
@@ -0,0 +1,27 @@
+
+/*
+ rgb2hsv and hsv2rgb functions adapted
+ from https://godotshaders.com/shader/hsv-adjustment/
+ original code by https://godotshaders.com/author/al1-ce/
+
+ Color space conversion functions always work with vec4.
+ The fourth value is always alpha.
+*/
+
+// Convert RGB to HSV (hue, saturation, brightness)
+vec4 rgb2hsv(vec4 c) {
+ vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
+ vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
+ vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
+ float d = q.x - min(q.w, q.y);
+ float e = 1.0e-10;
+ return vec4(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x, c.a);
+}
+
+// Convert HSV back to RGB (red, green, blue)
+vec4 hsv2rgb(vec4 c) {
+ vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
+ vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
+ vec3 rgb = c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
+ return vec4(rgb.r, rgb.g, rgb.b, c.a);
+}
diff --git a/shaderlib/hsv.gdshaderinc.uid b/shaderlib/hsv.gdshaderinc.uid
new file mode 100644
index 0000000..771cc38
--- /dev/null
+++ b/shaderlib/hsv.gdshaderinc.uid
@@ -0,0 +1 @@
+uid://bbr3tq6mp5qa2
diff --git a/shaderlib/oklab.gdshaderinc b/shaderlib/oklab.gdshaderinc
new file mode 100644
index 0000000..5a456c7
--- /dev/null
+++ b/shaderlib/oklab.gdshaderinc
@@ -0,0 +1,70 @@
+
+/*
+ OkLab and OkLCh
+ For more details on oklab, see
+ - https://bottosson.github.io/posts/oklab/
+ - https://en.wikipedia.org/wiki/Oklab_color_space
+
+ Color space conversion functions always work with vec4.
+ The fourth value is always alpha.
+*/
+
+#include "./common.gdshaderinc"
+
+vec4 rgb2oklab(vec4 c) {
+ // oklab.x and .y (a and b) should range from -0.5 to 0.5
+
+ float l = 0.4122214708f * c.r + 0.5363325363f * c.g + 0.0514459929f * c.b;
+ float m = 0.2119034982f * c.r + 0.6806995451f * c.g + 0.1073969566f * c.b;
+ float s = 0.0883024619f * c.r + 0.2817188376f * c.g + 0.6299787005f * c.b;
+
+ float l_ = cbrt(l);
+ float m_ = cbrt(m);
+ float s_ = cbrt(s);
+
+ return vec4(
+ 0.2104542553f*l_ + 0.7936177850f*m_ - 0.0040720468f*s_,
+ 1.9779984951f*l_ - 2.4285922050f*m_ + 0.4505937099f*s_,
+ 0.0259040371f*l_ + 0.7827717662f*m_ - 0.8086757660f*s_,
+ c.a
+ );
+}
+
+vec4 oklab2rgb(vec4 c) {
+ // oklab.x and .y (a and b) should range from -0.5 to 0.5
+
+ float l_ = c.x + 0.3963377774f * c.y + 0.2158037573f * c.z;
+ float m_ = c.x - 0.1055613458f * c.y - 0.0638541728f * c.z;
+ float s_ = c.x - 0.0894841775f * c.y - 1.2914855480f * c.z;
+
+ float l = l_*l_*l_;
+ float m = m_*m_*m_;
+ float s = s_*s_*s_;
+
+ return vec4(
+ +4.0767416621f * l - 3.3077115913f * m + 0.2309699292f * s,
+ -1.2684380046f * l + 2.6097574011f * m - 0.3413193965f * s,
+ -0.0041960863f * l - 0.7034186147f * m + 1.7076147010f * s,
+ c.a
+ );
+}
+
+vec4 oklab2oklch(vec4 c) {
+ // oklch.z (hue) ranges from -3.6 to 3.6
+ return vec4(
+ c.x,
+ sqrt((c.y * c.y) + (c.z * c.z)),
+ atan(c.z, c.y),
+ c.a
+ );
+}
+
+vec4 oklch2oklab(vec4 c) {
+ // oklch.z (hue) ranges from -3.6 to 3.6
+ return vec4(
+ c.x,
+ c.y * cos(c.z),
+ c.y * sin(c.z),
+ c.a
+ );
+}
diff --git a/shaderlib/oklab.gdshaderinc.uid b/shaderlib/oklab.gdshaderinc.uid
new file mode 100644
index 0000000..7f0bf5f
--- /dev/null
+++ b/shaderlib/oklab.gdshaderinc.uid
@@ -0,0 +1 @@
+uid://ckw4nfslk4m6l
diff --git a/src/Camera.gd b/src/Camera.gd
new file mode 100644
index 0000000..416e035
--- /dev/null
+++ b/src/Camera.gd
@@ -0,0 +1,50 @@
+extends Camera2D
+
+@onready var image_viewport_display = %ImageViewportDisplay
+
+var drag = false
+
+func _input(event):
+ if event.is_action_pressed("zoom_out"):
+ zoom_out()
+ elif event.is_action_pressed("zoom_in"):
+ zoom_in()
+ if event.is_action_pressed("drag"):
+ self.drag = true
+ elif event.is_action_released("drag"):
+ self.drag = false
+ if self.drag && event is InputEventMouseMotion:
+ self.global_position -= event.relative / self.zoom
+
+var old_zoom = self.zoom
+
+func _process(_delta: float) -> void:
+ if self.zoom != old_zoom:
+ image_viewport_display.update_zoom_texture_filter(self.zoom)
+ image_viewport_display.material.set_shader_parameter("zoom_level", self.zoom)
+ old_zoom = self.zoom
+
+func fit_image():
+ if Filesystem.original_image != null:
+ var image_size = Filesystem.original_image.get_size()
+ var viewport_size = get_viewport_rect().size
+ var zoomf = 1.0
+ if viewport_size.x / image_size.x * image_size.y > viewport_size.y:
+ zoomf = viewport_size.y / image_size.y / 1.25
+ else:
+ zoomf = viewport_size.x / image_size.x / 1.2
+ self.zoom = Vector2(zoomf, zoomf)
+ self.global_position = Vector2(0, 0)
+
+func zoom_in():
+ var old_mouse_pos = get_global_mouse_position()
+ self.zoom *= 1.2
+ self.global_position += old_mouse_pos - get_global_mouse_position()
+
+func zoom_out():
+ var old_mouse_pos = get_global_mouse_position()
+ self.zoom *= 1/1.2
+ self.global_position += old_mouse_pos - get_global_mouse_position()
+
+func _on_fit_image_button_pressed():
+ fit_image()
diff --git a/src/Camera.gd.uid b/src/Camera.gd.uid
new file mode 100644
index 0000000..f25b715
--- /dev/null
+++ b/src/Camera.gd.uid
@@ -0,0 +1 @@
+uid://b6r8rigubdctk
diff --git a/src/Filesystem.gd b/src/Filesystem.gd
new file mode 100644
index 0000000..07911a1
--- /dev/null
+++ b/src/Filesystem.gd
@@ -0,0 +1,52 @@
+extends Node
+
+var cwd = "."
+
+var shader_path = "":
+ get():
+ return shader_path
+ set(v):
+ shader_path = v
+ if "/" in v: # update current working directory
+ cwd = v.substr(0, v.rfind("/"))
+
+var shader: Shader:
+ get():
+ if shader_path == "":
+ return null
+ print("Load ", shader_path)
+ return load(shader_path)
+
+var image_path = ""
+
+var original_image: ImageTexture
+var result: Image
+
+var last_image_savepath = ""
+var last_original_image_path = ""
+
+func get_absolute_path(p: String) -> String:
+ # this only works on Linux!
+ if !p.begins_with("/"):
+ return self.cwd + "/" + p.lstrip("./")
+ return p
+
+func load_image() -> String: # returns an error message
+ print("Load ", image_path)
+ var img = Image.new()
+ var err = img.load(image_path)
+ if err == OK:
+ original_image = ImageTexture.create_from_image(img)
+ if self.last_image_savepath == "" or image_path != self.last_original_image_path:
+ self.last_image_savepath = image_path
+ self.last_original_image_path = image_path
+ return ""
+ return error_string(err) + " " + image_path
+
+func save_result(path: String):
+ print("Export ", path)
+ var err = self.result.save_png(path)
+ if err != OK:
+ print("An error occured!")
+ else:
+ self.last_image_savepath = path
diff --git a/src/Filesystem.gd.uid b/src/Filesystem.gd.uid
new file mode 100644
index 0000000..a89dcab
--- /dev/null
+++ b/src/Filesystem.gd.uid
@@ -0,0 +1 @@
+uid://rlb041ygdwol
diff --git a/src/ImageCompositor.gd b/src/ImageCompositor.gd
new file mode 100644
index 0000000..8ffd699
--- /dev/null
+++ b/src/ImageCompositor.gd
@@ -0,0 +1,90 @@
+class_name ImageCompositor extends SubViewport
+
+var image_sprite: Sprite2D
+
+func _init() -> void:
+ # Overwrite some variables
+ self.render_target_update_mode = SubViewport.UPDATE_ALWAYS
+ self.disable_3d = true
+ self.transparent_bg = true
+ self.canvas_item_default_texture_filter = Viewport.DEFAULT_CANVAS_ITEM_TEXTURE_FILTER_NEAREST
+ self.image_sprite = Sprite2D.new()
+
+@onready var camera = %Camera
+@onready var image_viewport_display = %ImageViewportDisplay
+
+func _ready() -> void:
+ # Add image sprite as child to be rendered
+ self.add_child(image_sprite)
+
+var _fragment_function_regex: RegEx = RegEx.create_from_string(r'\s*void\s+fragment\s*\(\s*\)\s*{\s*')
+
+func validate_shader_compilation(shader: Shader) -> bool:
+ # Inject code to validate shader compilation
+ var shader_code = shader.code;
+ # -> get position of fragment shader
+ var fragment_function_match = _fragment_function_regex.search(shader.code)
+ if fragment_function_match == null:
+ return false
+ # -> inject uniform
+ var uniform_name = "shader_compilation_validate_" + str(randi_range(999999999, 100000000))
+ var uniform_code_line = "\nuniform bool " + uniform_name + ";\n"
+ shader_code = shader_code.insert(fragment_function_match.get_start(), uniform_code_line)
+ # -> inject variable access to prevent that the uniform gets optimized away
+ shader_code = shader_code.insert(fragment_function_match.get_end() + len(uniform_code_line), "\n" + uniform_name + ";\n")
+ # apply shader code
+ shader.code = shader_code
+ # test if uniform list is empty -> if it is empty, the shader compilation failed
+ return len(shader.get_shader_uniform_list()) > 0
+
+func shader_has_uniform(shader: Shader, var_name: String, type: int) -> bool:
+ for u in shader.get_shader_uniform_list():
+ if u["name"] == var_name && u["type"] == type:
+ return true
+ return false
+
+func set_vsync(enabled: bool):
+ if enabled:
+ DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_ENABLED)
+ else:
+ DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)
+
+func update() -> Array: # returns error messages (strings)
+ if Filesystem.image_path == "":
+ return ["No image loaded!"]
+ var shader = Filesystem.shader # read from disk
+ if shader == null:
+ return ["No shader opened!"]
+ # validate shader
+ if not validate_shader_compilation(shader):
+ return ["Shader compilation failed!"]
+ var errors = []
+ # load texture
+ var fit_image = Filesystem.image_path != Filesystem.last_original_image_path
+ var err = Filesystem.load_image()
+ if err != "":
+ errors.append(err)
+ image_viewport_display.hide()
+ return errors
+ # apply texture
+ image_sprite.texture = Filesystem.original_image
+ image_sprite.offset = Filesystem.original_image.get_size() / 2
+ self.size = Filesystem.original_image.get_size()
+ # show the image viewport & fit the image
+ if fit_image: camera.fit_image()
+ image_viewport_display.show()
+ # create shader material
+ var mat = ShaderMaterial.new()
+ mat.shader = shader
+ # assign material
+ image_sprite.material = mat
+ # iterate n times
+ set_vsync(false) # speed up processing
+ # Get viewport texture
+ await RenderingServer.frame_post_draw # wait for next frame to get drawn
+ Filesystem.result = get_texture().get_image()
+ image_sprite.texture = ImageTexture.create_from_image(Filesystem.result)
+ set_vsync(true) # reenable vsync
+ image_sprite.material = null
+ # done
+ return errors
diff --git a/src/ImageCompositor.gd.uid b/src/ImageCompositor.gd.uid
new file mode 100644
index 0000000..2e55fae
--- /dev/null
+++ b/src/ImageCompositor.gd.uid
@@ -0,0 +1 @@
+uid://d106170kuigl3
diff --git a/src/ImageViewportDisplay.gd b/src/ImageViewportDisplay.gd
new file mode 100644
index 0000000..7a9c827
--- /dev/null
+++ b/src/ImageViewportDisplay.gd
@@ -0,0 +1,10 @@
+extends Sprite2D
+
+func _ready() -> void:
+ hide()
+
+func update_zoom_texture_filter(zoom: Vector2):
+ if zoom.x >= 1.5:
+ texture_filter = TEXTURE_FILTER_NEAREST_WITH_MIPMAPS
+ else:
+ texture_filter = TEXTURE_FILTER_LINEAR
diff --git a/src/ImageViewportDisplay.gd.uid b/src/ImageViewportDisplay.gd.uid
new file mode 100644
index 0000000..dbde761
--- /dev/null
+++ b/src/ImageViewportDisplay.gd.uid
@@ -0,0 +1 @@
+uid://ctc4lhbdsoq7u
diff --git a/src/Main.gd b/src/Main.gd
new file mode 100644
index 0000000..80d85ba
--- /dev/null
+++ b/src/Main.gd
@@ -0,0 +1,121 @@
+extends Node
+
+const BATCH_MODE_SUPPORTED_EXTS = [
+ ".bmp", ".dds", ".exr", ".hdr", ".jpeg", ".jpg", ".ktx", ".png", ".svg", ".webp"
+]
+
+@onready var app_name = ProjectSettings.get_setting("application/config/name")
+
+func show_help():
+ print(
+ "Usage:\n\n",
+ "./Pigment \n\n",
+ "Commands:\n\n",
+ " help\n\n",
+ " | Shows this help text.\n\n",
+ " apply --shader PATH [--load-image PATH]\n\n",
+ " | Applies a shader file.\n\n",
+ " --shader PATH The path to the shader\n",
+ " --input PATH The path to the image.\n",
+ " Passing a folder activates batch mode.\n",
+ " --output PATH Where to write the resulting image to.\n",
+ " In batch mode, this must be a folder.\n")
+
+func parse_custom_cmdline(args: PackedStringArray):
+ var kwargs: Dictionary = {"--shader": null, "--output": null, "--input": null}
+ var args_len = args.size()
+ var i = 0
+ while i < args_len:
+ var a = args[i]
+ if a in kwargs && args_len > i+1:
+ i += 1
+ kwargs[a] = args[i]
+ i += 1
+ return kwargs
+
+func cli_handle_errors(errors: Array) -> int:
+ # returns number of errors
+ var n_errors = errors.size()
+ if n_errors > 0:
+ print("One or more errors occurred.")
+ for e in errors:
+ printerr(e)
+ return n_errors
+
+func cli(args: PackedStringArray):
+ print(
+ "~ Pigment CLI ~\n",
+ "-=============-\n")
+ if "help" in args:
+ show_help()
+ get_tree().quit(1)
+ return
+ var kwargs: Dictionary = parse_custom_cmdline(args)
+ if kwargs["--shader"] == null or kwargs["--output"] == null:
+ show_help()
+ get_tree().quit(1)
+ return
+ var batch_mode = false
+ var load_image_dir: DirAccess
+ if kwargs["--input"] != null:
+ load_image_dir = DirAccess.open(kwargs["--input"])
+ if load_image_dir != null:
+ # batch mode
+ if DirAccess.open(kwargs["--output"]) == null:
+ printerr("If --input is a directory, --output has to be one too.\n")
+ show_help()
+ get_tree().quit(1)
+ return
+ else:
+ batch_mode = true
+ #
+ Filesystem.shader_path = kwargs["--shader"]
+ #
+ if batch_mode:
+ var in_dir_path = load_image_dir.get_current_dir()
+ var out_dir_path: String = kwargs["--output"].rstrip("/")
+ for f in load_image_dir.get_files():
+ var supported = false
+ for e in BATCH_MODE_SUPPORTED_EXTS:
+ if f.ends_with(e):
+ supported = true
+ break
+ if supported:
+ f = in_dir_path + "/" + f
+ print(f)
+ var errors = await $Compositor.update(f)
+ if cli_handle_errors(errors) == 0:
+ var filename = out_dir_path + "/" + f.substr(f.rfind("/"), -1)
+ Filesystem.save_result(filename)
+ else:
+ get_tree().quit(1)
+ return
+ get_tree().quit(0)
+ else:
+ var errors = []
+ if kwargs["--input"] == null:
+ errors = await $Compositor.update()
+ else:
+ errors = await $Compositor.update(kwargs["--input"])
+ if cli_handle_errors(errors) == 0:
+ Filesystem.save_result(kwargs["--output"])
+ get_tree().quit(0)
+ else:
+ get_tree().quit(1)
+
+func prepare_gui():
+ update_title()
+
+func _ready():
+ var args = OS.get_cmdline_args()
+ if len(args) > 0 and args[0] in ["apply", "help"]:
+ # use the commandline interface
+ cli(args)
+ else:
+ prepare_gui()
+
+func update_title(current_file: String = ""):
+ if current_file == "":
+ get_window().title = app_name + " - Viewer"
+ else:
+ get_window().title = current_file + " - " + app_name + " - Viewer"
diff --git a/src/Main.gd.uid b/src/Main.gd.uid
new file mode 100644
index 0000000..2f9a508
--- /dev/null
+++ b/src/Main.gd.uid
@@ -0,0 +1 @@
+uid://5sbslwysin5a
diff --git a/src/MainUI.gd b/src/MainUI.gd
new file mode 100644
index 0000000..c0dfa79
--- /dev/null
+++ b/src/MainUI.gd
@@ -0,0 +1,120 @@
+extends Control
+
+@onready var open_shader_dialog = %OpenShaderDialog
+@onready var open_image_dialog = %OpenImageDialog
+@onready var save_image_dialog = %SaveImageDialog
+
+@onready var open_shader_button = %OpenShaderButton
+@onready var open_image_button = %OpenImageButton
+@onready var save_image_button = %SaveImageButton
+@onready var fit_image_button = %FitImageButton
+@onready var apply_shader_button = %ApplyShaderButton
+
+@onready var status_indicator = %StatusIndicator
+@onready var error_msg_dialog = %ErrorMessageDialog
+
+@onready var main = get_tree().root.get_node("Main")
+@onready var compositor = %Compositor
+@onready var camera = %Camera
+
+var status_okay_texture: CompressedTexture2D = preload("uid://m1omb6g45vst")
+var status_error_texture: CompressedTexture2D = preload("uid://04iv1gogpuhu")
+
+enum Status {OKAY, ERROR, UNKNOWN = -1}
+
+#
+
+func _input(event):
+ if event.is_action_pressed("apply_shader"):
+ _on_apply_shader_button_pressed()
+ elif event.is_action_pressed("save_shader"):
+ accept_event() # Event is now handled.
+
+#
+
+func set_buttons_disabled(disabled: bool):
+ for b in [open_shader_button, open_image_button, save_image_button, fit_image_button, apply_shader_button, status_indicator]:
+ b.disabled = disabled
+
+#
+
+func _on_open_shader_button_pressed():
+ set_buttons_disabled(true)
+ open_shader_dialog.show()
+
+func _on_open_image_button_pressed():
+ set_buttons_disabled(true)
+ open_image_dialog.show()
+
+func _on_fit_image_button_pressed():
+ camera.fit_image()
+
+func _on_apply_shader_button_pressed():
+ set_buttons_disabled(true)
+ var errors = await compositor.update()
+ set_buttons_disabled(false)
+ if len(errors) > 0:
+ update_status(Status.ERROR, "\n".join(errors))
+ else:
+ update_status(Status.OKAY)
+ status_indicator.disabled = true
+
+func _on_save_image_button_pressed():
+ if Filesystem.result != null:
+ set_buttons_disabled(true)
+ save_image_dialog.current_path = Filesystem.last_image_savepath
+ save_image_dialog.show()
+
+#
+
+func _on_open_shader_dialog_file_selected(path: String):
+ Filesystem.shader_path = path
+ main.update_title(path.split("/")[-1])
+ self._on_apply_shader_button_pressed()
+
+func _on_open_shader_dialog_canceled() -> void:
+ set_buttons_disabled(false)
+
+func _on_open_shader_dialog_confirmed() -> void:
+ set_buttons_disabled(false)
+
+
+func _on_open_image_dialog_file_selected(path: String) -> void:
+ Filesystem.image_path = path
+ self._on_apply_shader_button_pressed()
+
+func _on_open_image_dialog_canceled() -> void:
+ set_buttons_disabled(false)
+
+func _on_open_image_dialog_confirmed() -> void:
+ set_buttons_disabled(false)
+
+
+func _on_save_image_dialog_file_selected(path):
+ Filesystem.save_result(path)
+ set_buttons_disabled(false)
+
+func _on_save_image_dialog_canceled() -> void:
+ set_buttons_disabled(false)
+
+func _on_save_image_dialog_confirmed() -> void:
+ set_buttons_disabled(false)
+
+#
+
+func update_status(status: Status, msg: String = ""):
+ error_msg_dialog.dialog_text = msg
+ error_msg_dialog.reset_size()
+ if status == Status.OKAY:
+ status_indicator.texture_normal = status_okay_texture
+ elif status == Status.ERROR:
+ status_indicator.texture_normal = status_error_texture
+ else:
+ status_indicator.texture_normal = null
+ if msg == "":
+ status_indicator.disabled = true
+ else:
+ status_indicator.disabled = false
+
+func _on_status_indicator_pressed() -> void:
+ error_msg_dialog.show()
diff --git a/src/MainUI.gd.uid b/src/MainUI.gd.uid
new file mode 100644
index 0000000..0077ccd
--- /dev/null
+++ b/src/MainUI.gd.uid
@@ -0,0 +1 @@
+uid://bxgmf2ny7yuc8
diff --git a/src/VersionLabel.gd b/src/VersionLabel.gd
new file mode 100644
index 0000000..c3c07e7
--- /dev/null
+++ b/src/VersionLabel.gd
@@ -0,0 +1,8 @@
+extends Label
+
+func _ready():
+ text = ProjectSettings.get_setting("application/config/name") \
+ + " " \
+ + ProjectSettings.get_setting("application/config/version") \
+ + " | Godot " \
+ + Engine.get_version_info()["string"]
diff --git a/src/VersionLabel.gd.uid b/src/VersionLabel.gd.uid
new file mode 100644
index 0000000..e731c41
--- /dev/null
+++ b/src/VersionLabel.gd.uid
@@ -0,0 +1 @@
+uid://bh0gpu3i2p47f
diff --git a/src/assets/bg.png b/src/assets/bg.png
new file mode 100644
index 0000000..fb67c19
Binary files /dev/null and b/src/assets/bg.png differ
diff --git a/src/assets/bg.png.import b/src/assets/bg.png.import
new file mode 100644
index 0000000..aeaccf6
--- /dev/null
+++ b/src/assets/bg.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://d2nwchyd6huob"
+path="res://.godot/imported/bg.png-7c8713dd1fab321784216191fa747e53.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://src/assets/bg.png"
+dest_files=["res://.godot/imported/bg.png-7c8713dd1fab321784216191fa747e53.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/src/assets/error.svg b/src/assets/error.svg
new file mode 100644
index 0000000..af2f066
--- /dev/null
+++ b/src/assets/error.svg
@@ -0,0 +1,80 @@
+
+
+
+
diff --git a/src/assets/error.svg.import b/src/assets/error.svg.import
new file mode 100644
index 0000000..0fe0b4f
--- /dev/null
+++ b/src/assets/error.svg.import
@@ -0,0 +1,43 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://04iv1gogpuhu"
+path="res://.godot/imported/error.svg-28fb29635cf59d39cabf7052619f602f.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://src/assets/error.svg"
+dest_files=["res://.godot/imported/error.svg-28fb29635cf59d39cabf7052619f602f.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=2.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/src/assets/icon.png b/src/assets/icon.png
new file mode 100644
index 0000000..fb1fe94
Binary files /dev/null and b/src/assets/icon.png differ
diff --git a/src/assets/icon.png.import b/src/assets/icon.png.import
new file mode 100644
index 0000000..3a27dab
--- /dev/null
+++ b/src/assets/icon.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://kqwc4avs2xdp"
+path="res://.godot/imported/icon.png-d8298ab6eda392a806be6bb7eec65b9c.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://src/assets/icon.png"
+dest_files=["res://.godot/imported/icon.png-d8298ab6eda392a806be6bb7eec65b9c.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/src/assets/okay.svg b/src/assets/okay.svg
new file mode 100644
index 0000000..5668fe8
--- /dev/null
+++ b/src/assets/okay.svg
@@ -0,0 +1,81 @@
+
+
+
+
diff --git a/src/assets/okay.svg.import b/src/assets/okay.svg.import
new file mode 100644
index 0000000..8822478
--- /dev/null
+++ b/src/assets/okay.svg.import
@@ -0,0 +1,43 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://m1omb6g45vst"
+path="res://.godot/imported/okay.svg-de66a022ef37753b085371b7c60aefd1.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://src/assets/okay.svg"
+dest_files=["res://.godot/imported/okay.svg-de66a022ef37753b085371b7c60aefd1.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=2.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/src/scenes/main.tscn b/src/scenes/main.tscn
new file mode 100644
index 0000000..df52a66
--- /dev/null
+++ b/src/scenes/main.tscn
@@ -0,0 +1,219 @@
+[gd_scene load_steps=13 format=3 uid="uid://bjah7k4bxo044"]
+
+[ext_resource type="Script" uid="uid://5sbslwysin5a" path="res://src/Main.gd" id="1_64y3g"]
+[ext_resource type="Script" uid="uid://d106170kuigl3" path="res://src/ImageCompositor.gd" id="2_4ykh7"]
+[ext_resource type="Shader" uid="uid://ctk7jomfyx0fh" path="res://src/shader/ivd_outline.gdshader" id="3_0fllm"]
+[ext_resource type="Script" uid="uid://ctc4lhbdsoq7u" path="res://src/ImageViewportDisplay.gd" id="4_pbpx2"]
+[ext_resource type="Script" uid="uid://b6r8rigubdctk" path="res://src/Camera.gd" id="5_hkdq6"]
+[ext_resource type="Texture2D" uid="uid://d2nwchyd6huob" path="res://src/assets/bg.png" id="6_kokaf"]
+[ext_resource type="Theme" uid="uid://cwqlns34rj3vx" path="res://src/theme.tres" id="6_rjp5f"]
+[ext_resource type="Script" uid="uid://bxgmf2ny7yuc8" path="res://src/MainUI.gd" id="7_5puhk"]
+[ext_resource type="Script" uid="uid://bh0gpu3i2p47f" path="res://src/VersionLabel.gd" id="8_kod8x"]
+
+[sub_resource type="ShaderMaterial" id="ShaderMaterial_y2ea0"]
+shader = ExtResource("3_0fllm")
+shader_parameter/zoom_level = Vector2(1, 1)
+
+[sub_resource type="ViewportTexture" id="ViewportTexture_lct1c"]
+viewport_path = NodePath("Compositor")
+
+[sub_resource type="LabelSettings" id="LabelSettings_6o860"]
+font_size = 12
+shadow_color = Color(0, 0, 0, 1)
+
+[node name="Main" type="Node2D"]
+script = ExtResource("1_64y3g")
+
+[node name="Compositor" type="SubViewport" parent="."]
+unique_name_in_owner = true
+script = ExtResource("2_4ykh7")
+
+[node name="ImageViewportDisplay" type="Sprite2D" parent="."]
+unique_name_in_owner = true
+material = SubResource("ShaderMaterial_y2ea0")
+texture = SubResource("ViewportTexture_lct1c")
+script = ExtResource("4_pbpx2")
+
+[node name="Camera" type="Camera2D" parent="."]
+unique_name_in_owner = true
+offset = Vector2(0, -64)
+script = ExtResource("5_hkdq6")
+
+[node name="CanvasLayerBg" type="CanvasLayer" parent="."]
+layer = -1
+
+[node name="Control" type="Control" parent="CanvasLayerBg"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="TextureRect" type="TextureRect" parent="CanvasLayerBg/Control"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+texture = ExtResource("6_kokaf")
+stretch_mode = 1
+
+[node name="CanvasLayer" type="CanvasLayer" parent="."]
+
+[node name="MainUI" type="Control" parent="CanvasLayer"]
+unique_name_in_owner = true
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+pivot_offset = Vector2(320, 320)
+theme = ExtResource("6_rjp5f")
+script = ExtResource("7_5puhk")
+
+[node name="OpenShaderDialog" type="FileDialog" parent="CanvasLayer/MainUI"]
+unique_name_in_owner = true
+auto_translate_mode = 1
+title = "Load Shader"
+size = Vector2i(521, 175)
+ok_button_text = "Open"
+mode_overrides_title = false
+file_mode = 0
+access = 2
+filters = PackedStringArray("*.gdshader")
+use_native_dialog = true
+
+[node name="OpenImageDialog" type="FileDialog" parent="CanvasLayer/MainUI"]
+unique_name_in_owner = true
+auto_translate_mode = 1
+title = "Load Image"
+size = Vector2i(521, 175)
+ok_button_text = "Open"
+mode_overrides_title = false
+file_mode = 0
+access = 2
+filters = PackedStringArray("*.png", "*.jpg", "*.jpeg")
+use_native_dialog = true
+
+[node name="SaveImageDialog" type="FileDialog" parent="CanvasLayer/MainUI"]
+unique_name_in_owner = true
+auto_translate_mode = 1
+title = "Export Image"
+size = Vector2i(661, 175)
+mode_overrides_title = false
+access = 2
+filters = PackedStringArray("*.png")
+use_native_dialog = true
+
+[node name="ErrorMessageDialog" type="AcceptDialog" parent="CanvasLayer/MainUI"]
+unique_name_in_owner = true
+auto_translate_mode = 1
+title = "Status"
+initial_position = 2
+size = Vector2i(256, 128)
+popup_window = true
+ok_button_text = "Close"
+
+[node name="OpenShaderButton" type="Button" parent="CanvasLayer/MainUI"]
+unique_name_in_owner = true
+layout_mode = 1
+offset_left = 16.0
+offset_top = 16.0
+offset_right = 128.0
+offset_bottom = 48.0
+text = "Open Shader"
+
+[node name="OpenImageButton" type="Button" parent="CanvasLayer/MainUI"]
+unique_name_in_owner = true
+layout_mode = 1
+offset_left = 16.0
+offset_top = 56.0
+offset_right = 128.0
+offset_bottom = 88.0
+text = "Open Image"
+
+[node name="SaveImageButton" type="Button" parent="CanvasLayer/MainUI"]
+unique_name_in_owner = true
+layout_mode = 1
+offset_left = 144.0
+offset_top = 16.0
+offset_right = 216.0
+offset_bottom = 48.0
+disabled = true
+text = "Export"
+
+[node name="FitImageButton" type="Button" parent="CanvasLayer/MainUI"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 1
+anchor_left = 1.0
+anchor_right = 1.0
+offset_left = -176.0
+offset_top = 16.0
+offset_right = -128.0
+offset_bottom = 48.0
+grow_horizontal = 0
+text = "Fit"
+
+[node name="ApplyShaderButton" type="Button" parent="CanvasLayer/MainUI"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 1
+anchor_left = 1.0
+anchor_right = 1.0
+offset_left = -112.0
+offset_top = 16.0
+offset_right = -16.0
+offset_bottom = 48.0
+grow_horizontal = 0
+text = "Apply (F5)"
+
+[node name="StatusIndicator" type="TextureButton" parent="CanvasLayer/MainUI"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 1
+anchor_left = 1.0
+anchor_right = 1.0
+offset_left = -220.0
+offset_top = 21.0
+offset_right = -196.0
+offset_bottom = 45.0
+grow_horizontal = 0
+disabled = true
+ignore_texture_size = true
+stretch_mode = 0
+
+[node name="VersionLabel" type="Label" parent="CanvasLayer/MainUI"]
+layout_mode = 1
+anchors_preset = 12
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 16.0
+offset_top = -24.0
+offset_right = -16.0
+grow_horizontal = 2
+grow_vertical = 0
+label_settings = SubResource("LabelSettings_6o860")
+horizontal_alignment = 1
+vertical_alignment = 1
+script = ExtResource("8_kod8x")
+
+[connection signal="canceled" from="CanvasLayer/MainUI/OpenShaderDialog" to="CanvasLayer/MainUI" method="_on_open_shader_dialog_canceled"]
+[connection signal="confirmed" from="CanvasLayer/MainUI/OpenShaderDialog" to="CanvasLayer/MainUI" method="_on_open_shader_dialog_confirmed"]
+[connection signal="file_selected" from="CanvasLayer/MainUI/OpenShaderDialog" to="CanvasLayer/MainUI" method="_on_open_shader_dialog_file_selected"]
+[connection signal="canceled" from="CanvasLayer/MainUI/OpenImageDialog" to="CanvasLayer/MainUI" method="_on_open_image_dialog_canceled"]
+[connection signal="confirmed" from="CanvasLayer/MainUI/OpenImageDialog" to="CanvasLayer/MainUI" method="_on_open_image_dialog_confirmed"]
+[connection signal="file_selected" from="CanvasLayer/MainUI/OpenImageDialog" to="CanvasLayer/MainUI" method="_on_open_image_dialog_file_selected"]
+[connection signal="canceled" from="CanvasLayer/MainUI/SaveImageDialog" to="CanvasLayer/MainUI" method="_on_save_image_dialog_canceled"]
+[connection signal="confirmed" from="CanvasLayer/MainUI/SaveImageDialog" to="CanvasLayer/MainUI" method="_on_save_image_dialog_confirmed"]
+[connection signal="file_selected" from="CanvasLayer/MainUI/SaveImageDialog" to="CanvasLayer/MainUI" method="_on_save_image_dialog_file_selected"]
+[connection signal="pressed" from="CanvasLayer/MainUI/OpenShaderButton" to="CanvasLayer/MainUI" method="_on_open_shader_button_pressed"]
+[connection signal="pressed" from="CanvasLayer/MainUI/OpenImageButton" to="CanvasLayer/MainUI" method="_on_open_image_button_pressed"]
+[connection signal="pressed" from="CanvasLayer/MainUI/SaveImageButton" to="CanvasLayer/MainUI" method="_on_save_image_button_pressed"]
+[connection signal="pressed" from="CanvasLayer/MainUI/FitImageButton" to="CanvasLayer/MainUI" method="_on_fit_image_button_pressed"]
+[connection signal="pressed" from="CanvasLayer/MainUI/ApplyShaderButton" to="CanvasLayer/MainUI" method="_on_apply_shader_button_pressed"]
+[connection signal="pressed" from="CanvasLayer/MainUI/StatusIndicator" to="CanvasLayer/MainUI" method="_on_status_indicator_pressed"]
diff --git a/src/shader/ivd_outline.gdshader b/src/shader/ivd_outline.gdshader
new file mode 100644
index 0000000..78bfe77
--- /dev/null
+++ b/src/shader/ivd_outline.gdshader
@@ -0,0 +1,16 @@
+shader_type canvas_item;
+
+uniform vec2 zoom_level = vec2(1.0);
+const float thickness = 3.0;
+
+void fragment() {
+ vec2 t = thickness * TEXTURE_PIXEL_SIZE / zoom_level;
+ if (
+ UV.x < t.x ||
+ UV.y < t.y ||
+ UV.x > 1.0-t.x ||
+ UV.y > 1.0-t.y
+ ) {
+ COLOR = mix(COLOR, vec4(0.5), 0.5);
+ }
+}
diff --git a/src/shader/ivd_outline.gdshader.uid b/src/shader/ivd_outline.gdshader.uid
new file mode 100644
index 0000000..b42ac31
--- /dev/null
+++ b/src/shader/ivd_outline.gdshader.uid
@@ -0,0 +1 @@
+uid://ctk7jomfyx0fh
diff --git a/src/theme.tres b/src/theme.tres
new file mode 100644
index 0000000..782d3e9
--- /dev/null
+++ b/src/theme.tres
@@ -0,0 +1,57 @@
+[gd_resource type="Theme" load_steps=4 format=3 uid="uid://cwqlns34rj3vx"]
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bm5o2"]
+content_margin_left = 4.0
+content_margin_top = 4.0
+content_margin_right = 4.0
+content_margin_bottom = 4.0
+bg_color = Color(0.1, 0.1, 0.1, 0.3)
+border_width_left = 1
+border_width_top = 1
+border_width_right = 1
+border_width_bottom = 1
+border_color = Color(1, 1, 1, 0.27451)
+corner_radius_top_left = 3
+corner_radius_top_right = 3
+corner_radius_bottom_right = 3
+corner_radius_bottom_left = 3
+corner_detail = 5
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_l0k8a"]
+content_margin_left = 4.0
+content_margin_top = 4.0
+content_margin_right = 4.0
+content_margin_bottom = 4.0
+bg_color = Color(0.225, 0.225, 0.225, 0.6)
+border_width_left = 1
+border_width_top = 1
+border_width_right = 1
+border_width_bottom = 1
+border_color = Color(1, 1, 1, 0.784314)
+corner_radius_top_left = 3
+corner_radius_top_right = 3
+corner_radius_bottom_right = 3
+corner_radius_bottom_left = 3
+corner_detail = 5
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_1dkyv"]
+content_margin_left = 4.0
+content_margin_top = 4.0
+content_margin_right = 4.0
+content_margin_bottom = 4.0
+bg_color = Color(0.1, 0.1, 0.1, 0.6)
+border_width_left = 1
+border_width_top = 1
+border_width_right = 1
+border_width_bottom = 1
+border_color = Color(1, 1, 1, 0.509804)
+corner_radius_top_left = 3
+corner_radius_top_right = 3
+corner_radius_bottom_right = 3
+corner_radius_bottom_left = 3
+corner_detail = 5
+
+[resource]
+Button/styles/disabled = SubResource("StyleBoxFlat_bm5o2")
+Button/styles/hover = SubResource("StyleBoxFlat_l0k8a")
+Button/styles/normal = SubResource("StyleBoxFlat_1dkyv")
diff --git a/tools/get_version.gd b/tools/get_version.gd
new file mode 100644
index 0000000..8384f1b
--- /dev/null
+++ b/tools/get_version.gd
@@ -0,0 +1,7 @@
+extends SceneTree
+
+# godot --headless --no-header -s tools/get_version.gd
+
+func _init() -> void:
+ print(ProjectSettings.get_setting("application/config/version"))
+ quit(0)
diff --git a/tools/get_version.gd.uid b/tools/get_version.gd.uid
new file mode 100644
index 0000000..e14829d
--- /dev/null
+++ b/tools/get_version.gd.uid
@@ -0,0 +1 @@
+uid://cdhqbascy6pvy