Skip to content

Commit fa0e218

Browse files
authored
feat: add published JSON schema CLI (#44)
* feat: add published JSON schema CLI * fix: tighten JSON schema contract * docs: clarify JSON schema scope
1 parent 5c52433 commit fa0e218

7 files changed

Lines changed: 304 additions & 15 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,7 @@ Notes:
604604
- JSON uses top-level `width`, `height`, and `layers`
605605
- Named custom layers added with `canvas.custom(fn, name="...", kwargs={...})` are JSON-serializable via the registry; unnamed custom layers are not
606606
- Enum-like values such as `blend_mode`, `fit`, and `align` can be passed as strings
607+
- `quickthumb schema` emits the current JSON Schema for constrained generation and editor autocomplete
607608

608609
## AI-Friendly Workflows
609610

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

630+
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.
631+
629632
Recommended workflow:
630633

631634
1. Have the model produce quickthumb Python or JSON.

docs/json-schema.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,36 @@ A quickthumb JSON document has three required top-level fields, plus an optional
3939

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

42+
## Published schema
43+
44+
Emit the current JSON Schema from the same Pydantic models used by `Canvas.from_json()`:
45+
46+
```bash
47+
quickthumb schema > quickthumb.schema.json
48+
quickthumb schema --output quickthumb.schema.json
49+
```
50+
51+
!!! note "Schema scope"
52+
`quickthumb schema` describes concrete quickthumb specs for external tooling
53+
and constrained generation. `Canvas.from_json()` remains the source of truth
54+
for what quickthumb can load.
55+
56+
Authoring conveniences such as `$theme.*` are resolved by quickthumb before
57+
model validation, so generic JSON Schema validators may reject unresolved
58+
authoring specs that quickthumb itself can load.
59+
60+
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.
61+
62+
For constrained generation, prefer concrete resolved values in typed fields:
63+
64+
```text
65+
Use quickthumb.schema.json as the JSON response schema.
66+
Generate one valid quickthumb canvas spec for a 1280x720 YouTube thumbnail.
67+
Return only the JSON object.
68+
Use concrete hex colors and numeric sizes in layer fields.
69+
Use only these layer type discriminators: background, text, image, shape, svg, group, outline.
70+
```
71+
4272
## Theme tokens
4373

4474
Define brand tokens once in a top-level `theme` block and reference them anywhere in the spec with `$theme.path`:

quickthumb/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
Wheel,
4545
Wipe,
4646
)
47+
from quickthumb.schema import canvas_json_schema
4748
from quickthumb.transitions import Transition
4849

4950
__all__ = [
@@ -94,5 +95,6 @@
9495
"TextLayer",
9596
"TextPart",
9697
"Transition",
98+
"canvas_json_schema",
9799
"transitions",
98100
]

quickthumb/cli.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from quickthumb.canvas import _VAR_RE, Canvas, _is_theme_reference
1111
from quickthumb.errors import RenderingError, ValidationError
12+
from quickthumb.schema import canvas_json_schema
1213

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

@@ -66,6 +67,27 @@ def main() -> None:
6667
app()
6768

6869

