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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,7 @@ Notes:
- JSON uses top-level `width`, `height`, and `layers`
- Named custom layers added with `canvas.custom(fn, name="...", kwargs={...})` are JSON-serializable via the registry; unnamed custom layers are not
- Enum-like values such as `blend_mode`, `fit`, and `align` can be passed as strings
- `quickthumb schema` emits the current JSON Schema for constrained generation and editor autocomplete

## AI-Friendly Workflows

Expand All @@ -626,6 +627,8 @@ Use one background image layer, one dark overlay background layer, two text laye
Only use valid quickthumb layer types and effect names.
```

For constrained generation, first run `quickthumb schema > quickthumb.schema.json` and pass that schema to the model or editor. Prefer concrete field values in schema-constrained calls; `$theme.*` tokens are resolved by quickthumb before model validation and may not satisfy generic JSON Schema validators by themselves.

Recommended workflow:

1. Have the model produce quickthumb Python or JSON.
Expand Down
30 changes: 30 additions & 0 deletions docs/json-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,36 @@ A quickthumb JSON document has three required top-level fields, plus an optional

Every layer object requires a `"type"` discriminator field. Layers render in array order — first item is backmost.

## Published schema

Emit the current JSON Schema from the same Pydantic models used by `Canvas.from_json()`:

```bash
quickthumb schema > quickthumb.schema.json
quickthumb schema --output quickthumb.schema.json
```

!!! note "Schema scope"
`quickthumb schema` describes concrete quickthumb specs for external tooling
and constrained generation. `Canvas.from_json()` remains the source of truth
for what quickthumb can load.

Authoring conveniences such as `$theme.*` are resolved by quickthumb before
model validation, so generic JSON Schema validators may reject unresolved
authoring specs that quickthumb itself can load.

The command writes deterministic JSON only, so it can be checked into a repo, piped into an editor, or passed directly to a constrained-generation API. The schema includes the current canvas fields, built-in layer discriminators, effects, animations, supported platform presets, and the optional top-level `theme` block.

For constrained generation, prefer concrete resolved values in typed fields:

```text
Use quickthumb.schema.json as the JSON response schema.
Generate one valid quickthumb canvas spec for a 1280x720 YouTube thumbnail.
Return only the JSON object.
Use concrete hex colors and numeric sizes in layer fields.
Use only these layer type discriminators: background, text, image, shape, svg, group, outline.
```

## Theme tokens

Define brand tokens once in a top-level `theme` block and reference them anywhere in the spec with `$theme.path`:
Expand Down
2 changes: 2 additions & 0 deletions quickthumb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
Wheel,
Wipe,
)
from quickthumb.schema import canvas_json_schema
from quickthumb.transitions import Transition

__all__ = [
Expand Down Expand Up @@ -94,5 +95,6 @@
"TextLayer",
"TextPart",
"Transition",
"canvas_json_schema",
"transitions",
]
22 changes: 22 additions & 0 deletions quickthumb/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from quickthumb.canvas import _VAR_RE, Canvas, _is_theme_reference
from quickthumb.errors import RenderingError, ValidationError
from quickthumb.schema import canvas_json_schema

_VALID_FORMATS = {"PNG", "JPEG", "WEBP"}

Expand Down Expand Up @@ -66,6 +67,27 @@ def main() -> None:
app()


@app.command()
def schema(
output: Annotated[
Path | None,
typer.Option("-o", "--output", help="Write schema JSON to a file instead of stdout"),
] = None,
) -> None:
"""Emit the JSON Schema for quickthumb canvas specs."""
payload = json.dumps(canvas_json_schema(), indent=2, sort_keys=True) + "\n"
if output is None:
typer.echo(payload, nl=False)
return

try:
output.write_text(payload)
except OSError as e:
typer.echo(str(e), err=True)
raise typer.Exit(1) from e
typer.echo(str(output))


@app.command()
def render(
spec: Annotated[Path, typer.Argument(help="Path to a JSON spec file")],
Expand Down
67 changes: 52 additions & 15 deletions quickthumb/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
NonNegativeInt,
PositiveFloat,
PositiveInt,
WithJsonSchema,
field_serializer,
field_validator,
model_validator,
Expand All @@ -21,6 +22,18 @@
from quickthumb.errors import ValidationError

HEX_COLOR_PATTERN = r"^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$"
PERCENT_COORDINATE_PATTERN = r"^-?(\d+(\.\d+)?)%$"
POSITIVE_PERCENT_PATTERN = r"^(\d+(\.\d+)?)%$"

PercentCoordinate = Annotated[
str,
WithJsonSchema({"type": "string", "pattern": PERCENT_COORDINATE_PATTERN}),
]
PositivePercent = Annotated[
str,
WithJsonSchema({"type": "string", "pattern": POSITIVE_PERCENT_PATTERN}),
]
Position = tuple[int | PercentCoordinate, int | PercentCoordinate]


def validate_hex_color(color: str) -> str:
Expand All @@ -31,7 +44,11 @@ def validate_hex_color(color: str) -> str:


# Reusable color type with validation
HexColor = Annotated[str, AfterValidator(validate_hex_color)]
HexColor = Annotated[
str,
AfterValidator(validate_hex_color),
WithJsonSchema({"type": "string", "pattern": HEX_COLOR_PATTERN}),
]


def _validate_opacity(v: float) -> float:
Expand All @@ -40,7 +57,11 @@ def _validate_opacity(v: float) -> float:
return v


OpacityField = Annotated[float, AfterValidator(_validate_opacity)]
OpacityField = Annotated[
float,
AfterValidator(_validate_opacity),
WithJsonSchema({"type": "number", "minimum": 0.0, "maximum": 1.0}),
]


# Generic enum converter
Expand Down Expand Up @@ -143,7 +164,15 @@ def _validate_align_with_hv_tuple(v: Any) -> Align | None:
raise ValueError(f"invalid align value: {v}")


AlignWithHVTuple = Annotated[Align | None, BeforeValidator(_validate_align_with_hv_tuple)]
AlignWithHVTuple = Annotated[
Align | None,
BeforeValidator(
_validate_align_with_hv_tuple,
json_schema_input_type=Align
| tuple[Literal["left", "center", "right"], Literal["top", "middle", "bottom"]]
| None,
),
]


class quickthumbModel(BaseModel): # noqa: N801
Expand Down Expand Up @@ -182,7 +211,7 @@ class TextFillImage(quickthumbModel):
type: Literal["image"] = "image"
path: str
fit: Annotated[
FitMode | str, AfterValidator(lambda v: enum_converter(FitMode)(v) if v else FitMode.COVER)
FitMode, BeforeValidator(lambda v: enum_converter(FitMode)(v) if v else FitMode.COVER)
] = FitMode.COVER


Expand Down Expand Up @@ -471,12 +500,12 @@ class TextLayer(quickthumbModel):
size: PositiveInt | None = None
color: HexColor | None = None
fill: TextFill | None = None
position: tuple | None = None
position: Position | None = None
align: AlignWithHVTuple = None
bold: bool = False
italic: bool = False
weight: int | str | None = None
max_width: int | str | None = None
max_width: int | PositivePercent | None = None
effects: list[TextEffect] = []
line_height: PositiveFloat | None = None
letter_spacing: int | None = None
Expand Down Expand Up @@ -526,7 +555,7 @@ def validate_auto_scale_requires_max_width(self) -> "TextLayer":

@field_validator("position", mode="before")
@classmethod
def validate_position(cls, v: tuple | list | None) -> tuple | None:
def validate_position(cls, v: tuple | list | None) -> Position | None:
if v is None:
return v

Expand Down Expand Up @@ -561,7 +590,7 @@ class OutlineLayer(quickthumbModel):
class ImageLayer(quickthumbModel):
type: Literal["image"]
path: str
position: tuple
position: Position
width: PositiveInt | None = None
height: PositiveInt | None = None
opacity: OpacityField = 1.0
Expand All @@ -580,7 +609,7 @@ class ImageLayer(quickthumbModel):

@field_validator("position", mode="before")
@classmethod
def validate_position(cls, v: tuple | list | None) -> tuple | None:
def validate_position(cls, v: tuple | list | None) -> Position | None:
if v is None:
raise ValueError("position is required")

Expand All @@ -605,7 +634,7 @@ def serialize_align(self, align: Align) -> str:
class ShapeLayer(quickthumbModel):
type: Literal["shape"]
shape: Literal["rectangle", "ellipse", "pill", "triangle", "star", "polygon"]
position: tuple
position: Position
width: PositiveInt
height: PositiveInt
color: HexColor
Expand Down Expand Up @@ -657,7 +686,7 @@ def validate_points_match_shape(self) -> "ShapeLayer":

@field_validator("position", mode="before")
@classmethod
def validate_position(cls, v: tuple | list | None) -> tuple | None:
def validate_position(cls, v: tuple | list | None) -> Position | None:
if v is None:
raise ValueError("position is required")

Expand All @@ -683,7 +712,7 @@ def serialize_align(self, align: Align | None) -> str | None:
class SvgLayer(quickthumbModel):
type: Literal["svg"]
path: str
position: tuple
position: Position
width: PositiveInt | None = None
height: PositiveInt | None = None
opacity: OpacityField = 1.0
Expand All @@ -697,7 +726,7 @@ class SvgLayer(quickthumbModel):

@field_validator("position", mode="before")
@classmethod
def validate_position(cls, v: tuple | list | None) -> tuple | None:
def validate_position(cls, v: tuple | list | None) -> Position | None:
if v is None:
raise ValueError("position is required")

Expand All @@ -723,7 +752,7 @@ class GroupLayer(quickthumbModel):
direction: Literal["row", "column"] = "column"
gap: NonNegativeInt = 0
padding: int | tuple[int, int] | tuple[int, int, int, int] = 0
position: tuple | None = None
position: Position | None = None
align: AlignWithHVTuple = None
item_align: Literal["start", "center", "end"] = "start"
animation: AnimationInput | None = None
Expand Down Expand Up @@ -775,7 +804,7 @@ def validate_padding(

@field_validator("position", mode="before")
@classmethod
def validate_position(cls, v: tuple | list | None) -> tuple | None:
def validate_position(cls, v: tuple | list | None) -> Position | None:
if v is None:
return v

Expand Down Expand Up @@ -877,3 +906,11 @@ class CanvasModel(quickthumbModel):
height: PositiveInt | None = None
platform: str | None = None
layers: list[LayerType]


class CanvasSpecModel(quickthumbModel):
width: PositiveInt | None = None
height: PositiveInt | None = None
platform: str | None = None
theme: dict[str, Any] = Field(default_factory=dict)
layers: list[LayerType]
46 changes: 46 additions & 0 deletions quickthumb/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from __future__ import annotations

from typing import Any

from quickthumb._diagnostic_rules import PLATFORM_SAFE_MARGIN_PRESETS
from quickthumb.models import CanvasSpecModel

JSON_SCHEMA_DRAFT = "https://json-schema.org/draft/2020-12/schema"
QUICKTHUMB_SCHEMA_ID = "https://sjquant.github.io/quickthumb/schema.json"


def canvas_json_schema() -> dict[str, Any]:
"""Return the JSON Schema for quickthumb canvas specs."""
schema = CanvasSpecModel.model_json_schema(mode="validation")
platform_name_schema = {"enum": sorted(PLATFORM_SAFE_MARGIN_PRESETS), "type": "string"}
platform_schema = {
"anyOf": [
platform_name_schema,
{"type": "null"},
],
"default": None,
"title": "Platform",
}
sized_dimension_schema = {"exclusiveMinimum": 0, "type": "integer"}
schema["$schema"] = JSON_SCHEMA_DRAFT
schema["$id"] = QUICKTHUMB_SCHEMA_ID
schema["title"] = "quickthumb Canvas JSON Spec"
schema["description"] = (
"A quickthumb canvas spec accepted by Canvas.from_json() and the quickthumb CLI."
)
schema["properties"]["platform"] = platform_schema
schema["anyOf"] = [
{
"properties": {
"width": sized_dimension_schema,
"height": sized_dimension_schema,
},
"required": ["width", "height"],
},
{
"properties": {"platform": platform_name_schema},
"required": ["platform"],
"not": {"anyOf": [{"required": ["width"]}, {"required": ["height"]}]},
},
]
return schema
Loading