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 +32 -0
- excanim-0.1.0/README.md +20 -0
- excanim-0.1.0/pyproject.toml +21 -0
- excanim-0.1.0/src/excanim/__init__.py +6 -0
- excanim-0.1.0/src/excanim/anim/__init__.py +19 -0
- excanim-0.1.0/src/excanim/anim/base.py +39 -0
- excanim-0.1.0/src/excanim/anim/create.py +53 -0
- excanim-0.1.0/src/excanim/anim/easing.py +84 -0
- excanim-0.1.0/src/excanim/anim/fade.py +41 -0
- excanim-0.1.0/src/excanim/anim/transform.py +110 -0
- excanim-0.1.0/src/excanim/bridge/bundle.js +254644 -0
- excanim-0.1.0/src/excanim/bridge/index.html +7 -0
- excanim-0.1.0/src/excanim/constants.py +11 -0
- excanim-0.1.0/src/excanim/elements/__init__.py +6 -0
- excanim-0.1.0/src/excanim/elements/base.py +83 -0
- excanim-0.1.0/src/excanim/elements/lines.py +117 -0
- excanim-0.1.0/src/excanim/elements/shapes.py +90 -0
- excanim-0.1.0/src/excanim/elements/text.py +61 -0
- excanim-0.1.0/src/excanim/py.typed +0 -0
- excanim-0.1.0/src/excanim/render/__init__.py +0 -0
- excanim-0.1.0/src/excanim/render/bridge.py +84 -0
- excanim-0.1.0/src/excanim/render/frames.py +91 -0
- excanim-0.1.0/src/excanim/render/video.py +60 -0
- excanim-0.1.0/src/excanim/scene.py +111 -0
- excanim-0.1.0/src/excanim/timeline.py +64 -0
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.
|
excanim-0.1.0/README.md
ADDED
|
@@ -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,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
|
+
]
|