excanim 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.
excanim-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.3
2
+ Name: excanim
3
+ Version: 0.1.0
4
+ Summary: Manim-style API for Excalidraw drawings and animations
5
+ Author: Dheemanth Manur
6
+ Author-email: Dheemanth Manur <dheemanthmanur72@gmail.com>
7
+ Requires-Dist: imageio>=2.37.3
8
+ Requires-Dist: imageio-ffmpeg>=0.6.0
9
+ Requires-Dist: playwright>=1.58.0
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+
13
+ # excanim
14
+
15
+ Manim-style Python API for Excalidraw drawings and animations.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install excanim
21
+ playwright install chromium
22
+ ```
23
+
24
+ **Requirements:** Python 3.12+
25
+
26
+ ## Examples
27
+
28
+ See [`examples/`](examples/) — architecture diagram, bouncing ball, physics simulation, matrix multiplication.
29
+
30
+ ## How it works
31
+
32
+ Python builds Excalidraw element JSON, renders it through `@excalidraw/excalidraw` in headless Chromium via Playwright, outputs SVG/PNG/MP4.
@@ -0,0 +1,20 @@
1
+ # excanim
2
+
3
+ Manim-style Python API for Excalidraw drawings and animations.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install excanim
9
+ playwright install chromium
10
+ ```
11
+
12
+ **Requirements:** Python 3.12+
13
+
14
+ ## Examples
15
+
16
+ See [`examples/`](examples/) — architecture diagram, bouncing ball, physics simulation, matrix multiplication.
17
+
18
+ ## How it works
19
+
20
+ Python builds Excalidraw element JSON, renders it through `@excalidraw/excalidraw` in headless Chromium via Playwright, outputs SVG/PNG/MP4.
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "excanim"
3
+ version = "0.1.0"
4
+ description = "Manim-style API for Excalidraw drawings and animations"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Dheemanth Manur", email = "dheemanthmanur72@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "imageio>=2.37.3",
12
+ "imageio-ffmpeg>=0.6.0",
13
+ "playwright>=1.58.0",
14
+ ]
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.10.11,<0.11.0"]
18
+ build-backend = "uv_build"
19
+
20
+ [tool.uv-build]
21
+ data-files = {"excanim/bridge" = ["src/excanim/bridge/*"]}
@@ -0,0 +1,6 @@
1
+ from excanim.scene import Scene
2
+ from excanim.elements.shapes import Rect, Ellipse, Diamond
3
+ from excanim.elements.lines import Line, Arrow
4
+ from excanim.elements.text import Text
5
+
6
+ __all__ = ["Scene", "Rect", "Ellipse", "Diamond", "Line", "Arrow", "Text"]
@@ -0,0 +1,19 @@
1
+ from excanim.anim.base import Animation, KeyFrame
2
+ from excanim.anim.easing import (
3
+ Linear, EaseIn, EaseOut, EaseInOut,
4
+ EaseInCubic, EaseOutCubic, EaseInOutCubic,
5
+ BounceIn, BounceOut,
6
+ )
7
+ from excanim.anim.fade import FadeIn, FadeOut
8
+ from excanim.anim.transform import MoveTo, ScaleTo
9
+ from excanim.anim.create import Create, Write
10
+
11
+ __all__ = [
12
+ "Animation", "KeyFrame",
13
+ "Linear", "EaseIn", "EaseOut", "EaseInOut",
14
+ "EaseInCubic", "EaseOutCubic", "EaseInOutCubic",
15
+ "BounceIn", "BounceOut",
16
+ "FadeIn", "FadeOut",
17
+ "MoveTo", "ScaleTo",
18
+ "Create", "Write",
19
+ ]
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from excanim.anim.easing import EasingFunc, linear
6
+ from excanim.elements.base import Element
7
+
8
+
9
+ @dataclass
10
+ class KeyFrame:
11
+ element_id: str
12
+ prop: str # "opacity", "x", "y", "width", "height"
13
+ t_start: float
14
+ t_end: float
15
+ val_start: float
16
+ val_end: float
17
+ easing: EasingFunc = field(default=linear, repr=False)
18
+
19
+ def interpolate(self, t: float) -> float:
20
+ if t <= self.t_start:
21
+ return self.val_start
22
+ if t >= self.t_end:
23
+ return self.val_end
24
+ duration = self.t_end - self.t_start
25
+ if duration == 0:
26
+ return self.val_end
27
+ progress = (t - self.t_start) / duration
28
+ eased = self.easing(progress)
29
+ return self.val_start + (self.val_end - self.val_start) * eased
30
+
31
+
32
+ class Animation:
33
+ def __init__(self, target: Element, duration: float = 1.0, easing: EasingFunc = linear):
34
+ self.target = target
35
+ self.duration = duration
36
+ self.easing = easing
37
+
38
+ def resolve(self, t_start: float) -> list[KeyFrame]:
39
+ raise NotImplementedError
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from excanim.anim.base import Animation, KeyFrame
4
+ from excanim.anim.easing import EasingFunc, linear
5
+ from excanim.elements.base import Element
6
+
7
+
8
+ class Create(Animation):
9
+ """Animate an element appearing with a stroke draw-on effect.
10
+
11
+ For now, implemented as a FadeIn since true stroke-dashoffset
12
+ animation requires SVG post-processing. Can be upgraded later.
13
+ """
14
+
15
+ def __init__(self, target: Element, duration: float = 1.0, easing: EasingFunc = linear):
16
+ super().__init__(target, duration, easing)
17
+
18
+ def resolve(self, t_start: float) -> list[KeyFrame]:
19
+ return [
20
+ KeyFrame(
21
+ element_id=self.target.id,
22
+ prop="opacity",
23
+ t_start=t_start,
24
+ t_end=t_start + self.duration,
25
+ val_start=0,
26
+ val_end=self.target.opacity,
27
+ easing=self.easing,
28
+ )
29
+ ]
30
+
31
+
32
+ class Write(Animation):
33
+ """Animate text appearing with a typewriter-like effect.
34
+
35
+ For now, implemented as FadeIn. True character-by-character
36
+ animation would require per-frame text content changes.
37
+ """
38
+
39
+ def __init__(self, target: Element, duration: float = 1.0, easing: EasingFunc = linear):
40
+ super().__init__(target, duration, easing)
41
+
42
+ def resolve(self, t_start: float) -> list[KeyFrame]:
43
+ return [
44
+ KeyFrame(
45
+ element_id=self.target.id,
46
+ prop="opacity",
47
+ t_start=t_start,
48
+ t_end=t_start + self.duration,
49
+ val_start=0,
50
+ val_end=self.target.opacity,
51
+ easing=self.easing,
52
+ )
53
+ ]
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from typing import Callable
5
+
6
+ EasingFunc = Callable[[float], float]
7
+
8
+
9
+ def linear(t: float) -> float:
10
+ return t
11
+
12
+
13
+ def ease_in(t: float) -> float:
14
+ return t * t
15
+
16
+
17
+ def ease_out(t: float) -> float:
18
+ return 1 - (1 - t) * (1 - t)
19
+
20
+
21
+ def ease_in_out(t: float) -> float:
22
+ if t < 0.5:
23
+ return 2 * t * t
24
+ return 1 - (-2 * t + 2) ** 2 / 2
25
+
26
+
27
+ def ease_in_cubic(t: float) -> float:
28
+ return t * t * t
29
+
30
+
31
+ def ease_out_cubic(t: float) -> float:
32
+ return 1 - (1 - t) ** 3
33
+
34
+
35
+ def ease_in_out_cubic(t: float) -> float:
36
+ if t < 0.5:
37
+ return 4 * t * t * t
38
+ return 1 - (-2 * t + 2) ** 3 / 2
39
+
40
+
41
+ def bounce_out(t: float) -> float:
42
+ n1, d1 = 7.5625, 2.75
43
+ if t < 1 / d1:
44
+ return n1 * t * t
45
+ elif t < 2 / d1:
46
+ t -= 1.5 / d1
47
+ return n1 * t * t + 0.75
48
+ elif t < 2.5 / d1:
49
+ t -= 2.25 / d1
50
+ return n1 * t * t + 0.9375
51
+ else:
52
+ t -= 2.625 / d1
53
+ return n1 * t * t + 0.984375
54
+
55
+
56
+ def bounce_in(t: float) -> float:
57
+ return 1 - bounce_out(1 - t)
58
+
59
+
60
+ def ease_out_quart(t: float) -> float:
61
+ return 1 - (1 - t) ** 4
62
+
63
+
64
+ def ease_in_out_quart(t: float) -> float:
65
+ if t < 0.5:
66
+ return 8 * t * t * t * t
67
+ return 1 - (-2 * t + 2) ** 4 / 2
68
+
69
+
70
+ def spring(t: float) -> float:
71
+ """Apple-style spring easing — slight overshoot then settle."""
72
+ return 1 - math.cos(t * math.pi * 0.5) * math.exp(-t * 3.5) * (1 - t)
73
+
74
+
75
+ # Convenience aliases
76
+ Linear = linear
77
+ EaseIn = ease_in
78
+ EaseOut = ease_out
79
+ EaseInOut = ease_in_out
80
+ EaseInCubic = ease_in_cubic
81
+ EaseOutCubic = ease_out_cubic
82
+ EaseInOutCubic = ease_in_out_cubic
83
+ BounceIn = bounce_in
84
+ BounceOut = bounce_out
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from excanim.anim.base import Animation, KeyFrame
4
+ from excanim.anim.easing import EasingFunc, linear
5
+ from excanim.elements.base import Element
6
+
7
+
8
+ class FadeIn(Animation):
9
+ def __init__(self, target: Element, duration: float = 1.0, easing: EasingFunc = linear):
10
+ super().__init__(target, duration, easing)
11
+
12
+ def resolve(self, t_start: float) -> list[KeyFrame]:
13
+ return [
14
+ KeyFrame(
15
+ element_id=self.target.id,
16
+ prop="opacity",
17
+ t_start=t_start,
18
+ t_end=t_start + self.duration,
19
+ val_start=0,
20
+ val_end=self.target.opacity,
21
+ easing=self.easing,
22
+ )
23
+ ]
24
+
25
+
26
+ class FadeOut(Animation):
27
+ def __init__(self, target: Element, duration: float = 1.0, easing: EasingFunc = linear):
28
+ super().__init__(target, duration, easing)
29
+
30
+ def resolve(self, t_start: float) -> list[KeyFrame]:
31
+ return [
32
+ KeyFrame(
33
+ element_id=self.target.id,
34
+ prop="opacity",
35
+ t_start=t_start,
36
+ t_end=t_start + self.duration,
37
+ val_start=self.target.opacity,
38
+ val_end=0,
39
+ easing=self.easing,
40
+ )
41
+ ]
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ from excanim.anim.base import Animation, KeyFrame
4
+ from excanim.anim.easing import EasingFunc, linear
5
+ from excanim.elements.base import Element
6
+
7
+
8
+ class MoveTo(Animation):
9
+ def __init__(
10
+ self,
11
+ target: Element,
12
+ x: float | None = None,
13
+ y: float | None = None,
14
+ duration: float = 1.0,
15
+ easing: EasingFunc = linear,
16
+ ):
17
+ super().__init__(target, duration, easing)
18
+ self._x = x
19
+ self._y = y
20
+
21
+ def resolve(self, t_start: float) -> list[KeyFrame]:
22
+ kfs = []
23
+ if self._x is not None:
24
+ kfs.append(
25
+ KeyFrame(
26
+ element_id=self.target.id,
27
+ prop="x",
28
+ t_start=t_start,
29
+ t_end=t_start + self.duration,
30
+ val_start=self.target.x,
31
+ val_end=self._x,
32
+ easing=self.easing,
33
+ )
34
+ )
35
+ if self._y is not None:
36
+ kfs.append(
37
+ KeyFrame(
38
+ element_id=self.target.id,
39
+ prop="y",
40
+ t_start=t_start,
41
+ t_end=t_start + self.duration,
42
+ val_start=self.target.y,
43
+ val_end=self._y,
44
+ easing=self.easing,
45
+ )
46
+ )
47
+ return kfs
48
+
49
+
50
+ class ScaleTo(Animation):
51
+ def __init__(
52
+ self,
53
+ target: Element,
54
+ sx: float = 1.0,
55
+ sy: float = 1.0,
56
+ duration: float = 1.0,
57
+ easing: EasingFunc = linear,
58
+ ):
59
+ super().__init__(target, duration, easing)
60
+ self._sx = sx
61
+ self._sy = sy
62
+
63
+ def resolve(self, t_start: float) -> list[KeyFrame]:
64
+ # Scale relative to ORIGINAL base size (not current animated size)
65
+ # This prevents compounding: ScaleTo(sx=1.0) always returns to original
66
+ cx = self.target.x + self.target.width / 2
67
+ cy = self.target.y + self.target.height / 2
68
+ new_w = self.target._base_width * self._sx
69
+ new_h = self.target._base_height * self._sy
70
+ new_x = cx - new_w / 2
71
+ new_y = cy - new_h / 2
72
+
73
+ return [
74
+ KeyFrame(
75
+ element_id=self.target.id,
76
+ prop="width",
77
+ t_start=t_start,
78
+ t_end=t_start + self.duration,
79
+ val_start=self.target.width,
80
+ val_end=new_w,
81
+ easing=self.easing,
82
+ ),
83
+ KeyFrame(
84
+ element_id=self.target.id,
85
+ prop="height",
86
+ t_start=t_start,
87
+ t_end=t_start + self.duration,
88
+ val_start=self.target.height,
89
+ val_end=new_h,
90
+ easing=self.easing,
91
+ ),
92
+ KeyFrame(
93
+ element_id=self.target.id,
94
+ prop="x",
95
+ t_start=t_start,
96
+ t_end=t_start + self.duration,
97
+ val_start=self.target.x,
98
+ val_end=new_x,
99
+ easing=self.easing,
100
+ ),
101
+ KeyFrame(
102
+ element_id=self.target.id,
103
+ prop="y",
104
+ t_start=t_start,
105
+ t_end=t_start + self.duration,
106
+ val_start=self.target.y,
107
+ val_end=new_y,
108
+ easing=self.easing,
109
+ ),
110
+ ]