Metadata-Version: 2.4
Name: codeanim-py
Version: 0.2.0
Summary: 用 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

用 Python 代码描述动画，一条命令渲染成视频文件。无窗口、无交互、不弹播放器，跑完就落盘。

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

**默认零落盘**：帧经管道直接进 ffmpeg，中途不写任何临时文件。
磁盘占用只有最终视频那么大 —— 4K / 高质 / 长片也敢跑。

---

## 安装

需要 **Python 3.8+**、**Pillow**、**ffmpeg**。

```bash
pip install codeanim-py      # 发行名带 -py 后缀（codeanim 已被占用）
codeanim doctor              # 检查依赖，缺什么会告诉你装什么
```

> **导入名仍然是 `codeanim`** —— 写代码时不用管发行名：
> ```python
> from codeanim import Scene      # 不是 codeanim_py
> ```

装完会有两个等价入口：

```bash
codeanim demo.py -o out.mp4
python -m codeanim demo.py -o out.mp4      # 效果一样
```

**不装包也能直接跑**（开发引擎本身时最方便）：

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

---

## 命令

```bash
python main.py <脚本.py> [选项]
python main.py doctor
```

| 选项 | 作用 | 默认 |
|---|---|---|
| `-o, --output` | 输出路径，扩展名决定格式 | `out.mp4` |
| `--fps` | 帧率 | 30 |
| `--quality` | `low` / `medium` / `high` / `lossless` | `high` |
| `--workers, -w` | 并行进程数 | CPU 核数−1 |
| `--snapshot <秒>` | 只出一张单帧 PNG | — |
| `--frames <目录>` | 导出 PNG 序列 | — |
| `--gif` | 导出 GIF | — |
| `--keep-frames` | 保留中间帧（调试用） | 关 |
| `--supersample <倍数>` | 超采样倍数 | 2 |
| `--mode <模式>` | 帧存储：`stream` / `ram` / `disk` | 自动 |
| `--alpha` | 输出带透明通道（`.mov` / `.webm`） | 自动 |
| `--ffmpeg <路径>` | 指定 ffmpeg | `ffmpeg` |
| `--watch, -W` | **热重载**：改代码自动刷新预览 | 关 |
| `--watch-at <秒>` | 热重载预览的时间点 | `0` |
| `--watch-output <路径>` | 热重载预览图路径 | `.codeanim/watch_preview.png` |
| `--inline` | 热重载时在终端直接画画面 | 自动判断 |
| `--no-inline` | 不画终端内嵌画面 | — |
| `--preview <秒>` | 快速预览：渲一张图并尝试打开 | — |
| `--no-audio` | 忽略场景里的音频轨道 | 关 |
| `--list-audio` | 只列出音频轨道，不渲染 | — |
| `--layers` | 只打印图层绘制顺序，不渲染 | — |
| `--layers-at <秒>` | `--layers` 求值的时间点 | `0` |
| `--quiet, -q` | 只输出错误 | 关 |
| `--free-margin <大小>` | 保留的空闲磁盘红线 | `256MB` |
| `--force` | 空间检查不通过也硬渲（不推荐） | 关 |
| `--no-disk-check` | 跳过所有磁盘检查 | 关 |

```bash
python main.py demo.py --watch               # 开着热重载改代码
python main.py demo.py --watch --watch-at 2.5  # 盯第 2.5 秒那一帧
python main.py demo.py --preview 2.0         # 渲一张图并打开
python main.py demo.py --snapshot 2.0        # 只出图，不打开
python main.py demo.py --gif                 # 出 GIF
python main.py demo.py --frames pngs/        # 导出 PNG 序列
python main.py demo.py -o out.mov --alpha    # 带透明通道
```

---

## 开发流程：热重载

写动画最烦的是「改一个数字 → 跑一条命令 → 等 → 打开文件看」。
`--watch` 把中间全砍掉：

```bash
python main.py examples/demo.py --watch
```

```
CodeAnim 热重载
────────────────────────────────────────────────────────
  入口    examples/demo.py
  监视    7 个 .py 文件
          examples/ball.py
          examples/demo.py
          examples/logo.py
          ... 还有 4 个
  预览    .codeanim/watch_preview.png
  时间点  t=2.00s
────────────────────────────────────────────────────────
  改文件即自动重渲染，Ctrl-C 退出。

✓ 已更新  1280x720  8.60s  30 fps  11 个图元
   预览  .codeanim/watch_preview.png  (t=2.00s)
   耗时  814 ms

↻ 检测到改动: examples/demo.py
✓ 已更新  1280x720  8.60s  30 fps  11 个图元
   耗时  225 ms
```

行为：

- 监视**入口脚本和它 import 的所有同项目 `.py`** —— 改片段文件也会触发
- 只渲染**一帧**，不跑整段视频，通常 **200~800 ms** 出结果
- **报错不退出**：语法错 / 运行错都只打印，等你下次保存
- 编辑器保存时的多次写事件会**防抖合并**成一次
- 新增的 `.py` 文件会被自动纳入监视
- 无第三方依赖，纯轮询 mtime，跨平台

> 无图形界面的环境（服务器 / Termux）也能用：预览图就是普通 PNG，
> 你可以用任何方式查看，或者 `codeanim` 会尝试 `xdg-open` / `open` / `am start` 帮你打开。

---

## 终端内嵌画面

不想开图片？直接让画面在终端里出现：

```bash
python main.py examples/demo.py --watch --inline
```

```
CodeAnim 热重载
────────────────────────────────────────────────────────
  入口    examples/demo.py
  预览    .codeanim/watch_preview.png
  时间点  t=4.00s
────────────────────────────────────────────────────────
  改文件即自动重渲染，Ctrl-C 退出。
✓ 已更新  1280x720  8.60s  30 fps  11 个图元   耗时 1.46 s

  ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
  ▄▄▄▄  ▄▄▄▄▄▄▄▄▄▄▄  ▄▄▄▄▄▄▄▄▄▄▄▄▄▄
  ▄▄▄  CodeAnim  用 Python 写动画  ▄▄▄
  ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
       ●                                ●
  t=4.00s   11 个图元   .codeanim/watch_preview.png
```

原理：终端字符格子通常宽:高 ≈ 1:2，用 **上下半块** `▄` 让一个字符格表示两个垂直像素，
宽高比自然正确。80×24 的终端 ≈ 80×48 个颜色块 —— 够看构图、配色、位置。

* 真彩色（24-bit）优先，退化为 256 色，再退化为灰度字符
* 每次重渲染**原地覆盖**（ANSI 光标移动），不刷屏
* 自动判断终端能力；用 `--no-inline` 关掉，`--inline` 强制开
* 也可以用 API：

```python
from codeanim import render_terminal_preview

print(render_terminal_preview(scene, at=2.0))          # 打印一段带色字符串
lines = preview_lines(scene, at=2.0, max_cols=80)      # 拿行列表
```

---

## 手感糖

写起来更短、更少心算。都是可选的，不改引擎语义。

### 一行建并加

```python
dot = scene.add_circle(cx=100, cy=100, r=20, fill="red", z=5)
box = scene.add_rect(cx=640, cy=360, w=200, h=100, radius=16)
cap = scene.add_text("标题", cx=640, cy=120, font_size=48)
ln  = scene.add_line(x=0, y=600, x2=1280, y2=600, stroke="#333")
```

### 自动算时长（不用再手算 `start`）

片段里打了一堆 `key()`，但不想手算它多长：

```python
title_objs = add_title(scene)
print(span_of(title_objs))        # => 3.2   （最晚的关键帧时间）

# 或者直接让场景撑到刚好
add_title(scene)
add_ball(scene)
scene.fit_duration(0.5)           # 撑到最长 + 末尾留 0.5 秒
```

`span_of` 认图元、`Group`（递归子项）、属性句柄、嵌套列表。

### 改文案不用重建

```python
title = scene.add_text("旧标题", cx=640, cy=100, font_size=64)

set_text(title, "新标题")          # 函数式
title.set_text("再改一次")          # 方法式，可链式
title.content = "也可以直接赋值"     # 属性式

print(title.content)              # 读当前文案
```

### 批量添加

```python
scene.add_all(make_particles(), z=5)               # 列表
scene.add_all(Circle(r=3) for _ in range(100))     # 生成器
scene.add_all([[a, b], c])                          # 嵌套
g = scene.add_group(Rect(...), Circle(...), cx=640, cy=360, z=10)
```

### 自动排布：`row` / `grid`

```python
from codeanim import row, grid

# 一排点，按自身尺寸 + 间距自动推进
dots = row(scene, [{"r": 8}] * 5, cls=Circle,
           gap=10, start=50, axis="x", cy=100, fill="#38bdf8")
# => 中心落在 x = 50, 76, 102, 128, 154

# 网格
tiles = grid(scene, rows=4, cols=6, cell_w=30, cell_h=30,
             gap_x=10, gap_y=10, origin=(100, 100),
             make_kwargs=lambda r, c: {"fill": COLORS[(r * 6 + c) % 7]})
```

### Digits 更短的写法

```python
d = Digits(value=0, cx=640, cy=120, font_size=64, thousands=True)

d.from_to(0, 12345.678, start=0.5, end=2.5, ease="outCubic")  # 一行滚完
d.set_value(100)        # 链式设值
print(d.content)        # 当前显示的文本
```

### Digits 自定义格式化

内置的 `decimals` / `thousands` / `prefix` / `suffix` 不够用时，用 `formatter=`：

```python
# 1) 传函数 —— 任意逻辑
Digits(value=0, formatter=lambda v: f"¥{v:,.2f}")
Digits(value=0, formatter=lambda v: f"{v/10000:.2f} 万" if v >= 10000 else f"{v:.0f}")

# 2) 传 format spec —— 短
Digits(value=0, formatter="{:,.1f} kg")
Digits(value=0, formatter="{:+.2f}")
Digits(value=0, formatter="{:05.0f}")
```

想在自定义逻辑里复用内置规则，用 `default_formatter` 造基础格式器：

```python
from codeanim import Digits, default_formatter

base = default_formatter(decimals=1, thousands=True, suffix=" 元")
Digits(value=0, formatter=lambda v: (
    "%.2f 万" % (v / 10000) if v >= 10000 else base(v)))
```

> 给了 `formatter` 后，`decimals` / `thousands` / `prefix` / `suffix` /
> `sign` / `min_int_digits` 会被忽略 —— 格式化完全由你的函数接管。
> 函数抛异常不会让渲染挂掉，那一帧会退回原始数值。

---

## 磁盘安全（重要）

高质动画最容易踩的坑就是把磁盘写爆：4K/60fps/60s 的全 PNG 序列约 **21 GB**。
CodeAnim 用三层防护解决：

### 1. 默认零落盘

默认走 `stream` 模式：渲染好的帧直接经管道喂给 ffmpeg 的 stdin，
**全程不产生任何中间文件**，磁盘占用 = 最终视频大小。

```bash
python main.py demo.py -o out.mp4      # 4K 也不会爆盘
```

### 2. 要落盘就提前算账

只有你明确要求 `--frames` 或 `--keep-frames` 时才走落盘模式。
这时会**先预估需要多少空间**，不够就直接拒绝并给出建议，而不是跑到一半炸掉：

```
磁盘空间不够，无法把中间帧全部存成 PNG。

  画面尺寸:   3840 x 2160
  帧数:       3600
  预计需要:   15.30 GB
  可用空间:   2.10 GB（/mnt/data，已预留 15% 余量）

三个解决办法（推荐第一个）：
  1. 去掉 --frames / --keep-frames，改用默认的零落盘流式渲染：
       python main.py 你的脚本.py -o out.mp4
     整个过程不写任何中间帧，磁盘只占最终视频那点大小。
  2. 换个空间充足的输出目录：
       python main.py 你的脚本.py --frames /别的/盘/目录/
  3. 降低分辨率或帧率，减少总数据量。
```

### 3. 渲染途中还有一道红线

流式模式也会每 40 帧查一次剩余空间，低于 `--free-margin` 就中止渲染，
避免把设备写到卡死。临时文件在正常结束 / 报错 / `Ctrl-C` 时都会被清掉。

---

## 写一个动画

```python
from codeanim import Scene, Circle, Digits, Text

scene = Scene(1280, 720, duration=5, fps=30, background="#0b0f19")

# 用 cx/cy 按"中心"定位，不用手算偏移
ball = Circle(cx=200, cy=360, r=60, fill="red")
ball.cx.keys([(0, 200), (2, 1000)])          # 中心从 x=200 到 x=1000
ball.fill.keys([(0, "red"), (2, "#00aaff")])  # 颜色平滑过渡
scene.add(ball)

# 会滚动的数字
n = Digits(value=0, cx=640, cy=120, font_size=64, fill="white",
           thousands=True, suffix=" fps")
n.value.keys([(0, 0), (2, 3000)], ease="outCubic")
scene.add(n)

scene.add(Text("你好，CodeAnim", cx=640, cy=640, font_size=48, fill="#e2e8f0"))
```

```bash
python main.py my_anim.py
```

### 定位：x/y 与 cx/cy

| 写法 | 含义 |
|---|---|
| `x=`, `y=` | 自然锚点。圆是圆心，矩形是左上角，线是起点，文字由 `anchor` 决定 |
| `cx=`, `cy=` | **几何中心**。给矩形/文字用最省心，旋转也绕中心 |

两种可以混用：`Rect(cx=100, y=50, ...)` 表示"水平中心在 100，顶边在 50"。

想让中心动起来：

```python
box.cx.keys([(0, 100), (2, 800)])   # 或者 box.center_x().keys([...])
```

> `cx` / `cy` 走独立的"中心通道"，`x` / `y` 始终是绘制坐标（左上角）。
> 即使 `w` / `h` / `r` 同时在变，中心点依然精确。

### 时间系统

时间全是**绝对秒**，不是相对偏移。

```python
ball.x.key(0, 100)          # 第 0 秒在 x=100
ball.x.key(2.5, 800)        # 第 2.5 秒在 x=800
```

`ease=` 写在**起始关键帧**上，描述"从这一帧走向下一帧"的曲线：

```python
ball.x.key(0, 0, ease="outBack").key(1, 500)   # 回弹
ball.x.key(1, 500, ease="linear").key(2, 0)    # 匀速
```

批量写：

```python
ball.opacity.keys([(0, 0), (0.5, 1), (4, 1), (5, 0)])
```

25 条内置曲线：`linear`、`inQuad`/`outQuad`/`inOutQuad`、`inCubic`/`outCubic`/`inOutCubic`、
`inQuart`/`outQuart`/`inOutQuart`、`inSine`/`outSine`/`inOutSine`、
`inExpo`/`outExpo`/`inOutExpo`、`inBack`/`outBack`/`inOutBack`、
`inElastic`/`outElastic`/`inOutElastic`、`inBounce`/`outBounce`/`inOutBounce`。

名字不区分大小写，`"out-cubic"` 也能用。

### 图元

| 图元 | 位置 | 主要参数 |
|---|---|---|
| `Circle` | 圆心 | `r`, `fill`, `stroke`, `stroke_width` |
| `Rect` | 左上角 | `w`, `h`, `radius`（圆角）, `fill`, `stroke` |
| `Line` | `x,y` → `x2,y2` | `stroke`, `stroke_width` |
| `Text` | 锚点 | `text`, `font_size`, `anchor`, `fill`, `weight`, `letter_spacing` |
| `Image` | 左上角 | `source`, `w`, `h`, `crop`, `slice`, `tint`, `radius`, `fit` |
| `Digits` | 锚点 | `value`, `decimals`, `thousands`, `prefix`, `suffix`, `sign`, `min_int_digits`, `formatter` |
| `Group` | `cx,cy` | `rotation`, `scale`, `scale_x/y`, `opacity` |

