Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,42 @@ canvas = Canvas(1280, 720).shape(
)
```

### Grain / noise effect

Add film-grain noise to background or image layers via `effects=[Grain(...)]`.

```python
from quickthumb import Canvas, Grain

canvas = (
Canvas(1280, 720)
.background(
color="#1A1A2E",
effects=[Grain(intensity=0.12, monochrome=True)],
)
.image(
path="portrait.png",
position=("70%", "50%"),
width=400,
height=500,
align=("center", "middle"),
effects=[Grain(intensity=0.08, monochrome=False, blend_mode="overlay", opacity=0.6)],
)
)
```

`Grain` parameters:

| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `intensity` | `float` | required | Noise amplitude, `0.0`–`1.0` |
| `monochrome` | `bool` | `True` | `True` = luminance noise; `False` = per-channel color noise |
| `blend_mode` | `str` | `"overlay"` | `"overlay"`, `"screen"`, `"multiply"`, or `"normal"` |
| `opacity` | `float` | `1.0` | Overall grain strength, `0.0`–`1.0` |
| `seed` | `int \| None` | `None` | Optional RNG seed for deterministic output |

`Grain` is valid in `effects` on **background** and **image** layers. It is serialized with `"type": "grain"` in JSON.

### Export helpers

```python
Expand Down Expand Up @@ -323,6 +359,7 @@ os.environ["QUICKTHUMB_DEFAULT_FONT"] = "Roboto"
| Fonts | Local fonts, CSS-style weights, italic/bold flags, webfont URLs, fallback mapping |
| Images | Local/remote images, sizing, fit modes, alignment, opacity, rotation |
| Image effects | Stroke, shadow, glow, filter effects, border radius, background removal |
| Grain / noise | Per-layer `Grain` effect on background and image layers; monochrome or color noise |
| Shapes | Rectangle and ellipse primitives with stroke/shadow/glow support |
| Export | PNG, JPEG, WebP, file output, base64, data URLs |
| Serialization | `to_json()` / `from_json()` for built-in layer types and named custom layers |
Expand All @@ -342,6 +379,8 @@ See the shipped examples in [`examples/README.md`](examples/README.md):
- `position` percentage values must be strings like `"50%"`
- `fill` and `color` are independent fields; when `fill` is set it takes visual precedence over `color`
- `canvas.custom(fn)` without a `name` runs during render order but cannot be serialized to JSON; pass `name=` and register the function with `Canvas.register_layer_fn()` to enable serialization
- `Grain` is valid only on background and image layer `effects`; it is not a valid text or shape effect
- `Grain(intensity=0.0)` is a no-op (no noise is generated or composited)

## Development

