northcel 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
northcel/__init__.py ADDED
@@ -0,0 +1,79 @@
1
+ """NorthCel (.ncel) — the AI-drawn video programming language."""
2
+
3
+ from .config import (
4
+ CLI_COMMAND,
5
+ CLI_SHORT_ALIAS,
6
+ DOCS_URL,
7
+ FILE_EXTENSION,
8
+ LANGUAGE_NAME,
9
+ NPM_PACKAGE,
10
+ PACKAGE_VERSION,
11
+ PYPI_PACKAGE,
12
+ SPEC_VERSION,
13
+ )
14
+ from .ast_nodes import (
15
+ BoilSpec,
16
+ ColorValue,
17
+ DefBlock,
18
+ DocumentNode,
19
+ FilmNode,
20
+ LayerNode,
21
+ PaperSpec,
22
+ RibbonNode,
23
+ SceneNode,
24
+ StrokeNode,
25
+ SwarmNode,
26
+ UseNode,
27
+ )
28
+ from .parser import NorthCelSyntaxError, parse_ncel
29
+ from .validator import ValidationDiagnostic, validate_document
30
+ from .formatter import format_document
31
+ from .rings import (
32
+ decode_ring_b63_1px,
33
+ decode_ring_q25,
34
+ decode_rings_payload,
35
+ encode_ring_b63_1px,
36
+ encode_ring_q25,
37
+ encode_rings_payload,
38
+ )
39
+ from .renderer import render_document_frame, render_film_frames
40
+
41
+ __version__ = PACKAGE_VERSION
42
+
43
+ __all__ = [
44
+ "LANGUAGE_NAME",
45
+ "FILE_EXTENSION",
46
+ "CLI_COMMAND",
47
+ "CLI_SHORT_ALIAS",
48
+ "PYPI_PACKAGE",
49
+ "NPM_PACKAGE",
50
+ "PACKAGE_VERSION",
51
+ "SPEC_VERSION",
52
+ "DOCS_URL",
53
+ "__version__",
54
+ "BoilSpec",
55
+ "ColorValue",
56
+ "DefBlock",
57
+ "DocumentNode",
58
+ "FilmNode",
59
+ "LayerNode",
60
+ "PaperSpec",
61
+ "RibbonNode",
62
+ "SceneNode",
63
+ "StrokeNode",
64
+ "SwarmNode",
65
+ "UseNode",
66
+ "NorthCelSyntaxError",
67
+ "parse_ncel",
68
+ "ValidationDiagnostic",
69
+ "validate_document",
70
+ "format_document",
71
+ "decode_ring_b63_1px",
72
+ "decode_ring_q25",
73
+ "decode_rings_payload",
74
+ "encode_ring_b63_1px",
75
+ "encode_ring_q25",
76
+ "encode_rings_payload",
77
+ "render_document_frame",
78
+ "render_film_frames",
79
+ ]
northcel/ast_nodes.py ADDED
@@ -0,0 +1,159 @@
1
+ """Typed AST nodes for NorthCel (.ncel) v0.1."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class SourceSpan:
11
+ line: int
12
+ column: int
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ColorValue:
17
+ """Represents either a greyscale tone (0..255) or an RGB color (r, g, b)."""
18
+
19
+ r: int
20
+ g: int
21
+ b: int
22
+ is_grey: bool = True
23
+
24
+ @classmethod
25
+ def from_tone(cls, tone: int) -> "ColorValue":
26
+ v = int(tone)
27
+ return cls(r=v, g=v, b=v, is_grey=True)
28
+
29
+ @classmethod
30
+ def from_rgb(cls, r: int, g: int, b: int) -> "ColorValue":
31
+ return cls(r=int(r), g=int(g), b=int(b), is_grey=(r == g == b))
32
+
33
+ @classmethod
34
+ def from_hex(cls, hex_str: str) -> "ColorValue":
35
+ h = hex_str.lstrip("#")
36
+ if len(h) == 3:
37
+ h = "".join(c * 2 for c in h)
38
+ r = int(h[0:2], 16)
39
+ g = int(h[2:4], 16)
40
+ b = int(h[4:6], 16)
41
+ return cls(r=r, g=g, b=b, is_grey=(r == g == b))
42
+
43
+ def to_hex(self) -> str:
44
+ return f"#{self.r:02X}{self.g:02X}{self.b:02X}"
45
+
46
+ def as_rgb_tuple(self) -> tuple[int, int, int]:
47
+ return (self.r, self.g, self.b)
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class BoilSpec:
52
+ amplitude_px: float = 0.0
53
+ frequency: float = 1.0
54
+ seed_offset: int = 0
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class PaperSpec:
59
+ color: ColorValue = field(default_factory=lambda: ColorValue.from_tone(239))
60
+ antialias_grid: int = 4
61
+ grain: float = 0.0
62
+ span: SourceSpan = field(default_factory=lambda: SourceSpan(1, 1))
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class LayerNode:
67
+ name: str
68
+ color: ColorValue
69
+ rings_count: int | None
70
+ payload: str
71
+ boil: BoilSpec | None = None
72
+ translate_expr: tuple[Any, Any] = (0.0, 0.0)
73
+ span: SourceSpan = field(default_factory=lambda: SourceSpan(1, 1))
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class SwarmNode:
78
+ name: str
79
+ agents: int
80
+ color: ColorValue
81
+ payload: str
82
+ boil: BoilSpec | None = None
83
+ translate_expr: tuple[Any, Any] = (0.0, 0.0)
84
+ span: SourceSpan = field(default_factory=lambda: SourceSpan(1, 1))
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class StrokeNode:
89
+ name: str
90
+ color: ColorValue
91
+ width_start: float
92
+ width_end: float
93
+ points: list[tuple[Any, Any]]
94
+ boil: BoilSpec | None = None
95
+ span: SourceSpan = field(default_factory=lambda: SourceSpan(1, 1))
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class RibbonNode:
100
+ name: str
101
+ color: ColorValue
102
+ points: list[tuple[Any, Any, float]]
103
+ boil: BoilSpec | None = None
104
+ span: SourceSpan = field(default_factory=lambda: SourceSpan(1, 1))
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class DefBlock:
109
+ name: str
110
+ params: dict[str, float]
111
+ items: list[LayerNode | SwarmNode | StrokeNode | RibbonNode]
112
+ span: SourceSpan = field(default_factory=lambda: SourceSpan(1, 1))
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class UseNode:
117
+ def_name: str
118
+ args: dict[str, Any]
119
+ span: SourceSpan = field(default_factory=lambda: SourceSpan(1, 1))
120
+
121
+
122
+ SceneItem = LayerNode | SwarmNode | StrokeNode | RibbonNode | UseNode
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class SceneNode:
127
+ name: str
128
+ width: int = 1024
129
+ height: int = 1024
130
+ fps: int = 6
131
+ precision: str = "0.25px"
132
+ seed: int = 42
133
+ paper: PaperSpec = field(default_factory=PaperSpec)
134
+ defs: dict[str, DefBlock] = field(default_factory=dict)
135
+ items: list[SceneItem] = field(default_factory=list)
136
+ span: SourceSpan = field(default_factory=lambda: SourceSpan(1, 1))
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class FilmNode:
141
+ name: str
142
+ width: int = 1024
143
+ height: int = 1024
144
+ fps: int = 6
145
+ frames: int = 2
146
+ precision: str = "0.25px"
147
+ seed: int = 42
148
+ paper: PaperSpec = field(default_factory=PaperSpec)
149
+ defs: dict[str, DefBlock] = field(default_factory=dict)
150
+ items: list[SceneItem] = field(default_factory=list)
151
+ frame_overrides: dict[int, SceneNode] = field(default_factory=dict)
152
+ span: SourceSpan = field(default_factory=lambda: SourceSpan(1, 1))
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class DocumentNode:
157
+ spec_version: str
158
+ root: SceneNode | FilmNode
159
+ span: SourceSpan = field(default_factory=lambda: SourceSpan(1, 1))
northcel/cli.py ADDED
@@ -0,0 +1,290 @@
1
+ """CLI entrypoint for `northcel` and `ncel`: check, fmt, info, render, film."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+ import sys
9
+ import time
10
+ from typing import Sequence
11
+
12
+ from PIL import Image
13
+
14
+ from .ast_nodes import FilmNode, LayerNode, RibbonNode, SceneNode, StrokeNode, SwarmNode
15
+ from .config import CLI_COMMAND, FILE_EXTENSION, LANGUAGE_NAME, PACKAGE_VERSION, SPEC_VERSION
16
+ from .formatter import format_document
17
+ from .parser import NorthCelSyntaxError, parse_ncel
18
+ from .renderer import render_document_frame, render_film_frames
19
+ from .rings import decode_rings_payload
20
+ from .validator import validate_document
21
+
22
+
23
+ def _load_and_validate(path: Path):
24
+ if not path.is_file():
25
+ return None, None, [f"1:1: error: File not found: {path}"]
26
+ src = path.read_text(encoding="utf-8")
27
+ try:
28
+ doc = parse_ncel(src)
29
+ except NorthCelSyntaxError as exc:
30
+ return src, None, [f"{exc.line}:{exc.column}: error: {exc.message}"]
31
+ diags = validate_document(doc)
32
+ return src, doc, diags
33
+
34
+
35
+ def cmd_check(args: argparse.Namespace) -> int:
36
+ path = Path(args.file)
37
+ _, doc, diags = _load_and_validate(path)
38
+ if doc is None:
39
+ errors = diags
40
+ warnings = []
41
+ else:
42
+ errors = [d.format() for d in diags if d.level == "error"]
43
+ warnings = [d.format() for d in diags if d.level == "warning"]
44
+
45
+ ok = len(errors) == 0
46
+ if args.json:
47
+ print(
48
+ json.dumps(
49
+ {
50
+ "ok": ok,
51
+ "file": str(path),
52
+ "spec_version": doc.spec_version if doc else None,
53
+ "errors": errors,
54
+ "warnings": warnings,
55
+ },
56
+ indent=2,
57
+ )
58
+ )
59
+ else:
60
+ for w in warnings:
61
+ print(f"{path}:{w}", file=sys.stderr)
62
+ for e in errors:
63
+ print(f"{path}:{e}", file=sys.stderr)
64
+ if ok:
65
+ print(f"{path}: OK ({LANGUAGE_NAME} {doc.spec_version if doc else SPEC_VERSION})")
66
+ return 0 if ok else 1
67
+
68
+
69
+ def cmd_fmt(args: argparse.Namespace) -> int:
70
+ path = Path(args.file)
71
+ src, doc, diags = _load_and_validate(path)
72
+ if doc is None:
73
+ for e in diags:
74
+ print(f"{path}:{e}", file=sys.stderr)
75
+ return 1
76
+ errors = [d for d in diags if d.level == "error"]
77
+ if errors:
78
+ for e in errors:
79
+ print(f"{path}:{e.format()}", file=sys.stderr)
80
+ return 1
81
+
82
+ formatted = format_document(doc, include_version_header=True)
83
+ if args.check:
84
+ if src != formatted:
85
+ print(f"{path}: needs formatting", file=sys.stderr)
86
+ return 1
87
+ print(f"{path}: formatted")
88
+ return 0
89
+ if args.write:
90
+ path.write_text(formatted, encoding="utf-8")
91
+ print(f"{path}: formatted in-place")
92
+ return 0
93
+ sys.stdout.write(formatted)
94
+ return 0
95
+
96
+
97
+ def cmd_info(args: argparse.Namespace) -> int:
98
+ path = Path(args.file)
99
+ src, doc, diags = _load_and_validate(path)
100
+ if doc is None:
101
+ for e in diags:
102
+ print(f"{path}:{e}", file=sys.stderr)
103
+ return 1
104
+ root = doc.root
105
+ total_rings = 0
106
+ total_vertices = 0
107
+ layers_summary = []
108
+
109
+ def inspect_items(items, precision: str):
110
+ nonlocal total_rings, total_vertices
111
+ for it in items:
112
+ if isinstance(it, (LayerNode, SwarmNode)):
113
+ rings = decode_rings_payload(it.payload, precision=precision)
114
+ v_cnt = sum(len(r) for r in rings)
115
+ total_rings += len(rings)
116
+ total_vertices += v_cnt
117
+ layers_summary.append(
118
+ {
119
+ "kind": "layer" if isinstance(it, LayerNode) else "bend_swarm",
120
+ "name": it.name,
121
+ "color": it.color.to_hex(),
122
+ "rings": len(rings),
123
+ "vertices": v_cnt,
124
+ }
125
+ )
126
+ elif isinstance(it, StrokeNode):
127
+ total_rings += 1
128
+ total_vertices += len(it.points) * 2
129
+ layers_summary.append(
130
+ {
131
+ "kind": "stroke",
132
+ "name": it.name,
133
+ "color": it.color.to_hex(),
134
+ "points": len(it.points),
135
+ }
136
+ )
137
+ elif isinstance(it, RibbonNode):
138
+ total_rings += 1
139
+ total_vertices += len(it.points) * 2
140
+ layers_summary.append(
141
+ {
142
+ "kind": "ribbon",
143
+ "name": it.name,
144
+ "color": it.color.to_hex(),
145
+ "points": len(it.points),
146
+ }
147
+ )
148
+
149
+ inspect_items(root.items, root.precision)
150
+ if isinstance(root, FilmNode):
151
+ for f_scene in root.frame_overrides.values():
152
+ inspect_items(f_scene.items, f_scene.precision)
153
+
154
+ info = {
155
+ "file": str(path),
156
+ "bytes": len(src.encode("utf-8")) if src else 0,
157
+ "spec_version": doc.spec_version,
158
+ "kind": "film" if isinstance(root, FilmNode) else "scene",
159
+ "name": root.name,
160
+ "width": root.width,
161
+ "height": root.height,
162
+ "fps": root.fps,
163
+ "frames": root.frames if isinstance(root, FilmNode) else 1,
164
+ "precision": root.precision,
165
+ "seed": root.seed,
166
+ "defs": list(root.defs.keys()),
167
+ "total_rings": total_rings,
168
+ "total_vertices": total_vertices,
169
+ "layers": layers_summary,
170
+ }
171
+ print(json.dumps(info, indent=2))
172
+ return 0
173
+
174
+
175
+ def cmd_render(args: argparse.Namespace) -> int:
176
+ path = Path(args.file)
177
+ _, doc, diags = _load_and_validate(path)
178
+ if doc is None:
179
+ for e in diags:
180
+ print(f"{path}:{e}", file=sys.stderr)
181
+ return 1
182
+ errors = [d for d in diags if d.level == "error"]
183
+ if errors:
184
+ for e in errors:
185
+ print(f"{path}:{e.format()}", file=sys.stderr)
186
+ return 1
187
+
188
+ t0 = time.monotonic()
189
+ rgb = render_document_frame(doc, frame_index=args.frame, seed_override=args.seed)
190
+ out_p = Path(args.out) if args.out else path.with_suffix(".png")
191
+ out_p.parent.mkdir(parents=True, exist_ok=True)
192
+ Image.fromarray(rgb, mode="RGB").save(out_p)
193
+ elapsed = time.monotonic() - t0
194
+ if args.json:
195
+ print(
196
+ json.dumps(
197
+ {
198
+ "ok": True,
199
+ "input": str(path),
200
+ "output": str(out_p),
201
+ "frame": args.frame,
202
+ "width": int(rgb.shape[1]),
203
+ "height": int(rgb.shape[0]),
204
+ "seconds": round(elapsed, 4),
205
+ },
206
+ indent=2,
207
+ )
208
+ )
209
+ else:
210
+ print(f"Rendered {out_p} ({rgb.shape[1]}x{rgb.shape[0]}, frame {args.frame}) in {elapsed:.3f}s")
211
+ return 0
212
+
213
+
214
+ def cmd_film(args: argparse.Namespace) -> int:
215
+ path = Path(args.file)
216
+ _, doc, diags = _load_and_validate(path)
217
+ if doc is None:
218
+ for e in diags:
219
+ print(f"{path}:{e}", file=sys.stderr)
220
+ return 1
221
+ errors = [d for d in diags if d.level == "error"]
222
+ if errors:
223
+ for e in errors:
224
+ print(f"{path}:{e.format()}", file=sys.stderr)
225
+ return 1
226
+
227
+ indices: list[int] | None = None
228
+ if args.frames:
229
+ if ".." in args.frames:
230
+ a_s, b_s = args.frames.split("..", 1)
231
+ indices = list(range(int(a_s), int(b_s) + 1))
232
+ else:
233
+ indices = [int(x.strip()) for x in args.frames.split(",") if x.strip()]
234
+
235
+ t0 = time.monotonic()
236
+ res = render_film_frames(doc, out_dir=args.out_dir, frame_indices=indices, mp4_path=args.mp4)
237
+ res["seconds"] = round(time.monotonic() - t0, 4)
238
+ print(json.dumps(res, indent=2))
239
+ return 0
240
+
241
+
242
+ def build_parser() -> argparse.ArgumentParser:
243
+ p = argparse.ArgumentParser(
244
+ prog=CLI_COMMAND,
245
+ description=f"{LANGUAGE_NAME} ({FILE_EXTENSION}) — AI-drawn video language compiler, formatter, validator, and reference renderer.",
246
+ )
247
+ p.add_argument("--version", action="version", version=f"{CLI_COMMAND} {PACKAGE_VERSION} (spec {SPEC_VERSION})")
248
+ sub = p.add_subparsers(dest="command", required=True)
249
+
250
+ p_check = sub.add_parser("check", help="Parse and validate a .ncel file with line:column diagnostics.")
251
+ p_check.add_argument("file", help="Path to .ncel file")
252
+ p_check.add_argument("--json", action="store_true", help="Output structured JSON diagnostics")
253
+ p_check.set_defaults(func=cmd_check)
254
+
255
+ p_fmt = sub.add_parser("fmt", help="Format a .ncel file into canonical form.")
256
+ p_fmt.add_argument("file", help="Path to .ncel file")
257
+ p_fmt.add_argument("-w", "--write", action="store_true", help="Write formatted output in-place")
258
+ p_fmt.add_argument("--check", action="store_true", help="Exit non-zero if file is not canonically formatted")
259
+ p_fmt.set_defaults(func=cmd_fmt)
260
+
261
+ p_info = sub.add_parser("info", help="Inspect scene/film metadata, layers, rings, and vertex counts.")
262
+ p_info.add_argument("file", help="Path to .ncel file")
263
+ p_info.set_defaults(func=cmd_info)
264
+
265
+ p_render = sub.add_parser("render", help="Render a single frame of a .ncel scene or film to PNG.")
266
+ p_render.add_argument("file", help="Path to .ncel file")
267
+ p_render.add_argument("-o", "--out", help="Output PNG path (default: <file>.png)")
268
+ p_render.add_argument("-f", "--frame", type=int, default=0, help="Frame index to render (default: 0)")
269
+ p_render.add_argument("--seed", type=int, default=None, help="Optional seed override")
270
+ p_render.add_argument("--json", action="store_true", help="Output JSON summary")
271
+ p_render.set_defaults(func=cmd_render)
272
+
273
+ p_film = sub.add_parser("film", help="Render a sequence of frames from a .ncel film to PNGs and optional MP4.")
274
+ p_film.add_argument("file", help="Path to .ncel file")
275
+ p_film.add_argument("-o", "--out-dir", required=True, help="Directory for rendered f0000.png frames")
276
+ p_film.add_argument("--frames", help="Optional frame range '0..5' or comma list '0,1,2'")
277
+ p_film.add_argument("--mp4", help="Optional output MP4 file path (muxed with ffmpeg if available)")
278
+ p_film.set_defaults(func=cmd_film)
279
+
280
+ return p
281
+
282
+
283
+ def main(argv: Sequence[str] | None = None) -> int:
284
+ parser = build_parser()
285
+ args = parser.parse_args(argv)
286
+ return int(args.func(args))
287
+
288
+
289
+ if __name__ == "__main__":
290
+ raise SystemExit(main())
northcel/config.py ADDED
@@ -0,0 +1,12 @@
1
+ """Single source of truth for NorthCel naming, extension, CLI commands, and version."""
2
+
3
+ LANGUAGE_NAME = "NorthCel"
4
+ FILE_EXTENSION = ".ncel"
5
+ CLI_COMMAND = "northcel"
6
+ CLI_SHORT_ALIAS = "ncel"
7
+ PYPI_PACKAGE = "northcel"
8
+ NPM_PACKAGE = "northcel"
9
+ PACKAGE_VERSION = "0.1.0"
10
+ SPEC_VERSION = "0.1.0-draft"
11
+ SUPPORTED_SPEC_VERSIONS = ("0.1", "0.1.0", "0.1.0-draft")
12
+ DOCS_URL = "https://northnoir.com/northcel"
@@ -0,0 +1,6 @@
1
+ northcel "0.1.0-draft"
2
+
3
+ scene EvenOddAperture(width: 64, height: 64, fps: 6, precision: 0.25px) {
4
+ paper { tone: 240, antialias_grid: 4 }
5
+ layer square_with_hole(tone: 20, rings: 2) { "32,32 192,0 0,192 -192,0|80,80 96,0 0,96 -96,0" }
6
+ }
@@ -0,0 +1,7 @@
1
+ northcel "0.1.0-draft"
2
+
3
+ scene ColorStrokeRibbon(width: 64, height: 64, fps: 12, precision: 0.25px) {
4
+ paper { color: #102030, antialias_grid: 4 }
5
+ stroke beam(color: #F08040, taper: (8, 4)) { (8, 16), (56, 16) }
6
+ ribbon wave(color: #40E0D0) { (8, 44, 10), (32, 44, 6), (56, 44, 10) }
7
+ }
@@ -0,0 +1,9 @@
1
+ northcel "0.1.0-draft"
2
+
3
+ film ReusableFilm(width: 64, height: 64, fps: 6, frames: 3, precision: 0.25px, seed: 77) {
4
+ paper { tone: 235, antialias_grid: 4 }
5
+ def Box(dx: 0, dy: 0) {
6
+ layer patch(tone: 30, rings: 1) { "32,32 64,0 0,64 -64,0" }
7
+ }
8
+ use Box(dx: lerp(0, 20, t), dy: 0)
9
+ }
@@ -0,0 +1,128 @@
1
+ {
2
+ "spec_version": "0.1.0-draft",
3
+ "tolerance_mae": 1.0,
4
+ "cases": [
5
+ {
6
+ "name": "01_evenodd_aperture",
7
+ "file": "01_evenodd_aperture.ncel",
8
+ "probes": [
9
+ {
10
+ "x": 4,
11
+ "y": 4,
12
+ "rgb": [
13
+ 240,
14
+ 240,
15
+ 240
16
+ ],
17
+ "note": "outside outer square (paper)"
18
+ },
19
+ {
20
+ "x": 12,
21
+ "y": 12,
22
+ "rgb": [
23
+ 20,
24
+ 20,
25
+ 20
26
+ ],
27
+ "note": "inside outer square, outside hole (dark)"
28
+ },
29
+ {
30
+ "x": 32,
31
+ "y": 32,
32
+ "rgb": [
33
+ 240,
34
+ 240,
35
+ 240
36
+ ],
37
+ "note": "inside inner hole (even-odd aperture cutout -> paper)"
38
+ }
39
+ ]
40
+ },
41
+ {
42
+ "name": "02_color_stroke_ribbon",
43
+ "file": "02_color_stroke_ribbon.ncel",
44
+ "probes": [
45
+ {
46
+ "x": 4,
47
+ "y": 4,
48
+ "rgb": [
49
+ 16,
50
+ 32,
51
+ 48
52
+ ],
53
+ "note": "paper #102030"
54
+ },
55
+ {
56
+ "x": 32,
57
+ "y": 16,
58
+ "rgb": [
59
+ 240,
60
+ 128,
61
+ 64
62
+ ],
63
+ "note": "center of tapered stroke #F08040"
64
+ },
65
+ {
66
+ "x": 32,
67
+ "y": 44,
68
+ "rgb": [
69
+ 64,
70
+ 224,
71
+ 208
72
+ ],
73
+ "note": "center of ribbon #40E0D0"
74
+ }
75
+ ]
76
+ },
77
+ {
78
+ "name": "03_film_def_use_boil",
79
+ "file": "03_film_def_use_boil.ncel",
80
+ "probes_by_frame": {
81
+ "0": [
82
+ {
83
+ "x": 16,
84
+ "y": 16,
85
+ "rgb": [
86
+ 30,
87
+ 30,
88
+ 30
89
+ ],
90
+ "note": "frame 0 box at x in [8..24]"
91
+ },
92
+ {
93
+ "x": 36,
94
+ "y": 16,
95
+ "rgb": [
96
+ 235,
97
+ 235,
98
+ 235
99
+ ],
100
+ "note": "frame 0 outside shifted box"
101
+ }
102
+ ],
103
+ "2": [
104
+ {
105
+ "x": 16,
106
+ "y": 16,
107
+ "rgb": [
108
+ 235,
109
+ 235,
110
+ 235
111
+ ],
112
+ "note": "frame 2 box shifted right by +20px"
113
+ },
114
+ {
115
+ "x": 36,
116
+ "y": 16,
117
+ "rgb": [
118
+ 30,
119
+ 30,
120
+ 30
121
+ ],
122
+ "note": "frame 2 inside shifted box [28..44]"
123
+ }
124
+ ]
125
+ }
126
+ }
127
+ ]
128
+ }