坐标原点在左上角，y 轴向下。

### 可动画属性

`x` `y` `cx` `cy` `w` `h` `r` `radius` `rotation` `scale` `scale_x` `scale_y`
`opacity` `fill` `stroke` `stroke_width` `text` `font_size` `letter_spacing` `value`

颜色平滑插值；`text` 按时点切换；`Digits.value` 是数值，插值后自动格式化。

### 图片：把素材当普通物体

```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)])

# 只给 w，按原图比例算 h
scene.add_image("hero.png", cx=300, cy=200, w=240)

# 同一张图复用多次（只加载一次）
tex = load_image("icon.png")
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(load_or_placeholder("还没上传.png"), cx=100, cy=100, w=60)
# 或者直接
scene.add_image(None, cx=100, cy=100, w=60, h=60)
```

| 参数 | 作用 |
|---|---|
| `w` / `h` | 只给一个，按原图比例算另一个；都不给就用原尺寸 |
| `crop=(x,y,w,h)` | 从原图裁一块（雪碧图） |
| `slice=` | 九宫格拉伸：`12` 或 `(12,)` 或 `(l,t,r,b)` —— 缩放时边角不变形 |
| `radius=` | 圆角 |
| `tint=` + `tint_amount=` | 着色，做单色图标 |
| `fit=` | `stretch`（默认）/ `contain` / `cover` |

图片是**普通图元**：`cx/cy` 定位、`rotation`、`scale`、`opacity`、`z`、
丢进 `Group` —— 全都照常工作。

> 支持 PNG / JPG / WEBP / BMP / GIF（首帧）等常见格式。
> 位图带缓存：尺寸/着色没变时每帧几乎零成本。

### 音频：导入 + 生成

配置刻意保持朴素 —— **一句话说清"什么声音、第几秒响、多大声"**。

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

# ── 1. 导入现成文件 ──
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))

# 只要源文件第 3-8 秒那一段，放到第 2 秒处
scene.add_audio(Audio("speech.mp3", at=2.0, trim=(3.0, 8.0)))

# ── 2. 用代码生成，不需要任何素材 ──
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` 的参数：

| 参数 | 含义 |
|---|---|
| `at` | **从第几秒开始播**（绝对时间，毫秒精度） |
| `volume` | 音量倍数，1.0 为原声 |
| `loop` | 循环填满剩下的时长 |
| `fade_in` / `fade_out` | 淡入 / 淡出秒数 |
| `trim=(起, 止)` | 只取源文件的一段 |
| `speed` | 变速（会同时改音调） |

生成的音用 `Tone` 家族：

| 函数 | 用途 |
|---|---|
| `beep(freq, duration=0.15, at=0)` | UI 反馈音 |
| `chime(freq=784, duration=0.9, at=0)` | 有余韵的"叮"（叠泛音 + 长淡出） |
| `sweep(200, 1600, duration=0.4, at=0)` | 扫频（低→高像"嗖"，高→低像"嘟"） |
| `whoosh(duration=0.3, at=0)` | 噪声 + 扫频，转场音效 |
| `arpeggio([...], step=0.14, at=0)` | 一串音符依次响 |

波形可选 `sine` / `square` / `triangle` / `saw`，还能加泛音（`harmonic`）
和白噪（`noise`）。**纯标准库实现，不需要 numpy。**

混音交给 ffmpeg（`adelay` + `amix`），不自己算采样。

排查工具：

```bash
python main.py 脚本.py --list-audio    # 列出现有音轨（含起始时间、音量）
python main.py 脚本.py --no-audio      # 忽略音轨，只出画面
```

> **GIF 不支持音频**。带声音的动画请输出 `.mp4` / `.webm`；
> 如果场景有音轨却要出 GIF，引擎会明确提示，而不是静默丢掉声音。

### 分组：把一堆图元当一个用

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

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.scale.key(0, 1.0).key(0.5, 1.2).key(1.0, 1.0)
card.opacity.keys([(0, 0), (0.6, 1)])
scene.add(card)

# 嵌套也支持
outer = Group(cx=200, cy=200)
inner = Group(cx=50, cy=0)                # 相对 outer 的坐标
inner.add(Circle(r=10))
outer.add(inner)
```

组内坐标的 `(0, 0)` **就是组的中心**。父组的旋转/缩放/透明度会正确传递到子树。
### 图层顺序

默认按添加顺序（先加的在下）。用 `z=` 强制排序，数值越大越靠上：

```python
scene.add(background, z=-100)   # 压到最底
scene.add(watermark, z=100)     # 永远在最上层
```

同 `z` 时保持添加顺序。

### 图层顺序看不清？让引擎打出来

图元一多，"谁盖着谁"就容易糊。直接问它：

```bash
codeanim 脚本.py --layers              # 打印绘制顺序
codeanim 脚本.py --layers --layers-at 2.5   # 按第 2.5 秒的状态算位置
```

```
图层顺序（从下到上绘制，共 30 项）
────────────────────────────────────────────────────────────────────────
    #  z    类型      名称        位置 / 尺寸
────────────────────────────────────────────────────────────────────────
    1  -100  rect    <rect>    (0,0) 1280x720
    2  0     text    <text>    c(640,340) '一条命令，一份视频'
    3  10    text    <text>    c(640,330) 'CodeAnim'
    4  20    digits  <digits>  c(1220,650) '0,000 帧'
────────────────────────────────────────────────────────────────────────
⚠ 有多个图元共用同一个 z：0(25项), 10(3项)。
    同 z 时按添加顺序画 —— 后加的盖在前面之上。
⚠ 有 1 个静态图元（无关键帧）会被预合成到底层，它们的绘制顺序和动态图元分开处理。
    如果你打算让静态图元盖住动态内容，就给它加个无用关键帧。
```

脚本里也能调：

```python
from codeanim import describe_layers, layer_warnings

print(describe_layers(scene, t=2.0))   # 完整表格
layer_warnings(scene)                   # 只要提示
```

会提醒的几种情况：

* **所有 z 都是同一个值** → 顺序全靠添加顺序，容易和预期不符
* **同 z 有多项** → 明确告诉你同 z 的规则
* **有静态图元** → 它们会被预合成到底层，不参与逐帧排序
* **z 出现下降** → 通常是组内和组外混排导致的

---
### 时间线：不写魔法数字

把片段写成"接受起始时间、**返回时长**"的函数，然后排列：

```python
from codeanim import seq, par

total = seq(scene, [
    lambda s: add_title(scene, start=s),                  # 自动排 0
    lambda s: par(scene, [                                # 两个同时
        lambda t: add_logo(scene, start=t),
        lambda t: add_counter(scene, start=t),
    ]),
    lambda s: add_outro(scene, start=s),
])
scene.duration = total          # 场景时长自动跟上
```

片段不返回时长也行，引擎会去问图元最晚的关键帧（不如显式返回精确）。

### 也可以直接指定每段占多久

不想让片段自己算？用 `(片段, 时长)`：

```python
total = seq(scene, [
    add_title,                    # 片段自己 return
    (add_ball, 2.0),              # 至少占 2 秒
    (add_logo, 1.5, 0.3),         # 至少 1.5 秒，后面再空 0.3 秒
    {"clip": add_outro, "span": 2.0, "gap": 0.5},   # 完整写法
])
```

**指定时长是下界，不会截断动画**：

| 片段自然时长 | 指定时长 | 实际占用 |
|---|---|---|
| 0.5s | 2.0 | **2.0s**（拉长，留静止尾巴） |
| 3.0s | 1.0 | **3.0s**（以片段为准，不切半截） |