Expand Down
4 changes: 2 additions & 2 deletions docs/api/background.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ canvas.background(
| `opacity` | `float` | `1.0` | Layer opacity from `0.0` (transparent) to `1.0` (opaque). |
| `blend_mode` | `str \| BlendMode \| None` | `None` | How this layer composites over previous layers. See [BlendMode](enums.md#blendmode). |
| `fit` | `str \| FitMode \| None` | `None` | How an image fills the canvas. See [FitMode](enums.md#fitmode). |
| `effects` | `list[Filter] \| None` | `[]` | Background effects. Currently only `Filter` is supported. |
| `effects` | `list \| None` | `[]` | Background effects. Accepts `Filter` and `Grain`. |

## Examples

Expand Down Expand Up @@ -75,4 +75,4 @@ canvas.background(color="#000000", opacity=0.4)
- `color`, `gradient`, and `image` can each be used independently or together within one layer.
- `blend_mode` applies when compositing this layer over previous layers.
- Supports both local file paths and remote URLs for `image`.
- For available effects, see [Filter](effects.md#filter).
- For available effects, see [Filter](effects.md#filter) and [Grain](effects.md#grain).
48 changes: 48 additions & 0 deletions docs/api/effects.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Effects are modifiers applied to layers. Each layer type accepts a specific set.
| `Glow` | ✓ | ✓ | ✓ | — |
| `Filter` | — | ✓ | — | ✓ |
| `Background` | ✓ | — | — | — |
| `Grain` | — | ✓ | — | ✓ |

Pass effects as a list to the `effects` parameter of any layer:

Expand Down Expand Up @@ -193,3 +194,50 @@ canvas.text(

!!! note
`Background` as an effect is separate from the `.background()` layer builder. The effect applies behind a text block; the layer covers the full canvas.

---

## Grain

Adds film-grain noise to a background or image layer.

```python
from quickthumb import Grain

Grain(intensity=0.12, monochrome=True, blend_mode="overlay", opacity=1.0)
```

| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `intensity` | `float` | **required** | Noise amplitude from `0.0` (none) to `1.0` (maximum). |
| `monochrome` | `bool` | `True` | `True` = luminance noise (grey grain); `False` = independent per-channel color noise. |
| `blend_mode` | `str` | `"overlay"` | How noise composites onto the layer. One of `"overlay"`, `"screen"`, `"multiply"`, `"normal"`. |
| `opacity` | `float` | `1.0` | Overall grain strength from `0.0` (invisible) to `1.0` (full). |
| `seed` | `int \| None` | `None` | RNG seed for deterministic output. `None` = random each render. |

`intensity=0.0` is a no-op — no noise is generated.

### Example

```python
from quickthumb import Canvas, Grain

canvas = (
Canvas(1280, 720)
.background(
color="#1A1A2E",
effects=[Grain(intensity=0.12, monochrome=True)],
)
.image(
path="portrait.png",
position=("70%", "50%"),
width=400,
height=500,
align=("center", "middle"),
effects=[Grain(intensity=0.08, monochrome=False, blend_mode="overlay", opacity=0.6)],
)
)
```

!!! note
`Grain` is valid only on **background** and **image** layers. It is not available on text or shape layers.
2 changes: 1 addition & 1 deletion docs/api/image.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ canvas.image(
| `remove_background` | `bool` | `False` | Remove the image background using AI. Requires `quickthumb[rembg]`. |
| `border_radius` | `int` | `0` | Corner rounding in pixels. Non-negative integer. |
| `blend_mode` | `str \| BlendMode \| None` | `None` | Compositing blend mode. See [BlendMode](enums.md#blendmode). |
| `effects` | `list \| None` | `[]` | List of effects: `Stroke`, `Shadow`, `Glow`, `Filter`. |
| `effects` | `list \| None` | `[]` | List of effects: `Stroke`, `Shadow`, `Glow`, `Filter`, `Grain`. |

## Examples

Expand Down
3 changes: 2 additions & 1 deletion docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ from quickthumb import (
Filter,
FitMode,
Glow,
Grain,
LinearGradient,
RadialGradient,
Shadow,
Expand All @@ -33,7 +34,7 @@ from quickthumb import (
| [Image](image.md) | `.image()` — overlay images and cutouts |
| [Shape](shape.md) | `.shape()` — rectangles and ellipses |
| [Outline](outline.md) | `.outline()` — canvas border |
| [Effects](effects.md) | `Stroke`, `Shadow`, `Glow`, `Filter`, `Background` |
| [Effects](effects.md) | `Stroke`, `Shadow`, `Glow`, `Filter`, `Background`, `Grain` |
| [Enums & Gradients](enums.md) | `Align`, `BlendMode`, `FitMode`, `LinearGradient`, `RadialGradient`, `TextFillImage` |

## Error types
Expand Down
9 changes: 8 additions & 1 deletion docs/json-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,13 @@ Effects are embedded in each layer's `"effects"` array and use a `"type"` discri
{ "type": "background", "color": "#111827CC", "padding": [16, 24], "border_radius": 14, "opacity": 1.0 }
```

=== "Grain"
```json
{ "type": "grain", "intensity": 0.12, "monochrome": true, "blend_mode": "overlay", "opacity": 1.0 }
```

`blend_mode` values: `"overlay"`, `"screen"`, `"multiply"`, `"normal"`. Optional `"seed"` integer for deterministic output.

## Complete example

A full YouTube-style thumbnail spec:
Expand Down Expand Up @@ -349,7 +356,7 @@ Generate a QuickThumb JSON config for a 1280×720 YouTube thumbnail.
Rules:
- Top-level fields: "width", "height", "layers"
- Every layer must have a "type" field: "background", "text", "image", "shape", or "outline"
- Every effect must have a "type" field: "stroke", "shadow", "glow", "filter", or "background"
- Every effect must have a "type" field: "stroke", "shadow", "glow", "filter", "background", or "grain"
- Positions are [x, y] arrays — values can be integers (px) or percentage strings like "50%"
- Colors are hex strings: "#RRGGBB" or "#RRGGBBAA"
- Layers render bottom-to-top in array order
Expand Down
2 changes: 2 additions & 0 deletions quickthumb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
Filter,
FitMode,
Glow,
Grain,
ImageEffect,
ImageLayer,
LinearGradient,
Expand Down Expand Up @@ -35,6 +36,7 @@
"Filter",
"FitMode",
"Glow",
"Grain",
"ImageEffect",
"ImageLayer",
"LinearGradient",
Expand Down
92 changes: 91 additions & 1 deletion quickthumb/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
Filter,
FitMode,
Glow,
Grain,
ImageEffect,
ImageLayer,
LayerType,
Expand Down Expand Up @@ -575,7 +576,10 @@ def _render_background_layer(self, image: Image.Image, layer: BackgroundLayer):
return

for effect in layer.effects:
layer_image = self._apply_filter(layer_image, effect)
if isinstance(effect, Grain):
layer_image = self._apply_grain(layer_image, effect)
else:
layer_image = self._apply_filter(layer_image, effect)

if layer.opacity < 1.0 and not layer.color:
layer_image = self._apply_opacity(layer_image, layer.opacity)
Expand Down Expand Up @@ -652,6 +656,90 @@ def _apply_filter(self, image: Image.Image, effect: Filter) -> Image.Image:
image = self._apply_saturation(image, effect.saturation)
return image

@staticmethod
def _generate_noise_image(
size: tuple[int, int],
intensity: float,
monochrome: bool,
seed: int | None,
) -> Image.Image | None:
import random as _random

pixel_count = size[0] * size[1]
max_val = int(intensity * 255)

if max_val == 0:
return None

lut = bytes(i * max_val // 255 for i in range(256))

if seed is not None:
rng = _random.Random(seed)
if monochrome:
raw = rng.randbytes(pixel_count)
else:
raw_r, raw_g, raw_b = (
rng.randbytes(pixel_count),
rng.randbytes(pixel_count),
rng.randbytes(pixel_count),
)
else:
if monochrome:
raw = os.urandom(pixel_count)
else:
raw_r, raw_g, raw_b = (
os.urandom(pixel_count),
os.urandom(pixel_count),
os.urandom(pixel_count),
)

if monochrome:
ch = Image.frombytes("L", size, raw).point(lut)
noise_img = Image.merge("RGB", [ch, ch, ch])
else:
noise_img = Image.merge(
"RGB",
[
Image.frombytes("L", size, raw_r).point(lut),
Image.frombytes("L", size, raw_g).point(lut),
Image.frombytes("L", size, raw_b).point(lut),
],
)
return noise_img.convert("RGBA")

def _blend_grain(
self,
image: Image.Image,
intensity: float,
monochrome: bool,
seed: int | None,
blend_mode: str,
opacity: float,
) -> Image.Image:
if opacity == 0.0:
return image
noise = self._generate_noise_image(image.size, intensity, monochrome, seed)
if noise is None:
return image
r, g, b, original_alpha = image.split()
blended = self._apply_blend_mode(image, noise, blend_mode)
br, bg, bb, _ = blended.split()
if opacity < 1.0:
br = Image.blend(r, br, opacity)
bg = Image.blend(g, bg, opacity)
bb = Image.blend(b, bb, opacity)
return Image.merge("RGBA", (br, bg, bb, original_alpha))

def _apply_grain(self, image: Image.Image, effect: Grain) -> Image.Image:
return self._blend_grain(
image,
effect.intensity,
effect.monochrome,
effect.seed,
effect.blend_mode,
effect.opacity,
)

def _apply_opacity_to_color(self, color: tuple[int, ...], opacity: float) -> tuple[int, ...]:
r, g, b = color[:3]

Expand Down Expand Up @@ -799,6 +887,8 @@ def _render_image_layer(self, image: Image.Image, layer: ImageLayer):
for effect in layer.effects:
if isinstance(effect, Filter):
img = self._apply_filter(img, effect)
elif isinstance(effect, Grain):
img = self._apply_grain(img, effect)

for effect in layer.effects:
if isinstance(effect, Glow):
Expand Down
30 changes: 28 additions & 2 deletions quickthumb/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,13 +261,39 @@ def validate_saturation(cls, v: float) -> float:
return v


_GRAIN_BLEND_MODES = frozenset({"overlay", "screen", "multiply", "normal"})


class Grain(QuickThumbModel):
type: Literal["grain"] = "grain"
intensity: float
monochrome: bool = True
blend_mode: str = "overlay"
opacity: OpacityField = 1.0
seed: int | None = None

@field_validator("intensity")
@classmethod
def validate_intensity(cls, v: float) -> float:
if v < 0.0 or v > 1.0:
raise ValueError("intensity must be between 0.0 and 1.0")
return v

@field_validator("blend_mode")
@classmethod
def validate_blend_mode(cls, v: str) -> str:
if v not in _GRAIN_BLEND_MODES:
raise ValueError(f"blend_mode must be one of: {', '.join(sorted(_GRAIN_BLEND_MODES))}")
return v


TextEffect = Annotated[Stroke | Shadow | Glow | Background, Discriminator("type")]

ImageEffect = Annotated[Stroke | Shadow | Glow | Filter, Discriminator("type")]
ImageEffect = Annotated[Stroke | Shadow | Glow | Filter | Grain, Discriminator("type")]

ShapeEffect = Annotated[Stroke | Shadow | Glow, Discriminator("type")]

BackgroundEffect = Filter
BackgroundEffect = Annotated[Filter | Grain, Discriminator("type")]


class TextPart(QuickThumbModel):
Expand Down
12 changes: 6 additions & 6 deletions specs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@ This document specifies planned and exploratory features for QuickThumb. It is a

| # | Feature | Status |
| --- | ---------------------------- | ------------- |
| 1 | CLI (`quickthumb` command) | `planned` |
| 2 | Template System | `planned` |
| 1 | CLI (`quickthumb` command) | `done` |
| 2 | Template System | `done` |
| 3 | Gradient / Image-Filled Text | `done` |
| 4 | Noise / Grain Effect | `planned` |
| 4 | Noise / Grain Effect | `done` |
| 5 | Presentation & Video | `exploratory` |

---

## 1. CLI — `planned`
## 1. CLI — `done`

A `quickthumb` command-line tool for rendering JSON specs without writing Python.

Expand Down Expand Up @@ -108,7 +108,7 @@ uv pip install "quickthumb[cli]"

---

## 2. Template System — `planned`
## 2. Template System — `done`

Reusable JSON specs with variable placeholders. Useful for batch generation and AI-driven workflows.

Expand Down Expand Up @@ -350,7 +350,7 @@ Fallback rule: if `fill` is `None`, `color` is used as before.

---

## 4. Noise / Grain Effect — `planned`
## 4. Noise / Grain Effect — `done`

Add film-grain noise to backgrounds, images, or the entire canvas.

Expand Down
Binary file added tests/snapshots/grain_effect_on_background.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading