# dwanim

Rust 코어 + Python DSL 비디오 렌더러. 프로그래밍 방식 애니메이션과 픽셀 기반 합성을 동일 엔진에서 렌더한다. Python으로 씬을 작성하면 Rust가 `wgpu`로 렌더링하고 `ffmpeg`로 인코딩한다.

## Status

**v1.0.0 (2026-06-03)**. Rust workspace, Python wheel metadata, 문서, smoke 예제는 final `1.0.0` 기준으로 맞춘다. dwanim은 특정 기존 API 호환 레이어가 아니라, Rust+PyO3 렌더러 위에 문서화된 Python DSL 표면을 제공한다.

- **패키지 버전**: Cargo workspace는 `1.0.0`, Python project는 `1.0.0`; Python 모듈도 `dwanim.__version__ == "1.0.0"`을 노출한다.
- **Rust 검증**: `cargo fmt --all --check`, `cargo check --locked --workspace --all-targets` 기본/no-default/all-features, `cargo test --locked --workspace --all-targets` 기본/no-default/all-features + release, `cargo clippy --locked --workspace --all-targets` 기본/no-default/all-features `-D warnings`, `cargo doc --locked` 기본/all-features `-D warnings` 통과.
- **Python smoke**: `bash scripts/python_smoke.sh` 통과. 스크립트는 package metadata/primitives/composites/timeline/state/layout helpers/graph helpers를 `OK:` marker로 확인하며, TeX 경로는 환경에 따라 real LaTeX 또는 missing-tool fallback 검증 중 하나가 실행된다.
- **공개 심볼**: 66 pyclass + 3 module helper (`Square`, `camera_preset`, `render_silence_to`) + 6 compatibility aliases (`Write`, `Create`, `ReplacementTransform`, `IntegerMatrix`, `Sphere`, `Torus`) — primitives + composites + charts + indication + 3D + compute + state/updater + declarative dynamic expressions.
- **Python 예시**: `examples/python/*.py` 64개 파일 (60개 `hello_*.py` runnable examples + helper/showcase modules).
- **릴리스 QA**: clean checkout 기준 CI, release wheel/sdist QA, Rust crate packaging, artifact smoke를 통과한 산출물을 배포 대상으로 삼는다.

자세한 변경 내역은 [CHANGELOG.md](./CHANGELOG.md), composite API는 [docs/composites.md](./docs/composites.md) 참고.

## Architecture


- **Cargo 워크스페이스**: `crates/` 아래 11개 크레이트 (resolver="3", edition 2024).
- **코어**: `dwanim-core` (pyo3 비의존 데이터 모델·타임라인·보간).
- **그래픽스**: `dwanim-geom` (lyon Bezier tessellation) + `dwanim-render` (wgpu 22 오프스크린 렌더러: clear → compute registry(Mandelbrot/Julia) → vector → text, preferred 8× MSAA with 4× fallback + 2× SSAA resolve).
- **텍스트**: `dwanim-text` (ab_glyph + 번들 DejaVu Sans).
- **인코딩**: `dwanim-encode` (ffmpeg 서브프로세스, H.264 encoder 자동 선택: libx264 우선, libopenh264 fallback, yuv420p + ultrafast).
- **Python 바인딩**: `dwanim-py` (PyO3 0.28 + abi3-py311, maturin 빌드).
- **하니스**: `dwanim-bench` (공유 `render_to_mp4` + dev 바이너리).
- **LaTeX**: `dwanim-tex` (`latex` + `dvisvgm` -> SVG path/rect import, disk cache, 도구 미설치 시 명시적 오류 또는 `allow_stub=True` fallback).
- **컴퓨트**: `dwanim-compute` (Mandelbrot/Julia fullscreen effect registry + Conway CPU simulator).
- **3D**: `dwanim-3d` (CPU projection 기반 wireframe sphere/torus + `ThreeDCamera`/camera presets).
- **상태**: `dwanim-state` (scalar input binding + derived `StateExpr` evaluator).

설계 결정과 릴리스 QA 메모는 현재 tracked 문서(`README.md`, `CHANGELOG.md`, `docs/composites.md`)와 커밋 이력에 유지한다. 로컬 `.omc/` 런타임/계획 파일은 저장소 추적 대상에서 제외한다.

## 현재 DSL 능력 (1.0.0 QA 기준)

아래는 API inventory다. 그대로 실행 가능한 예제는 `examples/python/hello_*.py`와 이 문서의 Example 섹션을 기준으로 한다.

```text
import dwanim

scene = dwanim.Scene(1920, 1080, 30, dwanim.Color.BLACK)

# Core primitives / renderable objects
c = dwanim.Circle(x, y, radius, color)
r = dwanim.Rectangle(cx, cy, width, height, color)
l = dwanim.Line(x1, y1, x2, y2, thickness, color)
t = dwanim.Text(cx=, cy=, size=, color=, content="HELLO", origin="center")
tex = dwanim.Tex(cx=, cy=, size=, color=, content="E=mc^2", origin="center")
svg = dwanim.Svg(cx=, cy=, size=, color=, content="<svg ...>")
d = dwanim.Dot(cx=, cy=, color=, radius=0.04)
p = dwanim.Polygon(points=[(x,y), ...], color=)
path = dwanim.StrokedPath(points=[(x1,y1), (x2,y2), ...], color=color, thickness=0.02)
m = dwanim.Mandelbrot(cx, cy, zoom, max_iter, color_a, color_b, alpha)
j = dwanim.Julia(c_re, c_im, zoom, max_iter, color_a, color_b, alpha)
s3 = dwanim.SphereSurface(radius, color, u_steps=12, v_steps=8, thickness=0.01)
torus = dwanim.TorusSurface(major_radius=0.55, minor_radius=0.18, color=dwanim.Color.WHITE)

# Composite helpers (Python-side decomposition — pieces() → list of primitives)
ax = dwanim.Axes(x_range=(-3, 3), y_range=(-2, 2))
ax.pieces()                              # list[Line + Text]
ax.c2p(x, y)                             # data → NDC
ax.get_graph(f, samples=60, color=...)   # list[Line]
ax.get_graph_label(f, "f(x)", x=1.0)     # Text
ax.get_v_line_to_graph(f, x=1.0)         # Line
ax.get_h_line_to_graph(f, x=1.0)         # Line
ax.get_tangent_line(f, x=1.0)            # Line
ax.get_riemann_rectangles(f, x_range=(0, 1), dx=0.1)  # list[Rectangle]
ax.get_area_under_graph(f, x_range=(0, 1))             # Polygon
grid = dwanim.Grid(3, 4, width=1.6, height=0.9)
grid.pieces()                            # list[Line]
grid.cell_center(1, 2) / cell_rect / cell_text
bars = dwanim.BarChart([1, 3, 2], labels=["A", "B", "C"], show_values=True)
bars.add_bar(4, "D")                     # mutable chart helper
bars.pieces()                            # list[Line + Rectangle + Text]
line_chart = dwanim.LineChart([(0, 1), (1, 3), (2, 2)])
line_chart.add_data_point((3, 4))         # autoscale by default
line_chart.pieces()                      # list[Line + Dot]
coord = dwanim.CoordinateScene(center_x=0, center_y=0, zoom=1.0)
coord.c2p(1, 1) / point_to_pixel / pixel_to_point
coord.pieces()                           # grid + axes + optional labels
slider = dwanim.StateSlider(dwanim.ValueTracker(0.5), label="x")
slider.pieces()                          # track + fill + handle + label
flash = dwanim.Flash(0.0, 0.0, n_lines=8)
flash.pieces()                           # list[Line] radial burst
highlight_box = dwanim.SurroundingRectangle(label, buff=0.03)
highlight_box.pieces()                   # list[Line] stroked rectangle
label_bg = dwanim.BackgroundRectangle(label, fill_opacity=0.75)
label_bg.pieces()                        # list[Rectangle]
under = dwanim.Underline(label)
under.pieces()                           # list[Line]
# Turing tape helpers are intentionally example-local, not public dwanim API.
# See examples/python/tape_helpers.py for Tape/TapeState/TapeTransition
# built from Grid/Line/Rectangle/Text primitives.

# Composite helpers — 모두 pieces() → list[primitive]
arrow = dwanim.Arrow((x1,y1), (x2,y2), color, tip_size=0.1, thickness=0.02)
# pieces(): [Line(shaft), Polygon(tip)]
brace = dwanim.Brace((x1,y1), (x2,y2), color, depth=0.15, thickness=0.02)
# pieces(): [Line(start→tip), Line(tip→end)] — V-shape MVP
darr = dwanim.DoubleArrow((x1,y1), (x2,y2), color, tip_size=0.1, thickness=0.02)
# pieces(): [Line(shaft), Polygon(end tip), Polygon(start tip)]
bl = dwanim.BraceLabel((x1,y1), (x2,y2), "label", color,
                       depth=0.15, thickness=0.02, label_offset=0.08, label_size=0.08)
# pieces(): [Line(start→tip), Line(tip→end), Text(label)]
ca = dwanim.CurvedArrow((x1,y1), (x2,y2), color, arc_offset=0.2,
                        tip_size=0.1, thickness=0.02, segments=16)
# pieces(): [StrokedPath(Bezier shaft), Polygon(end tip)]
cda = dwanim.CurvedDoubleArrow((x1,y1), (x2,y2), color, arc_offset=0.2,
                               tip_size=0.1, thickness=0.02, segments=16)
# pieces(): [Polygon(start tip), StrokedPath(Bezier shaft), Polygon(end tip)]
np_ = dwanim.NumberPlane(x_min=-1.5, x_max=1.5, y_min=-0.8, y_max=0.8,
                         x_step=0.2, y_step=0.2,
                         grid_color=dwanim.Color(0.3,0.3,0.3,1.0),
                         axis_color=dwanim.Color.WHITE,
                         thickness=0.004, axis_thickness=0.01)
# pieces(): list[Line] — grid lines + highlighted snapped axes
scene.add_layer(np_.pieces())            # bulk add 패턴 (가변 pieces)

pf = dwanim.ParametricFunction(lambda t: (t, t*t), (-1.0, 1.0), dwanim.Color.WHITE, samples=40)
dec = dwanim.DecimalNumber(3.14, cx=0.0, cy=0.0, size=0.1, color=dwanim.Color.WHITE)
integer = dwanim.Integer(42, cx=0.0, cy=0.0, size=0.1, color=dwanim.Color.WHITE)
arc = dwanim.Arc(cx=0.0, cy=0.0, radius=0.4, start_angle=0.0, end_angle=1.57, color=dwanim.Color.WHITE)
sector = dwanim.Sector(cx=0.0, cy=0.0, radius=0.4, start_angle=0.0, end_angle=1.57, color=dwanim.Color.WHITE)
annulus = dwanim.Annulus(cx=0.0, cy=0.0, inner_radius=0.2, outer_radius=0.4, color=dwanim.Color.WHITE)
ellipse = dwanim.Ellipse(cx=0.0, cy=0.0, rx=0.4, ry=0.2, color=dwanim.Color.WHITE)
poly = dwanim.RegularPolygon(cx=0.0, cy=0.0, radius=0.3, n=6, color=dwanim.Color.WHITE)
star = dwanim.Star(5, 0.0, 0.0, 0.35, 0.15, dwanim.Color.WHITE)
frac = dwanim.Fraction(1, 2, cx=0.0, cy=0.0, size=0.1, color=dwanim.Color.WHITE)
vg = dwanim.VGroup(dwanim.Circle(0.0, 0.0, 0.1, dwanim.Color.RED))
centered_pieces = vg.move_to(0.2, 0.0)  # origin/get_center 기준으로 그룹 전체 이동
arranged = dwanim.VGroup(circle, label).arrange(direction="RIGHT", buff=0.05)
above = dwanim.VGroup(*arranged).next_to(target, direction="UP", buff=0.05)
box = dwanim.VGroup(*above).surrounding_rectangle(buff=0.03)
underline = dwanim.VGroup(label).underline()
number_line = dwanim.NumberLine(cx=0.0, cy=0.0, x_min=-2.0, x_max=2.0, length=1.4)
ticks = dwanim.TickMarks(-0.5, 0.0, 0.5, 0.0, count=5)
angle = dwanim.AngleMarker(cx=0.0, cy=0.0, radius=0.25, start_angle=0.0, end_angle=1.57)
right_angle = dwanim.RightAngle(cx=0.0, cy=0.0, size=0.12, color=dwanim.Color.WHITE)
# RightAngle defaults to origin="center"; use origin="corner" when (cx, cy) is the angle vertex.
complex_plane = dwanim.ComplexPlane(x_min=-1.0, x_max=1.0, y_min=-1.0, y_max=1.0)
matrix = dwanim.Matrix(rows=2, cols=2, entries=[["1", "0"], ["0", "1"]])

# Dynamic values
tracker = dwanim.ValueTracker(-0.6)
scene.add_dynamic_lambda(lambda: dwanim.Circle(tracker.get_value(), 0.0, 0.12, dwanim.Color.WHITE))
scene.play(dwanim.AnimateValue(tracker, 0.6), run_time=1.0, rate_func="smooth")

# StateExpr fast path
scene.bind_state_value("x", tracker)
x = dwanim.StateExpr.var("x")
scene.add_state("y", x * 0.5)
scene.add_dynamic_expr(dwanim.DynamicCircle(x, dwanim.StateExpr.var("y"), 0.12, dwanim.Color.WHITE))
# StateExpr supports arithmetic/reverse arithmetic, comparisons (`==`, `!=`, `<`, `<=`, `>`, `>=`), select/where,
# min/max/clamp, lerp, sin/cos/abs, and smoothstep. Python lambdas remain
# available through add_dynamic/add_dynamic_lambda as an explicit slow path.

# Composite / layers (minimal Phase 7 slice)
comp = dwanim.CompositeScene()
comp.add_layer([dwanim.Mandelbrot(-0.5, 0.0, 0.85, 96, dwanim.Color.BLACK, dwanim.Color.WHITE, 1.0)])
comp.add_layer([
    (dwanim.Rectangle(0.0, 0.0, 0.65, 0.22, dwanim.Color.BLACK), 1),
    (dwanim.Text(cx=-0.28, cy=0.05, size=0.12, color=dwanim.Color.WHITE, content="COMPOSITE"), 2),
])
comp.add_child("badge", dwanim.Circle(0, 0, 0.06, dwanim.Color.WHITE), x=0.4, opacity=0.7)
scene.add_composite(comp)

# 3D (minimal Phase 5 slice)
scene.set_camera(dwanim.ThreeDCamera(yaw=0.8, pitch=0.5, distance=3.0, focal_length=1.6))
scene.play(dwanim.FadeIn(dwanim.SphereSurface(0.8, dwanim.Color.WHITE)), run_time=1.0)
scene.play(
    dwanim.AnimateCamera(
        dwanim.ThreeDCamera(yaw=1.4, pitch=0.45, distance=3.4, focal_length=1.6)
    ),
    run_time=1.0,
    rate_func="smooth",
)

# Color
dwanim.Color(r, g, b, a)  # finite 0.0..1.0 channels
dwanim.Color.RED / .WHITE / .BLACK

# Animations / groups
dwanim.FadeIn(shape)
dwanim.FadeOut(shape)
dwanim.Transform(from_shape, to_shape)
scene.play([dwanim.FadeIn(a), dwanim.FadeIn(b)], run_time=1.0, lag_ratio=0.4)

# Timeline
scene.play(
    anim,
    run_time=1.0,
    rate_func="linear"|"smooth"|"there_and_back"|"quad"|"sine_out"|"bounce"|"elastic"|...,
)
scene.wait(duration)
scene.render("out.mp4")
```

Python 예시 파일 64개는 `examples/python/*.py`에 있으며, 그중 60개가 `hello_*.py` runnable examples다. 핵심 smoke 대상은 primitives, animation/timeline, Mandelbrot/Julia, Text/Tex, ValueTracker/StateExpr, CompositeScene/layers, Grid, example-local Tape helpers, 3D sphere/torus/camera, axes/graph, charts/flash, arrow/brace 계열, NumberPlane/PolarPlane/ComplexPlane, VGroup transforms, matrix/number line/tick/angle marker, Conway, pixel mode를 포함한다.
`Scene.play()`는 단일 animation 또는 visual animation list/tuple을 받으며, list/tuple에는 `lag_ratio`를 적용할 수 있다. 지원 rate function은 `linear`, `smooth`, `there_and_back`에 더해 `rush_into`, `rush_from`, `slow_into`, `there_and_back_with_pause`, `wiggle`, `lingering`, `exponential_decay`, `quad*`, `cubic*`, `quart*`, `quint*`, `sine*`, `expo*`, `circ*`, `back*`, `bounce*`, `elastic*` 계열을 포함한다.

## Coordinate System

dwanim uses a **center-origin world coordinate system** where the shorter axis of the frame always spans `[-1, +1]` and the longer axis spans `[-ratio, +ratio]`, with `ratio = max(width, height) / min(width, height)`. The origin `(0, 0)` is the center of the frame.