段间统一留白：

```python
seq(scene, [a, b, c], gap=0.5)     # 每段之间空 0.5 秒
```

`par` 和 `Timeline` 也一样：

```python
par(scene, [(fade_in, 2.5), (music_bar, 1.0)])   # 取最长 2.5

tl = Timeline(scene)
tl.add((add_ball, 2.0))
tl.add(add_logo, span=1.5)          # 也可以走关键字
```

链式风格的 `Timeline`：

```python
from codeanim import Timeline

tl = Timeline(scene)
tl.at(0.0).add(add_title)
tl.after(0.5).add(add_ball)     # 接在上一段结束后 0.5 秒
tl.fit()                        # 撑长场景时长
```

---

## 文件组织

引擎是个包，你的项目里只放自己的 `.py`：

```
my_project/
├── main.py          入口
├── title.py         标题片段
├── ball.py          小球片段
└── logo.py          片头片段
```

每个片段导出一个小函数并**返回时长**：

```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          # 这一段占 1.5 秒
```

同目录的 `.py` 可以直接 `import`，引擎会自动把脚本目录加进 `sys.path`。
脚本里定义名为 `scene` 的变量，或一个返回 `Scene` 的 `build()` 函数，都行。

---

## 输出格式

| 格式 | 用途 | 触发方式 | 透明 |
|---|---|---|---|
| MP4 (H.264) | 默认，兼容性最好 | `-o out.mp4` | — |
| WebM (VP9) | 体积小 | `-o out.webm` | ✅\* |
| MOV | 苹果生态 | `-o out.mov` | ✅ ProRes 4444 |
| MKV | 通用容器 | `-o out.mkv` | — |
| GIF | 发群、贴文档 | `--gif` | — |
| PNG 序列 | 交给 AE / Blender | `--frames 目录/` | ✅ |
| 单帧 PNG | 快速检查某一刻 | `--snapshot 2.0` | ✅ |

\* WebM 的 alpha 取决于本机 ffmpeg 的 libvpx 是否编译了该特性。
CodeAnim 会**实测探测**（编一段带透明的数据再解回来验），
不支持时明确报错并建议改用 MOV，而不是静默输出一个不透明的文件。

### 背景透明

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

```bash
python main.py overlay.py -o overlay.mov    # ProRes 4444
```

拿去 Premiere / Final Cut / 网页 `<video>` 上直接叠。

---

## 设计约束

1. **无 GUI。** 永远不弹窗口，不做播放器。
2. **纯函数场景。** 任意时刻 `f(t)` 可独立求值，不依赖前一帧 —— 因此能任意跳帧、
   倒放、多进程并行。`tests/test_codeanim.py` 里有专门的回归用例守住这条。
3. **可脚本化。** 能塞进 Makefile、CI、GitHub Actions。
4. **命令行优先。** 所有能力都能通过参数触发。
5. **错误说人话。** 缺 ffmpeg 告诉你怎么装；脚本报错只显示你自己的文件和行号；
   空间不足、容器不支持透明这类问题给出具体解决办法，不糊一屏内部堆栈。

---

## 引擎能力

- **零落盘流式渲染**：磁盘占用 = 最终视频大小
- **抗锯齿**：2 倍超采样 + LANCZOS 高质量缩放
- **并行渲染**：帧之间零依赖，多进程打满 CPU
- **静态预合成**：不动的内容只画一次，之后每帧复用
- **快速路径**：不需要旋转/缩放的图元直接画在底图，省掉整张图层分配（实测快 8 倍）
- **中文字体**：自动查找系统字体，支持 `.ttc`；也可用 `CODEANIM_FONT` 或项目 `fonts/`

---

## 测试

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

335 项断言，覆盖颜色、缓动、关键帧、**纯函数性**、中心定位、分组父子变换与
组内定位回归、**图片图元**、**音频（导入 + 生成 + 混音方案 + 音量曲线）**、
**帧动画**、Digits（含自定义格式化）、时间线、片段时长指定、图层顺序、
手感糖、热重载监视与按键、终端内嵌预览、参数校验、磁盘预算、编码参数、超采样。

