nanoframes 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.
Files changed (62) hide show
  1. nanoframes-0.1.0/.gitignore +22 -0
  2. nanoframes-0.1.0/LICENSE +17 -0
  3. nanoframes-0.1.0/Makefile +37 -0
  4. nanoframes-0.1.0/PKG-INFO +54 -0
  5. nanoframes-0.1.0/README.md +39 -0
  6. nanoframes-0.1.0/docs/architecture.md +121 -0
  7. nanoframes-0.1.0/docs/composition.md +154 -0
  8. nanoframes-0.1.0/docs/text-capabilities.md +112 -0
  9. nanoframes-0.1.0/examples/assets/dot.png +0 -0
  10. nanoframes-0.1.0/examples/master-demo.nf.svg +87 -0
  11. nanoframes-0.1.0/examples/raster-title.nf.svg +29 -0
  12. nanoframes-0.1.0/examples/text-measure.nf.svg +45 -0
  13. nanoframes-0.1.0/examples/title-card.nf.svg +40 -0
  14. nanoframes-0.1.0/nanoframes/__init__.py +3 -0
  15. nanoframes-0.1.0/nanoframes/__main__.py +10 -0
  16. nanoframes-0.1.0/nanoframes/bake.py +151 -0
  17. nanoframes-0.1.0/nanoframes/cache.py +48 -0
  18. nanoframes-0.1.0/nanoframes/cli.py +423 -0
  19. nanoframes-0.1.0/nanoframes/curve.py +232 -0
  20. nanoframes-0.1.0/nanoframes/fonts/LICENSE-Sarasa-OFL.txt +113 -0
  21. nanoframes-0.1.0/nanoframes/fonts/SarasaMonoSC-Regular-noliga.ttf +0 -0
  22. nanoframes-0.1.0/nanoframes/fonts.py +298 -0
  23. nanoframes-0.1.0/nanoframes/lint.py +98 -0
  24. nanoframes-0.1.0/nanoframes/measure.py +110 -0
  25. nanoframes-0.1.0/nanoframes/model.py +95 -0
  26. nanoframes-0.1.0/nanoframes/parse.py +173 -0
  27. nanoframes-0.1.0/nanoframes/rastertext.py +207 -0
  28. nanoframes-0.1.0/nanoframes/render.py +128 -0
  29. nanoframes-0.1.0/nanoframes/scripts/__init__.py +70 -0
  30. nanoframes-0.1.0/nanoframes/scripts/verify_font.py +39 -0
  31. nanoframes-0.1.0/nanoframes/textflow.py +299 -0
  32. nanoframes-0.1.0/nanoframes/timeline.py +163 -0
  33. nanoframes-0.1.0/nanoframes/video.py +71 -0
  34. nanoframes-0.1.0/nanoframes/walkthrough.py +583 -0
  35. nanoframes-0.1.0/nanoframes/xmlutil.py +43 -0
  36. nanoframes-0.1.0/pyproject.toml +42 -0
  37. nanoframes-0.1.0/skills/nanoframes/SKILL.md +128 -0
  38. nanoframes-0.1.0/tests/snapshots/master-demo.nf_t0.1.png +0 -0
  39. nanoframes-0.1.0/tests/snapshots/master-demo.nf_t0.2.png +0 -0
  40. nanoframes-0.1.0/tests/snapshots/master-demo.nf_t1.png +0 -0
  41. nanoframes-0.1.0/tests/snapshots/master-demo.nf_t2.5.png +0 -0
  42. nanoframes-0.1.0/tests/snapshots/master-demo.nf_t2.png +0 -0
  43. nanoframes-0.1.0/tests/snapshots/master-demo.nf_t3.5.png +0 -0
  44. nanoframes-0.1.0/tests/snapshots/master-demo.nf_t4.5.png +0 -0
  45. nanoframes-0.1.0/tests/snapshots/text-measure.nf_t0.1.png +0 -0
  46. nanoframes-0.1.0/tests/snapshots/text-measure.nf_t1.png +0 -0
  47. nanoframes-0.1.0/tests/snapshots/text-measure.nf_t2.png +0 -0
  48. nanoframes-0.1.0/tests/snapshots/text-measure.nf_t3.5.png +0 -0
  49. nanoframes-0.1.0/tests/snapshots/title-card.nf_t0.1.png +0 -0
  50. nanoframes-0.1.0/tests/snapshots/title-card.nf_t1.png +0 -0
  51. nanoframes-0.1.0/tests/snapshots/title-card.nf_t2.png +0 -0
  52. nanoframes-0.1.0/tests/snapshots/title-card.nf_t3.5.png +0 -0
  53. nanoframes-0.1.0/tests/test_cache.py +42 -0
  54. nanoframes-0.1.0/tests/test_cli.py +122 -0
  55. nanoframes-0.1.0/tests/test_parse.py +112 -0
  56. nanoframes-0.1.0/tests/test_rastertext.py +272 -0
  57. nanoframes-0.1.0/tests/test_render.py +94 -0
  58. nanoframes-0.1.0/tests/test_skill.py +24 -0
  59. nanoframes-0.1.0/tests/test_snapshots.py +58 -0
  60. nanoframes-0.1.0/tests/test_textflow.py +111 -0
  61. nanoframes-0.1.0/tests/test_timeline.py +110 -0
  62. nanoframes-0.1.0/tests/test_walkthrough.py +44 -0