- In a 1280×720 (landscape) frame: `y ∈ [-1, +1]`, `x ∈ [-16/9, +16/9] ≈ [-1.778, +1.778]`.
- In a 720×1280 (portrait) frame: `x ∈ [-1, +1]`, `y ∈ [-16/9, +16/9]`.
- In a 500×500 (square) frame: both axes `[-1, +1]`.

Because the renderer applies the same scale to both axes, **shapes stay aspect-invariant regardless of frame ratio**: a `Circle(0, 0, 0.3)` renders as a perfect pixel-accurate circle whether the output is landscape, portrait, or square; a `Rectangle(0, 0, 0.3, 0.3)` stays square; a regular polygon keeps its angles. Public primitive shapes such as `Circle`, `Rectangle`, `Line`, `Polygon`, and `StrokedPath` share this contract.

`Text` and `Tex` use center-origin placement by default. For bounded text/formula
layouts, pass `origin="top_left"`, `"top_right"`, `"bottom_left"`, or
`"bottom_right"` to pin that corner of the rendered bounds to `(cx, cy)`.

### Example

```python
import dwanim

for (w, h) in [(1280, 720), (720, 1280), (500, 500)]:
    scene = dwanim.Scene(w, h, 30, dwanim.Color.BLACK)
    scene.play(dwanim.FadeIn(dwanim.Circle(0.0, 0.0, 0.3, dwanim.Color.RED)), run_time=1.0)
    scene.render(f"circle_{w}x{h}.mp4")
# All three outputs contain the same-sized circle at the center.
```