---

## 例子

| 文件 | 演示内容 |
|---|---|
| **`examples/promo/`** | **完整宣传片**（19s/1080p，四个片段文件，见下） |
| `examples/demo.py` | 完整片头：seq/par 编排 + Group + Digits + z 图层 |
| `examples/chart.py` | 柱状图 + 折线 + 数值滚动 |
| `examples/particles.py` | 300+ 图元压性能 |
| `examples/media.py` | **图片 + 音频**：图片复用、着色、圆角；声音按秒排布 |
| `examples/alpha_overlay.py` | 背景透明，供后期叠加 |
| `examples/title.py` `ball.py` `logo.py` | 可复用的片段模块 |

### 宣传片

用引擎自己的 API 做的 19 秒 1080p 宣传片，顺便当"这引擎能做什么"的实证：

```bash
python main.py examples/promo/__main__.py
python main.py examples/promo/__main__.py --watch --inline   # 改代码看效果
```

四段式，每段一个文件：

| 文件 | 内容 |
|---|---|
| `promo/intro.py` | 片头：`CodeAnim` 弹入 + 定位语 |
| `promo/code_panel.py` | 左边打真实 Python 代码（逐行浮现 + 光标），右边实时出画面 |
| `promo/showcase.py` | 四张能力卡，**卡内各自跑真动效**（公转圆、三种缓动对比、整体旋转、数字滚动） |
| `promo/outro.py` | `$ python main.py demo.py` → `out.mp4 已生成` → Slogan |

各段用 `segment_visibility()` 管生命周期，`seq` 自动排布：

```python
total = seq(scene, [
    lambda s: add_intro(scene, start=s),
    lambda s: add_code_panel(scene, start=s),
    lambda s: add_showcase(scene, start=s),
    lambda s: add_outro(scene, start=s),
])
scene.duration = total
```

---

## 全部特性一览

**渲染**
- 零落盘流式渲染（磁盘占用 = 最终视频大小）
- 2 倍超采样抗锯齿
- 多进程并行（帧间零依赖）
- 静态预合成 + 快速路径（不需要变换的图元直接画）
- 中文字体自动查找（支持 `.ttc`）

**API**
- 图元：`Circle` `Rect` `Line` `Text` `Image` `Digits` `Group`
- 定位：`cx` / `cy` 按几何中心，`center_x()` / `center_y()` 动态句柄
- 图层：`z=`，`--layers` 打印绘制顺序
- 时间线：`seq` / `par` / `Timeline`
- 手感：`add_circle` / `add_image` / `span_of` / `fit_duration` / `add_all` / `set_text` / `row` / `grid`
- `Digits` 自定义格式化：`formatter=` / `default_formatter()`
- 25 条缓动曲线
- 颜色插值

**素材**
- 图片：任意路径或内存位图，支持裁剪 / 九宫格 / 圆角 / 着色 / 等比适配
- 帧动画：雪碧图自动切帧（`sprite=`）或手动切（`frames=`），支持 `play(fps=)` / `frame` 关键帧 / 倒放
- 音频：导入任意格式，或用 `beep` / `chime` / `sweep` / `whoosh` / `arpeggio` 生成
- 音频按"第几秒响"配置，支持音量 / **音量曲线** / 淡入淡出 / 循环 / 裁切 / 变速

**工作流**
- `--watch` 热重载（含**按键交互**：暂停 / 单步 / 换时间点）
- `--inline` 终端内嵌画面（半块字符直接画）
- `--preview` 快速看图
- `--layers` 图层顺序调试
- `doctor` 环境检查
- 磁盘空间预估与红线保护
- 报错只显示用户文件 + 行号

**输出**
- MP4 / WebM / MOV / MKV / GIF / PNG 序列 / 单帧
- 背景透明的 MOV (ProRes 4444) 与 WebM (VP9)