70+
@app.command()
71+
def schema(
72+
output: Annotated[
73+
Path | None,
74+
typer.Option("-o", "--output", help="Write schema JSON to a file instead of stdout"),
75+
] = None,
76+
) -> None:
77+
"""Emit the JSON Schema for quickthumb canvas specs."""
78+
payload = json.dumps(canvas_json_schema(), indent=2, sort_keys=True) + "\n"
79+
if output is None:
80+
typer.echo(payload, nl=False)
81+
return
82+
83+
try:
84+
output.write_text(payload)
85+
except OSError as e:
86+
typer.echo(str(e), err=True)
87+
raise typer.Exit(1) from e
88+
typer.echo(str(output))
89+
90+
6991
@app.command()
7092
def render(
7193
spec: Annotated[Path, typer.Argument(help="Path to a JSON spec file")],

quickthumb/models.py

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
NonNegativeInt,
1313
PositiveFloat,
1414
PositiveInt,
15+
WithJsonSchema,
1516
field_serializer,
1617
field_validator,
1718
model_validator,
@@ -21,6 +22,18 @@
2122
from quickthumb.errors import ValidationError
2223

2324
HEX_COLOR_PATTERN = r"^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$"
25+
PERCENT_COORDINATE_PATTERN = r"^-?(\d+(\.\d+)?)%$"
26+
POSITIVE_PERCENT_PATTERN = r"^(\d+(\.\d+)?)%$"
27+
28+
PercentCoordinate = Annotated[
29+
str,
30+
WithJsonSchema({"type": "string", "pattern": PERCENT_COORDINATE_PATTERN}),
31+
]
32+
PositivePercent = Annotated[
33+
str,
34+
WithJsonSchema({"type": "string", "pattern": POSITIVE_PERCENT_PATTERN}),
35+
]
36+
Position = tuple[int | PercentCoordinate, int | PercentCoordinate]
2437

2538

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

3245

3346
# Reusable color type with validation
34-
HexColor = Annotated[str, AfterValidator(validate_hex_color)]
47+
HexColor = Annotated[
48+
str,
49+
AfterValidator(validate_hex_color),
50+
WithJsonSchema({"type": "string", "pattern": HEX_COLOR_PATTERN}),
51+
]
3552

3653

3754
def _validate_opacity(v: float) -> float:
@@ -40,7 +57,11 @@ def _validate_opacity(v: float) -> float:
4057
return v
4158

4259

43-
OpacityField = Annotated[float, AfterValidator(_validate_opacity)]
60+
OpacityField = Annotated[
61+
float,
62+
AfterValidator(_validate_opacity),
63+
WithJsonSchema({"type": "number", "minimum": 0.0, "maximum": 1.0}),
64+
]
4465

4566

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

145166

146-
AlignWithHVTuple = Annotated[Align | None, BeforeValidator(_validate_align_with_hv_tuple)]
167+
AlignWithHVTuple = Annotated[
168+
Align | None,
169+
BeforeValidator(
170+
_validate_align_with_hv_tuple,
171+
json_schema_input_type=Align
172+
| tuple[Literal["left", "center", "right"], Literal["top", "middle", "bottom"]]
173+
| None,
174+
),
175+
]
147176

148177

149178
class quickthumbModel(BaseModel): # noqa: N801
@@ -182,7 +211,7 @@ class TextFillImage(quickthumbModel):
182211
type: Literal["image"] = "image"
183212
path: str
184213
fit: Annotated[
185-
FitMode | str, AfterValidator(lambda v: enum_converter(FitMode)(v) if v else FitMode.COVER)
214+
FitMode, BeforeValidator(lambda v: enum_converter(FitMode)(v) if v else FitMode.COVER)
186215
] = FitMode.COVER
187216

188217

@@ -471,12 +500,12 @@ class TextLayer(quickthumbModel):
471500
size: PositiveInt | None = None
472501
color: HexColor | None = None
473502
fill: TextFill | None = None
474-
position: tuple | None = None
503+
position: Position | None = None
475504
align: AlignWithHVTuple = None
476505
bold: bool = False
477506
italic: bool = False
478507
weight: int | str | None = None
479-
max_width: int | str | None = None
508+
max_width: int | PositivePercent | None = None
480509
effects: list[TextEffect] = []
481510
line_height: PositiveFloat | None = None
482511
letter_spacing: int | None = None
@@ -526,7 +555,7 @@ def validate_auto_scale_requires_max_width(self) -> "TextLayer":
526555

527556
@field_validator("position", mode="before")
528557
@classmethod
529-
def validate_position(cls, v: tuple | list | None) -> tuple | None:
558+
def validate_position(cls, v: tuple | list | None) -> Position | None:
530559
if v is None:
531560
return v
532561

@@ -561,7 +590,7 @@ class OutlineLayer(quickthumbModel):
561590
class ImageLayer(quickthumbModel):
562591
type: Literal["image"]
563592
path: str
564-
position: tuple
593+
position: Position
565594
width: PositiveInt | None = None
566595
height: PositiveInt | None = None
567596
opacity: OpacityField = 1.0
@@ -580,7 +609,7 @@ class ImageLayer(quickthumbModel):
580609

581610
@field_validator("position", mode="before")
582611
@classmethod
583-
def validate_position(cls, v: tuple | list | None) -> tuple | None:
612+
def validate_position(cls, v: tuple | list | None) -> Position | None:
584613
if v is None:
585614
raise ValueError("position is required")
586615

@@ -605,7 +634,7 @@ def serialize_align(self, align: Align) -> str:
605634
class ShapeLayer(quickthumbModel):
606635
type: Literal["shape"]
607636
shape: Literal["rectangle", "ellipse", "pill", "triangle", "star", "polygon"]
608-
position: tuple
637+
position: Position
609638
width: PositiveInt
610639
height: PositiveInt
611640
color: HexColor
@@ -657,7 +686,7 @@ def validate_points_match_shape(self) -> "ShapeLayer":
657686

658687
@field_validator("position", mode="before")
659688
@classmethod
660-
def validate_position(cls, v: tuple | list | None) -> tuple | None:
689+
def validate_position(cls, v: tuple | list | None) -> Position | None:
661690
if v is None:
662691
raise ValueError("position is required")
663692

@@ -683,7 +712,7 @@ def serialize_align(self, align: Align | None) -> str | None:
683712
class SvgLayer(quickthumbModel):
684713
type: Literal["svg"]
685714
path: str
686-
position: tuple
715+
position: Position
687716
width: PositiveInt | None = None
688717
height: PositiveInt | None = None
689718
opacity: OpacityField = 1.0
@@ -697,7 +726,7 @@ class SvgLayer(quickthumbModel):
697726

698727
@field_validator("position", mode="before")
699728
@classmethod
700-
def validate_position(cls, v: tuple | list | None) -> tuple | None:
729+
def validate_position(cls, v: tuple | list | None) -> Position | None:
701730
if v is None:
702731
raise ValueError("position is required")
703732

@@ -723,7 +752,7 @@ class GroupLayer(quickthumbModel):
723752
direction: Literal["row", "column"] = "column"
724753
gap: NonNegativeInt = 0
725754
padding: int | tuple[int, int] | tuple[int, int, int, int] = 0
726-
position: tuple | None = None
755+
position: Position | None = None
727756
align: AlignWithHVTuple = None
728757
item_align: Literal["start", "center", "end"] = "start"
729758
animation: AnimationInput | None = None
@@ -775,7 +804,7 @@ def validate_padding(
775804

776805
@field_validator("position", mode="before")
777806
@classmethod
778-
def validate_position(cls, v: tuple | list | None) -> tuple | None:
807+
def validate_position(cls, v: tuple | list | None) -> Position | None:
779808
if v is None:
780809
return v
781810

@@ -877,3 +906,11 @@ class CanvasModel(quickthumbModel):
877906
height: PositiveInt | None = None
878907
platform: str | None = None
879908
layers: list[LayerType]
909+
910+
911+
class CanvasSpecModel(quickthumbModel):
912+
width: PositiveInt | None = None
913+
height: PositiveInt | None = None
914+
platform: str | None = None
915+
theme: dict[str, Any] = Field(default_factory=dict)
916+
layers: list[LayerType]

quickthumb/schema.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
from quickthumb._diagnostic_rules import PLATFORM_SAFE_MARGIN_PRESETS
6+
from quickthumb.models import CanvasSpecModel
7+
8+
JSON_SCHEMA_DRAFT = "https://json-schema.org/draft/2020-12/schema"
9+
QUICKTHUMB_SCHEMA_ID = "https://sjquant.github.io/quickthumb/schema.json"
10+
11+
12+
def canvas_json_schema() -> dict[str, Any]:
13+
"""Return the JSON Schema for quickthumb canvas specs."""
14+
schema = CanvasSpecModel.model_json_schema(mode="validation")
15+
platform_name_schema = {"enum": sorted(PLATFORM_SAFE_MARGIN_PRESETS), "type": "string"}
16+
platform_schema = {
17+
"anyOf": [
18+
platform_name_schema,
19+
{"type": "null"},
20+
],
21+
"default": None,
22+
"title": "Platform",
23+
}
24+
sized_dimension_schema = {"exclusiveMinimum": 0, "type": "integer"}
25+
schema["$schema"] = JSON_SCHEMA_DRAFT
26+
schema["$id"] = QUICKTHUMB_SCHEMA_ID
27+
schema["title"] = "quickthumb Canvas JSON Spec"
28+
schema["description"] = (
29+
"A quickthumb canvas spec accepted by Canvas.from_json() and the quickthumb CLI."
30+
)
31+
schema["properties"]["platform"] = platform_schema
32+
schema["anyOf"] = [
33+
{
34+
"properties": {
35+
"width": sized_dimension_schema,
36+
"height": sized_dimension_schema,
37+
},
38+
"required": ["width", "height"],
39+
},
40+
{
41+
"properties": {"platform": platform_name_schema},
42+
"required": ["platform"],
43+
"not": {"anyOf": [{"required": ["width"]}, {"required": ["height"]}]},
44+
},
45+
]
46+
return schema

0 commit comments

Comments
 (0)