## Pixel Mode (Advanced)

For pixel-precise layout — matching designs from image editors, overlaying on existing frames, or integrating with tools that emit pixel coordinates — pass `coord_mode="pixel"` to `Scene`. This switches the coordinate contract to **top-left pixel coordinates**:

- Origin `(0, 0)` is the top-left of the frame.
- `(width, height)` is the bottom-right.
- Supported 2D primitives use pixel coordinates, sizes, radii, and thicknesses (floats supported for subpixel placement). This includes `StrokedPath`, so composite helpers that decompose to stroked paths and polygons stay in one coordinate system. Native-coordinate surfaces such as `Mandelbrot`, `Julia`, `SphereSurface`, and `TorusSurface` keep their own coordinate systems.

### Example

```python
import dwanim

scene = dwanim.Scene(1280, 720, 30, dwanim.Color.BLACK, coord_mode="pixel")
scene.play(
    dwanim.FadeIn(dwanim.Circle(640.0, 360.0, 100.0, dwanim.Color.RED)),  # center, r=100px
    run_time=0.5,
)
scene.play(
    dwanim.FadeIn(dwanim.Rectangle(100.0, 100.0, 80.0, 80.0, dwanim.Color.WHITE)),  # 80×80 near top-left
    run_time=0.5,
)
scene.render("pixel_mode.mp4")
```

