northcel 0.1.0__tar.gz
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-0.1.0/LICENSE +10 -0
- northcel-0.1.0/PKG-INFO +93 -0
- northcel-0.1.0/README.md +67 -0
- northcel-0.1.0/northcel/__init__.py +79 -0
- northcel-0.1.0/northcel/ast_nodes.py +159 -0
- northcel-0.1.0/northcel/cli.py +290 -0
- northcel-0.1.0/northcel/config.py +12 -0
- northcel-0.1.0/northcel/conformance/01_evenodd_aperture.ncel +6 -0
- northcel-0.1.0/northcel/conformance/02_color_stroke_ribbon.ncel +7 -0
- northcel-0.1.0/northcel/conformance/03_film_def_use_boil.ncel +9 -0
- northcel-0.1.0/northcel/conformance/conformance_manifest.json +128 -0
- northcel-0.1.0/northcel/examples/lantern_film.ncel +14 -0
- northcel-0.1.0/northcel/examples/shower_f0001.ncel +11 -0
- northcel-0.1.0/northcel/examples/shower_f0001_compact.ncel +11 -0
- northcel-0.1.0/northcel/examples/shower_f0002.ncel +11 -0
- northcel-0.1.0/northcel/examples/shower_film_2f.ncel +21 -0
- northcel-0.1.0/northcel/formatter.py +168 -0
- northcel-0.1.0/northcel/parser.py +665 -0
- northcel-0.1.0/northcel/renderer.py +424 -0
- northcel-0.1.0/northcel/rings.py +151 -0
- northcel-0.1.0/northcel/validator.py +228 -0
- northcel-0.1.0/northcel.egg-info/PKG-INFO +93 -0
- northcel-0.1.0/northcel.egg-info/SOURCES.txt +28 -0
- northcel-0.1.0/northcel.egg-info/dependency_links.txt +1 -0
- northcel-0.1.0/northcel.egg-info/entry_points.txt +3 -0
- northcel-0.1.0/northcel.egg-info/requires.txt +2 -0
- northcel-0.1.0/northcel.egg-info/top_level.txt +1 -0
- northcel-0.1.0/pyproject.toml +52 -0
- northcel-0.1.0/setup.cfg +4 -0
- northcel-0.1.0/tests/test_northcel.py +210 -0
northcel-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Copyright (c) 2026 North Noir (https://northnoir.com). All rights reserved.
|
|
2
|
+
|
|
3
|
+
Proprietary License (matches packages/northnoir-cli).
|
|
4
|
+
|
|
5
|
+
Permission is granted to install and use the `northcel` package, CLI (`northcel` / `ncel`),
|
|
6
|
+
specification, examples, and editor grammar to author, validate, format, and render
|
|
7
|
+
`.ncel` AI-drawn images and videos locally or with North Noir services.
|
|
8
|
+
|
|
9
|
+
Redistribution of modified commercial forks of the North Noir platform or misrepresentation
|
|
10
|
+
of the official NorthCel specification without written permission from North Noir is prohibited.
|
northcel-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: northcel
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: NorthCel (.ncel) — the AI-drawn video programming language combining MoonBit typed ASTs, Bend parallel swarms, and GLSL 4x4 subpixel rendering.
|
|
5
|
+
Author: North Noir
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://northnoir.com/northcel
|
|
8
|
+
Project-URL: Documentation, https://northnoir.com/northcel
|
|
9
|
+
Project-URL: Repository, https://github.com/syver777/NorthNoir/tree/main/packages/northcel
|
|
10
|
+
Keywords: northcel,ncel,ai-drawn-video,animation,hand-drawn,dsl,northnoir
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
19
|
+
Classifier: Topic :: Multimedia :: Video
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: numpy>=1.24
|
|
24
|
+
Requires-Dist: Pillow>=10.0
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# `northcel` — The AI-Drawn Video Programming Language (`.ncel`)
|
|
28
|
+
|
|
29
|
+
**NorthCel** (`.ncel`) is North Noir's domain-specific programming language for AI-drawn images and frame-by-frame animation.
|
|
30
|
+
|
|
31
|
+
It combines the best properties of three complementary execution models into a single compact, deterministic format:
|
|
32
|
+
|
|
33
|
+
1. **MoonBit-style typed, declarative scene structure** — algebraic scene layers (`paper`, `def`/`use`, `layer`/`wash`, `stroke`, `ribbon`, `bend_swarm`), compact relative contour encodings (`0.25px` quarter-pixel deltas and `1px` Base-63 deltas), and strict `line:column` diagnostics that an AI coding agent can read and fix.
|
|
34
|
+
2. **Bend-style deterministic parallel evaluation** — repetitive organic marks (water droplets, beard bristles, wet hair strands, rain streaks, and line boil) are modeled as stateless agents whose output is a pure function of `(seed, frame_index, agent_id)`, independent of evaluation order.
|
|
35
|
+
3. **GLSL-style per-pixel supersampled rendering** — every frame is a pure function of `(time, seed, coordinates)` evaluated with even-odd winding parity on an `N×N` subpixel grid (default `4×4`, i.e., `0.25px` resolution) and box-integrated to 8-bit RGB/greyscale.
|
|
36
|
+
|
|
37
|
+
The reference implementation in this package (`northcel`) is a **pure-Python + NumPy renderer** requiring no GPU or external language compilers at user install time. MoonBit, Bend, and OpenGL/EGL GLSL remain optional documented backends.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
pipx install northcel
|
|
43
|
+
# or
|
|
44
|
+
pip install northcel
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Both `northcel` and the short alias `ncel` are installed on your `PATH`.
|
|
48
|
+
|
|
49
|
+
## CLI Commands
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
northcel check scene.ncel [--json] # Parse and validate with line:column diagnostics
|
|
53
|
+
northcel fmt scene.ncel [-w] [--check] # Canonical formatter
|
|
54
|
+
northcel info scene.ncel # JSON summary of layers, rings, vertices, and bytes
|
|
55
|
+
northcel render scene.ncel -o frame0.png # Render a single frame (--frame N, --seed S)
|
|
56
|
+
northcel film film.ncel -o frames/ --mp4 out.mp4 # Render frame sequence + optional MP4 via ffmpeg
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Example (`lantern_film.ncel`)
|
|
60
|
+
|
|
61
|
+
```ncel
|
|
62
|
+
northcel "0.1.0-draft"
|
|
63
|
+
|
|
64
|
+
film LanternFilm(width: 512, height: 512, fps: 12, frames: 6, precision: 0.25px, seed: 108) {
|
|
65
|
+
paper { color: #101824, antialias_grid: 4, grain: 0.012 }
|
|
66
|
+
|
|
67
|
+
def SeaWave(dx: 0, dy: 0) {
|
|
68
|
+
stroke crest(color: #6CCFF6, taper: (4.5, 1.5), boil: 0.75px) {
|
|
69
|
+
(40 + dx, 410 + dy), (180 + dx, 398 + dy), (330 + dx, 412 + dy), (470 + dx, 402 + dy)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
layer sea_base(color: #1B3B5A, rings: 1) { "0,1520 2048,0 0,528 -2048,0" }
|
|
74
|
+
layer lantern_halo(color: #F4A261, rings: 1, boil: 0.6px, translate: (sin(t * 6.28318) * 14, 0)) {
|
|
75
|
+
"920,640 208,0 40,120 -40,120 -208,0 -40,-120"
|
|
76
|
+
}
|
|
77
|
+
ribbon post_ink(color: #F8F9FA, boil: 0.4px) {
|
|
78
|
+
(256, 480, 12), (256 + sin(t * 6.28318) * 6, 320, 9), (256 + sin(t * 6.28318) * 14, 190, 6)
|
|
79
|
+
}
|
|
80
|
+
bend_swarm rain_streaks(agents: 4, color: #E0FBFC, boil: 0.35px, translate: (lerp(-18, 18, t), lerp(0, 48, t))) {
|
|
81
|
+
"320,240 24,72 -8,4 -24,-72|840,300 24,72 -8,4 -24,-72|1420,220 24,72 -8,4 -24,-72|1760,340 24,72 -8,4 -24,-72"
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
use SeaWave(dx: sin(t * 6.28318) * 10, dy: 0)
|
|
85
|
+
use SeaWave(dx: cos(t * 6.28318) * -12, dy: 42)
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Documentation
|
|
90
|
+
|
|
91
|
+
Full language specification (`0.1.0-draft`), AI-agent guide, benchmarks, and tutorials live at:
|
|
92
|
+
- Website: https://northnoir.com/northcel
|
|
93
|
+
- Repository docs: `docs/northcel/`
|
northcel-0.1.0/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# `northcel` — The AI-Drawn Video Programming Language (`.ncel`)
|
|
2
|
+
|
|
3
|
+
**NorthCel** (`.ncel`) is North Noir's domain-specific programming language for AI-drawn images and frame-by-frame animation.
|
|
4
|
+
|
|
5
|
+
It combines the best properties of three complementary execution models into a single compact, deterministic format:
|
|
6
|
+
|
|
7
|
+
1. **MoonBit-style typed, declarative scene structure** — algebraic scene layers (`paper`, `def`/`use`, `layer`/`wash`, `stroke`, `ribbon`, `bend_swarm`), compact relative contour encodings (`0.25px` quarter-pixel deltas and `1px` Base-63 deltas), and strict `line:column` diagnostics that an AI coding agent can read and fix.
|
|
8
|
+
2. **Bend-style deterministic parallel evaluation** — repetitive organic marks (water droplets, beard bristles, wet hair strands, rain streaks, and line boil) are modeled as stateless agents whose output is a pure function of `(seed, frame_index, agent_id)`, independent of evaluation order.
|
|
9
|
+
3. **GLSL-style per-pixel supersampled rendering** — every frame is a pure function of `(time, seed, coordinates)` evaluated with even-odd winding parity on an `N×N` subpixel grid (default `4×4`, i.e., `0.25px` resolution) and box-integrated to 8-bit RGB/greyscale.
|
|
10
|
+
|
|
11
|
+
The reference implementation in this package (`northcel`) is a **pure-Python + NumPy renderer** requiring no GPU or external language compilers at user install time. MoonBit, Bend, and OpenGL/EGL GLSL remain optional documented backends.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pipx install northcel
|
|
17
|
+
# or
|
|
18
|
+
pip install northcel
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Both `northcel` and the short alias `ncel` are installed on your `PATH`.
|
|
22
|
+
|
|
23
|
+
## CLI Commands
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
northcel check scene.ncel [--json] # Parse and validate with line:column diagnostics
|
|
27
|
+
northcel fmt scene.ncel [-w] [--check] # Canonical formatter
|
|
28
|
+
northcel info scene.ncel # JSON summary of layers, rings, vertices, and bytes
|
|
29
|
+
northcel render scene.ncel -o frame0.png # Render a single frame (--frame N, --seed S)
|
|
30
|
+
northcel film film.ncel -o frames/ --mp4 out.mp4 # Render frame sequence + optional MP4 via ffmpeg
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Example (`lantern_film.ncel`)
|
|
34
|
+
|
|
35
|
+
```ncel
|
|
36
|
+
northcel "0.1.0-draft"
|
|
37
|
+
|
|
38
|
+
film LanternFilm(width: 512, height: 512, fps: 12, frames: 6, precision: 0.25px, seed: 108) {
|
|
39
|
+
paper { color: #101824, antialias_grid: 4, grain: 0.012 }
|
|
40
|
+
|
|
41
|
+
def SeaWave(dx: 0, dy: 0) {
|
|
42
|
+
stroke crest(color: #6CCFF6, taper: (4.5, 1.5), boil: 0.75px) {
|
|
43
|
+
(40 + dx, 410 + dy), (180 + dx, 398 + dy), (330 + dx, 412 + dy), (470 + dx, 402 + dy)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
layer sea_base(color: #1B3B5A, rings: 1) { "0,1520 2048,0 0,528 -2048,0" }
|
|
48
|
+
layer lantern_halo(color: #F4A261, rings: 1, boil: 0.6px, translate: (sin(t * 6.28318) * 14, 0)) {
|
|
49
|
+
"920,640 208,0 40,120 -40,120 -208,0 -40,-120"
|
|
50
|
+
}
|
|
51
|
+
ribbon post_ink(color: #F8F9FA, boil: 0.4px) {
|
|
52
|
+
(256, 480, 12), (256 + sin(t * 6.28318) * 6, 320, 9), (256 + sin(t * 6.28318) * 14, 190, 6)
|
|
53
|
+
}
|
|
54
|
+
bend_swarm rain_streaks(agents: 4, color: #E0FBFC, boil: 0.35px, translate: (lerp(-18, 18, t), lerp(0, 48, t))) {
|
|
55
|
+
"320,240 24,72 -8,4 -24,-72|840,300 24,72 -8,4 -24,-72|1420,220 24,72 -8,4 -24,-72|1760,340 24,72 -8,4 -24,-72"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
use SeaWave(dx: sin(t * 6.28318) * 10, dy: 0)
|
|
59
|
+
use SeaWave(dx: cos(t * 6.28318) * -12, dy: 42)
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Documentation
|
|
64
|
+
|
|
65
|
+
Full language specification (`0.1.0-draft`), AI-agent guide, benchmarks, and tutorials live at:
|
|
66
|
+
- Website: https://northnoir.com/northcel
|
|
67
|
+
- Repository docs: `docs/northcel/`
|
|
@@ -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
|
+
]
|
|
@@ -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))
|
|
@@ -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())
|