@@ -0,0 +1,22 @@
1
+ # LoopX / local goal state (private evidence — never commit)
2
+ .loopx/
3
+ .codex/
4
+ __pycache__/
5
+ *.py[cod]
6
+ *.egg-info/
7
+ build/
8
+ dist/
9
+ .venv/
10
+ venv/
11
+ .pytest_cache/
12
+ .ruff_cache/
13
+
14
+ # render outputs
15
+ out/
16
+ build/
17
+ *.mp4
18
+ .nanoframes-cache/
19
+
20
+ # macOS
21
+ .DS_Store
22
+ **/.DS_Store
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 nanoframes contributors
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
@@ -0,0 +1,37 @@
1
+ SHELL := /bin/bash
2
+ PY ?= python3
3
+
4
+ .PHONY: install test pytest lint build sdist upload clean
5
+
6
+ install:
7
+ $(PY) -m pip install -e .[dev]
8
+
9
+ test:
10
+ $(PY) -m pytest -q
11
+
12
+ pytest:
13
+ $(PY) -m pytest -q
14
+
15
+ lint:
16
+ ruff check nanoframes tests
17
+
18
+ # Local build: sdist + wheel into dist/ (hatchling; wheel embeds docs/skills/font).
19
+ build:
20
+ $(PY) -m pip install --upgrade build
21
+ $(PY) -m build .
22
+
23
+ # PyPI artifact: a fresh sdist only (mirrors ../redisk; pip builds the wheel
24
+ # from it on install). Dry-run check: twine check dist/*.tar.gz
25
+ sdist:
26
+ rm -rf dist
27
+ python3 -m pipx run build --sdist
28
+
29
+ # Publish the sdist to PyPI (needs twine + ~/.pypirc credentials).
30
+ # Test index first: twine upload dist/nanoframes-*.tar.gz -r testpypi
31
+ upload: sdist
32
+ twine upload dist/nanoframes-*.tar.gz -r pypi
33
+
34
+ clean:
35
+ rm -rf build dist *.egg-info nanoframes.egg-info
36
+ find . -name __pycache__ -type d -prune -exec rm -rf {} +
37
+ rm -rf out
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.4
2
+ Name: nanoframes
3
+ Version: 0.1.0
4
+ Summary: SVG-first, browserless, deterministic frame rendering built on ThorVG. Offline hyperframes-style animated compositions for fast AI-agent iteration.
5
+ License: Apache-2.0
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.8
8
+ Requires-Dist: numpy
9
+ Requires-Dist: pillow
10
+ Requires-Dist: thorvg-python
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest; extra == 'dev'
13
+ Requires-Dist: ruff; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # nanoframes
17
+
18
+ **SVG-first, browserless, deterministic frame rendering on ThorVG.**
19
+
20
+ nanoframes is an offline counterpart to [hyperframes](https://github.com/heygen-com/hyperframes): the same
21
+ idea — *write a frame, render a video, built for agents* — but without a browser. Compositions are
22
+ authored as **SVG** documents plus a small declarative animation timeline, and every frame is
23
+ rasterized deterministically by **ThorVG** (`thorvg-python`) to PNG, then muxed to MP4 with FFmpeg.
24
+
25
+ Because nothing depends on a browser, an AI agent can render a frame or a whole clip locally in
26
+ milliseconds and iterate fast: `check` → `preview` → `render`.
27
+
28
+ ## Status
29
+
30
+ **v1 complete.** Full offline pipeline works end-to-end: `init` → `check` →
31
+ `render`/`preview` → `video`. SVG compositions + declarative keyframe timeline are
32
+ rendered deterministically by ThorVG; MP4 export via ffmpeg; visual snapshots guard
33
+ regressions. Text auto-layout (measured chips, wrap, curve, fit) + a bundled
34
+ **monospace CJK** font (`Sarasa Mono SC`, ligatures stripped) for solid Chinese.
35
+
36
+ ## Layout
37
+
38
+ ```
39
+ docs/ architecture + composition contract
40
+ examples/ `.nf.svg` compositions
41
+ skills/nanoframes/ SKILL.md — the agent production loop
42
+ nanoframes/ package (model, parser, timeline, bake, render, lint, cli, video)
43
+ tests/ unit + render + snapshot + CLI tests
44
+ ```
45
+
46
+ ## Docs
47
+
48
+ - [Architecture](docs/architecture.md) — why SVG + ThorVG, the pipeline, scope decisions.
49
+ - [Composition](docs/composition.md) — the `.nf.svg` contract (timing attributes + animation timeline).
50
+ - [Text capabilities](docs/text-capabilities.md) — measured chips/wrap/curve/fit, bundled CJK font, `text_handler` escape hatch.
51
+
52
+ `docs/` and `skills/` also ship inside the pip wheel (`nanoframes/docs`,
53
+ `nanoframes/skills`); running `nanoframes` with no arguments prints where the
54
+ docs and the agent skill live — repo checkout or installed package alike.
@@ -0,0 +1,39 @@
1
+ # nanoframes
2
+
3
+ **SVG-first, browserless, deterministic frame rendering on ThorVG.**
4
+
5
+ nanoframes is an offline counterpart to [hyperframes](https://github.com/heygen-com/hyperframes): the same
6
+ idea — *write a frame, render a video, built for agents* — but without a browser. Compositions are
7
+ authored as **SVG** documents plus a small declarative animation timeline, and every frame is
8
+ rasterized deterministically by **ThorVG** (`thorvg-python`) to PNG, then muxed to MP4 with FFmpeg.
9
+
10
+ Because nothing depends on a browser, an AI agent can render a frame or a whole clip locally in
11
+ milliseconds and iterate fast: `check` → `preview` → `render`.
12
+
13
+ ## Status
14
+
15
+ **v1 complete.** Full offline pipeline works end-to-end: `init` → `check` →
16
+ `render`/`preview` → `video`. SVG compositions + declarative keyframe timeline are
17
+ rendered deterministically by ThorVG; MP4 export via ffmpeg; visual snapshots guard
18
+ regressions. Text auto-layout (measured chips, wrap, curve, fit) + a bundled
19
+ **monospace CJK** font (`Sarasa Mono SC`, ligatures stripped) for solid Chinese.
20
+
21
+ ## Layout
22
+
23
+ ```
24
+ docs/ architecture + composition contract
25
+ examples/ `.nf.svg` compositions
26
+ skills/nanoframes/ SKILL.md — the agent production loop
27
+ nanoframes/ package (model, parser, timeline, bake, render, lint, cli, video)
28
+ tests/ unit + render + snapshot + CLI tests
29
+ ```
30
+
31
+ ## Docs
32
+
33
+ - [Architecture](docs/architecture.md) — why SVG + ThorVG, the pipeline, scope decisions.
34
+ - [Composition](docs/composition.md) — the `.nf.svg` contract (timing attributes + animation timeline).
35
+ - [Text capabilities](docs/text-capabilities.md) — measured chips/wrap/curve/fit, bundled CJK font, `text_handler` escape hatch.
36
+
37
+ `docs/` and `skills/` also ship inside the pip wheel (`nanoframes/docs`,
38
+ `nanoframes/skills`); running `nanoframes` with no arguments prints where the
39
+ docs and the agent skill live — repo checkout or installed package alike.
@@ -0,0 +1,121 @@
1
+ # nanoframes Architecture
2
+
3
+ ## North star
4
+
5
+ **Write a frame as SVG. Render frames, and a video, without a browser.** Built so an AI coding
6
+ agent can iterate fast and deterministically on every local change: `check` → `preview` →
7
+ `render` in milliseconds, no browser, no network.
8
+
9
+ ## Why SVG + ThorVG (instead of HTML + Chromium)
10
+
11
+ Hyperframes uses HTML/CSS + a browser engine + GSAP, and captures frames through Chromium to a
12
+ seekable MP4. That is powerful but heavy and non-deterministic in some dimensions:
13
+
14
+ - The browser is a huge, opaque surface; agents can't inspect it locally without it.
15
+ - HTML/CSS layout is complex; matching pixel output requires capture round-trips.
16
+
17
+ nanoframes chooses the minimal deterministic subset that still covers typical motion-graphic
18
+ output:
19
+
20
+ - **Authoring surface = SVG**, which is (a) a first-class native output of language models, (b)
21
+ declarative, (c) directly rasterizable.
22
+ - **Rendering engine = ThorVG** (`thorvg-python`), an embedded SVG rasterizer (conforms to
23
+ *SVG Tiny 1.2* — see `../thorvg.wiki/SVG-Support.md`). No browser, no DOM, pure software raster
24
+ → Pillow → PNG/MP4. Deterministic and trivially iterable.
25
+ - The `frames-pack` toolchain already demonstrated offline ThorVG→PNG rendering; we reuse that
26
+ pattern (thorvg-python + Pillow), and we reuse hyperframes' *timing/intent* ideas (clip
27
+ in/out, tracks, seek) but <b>flatten animation to a declarative timeline</b> instead of GSAP.
28
+
29
+ ## Pipeline
30
+
31
+ ```
32
+ composition.nf.svg (SVG scene + embedded <script> JSON animation timeline)
33
+ │ parse (stdlib xml) → nanoframes.Composition
34
+ v
35
+ seek(t): evaluate animation timeline → per-frame property values
36
+
37
+ v
38
+ bake(t): merge visibility + animated props into a frame-specific SVG
39
+
40
+ v
41
+ thorvg render → Pillow PNG (single frame, or batch)
42
+
43
+ v
44
+ ffmpeg → deterministic MP4 (P1)
45
+ ```
46
+
47
+ Every stage is pure and deterministic given `(composition, t, fps)`. No web surface anywhere.
48
+
49
+ ## Composition model
50
+
51
+ A composition is one `.nf.svg` file (valid SVG) carrying:
52
+
53
+ - root `<svg>` canvas metadata: `data-width`, `data-height`, `data-fps`, `data-duration`
54
+ (`data-composition-id` optional).
55
+ - per-element presence: `data-start` + `data-duration` (absolute seconds, clip in/out) and
56
+ optional `data-fade` (fade-in/out seconds). Elements outside their window are not rasterized.
57
+ - an embedded `<script type="application/nanoframes+json">` holding the **animation timeline**:
58
+ ordered animations, each targeting elements by CSS selector (`#id`, `.class`) with keyframe
59
+ lists over time.
60
+
61
+ Detailed contract: `docs/composition.md`.
62
+
63
+ ## Animation timeline (declarative, seekable)
64
+
65
+ Hyperframes uses GSAP timelines loaded into the browser. We replace JS with a deterministic
66
+ keyframe JSON timeline evaluated by `seek(t)`:
67
+
68
+ - supported animated properties: `opacity`, `transform` (`translate`, `scale`, `rotate`),
69
+ `fill`, `stroke` (interpolated color).
70
+ - keyframes are `{ "t": <sec>, <props>: value }`; between keyframes we interpolate linearly,
71
+ optionally eased (`linear`, `ease-in`, `ease-out`, `ease-in-out`).
72
+ - clip + fade is folded into effective opacity so visibility, fade and animation compose into a
73
+ single `opacity` / `transform` baked value per frame.
74
+
75
+ This is deliberately *small*. Full GSAP/CSS expressiveness is out of scope; the payoff is fast,
76
+ deterministic, inspectable motion.
77
+
78
+ ## Rendering (thorvg-python)
79
+
80
+ - `bake(t)` writes a temp standalone SVG (strips the `<script>`, applies the computed attrs).
81
+ - a renderer creates `thorvg_python.Engine` + `SwCanvas`, sets the target size, loads the SVG as
82
+ a `Picture`, `add → update → draw → sync`, and reads a Pillow image via `get_pillow()`.
83
+ - Energy is per-frame and isolated, so batch rendering stays deterministic and trivially
84
+ parallelizable later.
85
+
86
+ ## Scope decisions (what we drop from hyperframes)
87
+
88
+ | Hyperframes scope | nanoframes |
89
+ |--------------------------------|------------|
90
+ | HTML/CSS + browser capture | SVG + ThorVG software raster |
91
+ | GSAP / Lottie / Three.js / anime | declarative keyframe timeline (v1) |
92
+ | inline `<video>` playback | static `<image>` only (v1); no video-in-scene |
93
+ | full audio mixing (buses, ducking) | optional FFmpeg audio mux passthrough (deferred P2) |
94
+ | hosted / Lambda / GCP rendering | local offline render only |
95
+ | Figma import, Remotion port | out of scope |
96
+ | 20 authoring workflows | a few core example templates |
97
+ | frame.md / design.md system | composition doc + lint (v1) |
98
+ | binary TreePack compressed pack | deferred optional binary cache (P2) |
99
+
100
+ **Kept:** deterministic seekable frames, clip timing, `check`/`preview`/`render` agent loop,
101
+ MP4 export, and an agent-facing skill.
102
+
103
+ ## Non-goals
104
+
105
+ - No browser, no network, no live media.
106
+ - No JS runtime / no GSAP semantics.
107
+ - No production audio mastering; no cloud rendering.
108
+
109
+ ## Milestones
110
+
111
+ - **v1 (done, 2026-09)** composition model + parser, timeline evaluator, ThorVG
112
+ frame renderer, CLI (`init`/`check`/`render`/`preview`/`video`/`measure`/
113
+ `fonts`/`walkthrough`), MP4 export + audio mux, fast re-render cache,
114
+ renderer-exact text story (chips/wrap/curve/fit), bundled mono CJK font,
115
+ `text_handler` escape hatch, example compositions + visual snapshot suite,
116
+ agent skill (`skills/nanoframes/SKILL.md`).
117
+ - **Deferred** optional binary tree-pack cache; CLI bridge for `text_handler`
118
+ (it is a library-API feature by design); in-scene video.
119
+
120
+ `docs/` and `skills/` ship inside the pip wheel as `nanoframes/docs` and
121
+ `nanoframes/skills`; `nanoframes` (no args) prints their installed locations.
@@ -0,0 +1,154 @@
1
+ # nanoframes composition contract
2
+
3
+ A composition is a single **`.nf.svg`** file: valid SVG + an embedded animation
4
+ timeline. Rendering is deterministic: the same `(composition, t, fps)` always
5
+ yields the same frame.
6
+
7
+ ## Canvas (root `<svg>`)
8
+
9
+ | attribute | default | meaning |
10
+ |---|---|---|
11
+ | `data-width` / `data-height` | — (required) | output canvas, pixels |
12
+ | `data-fps` | `30` | frames per second |
13
+ | `data-duration` | `1.0` | total duration, seconds |
14
+ | `data-composition-id` | — | name used for frame/video output |
15
+
16
+ `width`/`height` are accepted as fallbacks for `data-width`/`data-height`.
17
+
18
+ ## Element presence (clip in/out)
19
+
20
+ | attribute | default | meaning |
21
+ |---|---|---|
22
+ | `data-start` | `0` | absolute start time, seconds |
23
+ | `data-duration` | = composition duration | how long the clip is visible |
24
+ | `data-fade` | `0` | fade-in seconds at clip start (fade-out mirrors at clip end) |
25
+
26
+ Outside its `[start, start+duration]` window an element is `display:none` (not
27
+ rasterized). Fade and clip fold into the effective opacity.
28
+
29
+ ## Animation timeline
30
+
31
+ Embedded as `<script type="application/nanoframes+json"><![CDATA[ … ]]></script>`.
32
+ The JSON has one `animations` array; each animation has a CSS `target` (selector
33
+ `#id`, `.class`, or tag) and ordered `keyframes`. Per keyframe: `t` (seconds),
34
+ any animated properties, and an optional `ease` (`linear` | `ease-in` |
35
+ `ease-out` | `ease-in-out`, default `linear`).
36
+
37
+ ```json
38
+ {
39
+ "animations": [
40
+ {
41
+ "target": "#title",
42
+ "keyframes": [
43
+ { "t": 0.0, "opacity": 0.0, "transform": { "translate": [0, 30] } },
44
+ { "t": 0.7, "opacity": 1.0, "transform": { "translate": [0, 0] }, "ease": "ease-out" }
45
+ ]
46
+ }
47
+ ]
48
+ }
49
+ ```
50
+
51
+ Between keyframes, numeric properties interpolate **linearly** (after easing);
52
+ colors interpolate RGB; anything non-interpolable holds the earlier keyframe.
53
+
54
+ ### Supported properties
55
+
56
+ | property | value | interpolation |
57
+ |---|---|---|
58
+ | `opacity` | float `0..1` | linear/eased |
59
+ | `transform` | `{"translate":[x,y], "scale":[sx,sy], "rotate":deg}` | elementwise |
60
+ | `fill` / `stroke` | `#RRGGBB` (or `#RRGGBBAA`) | RGB lerp |
61
+
62
+ Bake emits `transform="translate(x,y) rotate(d) scale(sx,sy)"` (translate ·
63
+ rotate · scale order).
64
+
65
+ ## Media
66
+
67
+ - **Raster images**: `<image href="assets/foo.png" />` (relative paths are
68
+ dereferenced against the composition directory).
69
+ - **Fonts**: text renders with a system font loaded automatically (Arial /
70
+ DejaVuSans fallback). Declare `font-family`, `font-size`, `font-weight` as in SVG.
71
+
72
+ ## Supported SVG surface
73
+
74
+ ThorVG rasterizes the **SVG Tiny 1.2** subset — see
75
+ `../thorvg.wiki/SVG-Support.md`. Shapes (`rect`, `circle`, `ellipse`, `path`,
76
+ `polygon`, `polyline`, `line`), `<image>`, gradients, `<text>`/`<tspan>`,
77
+ `<g>`, `clipPath`/`mask`, and transforms are supported. Layout/CSS keep their
78
+ SVG meaning but only within that subset.
79
+
80
+ ### Known ThorVG behaviors
81
+
82
+ - **Keep transformed elements on-canvas.** An element whose transformed extent
83
+ leaves the canvas (especially rotated, partially off-screen) is clipped
84
+ coarsely: ThorVG paints **black** over the off-screen extent. Animate within
85
+ the frame (fade/scale/grow in place, translate inside bounds) rather than
86
+ sliding in from outside the canvas.
87
+ - **Text needs a loaded font.** ThorVG only rasterizes `<text>` after a font is
88
+ registered (`Text.font_load`, done automatically per engine). Renders are
89
+ therefore limited to fonts present on the host unless you supply one.
90
+
91
+ ## Text auto-layout (`data-*` on `<text>`)
92
+
93
+ Measured by the same ThorVG SVG loader that rasterizes the frame, so background
94
+ chips / wrapped lines / curved text always line up with the rendered glyphs.
95
+
96
+ | attr | meaning | default |
97
+ |---|---|---|
98
+ | `data-bg="COLOR"` | auto-sized rounded background chip behind the text | — |
99
+ | `data-bg-rx` | chip corner radius | `10` |
100
+ | `data-bg-pad-x` / `data-bg-pad-y` | ink-to-chip padding | `12` / `8` |
101
+ | `data-wrap="WIDTH"` | wrap text into stacked `<text>` lines fitting `WIDTH` px | — |
102
+ | `data-fit="WIDTH"` | auto-shrink font-size (floor `data-fit-min`) so the text fits | `data-fit-min=9` |
103
+ | `data-curve-d="PATH"` | place each char along a sampled SVG path `d` | — |
104
+ | `data-curve-circle="cx,cy,r[,startDeg]"` | place each char along a circle | — |
105
+
106
+ When auto-layout is used without a `Measurer` (e.g. a bare `bake_svg` call) it
107
+ is a no-op, so plain compositions bake exactly as before. See
108
+ `docs/text-capabilities.md` for the full audit and caveats.
109
+
110
+ ## External raster text (`text_handler`)
111
+
112
+ Text that ThorVG fonts cannot cover — LaTeX math, an exotic brand face, emoji —
113
+ is rendered *outside* the framework by a library callback. Pass
114
+ `text_handler=callable` to `bake_svg` / `render_frame`; every
115
+ `<text data-raster="kind">` is offered to it as a `TextRequest` (content, kind,
116
+ style, fill, raw attrs). The handler returns **PNG bytes** and the node is
117
+ replaced by an `<image>` at an anchor-driven bbox, or `None` to keep the normal
118
+ ThorVG font path (auto-layout `data-*` still applies in that case).
119
+
120
+ | attr | meaning | default |
121
+ |---|---|---|
122
+ | `data-raster="KIND"` | opt in to the handler; `KIND` is passed through for dispatch | — |
123
+ | `data-anchor` | which bbox corner/edge sits at `(x, y)`: `top-*` / `middle-*` / `bottom-*` × `-left` / `-center` / `-right` | `center` |
124
+ | `data-width` / `data-height` | target box px; PNG is uniformly contained (no distortion) | natural size |
125
+ | `data-yaw` | degrees clockwise, rotating the image around the anchor point | `0` |
126
+
127
+ Contract: `x`/`y` mean the anchor point (not an SVG baseline) while rasterized;
128
+ bytes must decode as PNG (anything else raises at bake time); a handler should
129
+ be a deterministic pure function of the request and is invoked once per node
130
+ per bake — `render_frame` bypasses `FrameCache` whenever a handler is supplied
131
+ (its key knows nothing about the handler); CLI rendering has no handler and
132
+ falls back to fonts. See `nanoframes/rastertext.py` and
133
+ `tests/test_rastertext.py` for the reference contract.
134
+
135
+ ## Fonts
136
+
137
+ A bundled **monospace CJK** face ships with nanoframes
138
+ (`fonts/SarasaMonoSC-Regular-noliga.ttf`, Sarasa Mono SC, ligature feature
139
+ stripped). Use `font-family="Sarasa Mono SC"` for deterministic-width Chinese
140
+ labels (each Han char = `font-size` px wide). Query it with
141
+ `nanoframes fonts list`; add other faces with `nanoframes fonts add <path>`.
142
+
143
+ ## CLI examples
144
+
145
+ ```bash
146
+ nanoframes init my-video # scaffold a .nf.svg
147
+ nanoframes check my-video.nf.svg # lint (exit 1 on errors)
148
+ nanoframes measure my-video.nf.svg # report renderer-exact text widths
149
+ nanoframes render my-video.nf.svg --t 2.0 # single frame PNG
150
+ nanoframes preview my-video.nf.svg --t 2.0 # render + open
151
+ nanoframes render my-video.nf.svg -o frames # full batch
152
+ nanoframes video my-video.nf.svg -o out.mp4 # MP4 via ffmpeg
153
+ nanoframes video my-video.nf.svg -o out.mp4 --audio track.mp3 # + audio mux
154
+ ```
@@ -0,0 +1,112 @@
1
+ # Text capabilities: audit, findings, and the nanoframes answer
2
+
3
+ This doc is the result of a full audit of our text story against the reference
4
+ projects — **hyperframes** and (its ancestor) **remotion** — plus the actual
5
+ ThorVG runtime we render with. It answers three questions with evidence and
6
+ specifies the features we added (and how to extend further).
7
+
8
+ ## The three questions
9
+
10
+ ### 1. Can we measure a text bounding box exactly? — Yes, and it must be renderer-exact
11
+
12
+ Two ways, and one of them is misleading:
13
+
14
+ | approach | exact? | notes |
15
+ |---|---|---|
16
+ | **rasterize + read ink** (what we use) | ✅ exact by construction | Reuses the *same* SVG loader, so the measured box always matches the final render. Size = tiny isolated `<text>` raster; cached by `(text, family, weight, size)`. |
17
+ | ThorVG `Text.get_aabb` / `get_metrics` | ❌ no | Two gotchas found empirically: (a) `set_size` is in **points** (px = size × `96/72`, so you must pass `px × 0.75`); (b) in this `thorvg-python 1.1.1` build a hand-built `Text` **reports metrics but does not rasterize**, and per-glyph widths are unreliable — a lone `i` measured `0.86 px`. The *newer* source `../thorvg-python` adds `get_glyph_metrics` + `line_count`; upgrading would give fast per-glyph advances. |
18
+
19
+ Because the background-misalignment bug you hit is exactly the "my vet habit maths the text differently than my renderer" failure, the invariant we chose is: **measure by rendering.** Same fonts, same loader, same result. Add a `Measurer` and auto-size from ink — it cannot drift.
20
+
21
+ ### 2. Multiline text? — Not natively, so we build it
22
+
23
+ Empirically, in the installed ThorVG SVG loader neither route works:
24
+
25
+ - `<tspan>` with `x`/`y`/`dy` **renders all spans on the same baseline** (confirmed: two tspans at `y=100`/`dy=40` both ink a single 20 px-tall line).
26
+ - a literal `&#10;` newline is **collapsed into horizontal advance** (two "lines" render side by side, one line tall).
27
+
28
+ So multiline must be baked by us. We measure each candidate line and emit **separate `<text>` per line** (each line is its own paint, which ThorVG positions reliably). Interface: `data-wrap="<maxWidth>"`.
29
+
30
+ ### 3. Text along a shape / curve? — No `<textPath>`, so we sample the curve ourselves
31
+
32
+ ThorVG's SVG loader has **no `<textPath>`** (confirmed: it loads with error code 0 but rasterizes nothing — an 8 px sliver). We implement the standard offline approach remotion exposes in `@remotion/paths` (`getPointAtLength` / `getTangentAtLength`): flatten the path, build an arc-length table, place each character at `point(s)` rotated to `tangent(s)`. Uses per-character ink advances for spacing. Mirrors remotion's `warp-path`/`paths` suite but as pure Python.
33
+
34
+ ## Features we added (v1 of the text story)
35
+
36
+ All three are bake-time passes over the already-animated tree, driven by a
37
+ renderer-exact `Measurer` (`nanoframes/measure.py`, `curve.py`, `textflow.py`):
38
+
39
+ - **Measured background chip** — `<text data-bg="#3b5998" data-bg-pad-x="10" data-bg-pad-y="6" data-bg-rx="12">行人入侵</text>`: bake injects a rounded `<rect>` sized to the ink + padding. Padding/radius optional.
40
+ - **Auto-wrap paragraph** — `<text data-wrap="300">…</text>` (optionally with `data-bg` for a block chip): bake splits into stacked lines that fit the width.
41
+ - **Along-curve text** — `<text data-curve-d="M 90 300 C 200 190 420 190 560 300">DRIVE SAFE</text>` or `<text data-curve-circle="cx,cy,r[,startDeg]">…</text>`: bake emits rotated per-character `<text>` elements.
42
+ - **`nanoframes measure <comp>`** — prints each text's renderer-exact width, so an author can sanity-check widths before trusting a layout.
43
+
44
+ Consistency invariant: measurement uses the same font list, loader, and raster as the final frame. If auto-layout runs without a `Measurer` it is a no-op, so plain compositions bake exactly as before (all existing tests pass).
45
+
46
+ ## External text: the `text_handler` escape hatch (v1)
47
+
48
+ Every finding above is about text **ThorVG draws with a loaded font**. When the
49
+ text itself cannot live there — LaTeX math, a brand face that crashes this
50
+ loader, emoji — the framework steps aside instead of growing a font engine:
51
+
52
+ `<text data-raster="kind">` is offered to a library-supplied `text_handler`
53
+ (`bake_svg` / `render_frame` parameter). The handler receives a `TextRequest`
54
+ (content, kind, style, the live animated fill, raw attrs) and returns **PNG
55
+ bytes**; bake replaces the node with an `<image>` at an anchor-driven bbox
56
+ (`data-anchor` = one of 9 corner/edge/center anchors, default `center`;
57
+ `data-width` / `data-height` contain the PNG without distortion; `data-yaw`
58
+ rotates it around the anchor point). `None` keeps the normal ThorVG font
59
+ path — chips, wrap, curve and fit all still apply there.
60
+
61
+ Consistency note: this is the deliberate exception to "measure by rendering".
62
+ A handled text's ink box is exact **by construction**: the PNG is literally
63
+ what the frame draws, and its size comes from the bytes (Pillow), never from
64
+ ThorVG metrics or fallback-font widths — so nothing can drift. The handler
65
+ must be a deterministic pure function of the request (there is no framework
66
+ memo; slow backends cache internally), and `render_frame` bypasses
67
+ `FrameCache` while a handler is attached, since the cache key knows nothing
68
+ about it. CLI rendering has no Python callback, so `data-raster` files fall
69
+ back to fonts there — this is a library-API feature.
70
+
71
+ Reference: `docs/composition.md` → External raster text,
72
+ `nanoframes/rastertext.py`, `tests/test_rastertext.py` (a hello → 你好 dummy
73
+ handler end-to-end), and the walkthrough showcase built from
74
+ `examples/raster-title.nf.svg`.
75
+
76
+ ## What else is absorbable from hyperframes / remotion (ranked)
77
+
78
+ | idea | source | nanoframes status |
79
+ |---|---|---|
80
+ | TeX / any custom typesetting — LaTeX math, exotic faces (manim compiles TeX to glyph-path SVG so its renderer never loads fonts) | manim | ✅ v1 as the `text_handler` hook above — outside ThorVG fonts entirely (library API; a CLI bridge is deferred). |
81
+ | measure + auto-fit font size (`fitTextFontSize` shrink-down until it fits one line) | hyperframes `core/text`, remotion layout-utils | ⚠️ trivial to add on top of `Measurer` (binary/step search). |
82
+ | word-level wrapping / caption timing | remotion `@remotion/captions`, layout-utils; hyperframes caption blocks | wrap ✅ done; caption-timing needs duration→width math, straightforward. |
83
+ | shrinkwrap "rounded text box" | remotion `@remotion/rounded-text-box` | ✅ done as `data-bg`. |
84
+ | per-char / per-word highlight, kinetic captions (weight-shift, gradient-fill, clip-wipe…) | hyperframes `docs/catalog/components/caption-*` | examples to port later; core is just `tspan`-replacements + fills we already bake. |
85
+ | global transforms (e.g. `getBoundingRect` helpers, `@remotion/paths` point/tangent) | remotion paths | curve ✅; expose `getLength/point/tangent` as re-usable module (done in `curve.py`). |
86
+ | variable-font axis animation | hyperframes `registry/components/variable-font-flex` | ⚠️ needs font with variation axes + a shaper; stretch. |
87
+ | Google-fonts auto-download | hyperframes/remotion `fonts` | ⚠️ offline equivalent = a `fonts/` dir + exact-family registration; see font note below. |
88
+
89
+ ## Findings / caveats to keep in mind
90
+
91
+ - **Font coverage decides width.** A CJK string measured under a font lacking the glyphs still "measures" (via fallback), so a chip sized for it matches the *fallback* glyphs — not a real Chinese face. Use a face that actually has the glyphs.
92
+ - **ThorVG's loader font-family resolution is unreliable in this build.** A loaded font is not always used (`Arial Unicode MS` loads but never matches, so Chinese falls back to a dim built-in face). Empirically only some faces render solidly, and some *crash the process at teardown* when loaded (`AppleGothic` → segfault exit 139). `.ttc` collections can't be loaded at all.
93
+ - **We therefore do NOT auto-register arbitrary discovered fonts in the renderer.** Instead: `nanoframes fonts list` shows CJK faces + exact family names; `nanoframes fonts add <path>` copies a font into `~/.local/share/nanoframes/fonts`. The **bundled** face below is registered explicitly (verified safe).
94
+ - **Bundled CJK font: Sarasa Mono SC.** `nanoframes/fonts/SarasaMonoSC-Regular-noliga.ttf` (SIL OFL) is a **monospace** CJK face (Western = Iosevka-style, Han = Source Han Sans) — every Han char is exactly `font-size` px wide, and its `calt`/`dlig` ligature features are stripped. It is loaded **first** in the candidates list, so it is ThorVG's default: CJK text (and any font-family that doesn't resolve) renders through Sarasa, solid and monospaced. Explicit `font-family="Arial"` still resolves to Arial. Verified: load-safe, resolves, solid CJK, clean exit (unlike `AppleGothic`, which crashes this ThorVG build).
95
+ - **等宽 for authoring.** Use `font-family="Sarasa Mono SC"` for deterministic-width Chinese labels (and `nanoframes measure` reports its exact widths).
96
+ - **`set_size` points-vs-px trap.** If you use the raw `Text` metric API directly, px = size × `96/72`. We avoid it for exact work.
97
+ - **Upgrade path.** The newer `thorvg-python` (see `../thorvg-python`) reworks text/font support and exposes `get_glyph_metrics` + `line_count`; upgrading is the enabling change for a truly bundled/等宽 CJK font.
98
+
99
+ ## Contract (extended)
100
+
101
+ New optional `data-*` on `<text>`:
102
+
103
+ | attr | meaning | default |
104
+ |---|---|---|
105
+ | `data-bg` | fill color of an auto-sized rounded background chip | — |
106
+ | `data-bg-rx` | chip corner radius | 10 |
107
+ | `data-bg-pad-x` / `data-bg-pad-y` | ink→chip padding | 12 / 8 |
108
+ | `data-wrap` | max line width (px); wraps text into stacked `<text>` lines | — |
109
+ | `data-curve-d` | SVG path `d` to lay characters along | — |
110
+ | `data-curve-circle` | `cx,cy,r[,startDeg]` circular layout | — |
111
+
112
+ Rendering a frame/video with these is unchanged: `nanoframes render|preview|video`.
Binary file
@@ -0,0 +1,87 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg"
2
+ data-width="960" data-height="540" data-fps="30" data-duration="6.0"
3
+ data-composition-id="master-demo">
4
+ <defs>
5
+ <linearGradient id="sky" x1="0" y1="0" x2="1" y2="1">
6
+ <stop offset="0" stop-color="#0a1128"/>
7
+ <stop offset="1" stop-color="#203a6b"/>
8
+ </linearGradient>
9
+ <linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
10
+ <stop offset="0" stop-color="#5ef17c"/>
11
+ <stop offset="1" stop-color="#38bdf8"/>
12
+ </linearGradient>
13
+ </defs>
14
+
15
+ <rect id="bg" width="960" height="540" fill="url(#sky)"/>
16
+
17
+ <!-- floating image: translate + scale pulse -->
18
+ <image id="orb" class="float" href="assets/dot.png"
19
+ x="700" y="120" width="120" height="120"
20
+ data-start="0.2" data-duration="5.2"/>
21
+ <image id="orb2" class="float" href="assets/dot.png"
22
+ x="100" y="320" width="72" height="72" transform="scale(0.6)"
23
+ data-start="0.6" data-duration="4.8"/>
24
+
25
+ <!-- rotating gradient panel (translate + rotate) -->
26
+ <rect id="panel" x="0" y="0" width="200" height="120" rx="22" fill="url(#accent)"
27
+ opacity="0.9" data-start="0.0" data-duration="6.0"/>
28
+
29
+ <!-- title: fade + slide -->
30
+ <text id="title" x="60" y="120" font-family="Sarasa Mono SC" font-weight="700"
31
+ font-size="72" fill="#f4f7ff" data-start="0.0" data-duration="6.0">
32
+ nanoframes
33
+ </text>
34
+
35
+ <!-- subtitle with fade, gradient underline that grows -->
36
+ <rect id="rule" x="62" y="150" width="6" height="90" rx="3" fill="url(#accent)"
37
+ data-start="0.6" data-duration="5.0"/>
38
+ <text id="sub" x="90" y="205" font-family="Sarasa Mono SC" font-size="30"
39
+ fill="#b6c2d8" data-start="0.9" data-duration="4.8">
40
+ SVG renders MP4. No browser. Fast for agents.
41
+ </text>
42
+
43
+ <script type="application/nanoframes+json"><![CDATA[
44
+ {
45
+ "animations": [
46
+ {
47
+ "target": "#panel",
48
+ "keyframes": [
49
+ {"t": 0.0, "opacity": 0.0, "transform": {"translate": [-260, 260], "rotate": 0}},
50
+ {"t": 1.0, "opacity": 0.9, "transform": {"translate": [60, 380], "rotate": -14}, "ease": "ease-in-out"},
51
+ {"t": 4.0, "opacity": 0.9, "transform": {"translate": [700, 320], "rotate": 12}, "ease": "ease-in-out"},
52
+ {"t": 5.5, "opacity": 0.4, "transform": {"translate": [760, 420], "rotate": 0}}
53
+ ]
54
+ },
55
+ {
56
+ "target": "#title",
57
+ "keyframes": [
58
+ {"t": 0.0, "opacity": 0.0, "transform": {"translate": [0, 30]}},
59
+ {"t": 0.7, "opacity": 1.0, "transform": {"translate": [0, 0]}, "ease": "ease-out"}
60
+ ]
61
+ },
62
+ {
63
+ "target": ".float",
64
+ "keyframes": [
65
+ {"t": 0.2, "opacity": 0.0, "transform": {"translate": [0, 40], "scale": [0.5, 0.5]}},
66
+ {"t": 1.2, "opacity": 1.0, "transform": {"translate": [0, 0], "scale": [1.0, 1.0]}, "ease": "ease-out"},
67
+ {"t": 4.5, "opacity": 1.0, "transform": {"translate": [0, -30], "scale": [1.1, 1.1]}, "ease": "ease-in-out"}
68
+ ]
69
+ },
70
+ {
71
+ "target": "#rule",
72
+ "keyframes": [
73
+ {"t": 0.6, "opacity": 0.0, "transform": {"scale": [1.0, 0.05]}},
74
+ {"t": 1.2, "opacity": 1.0, "transform": {"scale": [1.0, 1.0]}, "ease": "ease-in-out"}
75
+ ]
76
+ },
77
+ {
78
+ "target": "#sub",
79
+ "keyframes": [
80
+ {"t": 0.9, "opacity": 0.0},
81
+ {"t": 1.4, "opacity": 1.0}
82
+ ]
83
+ }
84
+ ]
85
+ }
86
+ ]]></script>
87
+ </svg>