renoise-kit 0.5.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.
- renoise_kit-0.5.0.dist-info/METADATA +307 -0
- renoise_kit-0.5.0.dist-info/RECORD +33 -0
- renoise_kit-0.5.0.dist-info/WHEEL +5 -0
- renoise_kit-0.5.0.dist-info/entry_points.txt +2 -0
- renoise_kit-0.5.0.dist-info/licenses/LICENSE +21 -0
- renoise_kit-0.5.0.dist-info/top_level.txt +1 -0
- rn/__init__.py +2 -0
- rn/__main__.py +5 -0
- rn/align.py +92 -0
- rn/assemble.py +602 -0
- rn/assets.py +125 -0
- rn/audio.py +79 -0
- rn/captions.py +343 -0
- rn/capture.py +97 -0
- rn/cli.py +557 -0
- rn/compare.py +115 -0
- rn/cuts.py +81 -0
- rn/editops.py +252 -0
- rn/edl.py +898 -0
- rn/export.py +200 -0
- rn/faces.py +135 -0
- rn/ff.py +183 -0
- rn/fonts.py +87 -0
- rn/frames.py +205 -0
- rn/fx/__init__.py +223 -0
- rn/fx/fx.js +150 -0
- rn/listen.py +199 -0
- rn/matte.py +58 -0
- rn/qa.py +212 -0
- rn/ref.py +74 -0
- rn/ref_guide.md +245 -0
- rn/signal.py +190 -0
- rn/text.py +112 -0
rn/frames.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""rn frame / rn sheet: exact frames by decoded index, and labelled contact sheets small enough to view."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import io
|
|
5
|
+
import math
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from fractions import Fraction
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .ff import RnError, probe, run, source_fps
|
|
12
|
+
|
|
13
|
+
# sandbox_exec shows at most 4 images totalling 512 KB per call, so a page defaults to 125 KB.
|
|
14
|
+
DEFAULT_MAX_KB = 125
|
|
15
|
+
MAX_PER_PAGE = 24
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _select_expr(indices: list[int]) -> str:
|
|
19
|
+
return '+'.join(f'eq(n\\,{index})' for index in indices)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def extract(path: str | Path, indices: list[int], out_dir: Path, *, width: int | None = None) -> list[tuple[int, Path]]:
|
|
23
|
+
"""Decodes from the start and keeps the frames whose decoded index is listed. Exact by construction:
|
|
24
|
+
no input seeking (which can land 1-2 frames off on x264) and no timestamp arithmetic.
|
|
25
|
+
ponytail: decode time grows with the source; fine for clips and references up to a few minutes."""
|
|
26
|
+
wanted = sorted(set(i for i in indices if i >= 0))
|
|
27
|
+
if not wanted:
|
|
28
|
+
raise RnError('no frames selected')
|
|
29
|
+
results: list[tuple[int, Path]] = []
|
|
30
|
+
for start in range(0, len(wanted), 200): # keep the select expression a sane length
|
|
31
|
+
chunk = wanted[start:start + 200]
|
|
32
|
+
pattern = out_dir / f'f{start:06d}_%06d.png'
|
|
33
|
+
filters = f"select='{_select_expr(chunk)}'" + (f',scale={width}:-2:flags=bicubic' if width else '')
|
|
34
|
+
run(['ffmpeg', '-i', str(path), '-vf', filters, '-fps_mode', 'passthrough', '-frames:v', str(len(chunk)), '-y', str(pattern)])
|
|
35
|
+
produced = sorted(out_dir.glob(f'f{start:06d}_*.png'))
|
|
36
|
+
results.extend(zip(chunk, produced))
|
|
37
|
+
return results
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def frame_command(path: str, *, index: int | None, time: float | None, last: bool, out: str) -> dict:
|
|
41
|
+
info = probe(path)
|
|
42
|
+
video = info['video']
|
|
43
|
+
if not video:
|
|
44
|
+
raise RnError(f'{path}: no video stream')
|
|
45
|
+
if last:
|
|
46
|
+
info = probe(path, exact=True)
|
|
47
|
+
index = info['video']['frames'] - 1
|
|
48
|
+
elif time is not None:
|
|
49
|
+
index = math.floor(time * float(source_fps(info)) + 1e-6)
|
|
50
|
+
assert index is not None
|
|
51
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
52
|
+
got = extract(path, [index], Path(tmp))
|
|
53
|
+
if not got:
|
|
54
|
+
raise RnError(f'{path}: frame {index} does not exist (the clip has {video["frames"]} frames)')
|
|
55
|
+
Path(out).parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
if Path(out).suffix.lower() in ('.jpg', '.jpeg'):
|
|
57
|
+
run(['ffmpeg', '-i', str(got[0][1]), '-q:v', '2', '-y', out])
|
|
58
|
+
else:
|
|
59
|
+
shutil.copyfile(got[0][1], out)
|
|
60
|
+
fps = source_fps(info)
|
|
61
|
+
return {'path': out, 'index': index, 'time': round(float(index / fps), 4)}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def pick_indices(frames: int, fps: Fraction, *, every: int | None, rate: float | None, indices: list[int] | None,
|
|
65
|
+
seconds: list[float] | None, count: int | None, around: list[float] | None = None, span: int = 3) -> list[int]:
|
|
66
|
+
if around:
|
|
67
|
+
# Dense frames on both sides of each time (a cut, a transition, a beat): span frames before and after.
|
|
68
|
+
centres = [math.floor(t * float(fps) + 1e-6) for t in around if t >= 0]
|
|
69
|
+
return sorted({i for c in centres for i in range(c - span, c + span + 1) if 0 <= i < frames})
|
|
70
|
+
if indices:
|
|
71
|
+
return [i for i in indices if 0 <= i < frames]
|
|
72
|
+
if seconds:
|
|
73
|
+
return sorted({min(frames - 1, math.floor(s * float(fps) + 1e-6)) for s in seconds if s >= 0})
|
|
74
|
+
if every:
|
|
75
|
+
return list(range(0, frames, every))
|
|
76
|
+
if rate:
|
|
77
|
+
step = float(fps) / rate
|
|
78
|
+
return sorted({min(frames - 1, round(k * step)) for k in range(math.ceil(frames / step))})
|
|
79
|
+
count = min(count or 16, frames)
|
|
80
|
+
if count <= 1:
|
|
81
|
+
return [0]
|
|
82
|
+
return sorted({round(k * (frames - 1) / (count - 1)) for k in range(count)})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _timecode(index: int, fps: Fraction) -> str:
|
|
86
|
+
seconds = float(index / fps)
|
|
87
|
+
return f'{int(seconds // 60):02d}:{seconds % 60:05.2f}'
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _difference(current: Path, previous: Path | None):
|
|
91
|
+
"""|frame - previous frame| as a heat image: motion, flicker and repeated (dead) frames stand out; x4 gain."""
|
|
92
|
+
from PIL import Image, ImageChops
|
|
93
|
+
|
|
94
|
+
image = Image.open(current).convert('RGB')
|
|
95
|
+
if previous is None:
|
|
96
|
+
return Image.new('RGB', image.size)
|
|
97
|
+
return ImageChops.difference(image, Image.open(previous).convert('RGB')).point(lambda v: min(255, v * 4))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _compose(tiles: list[tuple[int, Path]], fps: Fraction, columns: int, title: str, images=None, labels: list[str] | None = None):
|
|
101
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
102
|
+
|
|
103
|
+
images = images or [Image.open(path).convert('RGB') for _, path in tiles]
|
|
104
|
+
labels = labels or [f'#{index} {_timecode(index, fps)}' for index, _ in tiles]
|
|
105
|
+
tile_w, tile_h = images[0].size
|
|
106
|
+
label_h = max(14, tile_w // 14)
|
|
107
|
+
font = ImageFont.load_default(size=label_h - 3)
|
|
108
|
+
title_h = label_h + 4
|
|
109
|
+
rows = math.ceil(len(images) / columns)
|
|
110
|
+
sheet = Image.new('RGB', (columns * tile_w + (columns + 1) * 2, title_h + rows * (tile_h + label_h + 2) + 2), (24, 24, 24))
|
|
111
|
+
draw = ImageDraw.Draw(sheet)
|
|
112
|
+
draw.text((4, 2), title, fill=(255, 255, 255), font=font)
|
|
113
|
+
for position, (label, image) in enumerate(zip(labels, images)):
|
|
114
|
+
x = 2 + (position % columns) * (tile_w + 2)
|
|
115
|
+
y = title_h + (position // columns) * (tile_h + label_h + 2)
|
|
116
|
+
sheet.paste(image, (x, y + label_h))
|
|
117
|
+
draw.text((x + 2, y + 1), label, fill=(255, 220, 64), font=font)
|
|
118
|
+
return sheet
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _save_under(sheet, path: Path, max_kb: int) -> int:
|
|
122
|
+
from PIL import Image
|
|
123
|
+
|
|
124
|
+
image = sheet
|
|
125
|
+
while True:
|
|
126
|
+
for quality in (82, 70, 58, 46, 36):
|
|
127
|
+
buffer = io.BytesIO()
|
|
128
|
+
image.save(buffer, 'JPEG', quality=quality, optimize=True)
|
|
129
|
+
if buffer.tell() <= max_kb * 1024:
|
|
130
|
+
path.write_bytes(buffer.getvalue())
|
|
131
|
+
return buffer.tell()
|
|
132
|
+
if image.width < 320:
|
|
133
|
+
path.write_bytes(buffer.getvalue())
|
|
134
|
+
return buffer.tell()
|
|
135
|
+
image = image.resize((int(image.width * 0.85), int(image.height * 0.85)), Image.LANCZOS)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
IMAGES = ('.png', '.jpg', '.jpeg', '.webp') # what a folder sheet tiles
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _folder_sheet(folder: Path, *, out: str | None, several: bool, width: int, columns: int, max_kb: int) -> list[dict]:
|
|
142
|
+
"""A folder of stills (renders of chosen frames, generated keyframes) as labelled pages, in natural name order
|
|
143
|
+
(f792 before f1062)."""
|
|
144
|
+
import re
|
|
145
|
+
from PIL import Image, ImageOps
|
|
146
|
+
natural = lambda f: [int(part) if part.isdigit() else part for part in re.split(r'(\d+)', f.name)] # noqa: E731
|
|
147
|
+
files = sorted((f for f in folder.iterdir() if f.suffix.lower() in IMAGES), key=natural)
|
|
148
|
+
if not files:
|
|
149
|
+
raise RnError(f'{folder}: no .png/.jpg/.webp images')
|
|
150
|
+
base = Path(out) if out else Path('sheets') / f'{folder.name}.jpg'
|
|
151
|
+
if several and out:
|
|
152
|
+
base = Path(out).with_name(f'{Path(out).stem}-{folder.name}{Path(out).suffix or ".jpg"}')
|
|
153
|
+
base.parent.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
thumbs = []
|
|
155
|
+
for f in files:
|
|
156
|
+
with Image.open(f) as image:
|
|
157
|
+
image = image.convert('RGB')
|
|
158
|
+
thumbs.append(image.resize((width, max(1, round(image.height * width / image.width))), Image.LANCZOS))
|
|
159
|
+
tile_h = max(t.height for t in thumbs)
|
|
160
|
+
thumbs = [ImageOps.pad(t, (width, tile_h)) for t in thumbs]
|
|
161
|
+
pages = []
|
|
162
|
+
page_count = math.ceil(len(files) / MAX_PER_PAGE)
|
|
163
|
+
for page in range(page_count):
|
|
164
|
+
part = slice(page * MAX_PER_PAGE, (page + 1) * MAX_PER_PAGE)
|
|
165
|
+
target = base if page_count == 1 else base.with_name(f'{base.stem}-p{page + 1}{base.suffix or ".jpg"}')
|
|
166
|
+
names = [f.name for f in files[part]]
|
|
167
|
+
size = _save_under(_compose([], Fraction(1), columns, f'{folder.name} {len(files)} images', thumbs[part], names), target, max_kb)
|
|
168
|
+
pages.append({'path': str(target), 'source': str(folder), 'files': names, 'indices': list(range(len(names))), 'bytes': size})
|
|
169
|
+
return pages
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def sheet_command(paths: list[str], *, out: str | None, every: int | None, rate: float | None, indices: list[int] | None,
|
|
173
|
+
seconds: list[float] | None, count: int | None, width: int, columns: int, max_kb: int,
|
|
174
|
+
around: list[float] | None = None, span: int = 3, diff: bool = False) -> dict:
|
|
175
|
+
pages = []
|
|
176
|
+
for path in paths:
|
|
177
|
+
if Path(path).is_dir():
|
|
178
|
+
pages += _folder_sheet(Path(path), out=out, several=len(paths) > 1, width=width, columns=columns, max_kb=max_kb)
|
|
179
|
+
continue
|
|
180
|
+
info = probe(path)
|
|
181
|
+
if not info['video'] or info['video']['still']:
|
|
182
|
+
raise RnError(f'{path}: not a video')
|
|
183
|
+
fps = source_fps(info)
|
|
184
|
+
frames = info['video']['frames']
|
|
185
|
+
chosen = pick_indices(frames, fps, every=every, rate=rate, indices=indices, seconds=seconds, count=count, around=around, span=span)
|
|
186
|
+
if not chosen:
|
|
187
|
+
raise RnError(f'{path}: no frames in range (the clip has {frames} frames)')
|
|
188
|
+
stem = Path(path).stem
|
|
189
|
+
base = Path(out) if out else Path('sheets') / f'{stem}.jpg'
|
|
190
|
+
if len(paths) > 1 and out:
|
|
191
|
+
base = Path(out).with_name(f'{Path(out).stem}-{stem}{Path(out).suffix or ".jpg"}')
|
|
192
|
+
base.parent.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
194
|
+
wanted = sorted({j for i in chosen for j in ((i - 1, i) if diff else (i,)) if j >= 0})
|
|
195
|
+
extracted = dict(extract(path, wanted, Path(tmp), width=width))
|
|
196
|
+
tiles = [(i, extracted[i]) for i in chosen if i in extracted]
|
|
197
|
+
page_count = math.ceil(len(tiles) / MAX_PER_PAGE)
|
|
198
|
+
for page in range(page_count):
|
|
199
|
+
part = tiles[page * MAX_PER_PAGE:(page + 1) * MAX_PER_PAGE]
|
|
200
|
+
target = base if page_count == 1 else base.with_name(f'{base.stem}-p{page + 1}{base.suffix or ".jpg"}')
|
|
201
|
+
title = f'{Path(path).name} {fps}fps {frames}f frames {part[0][0]}-{part[-1][0]}' + (' |difference to previous| x4' if diff else '')
|
|
202
|
+
images = [_difference(p, extracted.get(i - 1)) for i, p in part] if diff else None
|
|
203
|
+
size = _save_under(_compose(part, fps, columns, title, images), target, max_kb)
|
|
204
|
+
pages.append({'path': str(target), 'source': path, 'indices': [index for index, _ in part], 'bytes': size})
|
|
205
|
+
return {'pages': pages}
|
rn/fx/__init__.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Edit effects for numpy frames: readable source to call, copy, change or ignore (not a closed vocabulary).
|
|
2
|
+
|
|
3
|
+
Defaults are engine defaults, not design defaults. Images are H x W x 3 or x 4 uint8 in RGB order
|
|
4
|
+
(OpenCV gives BGR: convert with img[..., ::-1] first). Alpha is preserved. Every function returns a new array.
|
|
5
|
+
Deterministic: randomness only from hash/seeded RNG keyed by frame index + seed, so a frame renders the same
|
|
6
|
+
in any order, shard or rerun. `i` is the frame index, `t` seconds. fx.js (RNFX) is the browser twin.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
import math
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from rn.ff import RnError
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import numpy as np
|
|
18
|
+
except ImportError: # pragma: no cover
|
|
19
|
+
np = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _np():
|
|
23
|
+
if np is None:
|
|
24
|
+
raise RnError('rn.fx needs numpy (pip install numpy)')
|
|
25
|
+
return np
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def hash(a: int, b: int = 0, c: int = 0) -> float: # noqa: A001
|
|
29
|
+
"""Integer mix -> [0,1); same inputs always give the same number."""
|
|
30
|
+
m = 0xFFFFFFFF
|
|
31
|
+
h = ((a ^ 0x9E3779B9) * 0x85EBCA6B ^ (b + 0x7F4A7C15) * 0xC2B2AAE3 ^ (c + 0x165667B1) * 0x27D4EB2F) & m
|
|
32
|
+
h = (h ^ (h >> 15)) * 0x2C1B3C6D & m
|
|
33
|
+
h = (h ^ (h >> 12)) * 0x297A2D39 & m
|
|
34
|
+
return ((h ^ (h >> 15)) & m) / 4294967296
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def rng(seed: int):
|
|
38
|
+
"""Generator of floats in [0,1) (mulberry32)."""
|
|
39
|
+
m, s = 0xFFFFFFFF, seed & 0xFFFFFFFF
|
|
40
|
+
while True:
|
|
41
|
+
s = (s + 0x6D2B79F5) & m
|
|
42
|
+
t = ((s ^ (s >> 15)) * (1 | s)) & m
|
|
43
|
+
t = ((t + ((t ^ (t >> 7)) * (61 | t) & m)) ^ t) & m
|
|
44
|
+
yield ((t ^ (t >> 14)) & m) / 4294967296
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _color(value) -> tuple[int, int, int]:
|
|
48
|
+
if isinstance(value, str):
|
|
49
|
+
v = value.lstrip('#')
|
|
50
|
+
if len(v) == 3:
|
|
51
|
+
v = ''.join(ch * 2 for ch in v)
|
|
52
|
+
return int(v[0:2], 16), int(v[2:4], 16), int(v[4:6], 16)
|
|
53
|
+
return tuple(value)[:3]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _blend(base, layer, op: str, alpha):
|
|
57
|
+
"""base/layer float 0..255 (H,W,3); alpha scalar or (H,W,1) opacity of the layer."""
|
|
58
|
+
if op == 'soft-light':
|
|
59
|
+
b, s = base / 255, layer / 255
|
|
60
|
+
d = np.where(b <= 0.25, ((16 * b - 12) * b + 4) * b, np.sqrt(b))
|
|
61
|
+
mixed = 255 * np.where(s <= 0.5, b - (1 - 2 * s) * b * (1 - b), b + (2 * s - 1) * (d - b))
|
|
62
|
+
elif op == 'screen':
|
|
63
|
+
mixed = 255 - (255 - base) * (255 - layer) / 255
|
|
64
|
+
elif op == 'add':
|
|
65
|
+
mixed = base + layer
|
|
66
|
+
elif op == 'multiply':
|
|
67
|
+
mixed = base * layer / 255
|
|
68
|
+
elif op == 'normal':
|
|
69
|
+
mixed = layer
|
|
70
|
+
else:
|
|
71
|
+
raise RnError(f'unknown blend op {op!r} (soft-light, screen, add, multiply, normal)')
|
|
72
|
+
return base + (mixed - base) * alpha
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _run(img, fn):
|
|
76
|
+
"""Applies fn to the float RGB part; passes alpha through untouched."""
|
|
77
|
+
_np()
|
|
78
|
+
if img.ndim != 3 or img.shape[2] not in (3, 4) or img.dtype != np.uint8:
|
|
79
|
+
raise RnError('rn.fx expects an H x W x 3|4 uint8 array')
|
|
80
|
+
rgb = fn(img[..., :3].astype(np.float32))
|
|
81
|
+
out = img.copy()
|
|
82
|
+
out[..., :3] = np.clip(rgb + 0.5, 0, 255).astype(np.uint8)
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _fill(img, color, alpha):
|
|
87
|
+
return _run(img, lambda rgb: rgb + (np.array(_color(color), np.float32) - rgb) * alpha)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def grain(img, i: int, *, amount=0.3, cell=2, mono=True, seed=7, op='soft-light'):
|
|
91
|
+
"""Film grain. amount=opacity, cell=grain size px, mono=gray noise, op=blend mode."""
|
|
92
|
+
_np()
|
|
93
|
+
h, w = img.shape[:2]
|
|
94
|
+
gen = np.random.default_rng([seed, int(i)])
|
|
95
|
+
shape = (-(-h // cell), -(-w // cell), 1 if mono else 3)
|
|
96
|
+
noise = gen.random(shape, dtype=np.float32) * 255
|
|
97
|
+
noise = np.repeat(np.repeat(noise, cell, 0), cell, 1)[:h, :w]
|
|
98
|
+
return _run(img, lambda rgb: _blend(rgb, np.broadcast_to(noise, rgb.shape), op, amount))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def vignette(img, *, amount=0.35, inner=0.45, outer=0.95, color='#000'):
|
|
102
|
+
"""Darkens edges. inner/outer = start/full radius as a fraction of the half diagonal."""
|
|
103
|
+
_np()
|
|
104
|
+
h, w = img.shape[:2]
|
|
105
|
+
yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
|
|
106
|
+
d = np.hypot(xx - w / 2, yy - h / 2) / (math.hypot(w, h) / 2)
|
|
107
|
+
mask = np.clip((d - inner) / (outer - inner), 0, 1)[..., None] * amount
|
|
108
|
+
return _fill(img, color, mask)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def leak(img, i: int, *, color='#ff7a30', color2='#ffd27a', amount=0.55, x=0.85, y=0.2, r=0.6, drift=0.1, seed=3, op='screen', fps=30):
|
|
112
|
+
"""Drifting light leak. x,y = centre (0..1), r = radius as a fraction of the long side, drift = wander as a fraction of size."""
|
|
113
|
+
_np()
|
|
114
|
+
h, w = img.shape[:2]
|
|
115
|
+
ph = hash(seed, 0, 0) * 6.283
|
|
116
|
+
cx = (x + math.sin(i / fps * 0.9 + ph) * drift) * w
|
|
117
|
+
cy = (y + math.cos(i / fps * 0.7 + ph) * drift) * h
|
|
118
|
+
yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
|
|
119
|
+
d = (np.hypot(xx - cx, yy - cy) / (r * max(w, h)))[..., None]
|
|
120
|
+
fall = np.clip(1 - d, 0, 1) ** 2
|
|
121
|
+
c1, c2 = np.array(_color(color), np.float32), np.array(_color(color2), np.float32)
|
|
122
|
+
layer = c1 + (c2 - c1) * np.clip(1 - d / 0.35, 0, 1)
|
|
123
|
+
return _run(img, lambda rgb: _blend(rgb, layer, op, fall * amount))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def flash(img, t: float, *, at=0.0, duration=0.1, color='#fff', amount=1.0):
|
|
127
|
+
"""Full-frame colour fading out over `duration` s from `at`. color '#000' gives a dip."""
|
|
128
|
+
u = (t - at) / duration
|
|
129
|
+
return img.copy() if u < 0 or u >= 1 else _fill(img, color, amount * (1 - u))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def letterbox(img, *, top=0.1, bottom=0.1, color='#000'):
|
|
133
|
+
"""Bars as fractions of the height."""
|
|
134
|
+
_np()
|
|
135
|
+
out = img.copy()
|
|
136
|
+
h = img.shape[0]
|
|
137
|
+
out[: round(h * top), ..., :3] = _color(color)
|
|
138
|
+
out[h - round(h * bottom):, ..., :3] = _color(color)
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def rgb_split(img, *, px=6):
|
|
143
|
+
"""Red shifted right, blue left by px (edge pixels clamp)."""
|
|
144
|
+
_np()
|
|
145
|
+
px = int(round(px))
|
|
146
|
+
w = img.shape[1]
|
|
147
|
+
out = img.copy()
|
|
148
|
+
out[..., 0] = img[:, np.clip(np.arange(w) - px, 0, w - 1), 0]
|
|
149
|
+
out[..., 2] = img[:, np.clip(np.arange(w) + px, 0, w - 1), 2]
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def glitch(img, i: int, *, amount=0.5, slices=8, seed=5):
|
|
154
|
+
"""Horizontal slice displacement. amount = share of slices hit and how far they move."""
|
|
155
|
+
_np()
|
|
156
|
+
h, w = img.shape[:2]
|
|
157
|
+
bh = -(-h // slices)
|
|
158
|
+
out = img.copy()
|
|
159
|
+
for k in range(slices):
|
|
160
|
+
if hash(seed, i, k) >= amount:
|
|
161
|
+
continue
|
|
162
|
+
dx = int((hash(seed, i, k + 100) - 0.5) * amount * 0.3 * w)
|
|
163
|
+
out[k * bh:(k + 1) * bh] = np.roll(img[k * bh:(k + 1) * bh], dx, axis=1)
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def strobe(img, i: int, *, every=2, color='#fff', amount=1.0):
|
|
168
|
+
"""Colour overlay on every `every`-th frame."""
|
|
169
|
+
return img.copy() if i % every else _fill(img, color, amount)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def punch(t: float, *, at=0.0, amount=0.07, decay=0.25) -> float:
|
|
173
|
+
"""Scale for a punch-in at `at` s that is exactly 1 again after `decay` s. Feed it to apply_transform."""
|
|
174
|
+
e = max(0.0, 1 - (t - at) / decay)
|
|
175
|
+
return 1.0 if t < at else 1 + amount * e * e
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def shake(i: int, t: float, *, at=0.0, amp=(16, 12), decay=0.25, seed=9) -> tuple[float, float]:
|
|
179
|
+
"""(dx, dy) px offset, amp=(x,y) px, exactly 0 after `decay` s. Feed it to apply_transform."""
|
|
180
|
+
e = 0.0 if t < at else max(0.0, 1 - (t - at) / decay)
|
|
181
|
+
return (hash(seed, i, 1) * 2 - 1) * amp[0] * e, (hash(seed, i, 2) * 2 - 1) * amp[1] * e
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def apply_transform(img, scale=1.0, dx=0.0, dy=0.0):
|
|
185
|
+
"""Centre scale + translate; always covers the frame (scale >= 1, offset clamped so no edge shows)."""
|
|
186
|
+
_np()
|
|
187
|
+
from PIL import Image
|
|
188
|
+
h, w = img.shape[:2]
|
|
189
|
+
s = max(1.0, float(scale))
|
|
190
|
+
dx = max(-(s - 1) * w / 2, min((s - 1) * w / 2, dx))
|
|
191
|
+
dy = max(-(s - 1) * h / 2, min((s - 1) * h / 2, dy))
|
|
192
|
+
coeffs = (1 / s, 0, w / 2 - (w / 2 + dx) / s, 0, 1 / s, h / 2 - (h / 2 + dy) / s)
|
|
193
|
+
out = Image.fromarray(img).transform((w, h), Image.AFFINE, coeffs, Image.BILINEAR)
|
|
194
|
+
return np.asarray(out).copy()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
_JS_NAME = {'rgb_split': 'rgbSplit'}
|
|
198
|
+
_WHAT = {
|
|
199
|
+
'grain': 'film grain from per-frame seeded noise',
|
|
200
|
+
'vignette': 'darkened edges',
|
|
201
|
+
'leak': 'drifting radial light leak',
|
|
202
|
+
'flash': 'fading full-frame flash (or dip with a dark colour)',
|
|
203
|
+
'letterbox': 'top/bottom bars',
|
|
204
|
+
'rgb_split': 'red/blue channel offset',
|
|
205
|
+
'glitch': 'horizontal slice displacement',
|
|
206
|
+
'strobe': 'colour overlay on every Nth frame',
|
|
207
|
+
'punch': 'punch-in scale that settles back to 1 (number)',
|
|
208
|
+
'shake': 'decaying camera shake offset (dx, dy)',
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def catalog() -> list[dict]:
|
|
213
|
+
"""One entry per effect: {name, js, py, params (defaults), what}."""
|
|
214
|
+
entries = []
|
|
215
|
+
for name, what in _WHAT.items():
|
|
216
|
+
params = {k: p.default for k, p in inspect.signature(globals()[name]).parameters.items() if p.default is not inspect.Parameter.empty}
|
|
217
|
+
entries.append({'name': name, 'js': f'RNFX.{_JS_NAME.get(name, name)}', 'py': f'rn.fx.{name}', 'params': params, 'what': what})
|
|
218
|
+
return entries
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def js_path() -> Path:
|
|
222
|
+
"""Path of fx.js (copy or read it to adapt)."""
|
|
223
|
+
return Path(__file__).with_name('fx.js')
|
rn/fx/fx.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/* RNFX: edit effects for renderFrame pages (Canvas2D). Readable source: call it, copy it, change it, ignore it.
|
|
2
|
+
* Defaults are engine defaults, not design defaults; pick values for the piece in front of you.
|
|
3
|
+
* Deterministic: randomness comes only from hash(seed, frame index, ...), so any frame renders the same
|
|
4
|
+
* in any order, shard or rerun. Never use Math.random() in a frame.
|
|
5
|
+
* Time: functions taking `i` use the frame index; those taking `t` use seconds (t = i / fps).
|
|
6
|
+
* fps / size come from opts, else window.RN = {fps, frames, width, height}, else the canvas itself. */
|
|
7
|
+
(function () {
|
|
8
|
+
const RN = () => window.RN || {};
|
|
9
|
+
const size = (ctx, o) => [o.width || RN().width || ctx.canvas.width, o.height || RN().height || ctx.canvas.height];
|
|
10
|
+
|
|
11
|
+
// hash(a,b,c) -> [0,1): integer mix, same inputs always give the same number.
|
|
12
|
+
function hash(a, b, c) {
|
|
13
|
+
let h = Math.imul((a | 0) ^ 0x9e3779b9, 0x85ebca6b) ^ Math.imul((b | 0) + 0x7f4a7c15, 0xc2b2ae35) ^ Math.imul((c | 0) + 0x165667b1, 0x27d4eb2f);
|
|
14
|
+
h = Math.imul(h ^ (h >>> 15), 0x2c1b3c6d); h = Math.imul(h ^ (h >>> 12), 0x297a2d39); h ^= h >>> 15;
|
|
15
|
+
return (h >>> 0) / 4294967296;
|
|
16
|
+
}
|
|
17
|
+
// rng(seed) -> () => [0,1): mulberry32 stream.
|
|
18
|
+
function rng(seed) {
|
|
19
|
+
let s = seed | 0;
|
|
20
|
+
return () => { s = (s + 0x6d2b79f5) | 0; let t = Math.imul(s ^ (s >>> 15), 1 | s); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
|
|
21
|
+
}
|
|
22
|
+
const EASE = {
|
|
23
|
+
linear: (t) => t, in2: (t) => t * t, out2: (t) => 1 - (1 - t) * (1 - t), out3: (t) => 1 - Math.pow(1 - t, 3),
|
|
24
|
+
inout2: (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2),
|
|
25
|
+
outBack: (t) => 1 + 2.70158 * Math.pow(t - 1, 3) + 1.70158 * Math.pow(t - 1, 2),
|
|
26
|
+
};
|
|
27
|
+
// ease(name, t): t clamped to [0,1].
|
|
28
|
+
const ease = (name, t) => EASE[name](Math.min(1, Math.max(0, t)));
|
|
29
|
+
// envelope(t, at, attack, hold, release): 0 before `at`, ramps to 1 over attack, holds, ramps to 0 over release.
|
|
30
|
+
function envelope(t, at, attack = 0.05, hold = 0, release = 0.2) {
|
|
31
|
+
const u = t - at;
|
|
32
|
+
if (u < 0) return 0;
|
|
33
|
+
if (u < attack) return u / attack;
|
|
34
|
+
if (u < attack + hold) return 1;
|
|
35
|
+
const r = u - attack - hold;
|
|
36
|
+
return r < release ? 1 - r / release : 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const tiles = {};
|
|
40
|
+
// grain: film grain. amount=opacity, cell=grain size px, mono=gray noise, op=composite mode.
|
|
41
|
+
function grain(ctx, i, o = {}) {
|
|
42
|
+
const { amount = 0.3, cell = 2, mono = true, seed = 7, op = 'soft-light' } = o;
|
|
43
|
+
const [w, h] = size(ctx, o), T = 256, n = Math.ceil(T / cell), key = seed + ':' + cell + ':' + mono;
|
|
44
|
+
if (!tiles[key]) {
|
|
45
|
+
tiles[key] = [0, 1, 2, 3].map((k) => { // 4 cached tiles; frames pick one and offset it
|
|
46
|
+
const small = document.createElement('canvas'); small.width = small.height = n;
|
|
47
|
+
const sc = small.getContext('2d'), img = sc.createImageData(n, n), r = rng(seed * 131 + k);
|
|
48
|
+
for (let p = 0; p < n * n; p++) {
|
|
49
|
+
const v = r() * 255;
|
|
50
|
+
img.data[p * 4] = v; img.data[p * 4 + 1] = mono ? v : r() * 255; img.data[p * 4 + 2] = mono ? v : r() * 255; img.data[p * 4 + 3] = 255;
|
|
51
|
+
}
|
|
52
|
+
sc.putImageData(img, 0, 0);
|
|
53
|
+
const tile = document.createElement('canvas'); tile.width = tile.height = T;
|
|
54
|
+
const tc = tile.getContext('2d'); tc.imageSmoothingEnabled = false; tc.drawImage(small, 0, 0, T, T);
|
|
55
|
+
return tile;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const tile = tiles[key][Math.floor(hash(seed, i, 1) * 4)];
|
|
59
|
+
// Grain only textures what is already drawn: on a transparent overlay, soft-light over empty pixels would paint
|
|
60
|
+
// the noise itself and grey out the whole layer. Keep the frame's own alpha by masking with a copy of it.
|
|
61
|
+
const before = document.createElement('canvas'); before.width = ctx.canvas.width; before.height = ctx.canvas.height;
|
|
62
|
+
before.getContext('2d').drawImage(ctx.canvas, 0, 0);
|
|
63
|
+
ctx.save();
|
|
64
|
+
ctx.globalCompositeOperation = op; ctx.globalAlpha = amount;
|
|
65
|
+
ctx.translate(Math.floor(hash(seed, i, 2) * T), Math.floor(hash(seed, i, 3) * T));
|
|
66
|
+
ctx.fillStyle = ctx.createPattern(tile, 'repeat');
|
|
67
|
+
ctx.fillRect(-T, -T, w + T, h + T);
|
|
68
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'destination-in';
|
|
69
|
+
ctx.drawImage(before, 0, 0);
|
|
70
|
+
ctx.restore();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// vignette: darkens edges. inner/outer = start/full radius as fraction of the half diagonal.
|
|
74
|
+
function vignette(ctx, o = {}) {
|
|
75
|
+
const { amount = 0.35, inner = 0.45, outer = 0.95, color = '#000' } = o;
|
|
76
|
+
const [w, h] = size(ctx, o), R = Math.hypot(w, h) / 2;
|
|
77
|
+
const g = ctx.createRadialGradient(w / 2, h / 2, inner * R, w / 2, h / 2, outer * R);
|
|
78
|
+
g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, color);
|
|
79
|
+
ctx.save(); ctx.globalAlpha = amount; ctx.fillStyle = g; ctx.fillRect(0, 0, w, h); ctx.restore();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// leak: drifting light leak. x,y = centre (0..1), r = radius as fraction of the long side, drift = wander as fraction of width.
|
|
83
|
+
function leak(ctx, i, o = {}) {
|
|
84
|
+
const { color = '#ff7a30', color2 = '#ffd27a', amount = 0.55, x = 0.85, y = 0.2, r = 0.6, drift = 0.1, seed = 3, op = 'screen' } = o;
|
|
85
|
+
const [w, h] = size(ctx, o), fps = o.fps || RN().fps || 30, ph = hash(seed, 0, 0) * 6.283;
|
|
86
|
+
const cx = (x + Math.sin(i / fps * 0.9 + ph) * drift) * w, cy = (y + Math.cos(i / fps * 0.7 + ph) * drift) * h, R = r * Math.max(w, h);
|
|
87
|
+
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, R);
|
|
88
|
+
g.addColorStop(0, color2); g.addColorStop(0.35, color); g.addColorStop(1, 'rgba(0,0,0,0)');
|
|
89
|
+
ctx.save(); ctx.globalCompositeOperation = op; ctx.globalAlpha = amount; ctx.fillStyle = g; ctx.fillRect(0, 0, w, h); ctx.restore();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// flash: full-frame colour that fades out over `duration` s from `at`. Use color '#000' for a dip.
|
|
93
|
+
function flash(ctx, t, o = {}) {
|
|
94
|
+
const { at = 0, duration = 0.1, color = '#fff', amount = 1 } = o, u = (t - at) / duration;
|
|
95
|
+
if (u < 0 || u >= 1) return;
|
|
96
|
+
const [w, h] = size(ctx, o);
|
|
97
|
+
ctx.save(); ctx.globalAlpha = amount * (1 - u); ctx.fillStyle = color; ctx.fillRect(0, 0, w, h); ctx.restore();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// letterbox: bars as fractions of the height.
|
|
101
|
+
function letterbox(ctx, o = {}) {
|
|
102
|
+
const { top = 0.1, bottom = 0.1, color = '#000' } = o, [w, h] = size(ctx, o);
|
|
103
|
+
ctx.save(); ctx.fillStyle = color; ctx.fillRect(0, 0, w, h * top); ctx.fillRect(0, h * (1 - bottom), w, h * bottom); ctx.restore();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// rgbSplit: red shifted left, blue right by px (reads the canvas back, so call it on the finished frame).
|
|
107
|
+
function rgbSplit(ctx, o = {}) {
|
|
108
|
+
const px = o.px === undefined ? 6 : o.px, [w, h] = size(ctx, o);
|
|
109
|
+
const src = ctx.getImageData(0, 0, w, h), out = ctx.createImageData(w, h), s = src.data, d = out.data;
|
|
110
|
+
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
|
111
|
+
const p = (y * w + x) * 4, rx = Math.min(w - 1, Math.max(0, Math.round(x + px))), bx = Math.min(w - 1, Math.max(0, Math.round(x - px)));
|
|
112
|
+
d[p] = s[(y * w + rx) * 4]; d[p + 1] = s[p + 1]; d[p + 2] = s[(y * w + bx) * 4 + 2]; d[p + 3] = s[p + 3];
|
|
113
|
+
}
|
|
114
|
+
ctx.putImageData(out, 0, 0);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// glitch: horizontal slice displacement. amount = share of slices hit and how far they move.
|
|
118
|
+
function glitch(ctx, i, o = {}) {
|
|
119
|
+
const { amount = 0.5, slices = 8, seed = 5 } = o, [w, h] = size(ctx, o);
|
|
120
|
+
const snap = document.createElement('canvas'); snap.width = w; snap.height = h; snap.getContext('2d').drawImage(ctx.canvas, 0, 0);
|
|
121
|
+
const bh = Math.ceil(h / slices);
|
|
122
|
+
for (let k = 0; k < slices; k++) {
|
|
123
|
+
if (hash(seed, i, k) >= amount) continue;
|
|
124
|
+
const dx = (hash(seed, i, k + 100) - 0.5) * amount * 0.3 * w;
|
|
125
|
+
ctx.clearRect(0, k * bh, w, bh); ctx.drawImage(snap, 0, k * bh, w, bh, dx, k * bh, w, bh);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// strobe: colour overlay on every `every`-th frame.
|
|
130
|
+
function strobe(ctx, i, o = {}) {
|
|
131
|
+
const { every = 2, color = '#fff', amount = 1 } = o;
|
|
132
|
+
if (i % every) return;
|
|
133
|
+
const [w, h] = size(ctx, o);
|
|
134
|
+
ctx.save(); ctx.globalAlpha = amount; ctx.fillStyle = color; ctx.fillRect(0, 0, w, h); ctx.restore();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// punch: scale for a punch-in at `at` s that settles to 1 after `decay` s. Apply with ctx.scale around the centre.
|
|
138
|
+
function punch(t, o = {}) {
|
|
139
|
+
const { at = 0, amount = 0.07, decay = 0.25 } = o, e = Math.max(0, 1 - (t - at) / decay);
|
|
140
|
+
return t < at ? 1 : 1 + amount * e * e;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// shake: {dx, dy} px offset, amp=[x,y] px, decaying to 0 after `decay` s. Apply with ctx.translate.
|
|
144
|
+
function shake(i, t, o = {}) {
|
|
145
|
+
const { at = 0, amp = [16, 12], decay = 0.25, seed = 9 } = o, e = t < at ? 0 : Math.max(0, 1 - (t - at) / decay);
|
|
146
|
+
return { dx: (hash(seed, i, 1) * 2 - 1) * amp[0] * e, dy: (hash(seed, i, 2) * 2 - 1) * amp[1] * e };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
window.RNFX = { hash, rng, ease, envelope, grain, vignette, leak, flash, letterbox, rgbSplit, glitch, strobe, punch, shake };
|
|
150
|
+
})();
|