Metadata-Version: 2.4
Name: codeanim-py
Version: 0.4.0
Summary: Describe animation in Python code; render it to a video file. No window, no interaction. 用 Python 代码写动画，一条命令出视频。
License-Expression: MIT
Project-URL: Homepage, https://pypi.org/project/codeanim-py/
Project-URL: Source, https://github.com/example/codeanim-py
Project-URL: Issues, https://github.com/example/codeanim-py/issues
Keywords: animation,video,motion-graphics,ffmpeg,pillow,python,cli,headless,keyframes,easing
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Multimedia :: Graphics
Classifier: Topic :: Multimedia :: Video
Classifier: Topic :: Multimedia :: Video :: Non-Linear Editor
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Pillow>=9.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: build>=0.10; extra == "dev"
Requires-Dist: twine>=4.0; extra == "dev"
Dynamic: license-file

# CodeAnim

[中文说明](README.md) · **English**

Describe animation in Python code. One command renders it to a video file.
No window, no interaction, no player — it just writes the file and exits.

```bash
codeanim examples/demo.py
# -> out.mp4
```

**Zero-disk by default.** Frames are piped straight into ffmpeg's stdin; no
temporary files are written along the way. Disk usage equals the final video
size, so 4K / high-quality / long clips are safe to run.

---

## Contents