Only `"world"` (default) and `"pixel"` are accepted; any other string raises `ValueError`. World mode is the recommended default because it keeps coordinates aspect-invariant and resolution-independent.

## Build

시스템 요구: Linux, Python 3.11-3.13, ffmpeg, Rust stable.

LaTeX `Tex` 프리미티브는 런타임에 외부 `latex` + `dvisvgm`가 필요하다. 탐색 순서는 `PATH`, `DWANIM_TEX_BIN`, `/usr/local/texlive/*/bin/*`이며, TeX Live 자체의 Perl 의존성까지 동작해야 한다. 실제 SVG 결과는 `DWANIM_TEX_CACHE_DIR` 또는 `$XDG_CACHE_HOME/dwanim/tex`/`$HOME/.cache/dwanim/tex` 아래에 캐시되고, 캐시된 SVG는 외부 도구가 없어도 재사용된다. 캐시가 없고 도구도 없으면 명시적 오류를 반환하거나 `allow_stub=True`일 때 placeholder path로 fallback한다. `Svg`는 inline SVG 또는 `Svg.from_file(...)`을 path/rect/circle 기반 `VectorPath`로 가져온다.
`ValueTracker`/`add_dynamic_expr`는 `StateExpr` 기반 dynamic object를 Rust 쪽에서 per-frame 평가하는 fast path다. 현재 fast path object는 `DynamicCircle`부터 제공한다.
`add_dynamic`/`add_dynamic_lambda`는 Python callable fallback이다. callable은 shape 하나 또는 shape iterable 하나를 반환해야 하며, tracker 값의 순수 함수처럼 쓰는 것이 안전하다. 동적으로 생성된 shape는 `FadeIn`/`FadeOut`/`Transform`의 타깃으로 지원하지 않는다.
타임라인 segment는 half-open으로 저장하지만, material/value/camera animation 모두 마지막 in-range frame에서 terminal state를 렌더링한다.
3D는 최소 slice라서 현재는 `ThreeDCamera` + `SphereSurface`/`TorusSurface` wireframe을 지원하며, depth buffer / hidden-line removal은 없다.
카메라 animation은 지원하지만 여전히 최소 slice라서 전체 3D scene semantics가 아니라 고정 wireframe object + timeline-driven camera 변화 수준만 보장한다.
카메라 animation을 시작한 뒤에는 `set_camera()` 대신 `AnimateCamera(...)`를 사용해야 한다.
Scene state bindings는 scalar input binding + derived `StateExpr` 평가를 지원한다. `StateExpr`는 산술식, 비교식(`==`, `!=`, `<`, `<=`, `>`, `>=`), `select`/`where`, `min`/`max`/`clamp`, `lerp`, `sin`/`cos`/`abs`, `smoothstep`을 포함하지만, arbitrary Python function call이나 symbolic differentiation은 `1.0.0` 범위 밖이다.
CompositeScene은 static layer composition에 더해 named child transform을 지원한다. `Circle`/`Rectangle`/`Line`/`Polygon`/`StrokedPath` child는 child-local `(0, 0)`을 pivot으로 position/scale/opacity/angle을 적용하며, 회전된 `Rectangle`은 내부적으로 polygon geometry로 렌더링한다. `Text` child는 position/scale/opacity만 지원하고 angle은 명시적으로 거부한다. Mandelbrot/Julia/3D surface처럼 자체 좌표계를 가진 child는 opacity만 안전하게 조합할 수 있으므로 position/scale/angle transform 대상이 아니다. `CompositeScene.add_layer(...)`/`add_child(...)`는 duplicate shape id를 즉시 거부한다. child 자체 timeline propagation은 아직 별도 `Scene` timeline으로 승격하지 않았다.
`Scene.add_layer(...)`와 `CompositeScene.add_layer(...)`에 plain shape를 넘기면 레이어 번호는 추가 순서대로 자동 배정된다. `(shape, layer)` tuple을 넘기면 명시 layer를 우선한다.
Grid helper도 최소 slice라서 현재는 정적 cell geometry/text helper만 지원하며, 셀 단위 상태 머신은 아직 아니다.
Tape/TapeState/TapeTransition은 public dwanim API가 아니라 `examples/python/tape_helpers.py`에만 있는 예제 전용 조합이다.

