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.
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
+ })();