- [Install](#install)
- [Command line](#command-line)
- [Hot reload](#hot-reload)
- [In-terminal preview](#in-terminal-preview)
- [Drawn primitives](#drawn-primitives)
- [Gradients / shadows / 3D tilt](#gradients--shadows--3d-tilt)
- [Particles](#particles)
- [Text](#text)
- [Images & sprite animation](#images--sprite-animation)
- [Audio](#audio)
- [Composing a scene](#composing-a-scene)
- [Disk safety](#disk-safety)
- [Output formats](#output-formats)
- [Design constraints](#design-constraints)
- [Testing](#testing)
- [Examples](#examples)

---

## Install

Needs **Python 3.8+**, **Pillow**, and **ffmpeg**.

```bash
pip install codeanim-py      # "-py" suffix: `codeanim` was taken on PyPI
codeanim doctor              # checks the dependencies, tells you what to install
```

> **The import name is still `codeanim`** — you never type the distribution name
> in your code:
> ```python
> from codeanim import Scene      # not codeanim_py
> ```

### Language

Output is **English by default**. Add `--ch` for Chinese:

```bash
codeanim demo.py            # English help / logs / errors
codeanim demo.py --ch       # 全程中文
codeanim demo.py --zh       # same thing
CODEANIM_LANG=zh codeanim demo.py    # or via env var
```

This affects the CLI help, progress, warnings, and error messages.
Docstrings and comments in the source stay Chinese.

**Not installing is also fine** (handy when working on the engine itself):

```bash
python -m pip install -r requirements.txt
python main.py doctor
```

---

## Command line

```bash
codeanim <script.py> [options]
codeanim doctor
```

| Option | What it does | Default |
|---|---|---|
| `-o, --output` | Output path; extension picks the format | `out.mp4` |
| `--fps` | Frame rate | 30 |
| `--quality` | `low` / `medium` / `high` / `lossless` | `high` |
| `--workers, -w` | Parallel processes | CPU cores − 1 |
| `--snapshot <sec>` | Render a single PNG frame | — |
| `--frames <dir>` | Export a PNG sequence | — |
| `--gif` | Export GIF | — |
| `--keep-frames` | Keep intermediate frames (debug) | off |
| `--supersample <n>` | Supersampling factor | 2 |
| `--mode <m>` | Frame storage: `stream` / `ram` / `disk` | auto |
| `--alpha` | Keep alpha channel (`.mov` / `.webm`) | auto |
| `--ffmpeg <path>` | Path to ffmpeg | `ffmpeg` |
| `--watch, -W` | **Hot reload** on file change | off |
| `--watch-at <sec>` | Preview time point for `--watch` | `0` |
| `--inline` | Draw the frame in the terminal | auto |
| `--no-inline` | Don't draw in-terminal | — |
| `--preview <sec>` | Render one frame and open it | — |
| `--no-audio` | Ignore audio tracks | off |
| `--list-audio` | List the audio tracks and exit | — |
| `--layers` | Print the draw order and exit | — |
| `--ch` / `--zh` / `--en` | Output language | `en` |
| `--quiet, -q` | Only print errors | off |
| `--free-margin <size>` | Abort below this much free disk | `256MB` |
| `--force` | Render even if disk-space check fails | off |
| `--no-disk-check` | Skip all disk checks | off |

```bash
codeanim demo.py --watch                       # edit with live preview
codeanim demo.py --watch --watch-at 2.5        # watch one specific frame
codeanim demo.py --preview 2.0                 # one frame, then open it
codeanim demo.py --snapshot 2.0                # one frame, don't open
codeanim demo.py --gif                         # GIF
codeanim demo.py --frames pngs/                # PNG sequence
codeanim demo.py -o out.mov --alpha            # transparent MOV
codeanim demo.py --layers                      # who covers whom
codeanim demo.py --list-audio                  # the audio timeline
```

---

## Hot reload

The slow loop when writing animation is *edit → run → wait → open the file*.
`--watch` removes everything in the middle:

```bash
codeanim examples/demo.py --watch
```

```
CodeAnim hot reload
────────────────────────────────────────────────────────
  entry    examples/demo.py
  watching 7 .py files
  preview  .codeanim/watch_preview.png
  t = 2.00s
────────────────────────────────────────────────────────
  Save a file and the preview refreshes. Ctrl-C to quit.

✓ updated  1280x720  8.60s  30 fps  11 primitives
   preview  .codeanim/watch_preview.png  (t=2.00s)
   took     814 ms

↻ changed: examples/demo.py
✓ updated  1280x720  8.60s  30 fps  11 primitives
   took     225 ms
```

- Watches the entry script **and every project `.py` it imports** — editing a
  fragment file triggers it too
- Renders **one frame**, not the whole video — typically **200–800 ms**
- **Errors don't exit**: syntax / runtime errors are printed, then it keeps waiting
- Editor "save" bursts are **debounced** into a single re-render
- Newly added `.py` files are picked up automatically
- No third-party deps, plain mtime polling, cross-platform

---

## In-terminal preview

Don't want to open an image? Draw it right in the terminal:

```bash
codeanim examples/demo.py --watch --inline
```

```
✓ updated  1280x720  8.60s  30 fps  11 primitives   took 1.46 s

  ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
  ▄▄▄▄  ▄▄▄▄▄▄▄▄▄▄▄  ▄▄▄▄▄▄▄▄▄▄▄▄▄▄
  ▄▄▄  CodeAnim  animation in Python  ▄▄▄
  ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
       ●                                ●
  t=4.00s   11 primitives   .codeanim/watch_preview.png
```

A terminal cell is roughly 1:2 (width:height), so the **upper/lower half block**
`▄` lets one cell carry two vertical pixels and keeps the aspect ratio honest.
An 80×24 terminal is about 80×48 colour cells — enough for layout, palette,
and placement.

- True colour (24-bit) first, then 256 colour, then greyscale characters
- Repaints **in place** (ANSI cursor movement), no scrolling
- Auto-detects terminal support; `--no-inline` disables, `--inline` forces
- Also callable from code:

```python
from codeanim import render_terminal_preview, preview_lines

print(render_terminal_preview(scene, at=2.0))        # a coloured string
lines = preview_lines(scene, at=2.0, max_cols=80)    # list of lines
```

---

## Drawn primitives

| Primitive | Position anchor | Main options |
|---|---|---|
| `Circle` | centre | `r`, `fill`, `stroke`, `stroke_width` |
| `Rect` | top-left | `w`, `h`, `radius`, `fill`, `stroke` |
| `Line` | start point | `stroke`, `stroke_width` |
| `Text` | by `anchor` | `text`, `font_size`, `anchor`, `fill`, `fit_width` |
| `Image` | top-left | `source`, `w`, `h`, `crop`, `slice`, `tint`, `radius` |
| `Path` | top-left | `points`, `spline`, `closed`, `progress`, `arrow`, `dash` |
| `Digits` | by `anchor` | `value`, `decimals`, `thousands`, `formatter` |
| `Group` | centre | `rotation`, `scale`, `opacity` |

Origin is top-left, y grows downward.

**Every** primitive also accepts:

| Option | Meaning |
|---|---|
| `cx` / `cy` | position by **geometric centre** (no manual offset math) |
| `z` | layer number, higher draws on top |
| `rotation` / `scale` / `opacity` | transforms, all animatable |
| `shadow` | drop shadow (see below) |
| `perspective` | 3D-ish tilt (see below) |

Anything numeric or colour can be keyframed:

```python
ball.cx.keys([(0, 200), (2, 1000)])                 # centre over time
ball.fill.keys([(0, "red"), (2, "#00aaff")])        # colour interpolation
ball.x.keys([(0, 0), (1, 500)], ease="outBack")     # ease per segment
```

25 built-in easing curves (`linear`, `inOutCubic`, `outBack`, `inOutElastic`,
`outBounce`, …). `ease=` goes on the **starting** keyframe and describes the
segment *toward the next one*.

### Layer order

```bash
codeanim script.py --layers              # who covers whom
codeanim script.py --layers --layers-at 2.5
```

```
Draw order (bottom to top, 30 items)
────────────────────────────────────────────────────────────────────────
    #  z    type    name      position / size
────────────────────────────────────────────────────────────────────────
    1  -100 rect    <rect>    (0,0) 1280x720
    2  0    text    <text>    c(640,340) 'one command, one video'
    3  10   text    <text>    c(640,330) 'CodeAnim'
    4  20   digits  <digits>  c(1220,650) '0,000 frames'
────────────────────────────────────────────────────────────────────────
! Some primitives share the same z: 0(25), 10(3).
    Same z draws in insertion order -- later ones on top.
! 1 static primitive(s) get pre-composed into the bottom layer.
```

It warns about: all-equal `z`, duplicated `z`, static primitives (they get
baked into a bottom layer), and decreasing `z` (usually group mixing).

---

## Gradients / shadows / 3D tilt

```python
from codeanim import Gradient, Shadow, Perspective

# gradient fill — just pass it as `fill=`
box = Rect(cx=640, cy=360, w=400, h=200,
           fill=Gradient.linear("#38bdf8", "#a855f7", angle=45))

Circle(cx=200, cy=200, r=80, fill=Gradient.radial("#fff", "#f00"))
Circle(cx=500, cy=200, r=80, fill=Gradient.conic(["#f00", "#0f0", "#00f"]))

# uneven stops
Gradient.linear([(0.0, "#000"), (0.3, "#38bdf8"), (1.0, "#fff")])

# drop shadow
card = Rect(cx=640, cy=360, w=300, h=180, fill="#1e293b",
            shadow=Shadow(blur=20, dy=8, alpha=0.5))

# 3D-ish tilt (quad warp, not real 3D — still pure & parallel)
panel = Rect(cx=640, cy=360, w=400, h=300, fill="#0ea5e9",
             perspective=Perspective(tilt_y=25, tilt_x=-10, distance=900))
```

| Gradient | |
|---|---|
| `linear(*stops, angle=90)` | 0° = left→right, 90° = top→bottom |
| `radial(*stops, center=(.5,.5), radius=.5)` | centre → edge |
| `conic(*stops, angle=0)` | sweep around, good for rainbows / progress rings |

| Shadow | |
|---|---|
| `dx` / `dy` | offset in pixels |
| `blur` | gaussian radius, 0 = hard edge |
| `alpha` | opacity 0–1 |
| `spread` | grow the shape before blurring |
| `color` | default black |

| Perspective | |
|---|---|
| `tilt_x` | pitch, in degrees |
| `tilt_y` | yaw, in degrees |
| `rotate` | in-plane rotation |
| `distance` | camera distance; smaller = stronger |

> The output canvas is sized to the **projected quad only**. That's what makes
> the narrowing actually visible — if it kept the original size the content
> would just stretch back.

---

## Particles

Particles are **not** a renderer special case. `emit()` **bakes each particle's
motion into keyframes**, so scenes stay pure, seekable, and parallel-safe.

```python
from codeanim import emit, emit_snow, emit_sparkle, emit_ripple

# one burst
emit(scene, count=120, at=(640, 360), t0=1.0,
     velocity=(200, 0), spread=260, gravity=500,
     life=1.8, r=(2, 6), fill=["#38bdf8", "#a855f7", "#f472b6"])

# a fountain
emit(scene, count=60, at=(640, 700), t0=0.0, t1=3.0,
     velocity=(0, -420), spread=90, gravity=380, life=2.0, r=4)

# one-liners
emit_snow(scene, count=80)        # snowfall with sway
emit_sparkle(scene, count=40)     # twinkle in place
emit_ripple(scene, count=3)       # expanding rings
```

| `emit` option | |
|---|---|
| `count` | how many |
| `at` | `(x, y)`, or `((x1,y1),(x2,y2))` for a spawn rectangle |
| `t0` / `t1` | start time / end time (`t1` = continuous emission) |
| `velocity` + `spread` | initial velocity and its random range |
| `angle=(centre, cone)` | cone emission in degrees |
| `gravity` / `drag` | acceleration / air resistance |
| `life`, `r` | single value or `(min, max)` |
| `fill` | a colour or a list to pick from |
| `seed` | **reproducible** — same seed, same result every render |
| `max_particles` | safety cap (default 600), exceeded = loud error |

---

## Text

```python
from codeanim import Text

Text("Hello", cx=640, cy=360, font_size=48, fill="white", anchor="center")

# auto-shrink to fit a width (or height)
Text("A very long headline that must not overflow",
     cx=640, cy=120, font_size=96, fit_width=900, anchor="center")

# multi-line — "\n" splits, line_height scales the leading
Text("line one\nline two\nline three",
     cx=640, cy=360, font_size=36, line_height=1.4, anchor="center")

# change the copy in place, no rebuild
t = scene.add_text("old title", cx=640, cy=100)
t.set_text("new title")
t.content = "or assign it"
```

`fit_width` / `fit_height` **binary-search the font size at render time**;
`font_size` itself is unchanged, so you can animate it independently and they
compose.

`anchor` accepts `center` / `left` / `right` / `top` / `bottom` and the usual
combinations (`top_left`, `bottom_right`, …).

---

## Images & sprite animation

```python
from codeanim import Image, load_image, load_or_placeholder

logo = scene.add_image("logo.png", cx=640, cy=360, w=200, radius=16)
logo.rotation.keys([(0, 0), (2, 360)])

scene.add_image("hero.png", cx=300, cy=200, w=240)       # h from aspect
tex = load_image("icon.png")                             # load once, reuse
scene.add_image(tex, cx=200, cy=500, w=80)
scene.add_image(tex, cx=320, cy=500, w=80, tint="#22d3ee")

scene.add_image(None, cx=100, cy=100, w=60, h=60)        # checkerboard stub
```

| Option | |
|---|---|
| `w` / `h` | give one, the other follows the aspect ratio |
| `crop=(x,y,w,h)` | cut a rect out of the source (sprite sheets) |
| `slice=` | 9-slice: `12` or `(l,t,r,b)` — corners don't distort |
| `radius=` | rounded corners |
| `tint=` + `tint_amount=` | colourise, good for monochrome icons |
| `fit=` | `stretch` (default) / `contain` / `cover` |

### Sprite animation

```python
sheet = scene.add_image("run.png", sprite=(4, 3), w=96)
sheet.play(fps=12)                        # loop at 12 fps
sheet.show_frame(0, at=2.0)               # pin a frame

# reverse / manual stepping via keyframes
sheet.frame.keys([(0, 11), (2.6, 0)])

# or slice manually
scene.add_image("run.png", frames=[(0,0,48,48), (48,0,48,48)], w=48)
```

---

## Audio

Two things only: **import a file and say when it plays**, or **generate a sound
in code**. Deliberately plain — no node graph, no automation curves.

```python
from codeanim import Audio, beep, chime, sweep, whoosh, arpeggio

# --- import ---
scene.add_audio(Audio("bgm.mp3", at=0.0, volume=0.35,
                      loop=True, fade_in=0.5, fade_out=2.0))
scene.add_audio(Audio("whoosh.wav", at=3.5))
scene.add_audio(Audio("speech.mp3", at=2.0, trim=(3.0, 8.0)))

# --- generate (no assets needed) ---
scene.add_audio(beep(880, duration=0.2, at=1.0))
scene.add_audio(chime(at=5.0))
scene.add_audio(whoosh(at=2.6))
scene.add_audio(sweep(200, 1600, duration=0.35, at=3.0))
scene.add_audio(arpeggio([523, 587, 659, 784], at=7.4))
```

| `Audio` option | |
|---|---|
| `at` | **when it starts**, in absolute seconds (ms precision) |
| `volume` | gain multiplier |
| `loop` | loop to fill the rest |
| `fade_in` / `fade_out` | seconds |
| `trim=(start, end)` | take a slice of the source |
| `speed` | playback rate (also shifts pitch) |
| `volume_curve` | automation, see below |
| `pan` | `-1` left … `0` centre … `+1` right |
| `delay` | echo: seconds / `(delay, decay)` / `(delay, decay, count)` |
| `reverb` | 0–1, larger = roomier |
| `tone` | brightness, negative = duller |

### Music notation

Stop typing frequencies:

```python
from codeanim import note, chord, scale, melody, parse_melody

note("A4")                     # -> 440.0
note("C#5") / note("Db5")      # sharps / flats
chord("A3", "m7")              # 19 chord types
scale("C4", "blues")           # 14 scales
parse_melody("1 2 3 4 5")      # numbered notation
parse_melody("1' 2'")          # up an octave
parse_melody("1, 2,")          # down an octave
parse_melody("1 2 3", solfege_key="G")   # in G
```

Write a melody:

```python
scene.add_audio(melody("C4 D4 E4 F4 G4", note_dur=0.2, at=0.0))
scene.add_audio(melody("1 2 3 4 5 6 7 1'", note_dur=0.18))
scene.add_audio(melody([["C4","E4","G4"], ["F4","A4","C5"]], note_dur=0.8))
scene.add_audio(chord_sound("C4", "maj", duration=1.5, at=0.0))
```

### Voices & envelopes

```python
from codeanim import Tone, Envelope, VOICES

Tone(freq=note("C4"), duration=1.5, voice="epiano")
Tone(freq=note("C5"), duration=2.0, voice="bell", reverb=0.6)
Tone(freq=note("E4"), duration=1.0, voice="strings", vibrato=(5, 25))

# ADSR: attack / decay / sustain / release
Tone(freq=note("C4"), duration=1.5, voice="epiano",
     envelope=Envelope(attack=0.01, decay=0.25, sustain=0.45, release=0.5))
Tone(freq=440, duration=1.0, envelope=(0.01, 0.2, 0.5, 0.4))   # tuple works too
```

| Voice | Sounds like |
|---|---|
| `sine` `square` `triangle` `saw` | basic waveforms |
| `flute` | flute / wind |
| `strings` | strings (rich harmonics) |
| `epiano` | electric piano |
| `bell` | bell |
| `marimba` | marimba |
| `bass` | bass |
| `music_box` | music box |

Or roll your own harmonic table:

```python
Tone(freq=440, partials=[(1, 1.0), (2, 0.5), (3, 0.25), (5, 0.1)])
Tone(freq=440, detune=12)          # slight detune, warmer
Tone(freq=440, tremolo=(8, 0.6))   # amplitude wobble
```

### Volume curves

```python
Audio("bgm.mp3", at=0, volume_curve=[(0, 0), (3, 1), (6, 0)])
Audio("x.mp3", volume_curve=[0.0, 0.5, 1.0])        # one per second
Audio("x.mp3", at=1.0, volume_curve=[(1,0),(3,1)])  # absolute seconds OK
```

### Groups & other effects

```python
from codeanim import AudioGroup, noise, pulse

sfx = AudioGroup([whoosh(at=2.0), beep(880, at=3.0), pulse(4, at=5.0)],
                 name="sfx")
sfx.volume = 0.6        # whole-group gain
sfx.shift(0.5)          # shift everything later
sfx.mute() / sfx.unmute()
scene.add_audio(sfx)

scene.add_audio(noise(duration=3.0, volume=0.12, color="brown"))  # wind
scene.add_audio(pulse(8, interval=0.08))                          # typewriter
```

Everything is **pure standard library** — no numpy. Mixing is delegated to
ffmpeg (`adelay` + `amix` + `aecho` + `pan`).

```bash
codeanim script.py --list-audio    # show the timeline (with note names)
codeanim script.py --no-audio      # drop audio, video only
```

> **GIF has no audio.** With audio tracks present, exporting a GIF is a loud
> error suggesting `.mp4` / `.webm`, not a silent drop.

---

## Composing a scene

### Groups

```python
from codeanim import Group, Rect, Circle

card = Group(cx=640, cy=360)
card.add(Rect(cx=0, cy=0, w=300, h=200, fill="#1e293b"))
card.add(Circle(cx=0, cy=-60, r=30, fill="#38bdf8"))

card.rotation.keys([(0, 0), (2, 360)])
card.opacity.keys([(0, 0), (0.6, 1)])
scene.add(card)
```

Inside a group, `(0, 0)` **is the group centre**. Parent rotation / scale /
opacity propagate to the whole subtree. Nesting works.

### Timeline

```python
from codeanim import seq, par

total = seq(scene, [
    lambda s: add_title(scene, start=s),
    lambda s: par(scene, [                     # these two run together
        lambda t: add_logo(scene, start=t),
        lambda t: add_counter(scene, start=t),
    ]),
    lambda s: add_outro(scene, start=s),
])
scene.duration = total
```

Or pin how long each clip should take:

```python
seq(scene, [
    add_title,                    # whatever the clip returns
    (add_ball, 2.0),              # at least 2 seconds
    (add_logo, 1.5, 0.3),         # at least 1.5s, then 0.3s of silence
    {"clip": add_outro, "span": 2.0, "gap": 0.5},
])

seq(scene, [a, b, c], gap=0.5)    # uniform gap between clips
```

**The requested span is a lower bound** — a longer clip is never truncated:

| clip returns | you asked | actual |
|---|---|---|
| 0.5s | 2.0 | **2.0s** (padded) |
| 3.0s | 1.0 | **3.0s** (clip wins) |

### Reusable fragments

```
my_project/
├── main.py          entry
├── title.py         title fragment
├── ball.py          ball fragment
└── logo.py          intro fragment
```

```python
# title.py
from codeanim import Text

def add_title(scene, start=0.0, title="Hello"):
    t = Text(title, cx=scene.width/2, cy=scene.height/2,
             font_size=96, fill="white", anchor="center")
    t.opacity.keys([(start, 0), (start + 0.9, 1)])
    scene.add(t, z=10)
    return 1.5          # this clip takes 1.5s
```

Sibling `.py` files just `import` each other — the engine puts the script's
directory on `sys.path`.

---

## Disk safety

The easiest way to blow up a device with high-quality animation: 4K / 60fps /
60s of PNG frames is about **21 GB**. Three layers of protection:

**1. Zero-disk by default.** `stream` mode pipes rendered frames straight into
ffmpeg's stdin. **No intermediate files at all** — disk usage equals the final
video.

**2. If you do ask for disk, do the math first.** Only `--frames` /
`--keep-frames` use disk mode, and that path **estimates space up front**:

```
Not enough disk space for the intermediate PNG frames.

  frame size:  3840 x 2160
  frames:      3600
  needed:      15.30 GB
  available:   2.10 GB  (/mnt/data, 15% headroom reserved)

Three ways out (the first is recommended):
  1. Drop --frames / --keep-frames and use the default zero-disk streaming:
       codeanim your_script.py -o out.mp4
  2. Write to a roomier location:
       codeanim your_script.py --frames /other/drive/dir/
  3. Lower the resolution or frame rate.
```

**3. A live red line during render.** Streaming mode also checks free space
every 40 frames and aborts below `--free-margin`. Temp files are cleaned up on
success, error, and `Ctrl-C`.

---

## Output formats

| Format | Use for | How | Alpha |
|---|---|---|---|
| MP4 (H.264) | default, best compatibility | `-o out.mp4` | — |
| WebM (VP9) | small files | `-o out.webm` | yes\* |
| MOV | Apple ecosystem | `-o out.mov` | ProRes 4444 |
| MKV | generic container | `-o out.mkv` | — |
| GIF | chat, docs | `--gif` | — |
| PNG sequence | hand off to AE / Blender | `--frames dir/` | yes |
| Single PNG | check one moment | `--snapshot 2.0` | yes |

\* WebM alpha depends on whether your libvpx was built with it. CodeAnim
**actually probes it** (encodes a transparent sample and decodes it back),
and tells you to use MOV instead of silently writing an opaque file.

### Transparency

```python
scene = Scene(1280, 720, background=None)   # None = transparent
```

```bash
codeanim overlay.py -o overlay.mov          # ProRes 4444
```

Drop it straight onto a Premiere / Final Cut timeline or a web `<video>`.

---

## Design constraints

1. **No GUI.** Never opens a window, never a player.
2. **Pure-function scene.** `f(t)` is independently evaluable at any moment —
   so seeking, reversing, and multiprocess rendering all just work. There are
   dedicated regression tests for this.
3. **Scriptable.** Fits in a Makefile, CI, GitHub Actions.
4. **Command line first.** Every feature is reachable from a flag.
5. **Errors in plain language.** Missing ffmpeg tells you how to install it;
   script errors show only your file and line; space / container problems give
   concrete fixes instead of a stack trace.

---

## Testing

```bash
python tests/test_codeanim.py
```

527 assertions covering colour, easing, keyframes, **purity**, centre
positioning, group transforms, gradients / shadows / perspective, particles,
images, sprite animation, audio (import + generate + mixing + curves + voices +
ADSR + pan/echo/reverb + groups), music theory, paths, Digits formatting,
timelines, clip spans, layer order, sugar, hot reload, in-terminal preview,
parameter validation, disk budgeting, encoder args, supersampling.

---

## Examples

| File | Shows |
|---|---|
| `examples/promo/` | a full 19s 1080p promo, built with the engine itself |
| `examples/demo.py` | title card: seq/par + Group + Digits + z layers |
| `examples/chart.py` | bar chart + line + rolling numbers |
| `examples/particles.py` | 300+ primitives, performance |
| `examples/media.py` | images + sprite animation + audio |
| `examples/showcase.py` | **gradients / shadows / perspective / particles / text fit** |
| `examples/music_path.py` | music notation, voices, balls flying on beziers |
| `examples/alpha_overlay.py` | transparent background for compositing |

---

## At a glance

**Rendering**
- Zero-disk streaming (disk usage = final video size)
- 2× supersampled anti-aliasing
- Multiprocess parallel (frames are independent)
- Static pre-composition + fast path
- Automatic CJK font discovery (incl. `.ttc`)

**Graphics**
- Primitives: `Circle` `Rect` `Line` `Text` `Image` `Path` `Digits` `Group`
- Gradients: linear / radial / conic, uneven stops
- Drop shadows with blur / spread / offset
- 3D-ish perspective (quad warp)
- Particles: burst, fountain, snow, sparkle, ripple — reproducible
- Paths: bezier, Catmull-Rom splines, arcs, polygons, `follow()`, `along()`
- 25 easing curves, colour interpolation

**Audio**
- Import any format ffmpeg reads
- Generate in code: `beep` `chime` `sweep` `whoosh` `arpeggio` `melody`
  `chord_sound` `noise` `pulse`
- Music theory: `note` `chord` `scale` `parse_melody`
- 11 voices + custom harmonic tables
- ADSR envelopes, vibrato, tremolo, detune
- Volume curves, pan, echo, reverb, brightness
- Groups with batch gain / shift / mute
- Pure standard library — no numpy

**Workflow**
- `--watch` hot reload with live preview
- `--inline` in-terminal rendering (half-block characters)
- `--preview` render one frame and open it
- `--layers` draw-order inspector
- `doctor` dependency check
- Disk budgeting and live guards
- Errors show only your file and line
- **`--ch` for Chinese output** (English by default)
