Skip to content

Commit

Permalink
Add API functionality (#25)
Browse files Browse the repository at this point in the history
* inital draft

* Update dependencies

* avoid repetitive json parsing

* Move input class

* Reduce complexity

* Add json example

* Fix json example

* Add tests for fail cases

* update ruff to 0.3.0

* Add online tests

* Add documentation

* fix typos

* Address comments
  • Loading branch information
Vipitis authored Mar 7, 2024
1 parent 455f495 commit 684c28a
Show file tree
Hide file tree
Showing 13 changed files with 444 additions and 65 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ Possible sections in each release:

### [Unreleased]

Added:
* Run shaders from the website API https://github.com/pygfx/shadertoy/pull/25

### [v0.1.0] - 2024-01-21

Fixed:
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ This project is not affiliated with shadertoy.com.
```bash
pip install wgpu-shadertoy
```
To use the Shadertoy.com API, please setup an environment variable with the key `SHADERTOY_KEY`. See [How To](https://www.shadertoy.com/howto#q2) for instructions.

## Usage

Expand Down Expand Up @@ -60,6 +61,11 @@ channel0 = ShadertoyChannel(image_data, wrap="repeat")
shader = Shadertoy(shader_code, resolution=(800, 450), inputs=[channel0])
```

To easily load shaders from the website make use of the `.from_id` or `.from_json` classmethods. This will also download supported input media.
```python
shader = Shadertoy.from_id("NslGRN")
```

When passing `off_screen=True` the `.snapshot()` method allows you to render specific frames.
```python
shader = Shadertoy(shader_code, resolution=(800, 450), off_screen=True)
Expand All @@ -70,6 +76,12 @@ frame0_img.save("frame0.png")
```
For more examples see [examples](./examples).

### CLI Usage
A basic command line interface is provided as `wgpu-shadertoy`.
To display a shader from the website, simply provide its ID or url.
```bash
> wgpu-shadertoy tsXBzS --resolution 1024 640
```

## Status

Expand Down
52 changes: 52 additions & 0 deletions examples/shader_MllSzX.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"Shader": {
"ver": "0.1",
"info": {
"id": "MllSzX",
"date": "1438698189",
"viewed": 14064,
"name": "Bicubic Texture Filtering",
"username": "demofox",
"description": "Nearest neighbor texture filtering on left, Bilinear texture filtering in left middle, Lagrange Bicubic texture filtering on middle right, cubic hermite on the right. Use the mouse to control pan / zoom.\n",
"likes": 61,
"published": 3,
"flags": 0,
"usePreview": 0,
"tags": [
"2d",
"texturefilter"
],
"hasliked": 0
},
"renderpass": [
{
"inputs": [
{
"id": 16,
"src": "/media/a/3083c722c0c738cad0f468383167a0d246f91af2bfa373e9c5c094fb8c8413e0.png",
"ctype": "texture",
"channel": 0,
"sampler": {
"filter": "mipmap",
"wrap": "repeat",
"vflip": "false",
"srgb": "false",
"internal": "byte"
},
"published": 1
}
],
"outputs": [
{
"id": 37,
"channel": 0
}
],
"code": "float c_textureSize = 64.0;\n\n#define c_onePixel (1.0 / c_textureSize)\n#define c_twoPixels (2.0 / c_textureSize)\n\nfloat c_x0 = -1.0;\nfloat c_x1 = 0.0;\nfloat c_x2 = 1.0;\nfloat c_x3 = 2.0;\n \n//=======================================================================================\nvec3 CubicLagrange (vec3 A, vec3 B, vec3 C, vec3 D, float t)\n{\n return\n A * \n (\n (t - c_x1) / (c_x0 - c_x1) * \n (t - c_x2) / (c_x0 - c_x2) *\n (t - c_x3) / (c_x0 - c_x3)\n ) +\n B * \n (\n (t - c_x0) / (c_x1 - c_x0) * \n (t - c_x2) / (c_x1 - c_x2) *\n (t - c_x3) / (c_x1 - c_x3)\n ) +\n C * \n (\n (t - c_x0) / (c_x2 - c_x0) * \n (t - c_x1) / (c_x2 - c_x1) *\n (t - c_x3) / (c_x2 - c_x3)\n ) + \n D * \n (\n (t - c_x0) / (c_x3 - c_x0) * \n (t - c_x1) / (c_x3 - c_x1) *\n (t - c_x2) / (c_x3 - c_x2)\n );\n}\n\n//=======================================================================================\nvec3 BicubicLagrangeTextureSample (vec2 P)\n{\n vec2 pixel = P * c_textureSize + 0.5;\n \n vec2 frac = fract(pixel);\n pixel = floor(pixel) / c_textureSize - vec2(c_onePixel/2.0);\n \n vec3 C00 = texture(iChannel0, pixel + vec2(-c_onePixel ,-c_onePixel)).rgb;\n vec3 C10 = texture(iChannel0, pixel + vec2( 0.0 ,-c_onePixel)).rgb;\n vec3 C20 = texture(iChannel0, pixel + vec2( c_onePixel ,-c_onePixel)).rgb;\n vec3 C30 = texture(iChannel0, pixel + vec2( c_twoPixels,-c_onePixel)).rgb;\n \n vec3 C01 = texture(iChannel0, pixel + vec2(-c_onePixel , 0.0)).rgb;\n vec3 C11 = texture(iChannel0, pixel + vec2( 0.0 , 0.0)).rgb;\n vec3 C21 = texture(iChannel0, pixel + vec2( c_onePixel , 0.0)).rgb;\n vec3 C31 = texture(iChannel0, pixel + vec2( c_twoPixels, 0.0)).rgb; \n \n vec3 C02 = texture(iChannel0, pixel + vec2(-c_onePixel , c_onePixel)).rgb;\n vec3 C12 = texture(iChannel0, pixel + vec2( 0.0 , c_onePixel)).rgb;\n vec3 C22 = texture(iChannel0, pixel + vec2( c_onePixel , c_onePixel)).rgb;\n vec3 C32 = texture(iChannel0, pixel + vec2( c_twoPixels, c_onePixel)).rgb; \n \n vec3 C03 = texture(iChannel0, pixel + vec2(-c_onePixel , c_twoPixels)).rgb;\n vec3 C13 = texture(iChannel0, pixel + vec2( 0.0 , c_twoPixels)).rgb;\n vec3 C23 = texture(iChannel0, pixel + vec2( c_onePixel , c_twoPixels)).rgb;\n vec3 C33 = texture(iChannel0, pixel + vec2( c_twoPixels, c_twoPixels)).rgb; \n \n vec3 CP0X = CubicLagrange(C00, C10, C20, C30, frac.x);\n vec3 CP1X = CubicLagrange(C01, C11, C21, C31, frac.x);\n vec3 CP2X = CubicLagrange(C02, C12, C22, C32, frac.x);\n vec3 CP3X = CubicLagrange(C03, C13, C23, C33, frac.x);\n \n return CubicLagrange(CP0X, CP1X, CP2X, CP3X, frac.y);\n}\n\n//=======================================================================================\nvec3 CubicHermite (vec3 A, vec3 B, vec3 C, vec3 D, float t)\n{\n\tfloat t2 = t*t;\n float t3 = t*t*t;\n vec3 a = -A/2.0 + (3.0*B)/2.0 - (3.0*C)/2.0 + D/2.0;\n vec3 b = A - (5.0*B)/2.0 + 2.0*C - D / 2.0;\n vec3 c = -A/2.0 + C/2.0;\n \tvec3 d = B;\n \n return a*t3 + b*t2 + c*t + d;\n}\n\n//=======================================================================================\nvec3 BicubicHermiteTextureSample (vec2 P)\n{\n vec2 pixel = P * c_textureSize + 0.5;\n \n vec2 frac = fract(pixel);\n pixel = floor(pixel) / c_textureSize - vec2(c_onePixel/2.0);\n \n vec3 C00 = texture(iChannel0, pixel + vec2(-c_onePixel ,-c_onePixel)).rgb;\n vec3 C10 = texture(iChannel0, pixel + vec2( 0.0 ,-c_onePixel)).rgb;\n vec3 C20 = texture(iChannel0, pixel + vec2( c_onePixel ,-c_onePixel)).rgb;\n vec3 C30 = texture(iChannel0, pixel + vec2( c_twoPixels,-c_onePixel)).rgb;\n \n vec3 C01 = texture(iChannel0, pixel + vec2(-c_onePixel , 0.0)).rgb;\n vec3 C11 = texture(iChannel0, pixel + vec2( 0.0 , 0.0)).rgb;\n vec3 C21 = texture(iChannel0, pixel + vec2( c_onePixel , 0.0)).rgb;\n vec3 C31 = texture(iChannel0, pixel + vec2( c_twoPixels, 0.0)).rgb; \n \n vec3 C02 = texture(iChannel0, pixel + vec2(-c_onePixel , c_onePixel)).rgb;\n vec3 C12 = texture(iChannel0, pixel + vec2( 0.0 , c_onePixel)).rgb;\n vec3 C22 = texture(iChannel0, pixel + vec2( c_onePixel , c_onePixel)).rgb;\n vec3 C32 = texture(iChannel0, pixel + vec2( c_twoPixels, c_onePixel)).rgb; \n \n vec3 C03 = texture(iChannel0, pixel + vec2(-c_onePixel , c_twoPixels)).rgb;\n vec3 C13 = texture(iChannel0, pixel + vec2( 0.0 , c_twoPixels)).rgb;\n vec3 C23 = texture(iChannel0, pixel + vec2( c_onePixel , c_twoPixels)).rgb;\n vec3 C33 = texture(iChannel0, pixel + vec2( c_twoPixels, c_twoPixels)).rgb; \n \n vec3 CP0X = CubicHermite(C00, C10, C20, C30, frac.x);\n vec3 CP1X = CubicHermite(C01, C11, C21, C31, frac.x);\n vec3 CP2X = CubicHermite(C02, C12, C22, C32, frac.x);\n vec3 CP3X = CubicHermite(C03, C13, C23, C33, frac.x);\n \n return CubicHermite(CP0X, CP1X, CP2X, CP3X, frac.y);\n}\n\n//=======================================================================================\nvec3 BilinearTextureSample (vec2 P)\n{\n vec2 pixel = P * c_textureSize + 0.5;\n \n vec2 frac = fract(pixel);\n pixel = (floor(pixel) / c_textureSize) - vec2(c_onePixel/2.0);\n\n vec3 C11 = texture(iChannel0, pixel + vec2( 0.0 , 0.0)).rgb;\n vec3 C21 = texture(iChannel0, pixel + vec2( c_onePixel , 0.0)).rgb;\n vec3 C12 = texture(iChannel0, pixel + vec2( 0.0 , c_onePixel)).rgb;\n vec3 C22 = texture(iChannel0, pixel + vec2( c_onePixel , c_onePixel)).rgb;\n\n vec3 x1 = mix(C11, C21, frac.x);\n vec3 x2 = mix(C12, C22, frac.x);\n return mix(x1, x2, frac.y);\n}\n\n//=======================================================================================\nvec3 NearestTextureSample (vec2 P)\n{\n vec2 pixel = P * c_textureSize;\n \n vec2 frac = fract(pixel);\n pixel = (floor(pixel) / c_textureSize);\n return texture(iChannel0, pixel + vec2(c_onePixel/2.0)).rgb;\n}\n\n//=======================================================================================\nvoid AnimateUV (inout vec2 uv)\n{\n if (iMouse.z > 0.0)\n {\n uv -= vec2(0.0,0.5) * iResolution.y / iResolution.x;;\n uv *= vec2(iMouse.y / iResolution.y);\n uv += vec2(1.5 * iMouse.x / iResolution.x, 0.0);\n \n }\n else\n { \n \tuv += vec2(sin(iTime * 0.3)*0.5+0.5, sin(iTime * 0.7)*0.5+0.5);\n \tuv *= (sin(iTime * 0.3)*0.5+0.5)*3.0 + 0.2;\n }\n}\n\n//=======================================================================================\nvoid mainImage( out vec4 fragColor, in vec2 fragCoord )\n{\n // set up our coordinate system\n float aspectRatio = iResolution.y / iResolution.x;\n vec2 uv = (fragCoord.xy / iResolution.xy);\n uv.y *= aspectRatio;\n \n // do our sampling\n vec3 color;\n if (abs(uv.x - (1.0/4.0)) < 0.0025)\n {\n color = vec3(1.0);\n } \n else if (abs(uv.x - (2.0/4.0)) < 0.0025)\n {\n color = vec3(1.0);\n } \n else if (abs(uv.x - (3.0/4.0)) < 0.0025)\n {\n color = vec3(1.0);\n } \n else if (uv.x < (1.0/4.0))\n {\n AnimateUV(uv);\n color = NearestTextureSample(uv);\n }\n else if (uv.x < (2.0/4.0))\n {\n uv -= vec2((1.0/4.0),0.0);\n AnimateUV(uv);\n color = texture(iChannel0, uv).xyz;\n //color = BilinearTextureSample(uv);\n }\n else if (uv.x < (3.0/4.0))\n {\n uv -= vec2((2.0/4.0),0.0);\n AnimateUV(uv);\n color = BicubicLagrangeTextureSample(uv);\n }\n else\n {\n uv -= vec2((3.0/4.0),0.0);\n AnimateUV(uv);\n color = BicubicHermiteTextureSample(uv);\n\t}\n \n // set the final color\n\tfragColor = vec4(color,1.0); \n}",
"name": "Image",
"description": "",
"type": "image"
}
]
}
}
9 changes: 9 additions & 0 deletions examples/shadertoy_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# run_example = false

from wgpu_shadertoy import Shadertoy

# shadertoy source: https://www.shadertoy.com/view/wtcSzN by tdhooper CC-BY-NC-SA-3.0
shader = Shadertoy.from_id("wtcSzN")

if __name__ == "__main__":
shader.show()
10 changes: 10 additions & 0 deletions examples/shadertoy_json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from pathlib import Path

from wgpu_shadertoy import Shadertoy

# shadertoy source: https://www.shadertoy.com/view/MllSzX by demofox CC-BY-NC-SA-3.0
json_path = Path(Path(__file__).parent, "shader_MllSzX.json")
shader = Shadertoy.from_json(json_path)

if __name__ == "__main__":
shader.show()
10 changes: 8 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ build-backend = "setuptools.build_meta"
name = "wgpu-shadertoy"
dynamic = ["version", "readme"]
dependencies = [
"wgpu>=0.13.2,<0.14.0",
"wgpu>=0.14.1,<0.15.0",
"requests",
"numpy",
"Pillow",
]
description = "Shadertoy implementation based on wgpu-py"
license = {file = "LICENSE"}
Expand All @@ -18,6 +21,9 @@ authors = [
{name = "Jan Kels", email = "[email protected]"},
]

[project.scripts]
wgpu-shadertoy = "wgpu_shadertoy.cli:main_cli"

[project.urls]
Repository = "https://github.com/pygfx/shadertoy"

Expand Down Expand Up @@ -52,5 +58,5 @@ extend-ignore = [
"E501", # line too long
]

[tool.ruff.per-file-ignores]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"]
3 changes: 1 addition & 2 deletions tests/renderutils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
""" Utils to render to a texture or screen. Tuned to the tests, so quite some
"""Utils to render to a texture or screen. Tuned to the tests, so quite some
assumptions here.
"""


import ctypes

import numpy as np
Expand Down
59 changes: 59 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import pytest
from testutils import can_use_wgpu_lib

from wgpu_shadertoy.api import _get_api_key, shader_args_from_json, shadertoy_from_id

if not can_use_wgpu_lib:
pytest.skip("Skipping tests that need the wgpu lib", allow_module_level=True)


@pytest.fixture
def api_available():
"""
Skip tests some tests if no API is unavailable.
"""
try:
return _get_api_key()
except Exception as e:
pytest.skip("Skipping API tests: " + str(e))


# coverage for shadertoy_from_id(id_or_url)
def test_from_id_with_invalid_id(api_available):
with pytest.raises(RuntimeError):
shadertoy_from_id("invalid_id")


def test_from_id_with_valid_id(api_available):
# shadertoy source: https://www.shadertoy.com/view/mtyGWy by kishimisu
data = shadertoy_from_id("mtyGWy")
assert "Shader" in data
assert data["Shader"]["info"]["id"] == "mtyGWy"
assert data["Shader"]["info"]["username"] == "kishimisu"


def test_shadertoy_from_id(api_available):
# Import here, because it imports the wgpu.gui.auto
from wgpu_shadertoy import Shadertoy

# shadertoy source: https://www.shadertoy.com/view/l3fXWN by Vipitis
shader = Shadertoy.from_id("l3fXWN")

assert shader.title == "API test for CI by jakel101"
assert shader.shader_type == "glsl"
assert shader.shader_code.startswith("//Confirm API working!")
assert shader.common.startswith("//Common pass loaded!")
assert shader.inputs[0].sampler_settings["address_mode_u"] == "clamp-to-edge"
assert shader.inputs[0].data.shape == (32, 256, 4)
assert shader.inputs[0].texture_size == (256, 32, 1)


# coverage for shader_args_from_json(dict_or_path, **kwargs)
def test_from_json_with_invalid_path():
with pytest.raises(FileNotFoundError):
shader_args_from_json("/invalid/path")


def test_from_json_with_invalid_type():
with pytest.raises(TypeError):
shader_args_from_json(123)
3 changes: 2 additions & 1 deletion wgpu_shadertoy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .shadertoy import Shadertoy, ShadertoyChannel
from .inputs import ShadertoyChannel
from .shadertoy import Shadertoy

__version__ = "0.1.0"
version_info = tuple(map(int, __version__.split(".")))
130 changes: 130 additions & 0 deletions wgpu_shadertoy/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import json
import os

import numpy as np
import requests
from PIL import Image
from wgpu import logger

from .inputs import ShadertoyChannel

HEADERS = {"user-agent": "https://github.com/pygfx/shadertoy script"}


def _get_api_key():
key = os.environ.get("SHADERTOY_KEY", None)
if key is None:
raise ValueError(
"SHADERTOY_KEY environment variable not set, please set it to your Shadertoy API key to use API features. Follow the instructions at https://www.shadertoy.com/howto#q2"
)
test_url = "https://www.shadertoy.com/api/v1/shaders/query/test"
test_response = requests.get(test_url, params={"key": key}, headers=HEADERS)
if test_response.status_code != 200:
raise requests.exceptions.HTTPError(
f"Failed to use ShaderToy API with key: {test_response.status_code}"
)
test_response = test_response.json()
if "Error" in test_response:
raise ValueError(
f"Failed to use ShaderToy API with key: {test_response['Error']}"
)
return key


def _download_media_channels(inputs):
"""
Downloads media (currently just textures) from Shadertoy.com and returns a list of `ShadertoyChannel` to be directly used for `inputs`.
Requiers internet connection (API key not required).
"""
media_url = "https://www.shadertoy.com"
channels = {}
for inp in inputs:
if inp["ctype"] != "texture":
continue # TODO: support other media types
response = requests.get(media_url + inp["src"], headers=HEADERS, stream=True)
if response.status_code != 200:
raise requests.exceptions.HTTPError(
f"Failed to load media {media_url + inp['src']} with status code {response.status_code}"
)
img = Image.open(response.raw).convert("RGBA")
img_data = np.array(img)
channel = ShadertoyChannel(
img_data, kind="texture", wrap=inp["sampler"]["wrap"]
)
channels[inp["channel"]] = channel
return list(channels.values())


def _save_json(data, path):
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)


def _load_json(path) -> dict:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)


def shadertoy_from_id(id_or_url) -> dict:
"""
Fetches a shader from Shadertoy.com by its ID (or url) and returns the JSON data as dict.
"""
if "/" in id_or_url:
shader_id = id_or_url.rstrip("/").split("/")[-1]
else:
shader_id = id_or_url
url = f"https://www.shadertoy.com/api/v1/shaders/{shader_id}"
response = requests.get(url, params={"key": _get_api_key()}, headers=HEADERS)
if response.status_code != 200:
raise requests.exceptions.HTTPError(
f"Failed to load shader at https://www.shadertoy.com/view/{shader_id} with status code {response.status_code}"
)
shader_data = response.json()
if "Error" in shader_data:
raise RuntimeError(
f"Shadertoy API error: {shader_data['Error']} for https://www.shadertoy.com/view/{shader_id}, perhaps the shader isn't set to `public+api`"
)
return shader_data


def shader_args_from_json(dict_or_path, **kwargs) -> dict:
"""
Builds the args for a `Shadertoy` instance from a JSON-like dict of Shadertoy.com shader data.
"""
if isinstance(dict_or_path, (str, os.PathLike)):
shader_data = _load_json(dict_or_path)
else:
shader_data = dict_or_path

if not isinstance(shader_data, dict):
raise TypeError("shader_data must be a dict")
main_image_code = ""
common_code = ""
inputs = []
if "Shader" not in shader_data:
raise ValueError(
"shader_data must have a 'Shader' key, following Shadertoy export format."
)
for r_pass in shader_data["Shader"]["renderpass"]:
if r_pass["type"] == "image":
main_image_code = r_pass["code"]
if r_pass["inputs"] is not []:
inputs = _download_media_channels(r_pass["inputs"])
elif r_pass["type"] == "common":
common_code = r_pass["code"]
else:
# TODO should be a warning and not verbose!
logger.warn(
f"renderpass of type {r_pass['type']} not yet supported, will be omitted."
)
title = f'{shader_data["Shader"]["info"]["name"]} by {shader_data["Shader"]["info"]["username"]}'

shader_args = {
"shader_code": main_image_code,
"common": common_code,
"shader_type": "glsl",
"inputs": inputs,
"title": title,
**kwargs,
}
return shader_args
30 changes: 30 additions & 0 deletions wgpu_shadertoy/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import argparse

from .shadertoy import Shadertoy

argument_parser = argparse.ArgumentParser(
description="Download and render Shadertoy shaders"
)

argument_parser.add_argument(
"shader_id", type=str, help="The ID of the shader to download and render"
)
argument_parser.add_argument(
"--resolution",
type=int,
nargs=2,
help="The resolution to render the shader at",
default=(800, 450),
)


def main_cli():
args = argument_parser.parse_args()
shader_id = args.shader_id
resolution = args.resolution
shader = Shadertoy.from_id(shader_id, resolution=resolution)
shader.show()


if __name__ == "__main__":
main_cli()
Loading

0 comments on commit 684c28a

Please sign in to comment.