flits-video 0.2.0__py3-none-any.whl
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.
- flits/__init__.py +38 -0
- flits/_cli.py +230 -0
- flits/compile.py +102 -0
- flits/py.typed +0 -0
- flits/types.py +404 -0
- flits_video-0.2.0.dist-info/METADATA +16 -0
- flits_video-0.2.0.dist-info/RECORD +9 -0
- flits_video-0.2.0.dist-info/WHEEL +5 -0
- flits_video-0.2.0.dist-info/top_level.txt +1 -0
flits/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Python wrapper for flits, the fast GPU video renderer.
|
|
2
|
+
|
|
3
|
+
Build a scene as a dict (typed with `flits.types.Scene`) and render it with the flits CLI:
|
|
4
|
+
|
|
5
|
+
import flits
|
|
6
|
+
scene = {"width": 1080, "height": 1920, "duration": 3,
|
|
7
|
+
"layers": [{"type": "text", "text": "Hello", "size": 120, "in": "pop"}]}
|
|
8
|
+
print(flits.check(scene))
|
|
9
|
+
flits.render(scene, "hello.mp4", scale=0.5)
|
|
10
|
+
|
|
11
|
+
The CLI is found via the `bin=` argument, $FLITS_BIN or PATH. Relative media paths in a scene dict resolve
|
|
12
|
+
against `base_dir` (default: the current directory).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .compile import compile, flatten, sample
|
|
16
|
+
from ._cli import CheckResult, FlitsError, binary, check, render, run, scene_file, schema, sheet, still, to_args, transcribe
|
|
17
|
+
from .types import Layer, Scene
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"compile",
|
|
21
|
+
"flatten",
|
|
22
|
+
"sample",
|
|
23
|
+
"CheckResult",
|
|
24
|
+
"FlitsError",
|
|
25
|
+
"Layer",
|
|
26
|
+
"Scene",
|
|
27
|
+
"binary",
|
|
28
|
+
"check",
|
|
29
|
+
"render",
|
|
30
|
+
"run",
|
|
31
|
+
"scene_file",
|
|
32
|
+
"schema",
|
|
33
|
+
"sheet",
|
|
34
|
+
"still",
|
|
35
|
+
"to_args",
|
|
36
|
+
"transcribe",
|
|
37
|
+
]
|
|
38
|
+
__version__ = "0.2.0"
|
flits/_cli.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Run the flits CLI from Python (subprocess; no other dependencies)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, Iterator, List, Mapping, Optional, Sequence, Union
|
|
13
|
+
|
|
14
|
+
from .compile import compile
|
|
15
|
+
|
|
16
|
+
PathLike = Union[str, "os.PathLike[str]"]
|
|
17
|
+
SceneInput = Union[Mapping[str, Any], PathLike]
|
|
18
|
+
"""A scene dict (see flits.types.Scene) or the path of a scene .json file."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FlitsError(RuntimeError):
|
|
22
|
+
"""flits failed (or could not be started; then returncode is None). `stderr`/`stdout` hold its output."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, message: str, returncode: Optional[int] = None, stderr: str = "", args: Sequence[str] = (), stdout: str = ""):
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.returncode = returncode
|
|
27
|
+
self.stderr = stderr
|
|
28
|
+
self.stdout = stdout
|
|
29
|
+
self.cmd = list(args)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class CheckResult:
|
|
34
|
+
"""Outcome of `check`: ok means the scene loads and prepares (warnings don't make it fail)."""
|
|
35
|
+
|
|
36
|
+
ok: bool
|
|
37
|
+
warnings: List[str] = field(default_factory=list)
|
|
38
|
+
error: Optional[str] = None
|
|
39
|
+
output: str = ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def binary(bin: Optional[PathLike] = None) -> str:
|
|
43
|
+
"""The flits executable: `bin`, $FLITS_BIN or `flits` on PATH."""
|
|
44
|
+
if bin is not None:
|
|
45
|
+
return os.fspath(bin)
|
|
46
|
+
env = os.environ.get("FLITS_BIN")
|
|
47
|
+
if env:
|
|
48
|
+
return env
|
|
49
|
+
found = shutil.which("flits")
|
|
50
|
+
if not found:
|
|
51
|
+
raise FlitsError("flits not found: install it (https://github.com/ArjandenHartog/flits) or set FLITS_BIN")
|
|
52
|
+
return found
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def to_args(options: Mapping[str, Any]) -> List[str]:
|
|
56
|
+
"""{"keep_temp": True, "workers": 4} -> ["--keep-temp", "--workers", "4"]"""
|
|
57
|
+
args: List[str] = []
|
|
58
|
+
for key, value in options.items():
|
|
59
|
+
if value is None or value is False:
|
|
60
|
+
continue
|
|
61
|
+
flag = "--" + key.replace("_", "-")
|
|
62
|
+
if value is True:
|
|
63
|
+
args.append(flag)
|
|
64
|
+
elif isinstance(value, (list, tuple)):
|
|
65
|
+
for v in value:
|
|
66
|
+
args += [flag, str(v)]
|
|
67
|
+
else:
|
|
68
|
+
args += [flag, os.fspath(value) if isinstance(value, os.PathLike) else str(value)]
|
|
69
|
+
return args
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def run(args: Sequence[str], *, bin: Optional[PathLike] = None, echo: bool = False, cwd: Optional[PathLike] = None) -> str:
|
|
73
|
+
"""Run `flits <args>` and return stdout. With echo=True, progress (stderr) is copied to sys.stderr live."""
|
|
74
|
+
cmd = [binary(bin), *map(str, args)]
|
|
75
|
+
try:
|
|
76
|
+
proc = subprocess.Popen(
|
|
77
|
+
cmd,
|
|
78
|
+
cwd=cwd,
|
|
79
|
+
stdin=subprocess.DEVNULL,
|
|
80
|
+
stdout=subprocess.PIPE,
|
|
81
|
+
stderr=subprocess.PIPE,
|
|
82
|
+
text=True,
|
|
83
|
+
encoding="utf-8",
|
|
84
|
+
errors="replace",
|
|
85
|
+
)
|
|
86
|
+
except FileNotFoundError as e:
|
|
87
|
+
raise FlitsError(f"flits not found ({cmd[0]}): install it or set FLITS_BIN") from e
|
|
88
|
+
if echo:
|
|
89
|
+
# stdout is small (paths, timelines); stream stderr, then collect stdout
|
|
90
|
+
err_parts: List[str] = []
|
|
91
|
+
stderr, stdout = proc.stderr, proc.stdout
|
|
92
|
+
assert stderr is not None and stdout is not None
|
|
93
|
+
for chunk in iter(lambda: stderr.read(256), ""):
|
|
94
|
+
err_parts.append(chunk)
|
|
95
|
+
sys.stderr.write(chunk)
|
|
96
|
+
sys.stderr.flush()
|
|
97
|
+
out = stdout.read()
|
|
98
|
+
proc.wait()
|
|
99
|
+
err = "".join(err_parts)
|
|
100
|
+
else:
|
|
101
|
+
out, err = proc.communicate()
|
|
102
|
+
if proc.returncode != 0:
|
|
103
|
+
lines = [line for line in err.splitlines() if line.startswith("error:")]
|
|
104
|
+
msg = lines[-1] if lines else f"flits exited with code {proc.returncode}"
|
|
105
|
+
raise FlitsError(msg, proc.returncode, err, cmd, out)
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@contextmanager
|
|
110
|
+
def scene_file(scene: SceneInput, base_dir: Optional[PathLike] = None) -> Iterator[str]:
|
|
111
|
+
"""Yield the scene as a file path. A dict is written to a temporary file in `base_dir` (default: the current
|
|
112
|
+
directory) so relative media paths in it resolve against that folder; the file is removed afterwards."""
|
|
113
|
+
if not isinstance(scene, Mapping):
|
|
114
|
+
yield os.fspath(scene)
|
|
115
|
+
return
|
|
116
|
+
folder = Path(base_dir if base_dir is not None else os.getcwd()).resolve()
|
|
117
|
+
fd, path = tempfile.mkstemp(prefix=".flits-", suffix=".scene.json", dir=folder)
|
|
118
|
+
try:
|
|
119
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
120
|
+
json.dump(compile(dict(scene)), f, ensure_ascii=False, indent=1)
|
|
121
|
+
yield path
|
|
122
|
+
finally:
|
|
123
|
+
try:
|
|
124
|
+
os.remove(path)
|
|
125
|
+
except OSError:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _lines(s: str) -> List[str]:
|
|
130
|
+
return [line.strip() for line in s.splitlines() if line.strip()]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def render(
|
|
134
|
+
scene: SceneInput,
|
|
135
|
+
out: PathLike,
|
|
136
|
+
*,
|
|
137
|
+
base_dir: Optional[PathLike] = None,
|
|
138
|
+
bin: Optional[PathLike] = None,
|
|
139
|
+
echo: bool = False,
|
|
140
|
+
**options: Any,
|
|
141
|
+
) -> Path:
|
|
142
|
+
"""Render a scene to a video and return the output path.
|
|
143
|
+
|
|
144
|
+
options are `flits render` flags: workers, encoder ("auto"|"nvenc"|"qsv"|"vaapi"|"cpu"|"null"),
|
|
145
|
+
codec ("h264"|"hevc"|"av1"), speed ("fast"|"balanced"|"quality"), quality (18..28), scale (0.5 = preview),
|
|
146
|
+
gpu ("auto"|"gpu"|"cpu"), keep_temp, quiet, remote.
|
|
147
|
+
"""
|
|
148
|
+
out = Path(out).resolve()
|
|
149
|
+
with scene_file(scene, base_dir) as path:
|
|
150
|
+
run(["render", path, "-o", str(out), *to_args(options)], bin=bin, echo=echo)
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def still(
|
|
155
|
+
scene: SceneInput,
|
|
156
|
+
at: Union[float, str, Sequence[Union[float, str]]],
|
|
157
|
+
out: PathLike,
|
|
158
|
+
*,
|
|
159
|
+
scale: Optional[float] = None,
|
|
160
|
+
base_dir: Optional[PathLike] = None,
|
|
161
|
+
bin: Optional[PathLike] = None,
|
|
162
|
+
) -> List[Path]:
|
|
163
|
+
"""Render frame(s) at the given time(s) (seconds or "mm:ss") to PNG; returns the written files."""
|
|
164
|
+
times = list(at) if isinstance(at, (list, tuple)) else [at]
|
|
165
|
+
with scene_file(scene, base_dir) as path:
|
|
166
|
+
stdout = run(["still", path, *to_args({"at": times, "scale": scale}), "-o", str(Path(out).resolve())], bin=bin)
|
|
167
|
+
return [Path(p) for p in _lines(stdout)]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def sheet(
|
|
171
|
+
scene: SceneInput,
|
|
172
|
+
out: PathLike,
|
|
173
|
+
*,
|
|
174
|
+
base_dir: Optional[PathLike] = None,
|
|
175
|
+
bin: Optional[PathLike] = None,
|
|
176
|
+
**options: Any,
|
|
177
|
+
) -> Path:
|
|
178
|
+
"""Contact sheet: evenly spaced frames in one PNG (options: count, cols, thumb)."""
|
|
179
|
+
out = Path(out).resolve()
|
|
180
|
+
with scene_file(scene, base_dir) as path:
|
|
181
|
+
run(["sheet", path, "-o", str(out), *to_args(options)], bin=bin)
|
|
182
|
+
return out
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def check(scene: SceneInput, *, base_dir: Optional[PathLike] = None, bin: Optional[PathLike] = None) -> CheckResult:
|
|
186
|
+
"""Validate a scene the way flits sees it (typos, bad values, missing files). Does not raise for an invalid
|
|
187
|
+
scene: inspect `result.ok`, `result.error` and `result.warnings`."""
|
|
188
|
+
def warnings(output: str) -> List[str]:
|
|
189
|
+
return [line[len("warning: ") :] for line in _lines(output) if line.startswith("warning: ")]
|
|
190
|
+
|
|
191
|
+
with scene_file(scene, base_dir) as path:
|
|
192
|
+
try:
|
|
193
|
+
output = run(["check", path], bin=bin)
|
|
194
|
+
except FlitsError as e:
|
|
195
|
+
if e.returncode is None:
|
|
196
|
+
raise
|
|
197
|
+
msg = str(e)
|
|
198
|
+
error = msg[len("error: ") :] if msg.startswith("error: ") else msg
|
|
199
|
+
return CheckResult(ok=False, warnings=warnings(e.stdout), error=error, output=e.stdout + e.stderr)
|
|
200
|
+
return CheckResult(ok=True, warnings=warnings(output), output=output)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def transcribe(
|
|
204
|
+
inputs: Union[PathLike, Sequence[PathLike]],
|
|
205
|
+
out: Optional[PathLike] = None,
|
|
206
|
+
*,
|
|
207
|
+
outdir: Optional[PathLike] = None,
|
|
208
|
+
model: Optional[str] = None,
|
|
209
|
+
lang: Optional[str] = None,
|
|
210
|
+
device: Optional[str] = None,
|
|
211
|
+
python: Optional[PathLike] = None,
|
|
212
|
+
remote: Optional[str] = None,
|
|
213
|
+
bin: Optional[PathLike] = None,
|
|
214
|
+
echo: bool = False,
|
|
215
|
+
) -> List[Path]:
|
|
216
|
+
"""Transcribe audio/video to word-timed JSON (+ .srt/.vtt/.txt next to it); returns the .words.json paths.
|
|
217
|
+
|
|
218
|
+
model: "default" (large-v3-turbo on GPU, small on CPU), "tiny", ... "large-v3-turbo"; lang: "en", "nl", ... or "auto";
|
|
219
|
+
device: "auto" | "cuda" | "cpu"; remote: GPU server URL or "off".
|
|
220
|
+
"""
|
|
221
|
+
files = [inputs] if isinstance(inputs, (str, os.PathLike)) else list(inputs)
|
|
222
|
+
opts = {"out": out, "outdir": outdir, "model": model, "lang": lang, "device": device, "python": python, "remote": remote}
|
|
223
|
+
stdout = run(["transcribe", *[str(Path(f).resolve()) for f in files], *to_args(opts)], bin=bin, echo=echo)
|
|
224
|
+
return [Path(p) for p in _lines(stdout)]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def schema(*, bin: Optional[PathLike] = None) -> Dict[str, Any]:
|
|
228
|
+
"""The JSON Schema of the scene format, from the installed flits."""
|
|
229
|
+
result: Dict[str, Any] = json.loads(run(["schema"], bin=bin))
|
|
230
|
+
return result
|
flits/compile.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Plain scene JSON from a scene built in Python (mirrors bindings/typescript/compile.js).
|
|
2
|
+
|
|
3
|
+
* Animatable properties may be callables ``f(t, ctx) -> float`` (``t`` = seconds since the layer started,
|
|
4
|
+
``ctx`` = ``{"frame", "fps", "duration", "progress"}``); they are sampled once per frame into linear
|
|
5
|
+
keyframes, so any maths animates exactly as computed.
|
|
6
|
+
* ``children``/``layers`` may be nested lists with ``None``/``False`` entries (dropped).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
ANIMATABLE = ("x", "y", "scale", "scaleX", "scaleY", "rotation", "opacity", "zoom", "panX", "panY", "value", "progress", "volume")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def flatten(children: Any) -> List[Any]:
|
|
16
|
+
"""Nested lists flattened; None, False and True dropped."""
|
|
17
|
+
out: List[Any] = []
|
|
18
|
+
|
|
19
|
+
def walk(c: Any) -> None:
|
|
20
|
+
if isinstance(c, (list, tuple)):
|
|
21
|
+
for x in c:
|
|
22
|
+
walk(x)
|
|
23
|
+
elif c is not None and c is not False and c is not True:
|
|
24
|
+
out.append(c)
|
|
25
|
+
|
|
26
|
+
walk(children)
|
|
27
|
+
return out
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sample(fn: Callable[..., float], duration: Optional[float], fps: float, what: str = "property") -> List[list]:
|
|
31
|
+
"""``fn`` sampled every frame over ``duration`` seconds as linear keyframes (straight stretches collapsed)."""
|
|
32
|
+
if duration is None or not duration > 0 or math.isinf(duration):
|
|
33
|
+
raise ValueError(f"{what}: a function needs to know how long the layer lasts: give it (or its group, or the scene) a duration")
|
|
34
|
+
n = max(1, math.ceil(duration * fps - 1e-9))
|
|
35
|
+
pts = []
|
|
36
|
+
for frame in range(n + 1):
|
|
37
|
+
t = min(frame / fps, duration)
|
|
38
|
+
v = fn(t, {"frame": frame, "fps": fps, "duration": duration, "progress": t / duration})
|
|
39
|
+
if not isinstance(v, (int, float)) or isinstance(v, bool) or not math.isfinite(v):
|
|
40
|
+
raise ValueError(f"{what}: the function returned {v!r} at t={t:.3f}s (expected a finite number)")
|
|
41
|
+
pts.append([round(t, 6), round(float(v), 6)])
|
|
42
|
+
keep = [pts[0]]
|
|
43
|
+
for i in range(1, len(pts) - 1):
|
|
44
|
+
a, b, c = keep[-1], pts[i], pts[i + 1]
|
|
45
|
+
lin = a[1] + (c[1] - a[1]) * (b[0] - a[0]) / (c[0] - a[0])
|
|
46
|
+
if abs(lin - b[1]) > 1e-4:
|
|
47
|
+
keep.append(b)
|
|
48
|
+
if len(pts) > 1:
|
|
49
|
+
keep.append(pts[-1])
|
|
50
|
+
return [k if i == 0 else [k[0], k[1], "linear"] for i, k in enumerate(keep)]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _lifetime(layer: Dict[str, Any], parent: Optional[float]) -> Optional[float]:
|
|
54
|
+
if isinstance(layer.get("duration"), (int, float)):
|
|
55
|
+
return layer["duration"]
|
|
56
|
+
if isinstance(layer.get("end"), (int, float)):
|
|
57
|
+
return layer["end"] - layer.get("start", 0)
|
|
58
|
+
if parent is not None:
|
|
59
|
+
return parent - layer.get("start", 0)
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _layer(layer: Any, parent: Optional[float], fps: float, path: str) -> Dict[str, Any]:
|
|
64
|
+
if not isinstance(layer, dict):
|
|
65
|
+
raise ValueError(f"{path}: expected a layer dict, got {type(layer).__name__}")
|
|
66
|
+
dur = _lifetime(layer, parent)
|
|
67
|
+
out: Dict[str, Any] = {}
|
|
68
|
+
for k, v in layer.items():
|
|
69
|
+
if v is None:
|
|
70
|
+
continue
|
|
71
|
+
if callable(v):
|
|
72
|
+
if k not in ANIMATABLE:
|
|
73
|
+
raise ValueError(f"{path}.{k}: only animatable properties ({', '.join(ANIMATABLE)}) can be functions")
|
|
74
|
+
out[k] = sample(v, dur, fps, f"{path}.{k}")
|
|
75
|
+
elif k == "children":
|
|
76
|
+
kids = flatten(v)
|
|
77
|
+
out[k] = [_layer(c, None if layer.get("sequence") else dur, fps, f"{path}.children[{i}]") for i, c in enumerate(kids)]
|
|
78
|
+
else:
|
|
79
|
+
out[k] = v
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def compile(scene: Dict[str, Any]) -> Dict[str, Any]:
|
|
84
|
+
"""Plain scene JSON: callables sampled, children flattened."""
|
|
85
|
+
if not isinstance(scene, dict):
|
|
86
|
+
raise ValueError("the scene must be a dict like {'width': ..., 'height': ..., 'layers': [...]}")
|
|
87
|
+
fps = scene.get("fps", 30)
|
|
88
|
+
out = {k: v for k, v in scene.items() if v is not None and k not in ("layers", "audio")}
|
|
89
|
+
out["layers"] = [_layer(l, scene.get("duration"), fps, f"layers[{i}]") for i, l in enumerate(flatten(scene.get("layers", [])))]
|
|
90
|
+
audio = flatten(scene.get("audio", []))
|
|
91
|
+
if audio:
|
|
92
|
+
tracks = []
|
|
93
|
+
for i, a in enumerate(audio):
|
|
94
|
+
a = dict(a)
|
|
95
|
+
if callable(a.get("volume")):
|
|
96
|
+
d = a.get("duration")
|
|
97
|
+
if d is None and scene.get("duration") is not None:
|
|
98
|
+
d = scene["duration"] - a.get("start", 0)
|
|
99
|
+
a["volume"] = sample(a["volume"], d, fps, f"audio[{i}].volume")
|
|
100
|
+
tracks.append(a)
|
|
101
|
+
out["audio"] = tracks
|
|
102
|
+
return out
|
flits/py.typed
ADDED
|
File without changes
|
flits/types.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""TypedDict definitions for the flits scene format.
|
|
2
|
+
|
|
3
|
+
Mirrors schema/scene.schema.json (generated from the Rust structs with `flits schema`); the Rust test
|
|
4
|
+
tests/schema.rs checks that every property and enum value of the schema appears here. These are plain
|
|
5
|
+
dicts at runtime: use them for editor completion and type checking (mypy, pyright).
|
|
6
|
+
|
|
7
|
+
Units are pixels (origin top-left) and seconds; relative paths resolve against the scene's folder.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Callable, Dict, List, Literal, Tuple, TypedDict, Union
|
|
12
|
+
|
|
13
|
+
if sys.version_info >= (3, 11):
|
|
14
|
+
from typing import Required
|
|
15
|
+
else: # pragma: no cover
|
|
16
|
+
from typing_extensions import Required
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Color",
|
|
20
|
+
"Ease",
|
|
21
|
+
"KeyTime",
|
|
22
|
+
"Keyframe",
|
|
23
|
+
"FromTo",
|
|
24
|
+
"Animated",
|
|
25
|
+
"TransitionType",
|
|
26
|
+
"TransitionSpec",
|
|
27
|
+
"Transition",
|
|
28
|
+
"Duck",
|
|
29
|
+
"Padding",
|
|
30
|
+
"LayerType",
|
|
31
|
+
"Fit",
|
|
32
|
+
"Align",
|
|
33
|
+
"Reveal",
|
|
34
|
+
"Wrap",
|
|
35
|
+
"CaptionStyle",
|
|
36
|
+
"CaptionPreset",
|
|
37
|
+
"ShapeKind",
|
|
38
|
+
"LineCap",
|
|
39
|
+
"HighlightBox",
|
|
40
|
+
"TextBgMode",
|
|
41
|
+
"GradientType",
|
|
42
|
+
"Border",
|
|
43
|
+
"Shadow",
|
|
44
|
+
"StrokeSpec",
|
|
45
|
+
"TextBg",
|
|
46
|
+
"Filter",
|
|
47
|
+
"Gradient",
|
|
48
|
+
"AudioTrack",
|
|
49
|
+
"Layer",
|
|
50
|
+
"Scene",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------------------ values
|
|
54
|
+
|
|
55
|
+
Color = str
|
|
56
|
+
"""'#rgb', '#rgba', '#rrggbb', '#rrggbbaa', 'rgb(r,g,b)', 'rgba(r,g,b,a)' or a name:
|
|
57
|
+
"white", "black", "transparent", "red", "green", "blue", "yellow", "orange", "purple", "pink", "gray", "grey"."""
|
|
58
|
+
|
|
59
|
+
EaseName = Literal[
|
|
60
|
+
"linear",
|
|
61
|
+
"in",
|
|
62
|
+
"out",
|
|
63
|
+
"in-out",
|
|
64
|
+
"smooth",
|
|
65
|
+
"back-in",
|
|
66
|
+
"back-out",
|
|
67
|
+
"elastic",
|
|
68
|
+
"bounce",
|
|
69
|
+
"spring",
|
|
70
|
+
"hold",
|
|
71
|
+
"expo-in",
|
|
72
|
+
"expo-out",
|
|
73
|
+
"expo-in-out",
|
|
74
|
+
"quint-out",
|
|
75
|
+
"quint-in-out",
|
|
76
|
+
"circ-out",
|
|
77
|
+
# aliases
|
|
78
|
+
"ease-in",
|
|
79
|
+
"easein",
|
|
80
|
+
"ease-out",
|
|
81
|
+
"easeout",
|
|
82
|
+
"ease",
|
|
83
|
+
"inout",
|
|
84
|
+
"ease-in-out",
|
|
85
|
+
"easeinout",
|
|
86
|
+
"sine",
|
|
87
|
+
"sine-in-out",
|
|
88
|
+
"anticipate",
|
|
89
|
+
"back",
|
|
90
|
+
"overshoot",
|
|
91
|
+
"step",
|
|
92
|
+
"expo",
|
|
93
|
+
"quint",
|
|
94
|
+
"circ",
|
|
95
|
+
]
|
|
96
|
+
Ease = Union[EaseName, str]
|
|
97
|
+
"""An easing name, or a CSS curve as the string "cubic-bezier(x1, y1, x2, y2)"."""
|
|
98
|
+
|
|
99
|
+
KeyTime = Union[float, str]
|
|
100
|
+
"""Seconds from the layer start, or "end" / "end-0.5" (relative to the layer end)."""
|
|
101
|
+
|
|
102
|
+
Keyframe = Union[Tuple[KeyTime, float], Tuple[KeyTime, float, Ease], List[Union[KeyTime, float, str]]]
|
|
103
|
+
"""[time, value] or [time, value, ease]; the ease applies to the segment ending at this key."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# functional syntax because "from" is a Python keyword
|
|
107
|
+
FromTo = TypedDict(
|
|
108
|
+
"FromTo",
|
|
109
|
+
{
|
|
110
|
+
"from": Required[float],
|
|
111
|
+
"to": Required[float],
|
|
112
|
+
"ease": Ease,
|
|
113
|
+
"start": KeyTime,
|
|
114
|
+
"end": KeyTime,
|
|
115
|
+
"comment": str,
|
|
116
|
+
},
|
|
117
|
+
total=False,
|
|
118
|
+
)
|
|
119
|
+
"""Animate from one value to another over the layer (or between "start" and "end")."""
|
|
120
|
+
|
|
121
|
+
AnimFn = Callable[[float, Dict[str, float]], float]
|
|
122
|
+
"""f(t, {"frame", "fps", "duration", "progress"}) -> value; sampled per frame by flits.compile()."""
|
|
123
|
+
|
|
124
|
+
Animated = Union[float, List[Keyframe], FromTo, AnimFn]
|
|
125
|
+
"""A constant, keyframes [[time, value, ease?], ...] or {"from", "to", "ease", "start", "end"}."""
|
|
126
|
+
|
|
127
|
+
TransitionType = Literal[
|
|
128
|
+
"fade",
|
|
129
|
+
"slide-up",
|
|
130
|
+
"slide-down",
|
|
131
|
+
"slide-left",
|
|
132
|
+
"slide-right",
|
|
133
|
+
"slide-fade",
|
|
134
|
+
"zoom-in",
|
|
135
|
+
"zoom-out",
|
|
136
|
+
"zoom-fade",
|
|
137
|
+
"zoom-blur",
|
|
138
|
+
"pop",
|
|
139
|
+
"wipe-left",
|
|
140
|
+
"wipe-right",
|
|
141
|
+
"wipe-up",
|
|
142
|
+
"wipe-down",
|
|
143
|
+
"push-left",
|
|
144
|
+
"push-right",
|
|
145
|
+
"push-up",
|
|
146
|
+
"push-down",
|
|
147
|
+
"rotate-in",
|
|
148
|
+
"flip-x",
|
|
149
|
+
"flip-y",
|
|
150
|
+
"iris",
|
|
151
|
+
# aliases: rise = slide-fade, zoom = zoom-in, wipe = wipe-right, push = push-right, rotate = rotate-in, flip = flip-x, circle = iris
|
|
152
|
+
"up",
|
|
153
|
+
"down",
|
|
154
|
+
"left",
|
|
155
|
+
"right",
|
|
156
|
+
"rise",
|
|
157
|
+
"zoom",
|
|
158
|
+
"wipe",
|
|
159
|
+
"push",
|
|
160
|
+
"rotate",
|
|
161
|
+
"flip",
|
|
162
|
+
"circle",
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
TransitionSpec = TypedDict(
|
|
166
|
+
"TransitionSpec",
|
|
167
|
+
{
|
|
168
|
+
"type": TransitionType, # default "fade"
|
|
169
|
+
"duration": float, # seconds (default 0.5; pop 0.45; iris and push 0.6)
|
|
170
|
+
"ease": Ease, # default "out"; pop "back-out"; wipes, push and iris "in-out"
|
|
171
|
+
"distance": float, # slide distance in px (default 120; slide-fade 40)
|
|
172
|
+
"angle": float, # rotate-in: degrees turned during the transition (default 90)
|
|
173
|
+
"comment": str,
|
|
174
|
+
},
|
|
175
|
+
total=False,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
Transition = Union[str, TransitionSpec]
|
|
179
|
+
""""fade", "pop 0.4" (type + seconds) or a TransitionSpec."""
|
|
180
|
+
|
|
181
|
+
Duck = Union[bool, float]
|
|
182
|
+
"""True (duck to 0.25) or the volume level (0..1) to duck to while other audio plays."""
|
|
183
|
+
|
|
184
|
+
Padding = Union[float, Tuple[float, float], List[float]]
|
|
185
|
+
|
|
186
|
+
LayerType = Literal["rect", "image", "video", "text", "counter", "captions", "waveform", "shape", "group"]
|
|
187
|
+
ShapeKind = Literal["rect", "ellipse", "circle", "line", "arrow", "polygon", "star", "path"]
|
|
188
|
+
LineCap = Literal["round", "butt", "square"]
|
|
189
|
+
Fit = Literal["cover", "contain", "fill", "stretch"]
|
|
190
|
+
Align = Literal["left", "center", "right", "start", "middle", "end"]
|
|
191
|
+
Reveal = Literal["words", "lines", "chars", "typewriter", "none"]
|
|
192
|
+
RevealStyle = Literal["rise", "mask", "fade", "blur"]
|
|
193
|
+
MotionBlurSpec = TypedDict("MotionBlurSpec", {"samples": int, "shutter": float}, total=False)
|
|
194
|
+
MotionBlur = Union[bool, MotionBlurSpec]
|
|
195
|
+
"""True (up to 8 samples, 180 degree shutter) or the most samples per moving layer and the shutter (0.5 = 180 degrees)."""
|
|
196
|
+
Wrap = Literal["balance", "greedy"]
|
|
197
|
+
CaptionStyle = Literal[
|
|
198
|
+
"karaoke", "pop", "bounce", "word", "line", "highlight", "tiktok", "jump", "single", "plain", "subtitle",
|
|
199
|
+
]
|
|
200
|
+
CaptionPreset = Literal[
|
|
201
|
+
"tiktok", "karaoke-box", "bounce", "minimal", "neon", "highlighter", "subtitle", "word-pop-big",
|
|
202
|
+
]
|
|
203
|
+
HighlightBox = Literal["pill", "marker", "box", "highlighter"]
|
|
204
|
+
TextBgMode = Literal["box", "lines"]
|
|
205
|
+
GradientType = Literal["linear", "radial"]
|
|
206
|
+
|
|
207
|
+
# ------------------------------------------------------------------ objects
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class Border(TypedDict, total=False):
|
|
211
|
+
"""Border drawn outside the box."""
|
|
212
|
+
|
|
213
|
+
width: Required[float]
|
|
214
|
+
color: Required[Color]
|
|
215
|
+
comment: str
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class Shadow(TypedDict, total=False):
|
|
219
|
+
"""Drop shadow (text: text shadow; other layers: box shadow)."""
|
|
220
|
+
|
|
221
|
+
color: Color # default "rgba(0,0,0,0.5)"
|
|
222
|
+
blur: float # default 24
|
|
223
|
+
x: float # default 0
|
|
224
|
+
y: float # default 12
|
|
225
|
+
comment: str
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class StrokeSpec(TypedDict, total=False):
|
|
229
|
+
"""Text outline."""
|
|
230
|
+
|
|
231
|
+
width: Required[float]
|
|
232
|
+
color: Required[Color]
|
|
233
|
+
comment: str
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class TextBg(TypedDict, total=False):
|
|
237
|
+
"""Box (or one pill per line) behind text."""
|
|
238
|
+
|
|
239
|
+
color: Required[Color]
|
|
240
|
+
padding: Padding
|
|
241
|
+
radius: float
|
|
242
|
+
mode: TextBgMode
|
|
243
|
+
comment: str
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class Filter(TypedDict, total=False):
|
|
247
|
+
"""Colour grading for image and video layers."""
|
|
248
|
+
|
|
249
|
+
brightness: float # added, e.g. 0.05
|
|
250
|
+
contrast: float # factor, default 1
|
|
251
|
+
saturation: float # factor, default 1 (0 = gray)
|
|
252
|
+
warmth: float # e.g. 0.3 (negative = cooler)
|
|
253
|
+
grayscale: bool
|
|
254
|
+
comment: str
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
Gradient = TypedDict(
|
|
258
|
+
"Gradient",
|
|
259
|
+
{
|
|
260
|
+
"type": GradientType, # default "linear"
|
|
261
|
+
"angle": float, # CSS-style degrees: 180 = top to bottom (default)
|
|
262
|
+
"stops": List[Tuple[float, Color]], # [[offset 0..1, colour], ...]
|
|
263
|
+
"colors": List[Color], # spread evenly (alternative to stops)
|
|
264
|
+
"comment": str,
|
|
265
|
+
},
|
|
266
|
+
total=False,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class AudioTrack(TypedDict, total=False):
|
|
271
|
+
"""An audio file in the soundtrack."""
|
|
272
|
+
|
|
273
|
+
src: Required[str]
|
|
274
|
+
start: float # scene time where the track starts
|
|
275
|
+
trim: float # seconds to skip in the source
|
|
276
|
+
duration: float
|
|
277
|
+
volume: Animated
|
|
278
|
+
fadeIn: float
|
|
279
|
+
fadeOut: float
|
|
280
|
+
loop: bool
|
|
281
|
+
duck: Duck
|
|
282
|
+
comment: str
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
Layer = TypedDict(
|
|
286
|
+
"Layer",
|
|
287
|
+
{
|
|
288
|
+
"type": Required[LayerType],
|
|
289
|
+
"id": str,
|
|
290
|
+
"start": float, # seconds, relative to the parent group
|
|
291
|
+
"end": float,
|
|
292
|
+
"duration": float,
|
|
293
|
+
"x": Animated, # anchor position in px (default: centre of the parent)
|
|
294
|
+
"y": Animated,
|
|
295
|
+
"width": float,
|
|
296
|
+
"height": float,
|
|
297
|
+
"anchor": Tuple[float, float], # [0.5, 0.5] = centre (default), [0, 0] = top-left
|
|
298
|
+
"scale": Animated,
|
|
299
|
+
"scaleX": Animated,
|
|
300
|
+
"scaleY": Animated,
|
|
301
|
+
"rotation": Animated, # degrees
|
|
302
|
+
"opacity": Animated,
|
|
303
|
+
"zoom": Animated, # image/video: Ken Burns zoom inside the box
|
|
304
|
+
"panX": Animated, # -1..1
|
|
305
|
+
"panY": Animated,
|
|
306
|
+
"in": Transition,
|
|
307
|
+
"out": Transition,
|
|
308
|
+
"radius": float,
|
|
309
|
+
"border": Border,
|
|
310
|
+
"shadow": Shadow,
|
|
311
|
+
"filter": Filter,
|
|
312
|
+
"clip": bool, # group: hide everything outside the box
|
|
313
|
+
"motionBlur": bool, # False: no motion blur for this layer and its children
|
|
314
|
+
"comment": str,
|
|
315
|
+
# image / video / captions / waveform
|
|
316
|
+
"src": str,
|
|
317
|
+
"fit": Fit,
|
|
318
|
+
"blur": float,
|
|
319
|
+
"trim": float,
|
|
320
|
+
"volume": Animated,
|
|
321
|
+
"loop": bool,
|
|
322
|
+
"speed": float,
|
|
323
|
+
"fadeIn": float,
|
|
324
|
+
"fadeOut": float,
|
|
325
|
+
# rect
|
|
326
|
+
"color": Color,
|
|
327
|
+
"gradient": Gradient,
|
|
328
|
+
# text (and counter/captions styling)
|
|
329
|
+
"text": str,
|
|
330
|
+
"font": str,
|
|
331
|
+
"size": float,
|
|
332
|
+
"weight": float,
|
|
333
|
+
"align": Align,
|
|
334
|
+
"maxWidth": float,
|
|
335
|
+
"lineHeight": float,
|
|
336
|
+
"letterSpacing": float,
|
|
337
|
+
"stroke": StrokeSpec,
|
|
338
|
+
"background": TextBg,
|
|
339
|
+
"uppercase": bool,
|
|
340
|
+
"reveal": Reveal,
|
|
341
|
+
"wrap": Wrap,
|
|
342
|
+
"revealDuration": float,
|
|
343
|
+
"revealStyle": RevealStyle, # rise (default), mask, fade, blur
|
|
344
|
+
# captions
|
|
345
|
+
"preset": CaptionPreset, # named look; layer properties override it
|
|
346
|
+
"style": CaptionStyle,
|
|
347
|
+
"highlight": Color, # active word colour (default yellow)
|
|
348
|
+
"highlightBg": Color, # box behind the active word
|
|
349
|
+
"highlightBox": HighlightBox, # "pill" (default) or "marker"
|
|
350
|
+
"maxWords": int,
|
|
351
|
+
"maxLines": int, # default 2
|
|
352
|
+
"colors": List[Color], # active-word colours, cycled word by word
|
|
353
|
+
"activeScale": float, # active word size (default 1.12 for pop, 1 otherwise)
|
|
354
|
+
"speakerColors": Dict[str, Color], # word colour per diarization speaker
|
|
355
|
+
# shape
|
|
356
|
+
"shape": ShapeKind, # rect (default), ellipse, circle, line, arrow, polygon, star, path
|
|
357
|
+
"points": List[Tuple[float, float]], # [[x, y], ...] in the layer box
|
|
358
|
+
"d": str, # shape "path": SVG path data in layer-box units
|
|
359
|
+
"sides": int, # polygon corners (6) / star points (5)
|
|
360
|
+
"innerRadius": float, # star: inner radius as a fraction of the outer one (0.5)
|
|
361
|
+
"lineCap": LineCap,
|
|
362
|
+
"dash": List[float], # dash pattern in px, e.g. [20, 10]
|
|
363
|
+
"progress": Animated, # how much of the outline is drawn, 0..1 (animate to draw on)
|
|
364
|
+
"headSize": float, # arrow head length in px (default 4 x the stroke width)
|
|
365
|
+
# waveform
|
|
366
|
+
"bars": int,
|
|
367
|
+
"mirror": bool,
|
|
368
|
+
"gap": float,
|
|
369
|
+
"gain": float,
|
|
370
|
+
# counter
|
|
371
|
+
"value": Animated,
|
|
372
|
+
"decimals": int,
|
|
373
|
+
"prefix": str,
|
|
374
|
+
"suffix": str,
|
|
375
|
+
"separator": str, # "," (default), "." or "none"
|
|
376
|
+
# group
|
|
377
|
+
"children": List["Layer"],
|
|
378
|
+
"sequence": bool,
|
|
379
|
+
"overlap": float,
|
|
380
|
+
},
|
|
381
|
+
total=False,
|
|
382
|
+
)
|
|
383
|
+
"""One layer. Required per type: image/video/captions/waveform need "src", text needs "text", counter needs "value"."""
|
|
384
|
+
|
|
385
|
+
Scene = TypedDict(
|
|
386
|
+
"Scene",
|
|
387
|
+
{
|
|
388
|
+
"$schema": str, # ignored by flits
|
|
389
|
+
"width": Required[int],
|
|
390
|
+
"height": Required[int],
|
|
391
|
+
"fps": float, # default 30
|
|
392
|
+
"duration": float, # default: end of the last layer
|
|
393
|
+
"background": Color,
|
|
394
|
+
"fonts": Dict[str, str], # alias -> font name or file
|
|
395
|
+
"fontFallback": List[str], # fonts for characters a layer's font lacks (emoji, other scripts)
|
|
396
|
+
"audio": List[AudioTrack],
|
|
397
|
+
"loudness": float, # LUFS, e.g. -14
|
|
398
|
+
"motionBlur": MotionBlur,
|
|
399
|
+
"layers": List[Layer],
|
|
400
|
+
"comment": str,
|
|
401
|
+
},
|
|
402
|
+
total=False,
|
|
403
|
+
)
|
|
404
|
+
"""A flits scene: one video described as layers on a canvas."""
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flits-video
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python wrapper for the flits video renderer: build scenes as dicts, render them with the flits CLI
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/ArjandenHartog/flits
|
|
7
|
+
Keywords: video,rendering,flits,transcription,captions
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Topic :: Multimedia :: Video
|
|
11
|
+
Classifier: Typing :: Typed
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: typing_extensions>=4.0; python_version < "3.11"
|
|
15
|
+
|
|
16
|
+
Render flits scenes (JSON video descriptions) from Python. Needs the `flits` CLI on PATH or $FLITS_BIN. See https://github.com/ArjandenHartog/flits
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
flits/__init__.py,sha256=W04-Pyld293jzsy5MOg94nAJb6ihe2X6rFHfPLEupyo,1047
|
|
2
|
+
flits/_cli.py,sha256=T0pFYiOnXf7BVnyqA_DO4zUMMQZ_Mwss0qn2x6TwB2M,8677
|
|
3
|
+
flits/compile.py,sha256=mkVsinAU6Qdd0U2y2v__jO0RkU-uWeyG2uZxkccVBmM,4531
|
|
4
|
+
flits/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
flits/types.py,sha256=mgcsxHdhU3aT8oef2Ef-9iWMyWUXWu6J4taY-2bJ46s,12021
|
|
6
|
+
flits_video-0.2.0.dist-info/METADATA,sha256=fOMW0RxtIsrI_QAfwwI3AJgkxxUBuCnkNJJ55xXn0So,753
|
|
7
|
+
flits_video-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
8
|
+
flits_video-0.2.0.dist-info/top_level.txt,sha256=-3yg-zvnKSlQNvHzAu70rEiwLdCSq0oGbc5uLFAvrkg,6
|
|
9
|
+
flits_video-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flits
|