```bash
# Rust-only 빌드
cargo build --locked --workspace
cargo test --locked --workspace -- --test-threads=1   # GPU 테스트 직렬 권장
cargo clippy --locked --workspace --all-targets -- -D warnings

# Python 바인딩 (maturin)
python3 -m venv .venv
source .venv/bin/activate
.venv/bin/python -m pip install -q -r requirements-release.txt
maturin develop -m crates/dwanim-py/Cargo.toml --release --features extension-module --locked

# Wheel 빌드
scripts/build_wheel.sh local                 # dist/*.linux_x86_64.whl, 로컬 QA/install용
scripts/build_wheel.sh sdist                 # dist/*.tar.gz source distribution
.venv/bin/python -m pip install -q -r requirements-release.txt  # PyPI 업로드용 manylinux wheel 포함
scripts/build_wheel.sh pypi                  # dist/*manylinux_2_17*.whl
scripts/build_wheel.sh release               # sdist + manylinux wheel
scripts/artifact_smoke.sh dist/*.whl dist/*.tar.gz

# 한 줄 검증 (TeX 환경별 분기 포함, DWANIM_SMOKE_VENV=/path/to/venv로 venv 대체 가능)
bash scripts/python_smoke.sh
```

## Release

`v1.0.0` 배포는 tag `v1.0.0`에서 release workflow를 실행해 검증한다. release gate는 `pyproject.toml`의 `1.0.0`, Cargo workspace `1.0.0`, Production/Stable classifier, tag 이름 일치를 확인한 뒤 Rust QA, Python wheel/sdist QA, artifact smoke를 수행하고 GitHub Actions artifact로 산출물을 업로드한다.

배포 업로드는 자동 publish가 아니라 수동 단계다. release workflow 산출물과 로컬 `scripts/check_pypi_artifacts.py dist`, `scripts/artifact_smoke.sh dist/*.whl dist/*.tar.gz`, `scripts/check_cargo_packages.py target/package` 검증을 통과한 파일만 PyPI/crates.io/GitHub Release에 업로드한다. `scripts/check_cargo_packages.py target/package/*.crate`처럼 archive 파일 목록을 직접 넘기는 방식도 지원한다.

## License

프로젝트 코드는 MIT 라이선스다. 자세한 본문은 [LICENSE-MIT](./LICENSE-MIT)를 참고한다. 배포 산출물에는 DejaVu Sans (`crates/dwanim-text/fonts/DejaVuSans.ttf`) 고지를 [LICENSE-THIRD-PARTY.md](./LICENSE-THIRD-PARTY.md)로 포함한다. Rust/Python dependency 라이선스는 각 dependency manifest와 lockfile을 기준으로 한다.
