qareel-linux-x64 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Lai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/bin/qareel ADDED
Binary file
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "qareel-linux-x64",
3
+ "version": "0.1.0",
4
+ "description": "The linux-x64 binaries for qareel. Install qareel instead of this package.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/patrick-lai/qareel.git"
9
+ },
10
+ "homepage": "https://github.com/patrick-lai/qareel",
11
+ "os": [
12
+ "linux"
13
+ ],
14
+ "cpu": [
15
+ "x64"
16
+ ],
17
+ "files": [
18
+ "bin/",
19
+ "libexec/",
20
+ "share/"
21
+ ],
22
+ "preferUnplugged": true
23
+ }
@@ -0,0 +1,892 @@
1
+ import argparse
2
+ import json
3
+ import math
4
+ import os
5
+ from pathlib import Path
6
+ import random
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+
12
+ try:
13
+ import numpy as np
14
+ from PIL import Image, ImageDraw, ImageFilter, ImageFont
15
+ except ImportError:
16
+ np = None
17
+ Image = ImageDraw = ImageFilter = ImageFont = None
18
+
19
+ import reel_sound
20
+
21
+
22
+ ASSETS = Path(__file__).resolve().parent / 'reel_assets'
23
+ CANVAS = (3840, 2160)
24
+ FPS = 30
25
+ ACCENT = (255, 86, 48)
26
+ INK = (23, 43, 77)
27
+ MUTED = (68, 84, 111)
28
+ FONT_FILES = {'regular': 'NotoSans-Regular.ttf', 'bold': 'NotoSans-Bold.ttf'}
29
+ MARK_KINDS = ('click', 'type', 'focus')
30
+ STYLES = ('circle', 'box', 'underline')
31
+ MAX_FRAMES = 60 * 60 * FPS
32
+ LEAD_GAP = 0.1
33
+ CURSOR_TRAVEL = 0.7
34
+ DRAW_START = 0.5
35
+ READ_PAUSE = 1.05
36
+ CLICK_FADE = 0.3
37
+ MARK_SPACING = 0.4
38
+
39
+
40
+ class ReelError(ValueError):
41
+ pass
42
+
43
+
44
+ def need_imaging():
45
+ if np is None or Image is None:
46
+ raise ReelError('reel needs Pillow and numpy (pip install pillow numpy).')
47
+
48
+
49
+ def clamp(value, low=0.0, high=1.0):
50
+ return max(low, min(high, value))
51
+
52
+
53
+ def smooth(value):
54
+ value = clamp(value)
55
+ return value * value * (3 - 2 * value)
56
+
57
+
58
+ def ease_out(value):
59
+ value = clamp(value)
60
+ return 1 - (1 - value) ** 3
61
+
62
+
63
+ def even(value):
64
+ return max(2, int(round(value)) & ~1)
65
+
66
+
67
+ def load_timeline(path, events_path=None):
68
+ data = json.loads(Path(path).read_text()) if path else {}
69
+ if not isinstance(data, dict):
70
+ raise ReelError('The timeline must be a JSON object.')
71
+ source = data.get('source') or {}
72
+ steps = []
73
+ for index, step in enumerate(data.get('steps') or []):
74
+ text = str(step.get('caption') or '').strip()
75
+ start = float(step.get('t', 0))
76
+ end = float(step.get('end', start + 4))
77
+ if not text or end <= start:
78
+ raise ReelError(f'Step {index + 1} needs a caption and end > t.')
79
+ steps.append({'t': start, 'end': end, 'caption': text})
80
+ if not steps and events_path:
81
+ steps = steps_from_events(events_path)
82
+ marks = []
83
+ for index, mark in enumerate(data.get('marks') or []):
84
+ kind = mark.get('kind', 'click')
85
+ rect = mark.get('rect')
86
+ if kind not in MARK_KINDS or not (isinstance(rect, list) and len(rect) == 4 and all(isinstance(item, (int, float)) for item in rect)):
87
+ raise ReelError(f'Mark {index + 1} needs kind {MARK_KINDS} and rect [x, y, width, height].')
88
+ style = mark.get('style') or ('underline' if kind == 'type' else 'circle')
89
+ if style not in STYLES:
90
+ raise ReelError(f'Mark {index + 1} style must be one of {STYLES}.')
91
+ marks.append({'t': float(mark['t']), 'kind': kind, 'rect': [float(item) for item in rect], 'style': style,
92
+ 'label': str(mark.get('label') or '').strip(), 'hold': float(mark.get('hold', 0.0 if kind == 'click' else 1.2))})
93
+ event_source = None
94
+ if events_path:
95
+ event_marks, event_source = marks_from_events(events_path)
96
+ if not marks:
97
+ marks = event_marks
98
+ marks.sort(key=lambda mark: mark['t'])
99
+ return {'title': str(data.get('title') or ''), 'subtitle': str(data.get('subtitle') or ''), 'badge': str(data.get('badge') or 'QA demo'),
100
+ 'url': str(data.get('url') or ''), 'tab_title': str(data.get('tab_title') or data.get('title') or ''),
101
+ 'source': (int(source['width']), int(source['height'])) if source.get('width') and source.get('height') else event_source,
102
+ 'intro': float(data.get('intro_seconds', 2.6)), 'outro': float(data.get('outro_seconds', 1.4)),
103
+ 'steps': steps, 'marks': marks}
104
+
105
+
106
+ def marks_from_events(path):
107
+ events = json.loads(Path(path).read_text())
108
+ width, height = int(events.get('viewport_width') or 0), int(events.get('viewport_height') or 0)
109
+ marks = [{'t': float(mark['time_ms']) / 1000, 'kind': 'click', 'style': 'circle', 'label': '', 'hold': 0.0,
110
+ 'rect': [float(mark['x']), float(mark['y']), float(mark['width']), float(mark['height'])]}
111
+ for mark in events.get('marks') or []]
112
+ return marks, ((width, height) if width and height else None)
113
+
114
+
115
+ def steps_from_events(path):
116
+ events = json.loads(Path(path).read_text())
117
+ cues = events.get('captions') or []
118
+ duration = float(events.get('duration_ms', 0)) / 1000
119
+ steps = []
120
+ for index, cue in enumerate(cues):
121
+ start = float(cue['time_ms']) / 1000
122
+ end = float(cues[index + 1]['time_ms']) / 1000 if index + 1 < len(cues) else duration
123
+ if end > start:
124
+ steps.append({'t': start, 'end': end, 'caption': str(cue['text'])})
125
+ return steps
126
+
127
+
128
+ def frame_count(seconds):
129
+ return int(math.floor(seconds * FPS + 0.5))
130
+
131
+
132
+ def draw_seconds(style):
133
+ return 0.45 if style == 'underline' else 0.55
134
+
135
+
136
+ def read_seconds(text):
137
+ return clamp(0.9 + len(text) / 17, 2.0, 7.0)
138
+
139
+
140
+ def plan_timeline(marks, steps):
141
+ leads = []
142
+ last_lead = None
143
+ for index, mark in enumerate(marks):
144
+ if last_lead is None or mark['t'] - last_lead >= MARK_SPACING:
145
+ leads.append(index)
146
+ last_lead = mark['t']
147
+ leading = set(leads)
148
+ items = []
149
+ for index, mark in enumerate(marks):
150
+ items.append((max(0.0, mark['t'] - LEAD_GAP) if index in leading else mark['t'], 1, 'mark', index))
151
+ for index, step in enumerate(steps):
152
+ effective = step['t']
153
+ for mark_index in leads:
154
+ freeze = max(0.0, marks[mark_index]['t'] - LEAD_GAP)
155
+ if freeze < step['t'] <= marks[mark_index]['t']:
156
+ effective = freeze
157
+ items.append((effective, 0, 'step', index))
158
+ items.sort()
159
+ holds = {}
160
+ schedule = [None] * len(marks)
161
+ starts = [None] * len(steps)
162
+ shift = 0
163
+ dwell_until = 0.0
164
+ last_frame = 0
165
+
166
+ def hold(frame, count):
167
+ nonlocal shift, last_frame
168
+ if count <= 0:
169
+ return
170
+ holds[frame] = holds.get(frame, 0) + count
171
+ shift += count
172
+ last_frame = max(last_frame, frame)
173
+
174
+ for effective, _, kind, index in items:
175
+ frame = max(last_frame, frame_count(effective))
176
+ if kind == 'step':
177
+ output = effective + shift / FPS
178
+ if output < dwell_until:
179
+ hold(max(last_frame, frame - 1), math.ceil((dwell_until - output) * FPS))
180
+ output = effective + shift / FPS
181
+ starts[index] = output
182
+ dwell_until = output + read_seconds(steps[index]['caption'])
183
+ continue
184
+ mark = marks[index]
185
+ drawing = draw_seconds(mark['style'])
186
+ if index in leading:
187
+ begin = frame / FPS + shift / FPS
188
+ lead = DRAW_START + drawing + READ_PAUSE
189
+ hold(frame, frame_count(lead - LEAD_GAP))
190
+ click = mark['t'] + shift / FPS
191
+ schedule[index] = {'begin': begin, 'arrive': begin + CURSOR_TRAVEL, 'draw': begin + DRAW_START, 'click': click}
192
+ else:
193
+ click = mark['t'] + shift / FPS
194
+ schedule[index] = {'begin': click - drawing, 'arrive': click, 'draw': click - drawing, 'click': click}
195
+ schedule[index]['fade'] = schedule[index]['click'] + mark['hold']
196
+ ends = [starts[index + 1] if index + 1 < len(steps) else None for index in range(len(steps))]
197
+ return {'holds': holds, 'marks': schedule, 'starts': starts, 'ends': ends, 'extra_frames': shift}
198
+
199
+
200
+ def raw_frame_for_output(holds, output):
201
+ passed = 0
202
+ for frame in sorted(holds):
203
+ if output < frame + passed:
204
+ break
205
+ if output <= frame + passed + holds[frame]:
206
+ return frame
207
+ passed += holds[frame]
208
+ return output - passed
209
+
210
+
211
+ def layout(canvas, source):
212
+ width, height = canvas
213
+ scale = width / CANVAS[0]
214
+ box_w, box_h = 2880 * scale, 1620 * scale
215
+ fit = min(box_w / source[0], box_h / source[1])
216
+ content_w, content_h = even(source[0] * fit), even(source[1] * fit)
217
+ chrome = even(150 * scale)
218
+ window_w, window_h = content_w, content_h + chrome
219
+ window_x = (width - window_w) // 2
220
+ window_y = even(130 * scale)
221
+ return {'scale': scale, 'canvas': canvas, 'content': (window_x, window_y + chrome, content_w, content_h), 'window': (window_x, window_y, window_w, window_h),
222
+ 'chrome': chrome, 'radius': even(44 * scale), 'caption_y': height - even(130 * scale), 'source': source, 'fit': fit}
223
+
224
+
225
+ def font_path(weight, font_dir=None):
226
+ directory = Path(font_dir or os.environ.get('REEL_FONT_DIR') or ASSETS)
227
+ path = directory / FONT_FILES[weight]
228
+ if not path.is_file():
229
+ raise ReelError(f'Font {path} is missing; set --font-dir or REEL_FONT_DIR.')
230
+ return str(path)
231
+
232
+
233
+ class Fonts:
234
+ def __init__(self, font_dir=None):
235
+ self.font_dir = font_dir
236
+ self.cache = {}
237
+
238
+ def get(self, weight, size):
239
+ key = (weight, int(size))
240
+ if key not in self.cache:
241
+ self.cache[key] = ImageFont.truetype(font_path(weight, self.font_dir), int(size))
242
+ return self.cache[key]
243
+
244
+ def width(self, text, weight, size):
245
+ return self.get(weight, size).getlength(text)
246
+
247
+ def fit(self, text, weight, size, limit):
248
+ if self.width(text, weight, size) <= limit:
249
+ return text
250
+ while text and self.width(text + '…', weight, size) > limit:
251
+ text = text[:-1]
252
+ return text + '…'
253
+
254
+
255
+ def gradient_background(canvas):
256
+ width, height = canvas
257
+ ys = np.linspace(0, 1, height, dtype=np.float32)[:, None]
258
+ xs = np.linspace(0, 1, width, dtype=np.float32)[None, :]
259
+ top, bottom = np.array([234, 242, 255], np.float32), np.array([243, 234, 251], np.float32)
260
+ image = top[None, None, :] * (1 - ys[:, :, None]) + bottom[None, None, :] * ys[:, :, None]
261
+ image = np.broadcast_to(image, (height, width, 3)).copy()
262
+ aspect = width / height
263
+ blobs = (((0.10, 0.12), 0.55, (176, 208, 255), 0.95), ((0.92, 0.18), 0.50, (214, 200, 250), 0.95), ((0.14, 1.0), 0.55, (255, 205, 230), 0.9),
264
+ ((0.86, 0.95), 0.50, (186, 238, 245), 0.85), ((0.5, 0.5), 0.45, (244, 246, 255), 0.55))
265
+ for (cx, cy), radius, color, strength in blobs:
266
+ dist = np.sqrt(((xs - cx) * aspect) ** 2 + (ys - cy) ** 2)
267
+ weight = np.exp(-((dist / radius) ** 2) * 1.6) * strength
268
+ image += (np.array(color, np.float32)[None, None, :] - image) * weight[:, :, None]
269
+ rng = np.random.default_rng(7)
270
+ image += rng.normal(0, 1.1, (height, width, 1)).astype(np.float32)
271
+ return np.clip(image, 0, 255).astype(np.uint8)
272
+
273
+
274
+ def supersample(size, factor=2):
275
+ return (size[0] * factor, size[1] * factor)
276
+
277
+
278
+ def rounded_mask(size, radius, corners=(True, True, True, True)):
279
+ factor = 3
280
+ big = Image.new('L', supersample(size, factor), 0)
281
+ ImageDraw.Draw(big).rounded_rectangle((0, 0, big.width - 1, big.height - 1), radius=radius * factor, fill=255, corners=corners)
282
+ return big.resize(size, Image.LANCZOS)
283
+
284
+
285
+ def draw_chrome(lay, fonts, tab_title, url):
286
+ width, height = lay['window'][2], lay['window'][3]
287
+ chrome, scale = lay['chrome'], lay['scale']
288
+ factor = 2
289
+ sheet = Image.new('RGBA', (width * factor, chrome * factor), (0, 0, 0, 0))
290
+ draw = ImageDraw.Draw(sheet)
291
+ unit = scale * factor
292
+ draw.rectangle((0, 0, sheet.width, int(74 * unit)), fill=(232, 234, 238, 255))
293
+ draw.rectangle((0, int(74 * unit), sheet.width, sheet.height), fill=(246, 247, 249, 255))
294
+ draw.line((0, sheet.height - 2, sheet.width, sheet.height - 2), fill=(214, 218, 224, 255), width=max(2, int(2 * unit)))
295
+ for index, color in enumerate(((255, 95, 87), (254, 188, 46), (40, 200, 64))):
296
+ cx, cy, radius = (50 + index * 48) * unit, 37 * unit, 14 * unit
297
+ draw.ellipse((cx - radius, cy - radius, cx + radius, cy + radius), fill=color + (255,), outline=tuple(int(c * 0.82) for c in color) + (255,), width=max(1, int(unit)))
298
+ tab_x, tab_w = 250 * unit, 560 * unit
299
+ draw.rounded_rectangle((tab_x, 14 * unit, tab_x + tab_w, 78 * unit), radius=18 * unit, fill=(246, 247, 249, 255), corners=(True, True, False, False))
300
+ fav = (tab_x + 38 * unit, 46 * unit, 13 * unit)
301
+ draw.ellipse((fav[0] - fav[2], fav[1] - fav[2], fav[0] + fav[2], fav[1] + fav[2]), fill=(12, 102, 228, 255))
302
+ draw.polygon([(fav[0] - 5 * unit, fav[1] + 6 * unit), (fav[0], fav[1] - 7 * unit), (fav[0] + 5 * unit, fav[1] + 6 * unit)], fill=(255, 255, 255, 255))
303
+ tab_font = fonts.get('regular', int(25 * unit))
304
+ draw.text((tab_x + 72 * unit, 46 * unit), fonts.fit(tab_title, 'regular', 25 * unit, tab_w - 120 * unit), font=tab_font, fill=INK + (255,), anchor='lm')
305
+ row_y = 116 * unit
306
+ stroke = max(3, int(4 * unit))
307
+ for index, direction in enumerate((-1, 1)):
308
+ cx = (62 + index * 62) * unit
309
+ arm = 10 * unit
310
+ draw.line((cx + direction * -arm * 0.5, row_y - arm, cx + direction * arm * 0.5, row_y), fill=(120, 130, 146, 255), width=stroke, joint='curve')
311
+ draw.line((cx + direction * arm * 0.5, row_y, cx + direction * -arm * 0.5, row_y + arm), fill=(120, 130, 146, 255), width=stroke, joint='curve')
312
+ rx = 200 * unit
313
+ draw.arc((rx - 12 * unit, row_y - 12 * unit, rx + 12 * unit, row_y + 12 * unit), 40, 330, fill=(120, 130, 146, 255), width=stroke)
314
+ pill_x0, pill_x1 = 270 * unit, sheet.width - 150 * unit
315
+ draw.rounded_rectangle((pill_x0, 90 * unit, pill_x1, 142 * unit), radius=26 * unit, fill=(233, 235, 239, 255))
316
+ lock_x, lock_y = pill_x0 + 40 * unit, 116 * unit
317
+ if url.startswith('https://'):
318
+ draw.rounded_rectangle((lock_x - 8 * unit, lock_y - 3 * unit, lock_x + 8 * unit, lock_y + 10 * unit), radius=3 * unit, fill=(100, 110, 128, 255))
319
+ draw.arc((lock_x - 6 * unit, lock_y - 13 * unit, lock_x + 6 * unit, lock_y + 3 * unit), 180, 360, fill=(100, 110, 128, 255), width=max(2, int(2.6 * unit)))
320
+ url_font = fonts.get('regular', int(26 * unit))
321
+ draw.text((pill_x0 + 78 * unit, 116 * unit), fonts.fit(url, 'regular', 26 * unit, pill_x1 - pill_x0 - 120 * unit), font=url_font, fill=(59, 64, 72, 255), anchor='lm')
322
+ share_x = sheet.width - 90 * unit
323
+ draw.rectangle((share_x - 12 * unit, row_y - 4 * unit, share_x + 12 * unit, row_y + 16 * unit), outline=(120, 130, 146, 255), width=stroke)
324
+ draw.line((share_x, row_y + 6 * unit, share_x, row_y - 16 * unit), fill=(120, 130, 146, 255), width=stroke)
325
+ return sheet.resize((width, chrome), Image.LANCZOS)
326
+
327
+
328
+ def build_window(lay, fonts, tab_title, url):
329
+ x, y, width, height = lay['window']
330
+ chrome = lay['chrome']
331
+ layer = Image.new('RGBA', (width, height), (255, 255, 255, 255))
332
+ layer.paste(draw_chrome(lay, fonts, tab_title, url), (0, 0))
333
+ mask = rounded_mask((width, height), lay['radius'])
334
+ layer.putalpha(mask)
335
+ return layer
336
+
337
+
338
+ def shadowed(background, window, lay):
339
+ x, y, width, height = lay['window']
340
+ canvas = Image.fromarray(background)
341
+ scale = lay['scale']
342
+ for blur, alpha, offset in ((90 * scale, 0.30, 48 * scale), (22 * scale, 0.20, 12 * scale)):
343
+ pad = int(blur * 3)
344
+ shadow = Image.new('L', (width + pad * 2, height + pad * 2), 0)
345
+ shadow.paste(rounded_mask((width, height), lay['radius']), (pad, pad))
346
+ shadow = shadow.filter(ImageFilter.GaussianBlur(blur)).point(lambda value, a=alpha: int(value * a))
347
+ tint = Image.new('RGB', shadow.size, (20, 30, 60))
348
+ canvas.paste(tint, (x - pad, y - pad + int(offset)), shadow)
349
+ canvas.paste(window, (x, y), window)
350
+ return np.asarray(canvas).copy()
351
+
352
+
353
+ def text_sprite(text, fonts, weight, size, color, padding, fill, radius, accent=None, badge=None):
354
+ font = fonts.get(weight, size)
355
+ left = padding[0] + (size * 1.8 if badge else 0) + (size * 0.9 if accent else 0)
356
+ text_w = int(font.getlength(text))
357
+ width, height = int(left + text_w + padding[0]), int(size * 1.25 + padding[1] * 2)
358
+ factor = 2
359
+ sheet = Image.new('RGBA', (width * factor, height * factor), (0, 0, 0, 0))
360
+ draw = ImageDraw.Draw(sheet)
361
+ draw.rounded_rectangle((0, 0, sheet.width - 1, sheet.height - 1), radius=radius * factor, fill=fill)
362
+ if badge:
363
+ cx, cy, br = (padding[0] + size * 0.75) * factor, sheet.height / 2, size * 0.62 * factor
364
+ draw.ellipse((cx - br, cy - br, cx + br, cy + br), fill=ACCENT + (255,))
365
+ draw.text((cx, cy), badge, font=fonts.get('bold', int(size * 0.72 * factor)), fill=(255, 255, 255, 255), anchor='mm')
366
+ if accent:
367
+ cx, cy, br = (padding[0] + size * 0.3) * factor, sheet.height / 2, size * 0.2 * factor
368
+ draw.ellipse((cx - br, cy - br, cx + br, cy + br), fill=accent + (255,))
369
+ draw.text((left * factor, sheet.height / 2), text, font=fonts.get(weight, int(size * factor)), fill=color + (255,), anchor='lm')
370
+ return sheet.resize((width, height), Image.LANCZOS)
371
+
372
+
373
+ def to_array(image):
374
+ return np.asarray(image.convert('RGBA')).copy()
375
+
376
+
377
+ def blend(frame, sprite, x, y, opacity=1.0):
378
+ height, width = frame.shape[:2]
379
+ sh, sw = sprite.shape[:2]
380
+ x, y = int(round(x)), int(round(y))
381
+ x0, y0, x1, y1 = max(0, x), max(0, y), min(width, x + sw), min(height, y + sh)
382
+ if x1 <= x0 or y1 <= y0 or opacity <= 0:
383
+ return
384
+ part = sprite[y0 - y:y1 - y, x0 - x:x1 - x]
385
+ alpha = (part[:, :, 3:4].astype(np.float32) / 255.0) * min(1.0, opacity)
386
+ region = frame[y0:y1, x0:x1].astype(np.float32)
387
+ frame[y0:y1, x0:x1] = np.clip(region + (part[:, :, :3].astype(np.float32) - region) * alpha, 0, 255).astype(np.uint8)
388
+
389
+
390
+ def crayon_points(style, rect, scale, rng):
391
+ x, y, width, height = rect
392
+ pad = 22 * scale
393
+ points = []
394
+ if style == 'circle':
395
+ cx, cy = x + width / 2, y + height / 2
396
+ ry = height / 2 + pad
397
+ rx = max(width / 2 + pad + 16 * scale, ry * 1.25)
398
+ count = 150
399
+ start = rng.uniform(-2.7, -1.9)
400
+ sweep = math.tau * 1.12
401
+ phases = [rng.uniform(0, math.tau) for _ in range(3)]
402
+ for index in range(count + 1):
403
+ progress = index / count
404
+ angle = start + sweep * progress
405
+ wobble = 1 + 0.035 * math.sin(2 * angle + phases[0]) + 0.02 * math.sin(3 * angle + phases[1]) + 0.012 * math.sin(5 * angle + phases[2]) + 0.05 * progress
406
+ points.append((cx + rx * wobble * math.cos(angle), cy + ry * wobble * math.sin(angle)))
407
+ elif style == 'box':
408
+ left, top, right, bottom = x - pad, y - pad, x + width + pad, y + height + pad
409
+ corners = [(left, top), (right, top), (right, bottom), (left, bottom), (left - 10 * scale, top + 8 * scale)]
410
+ for a, b in zip(corners, corners[1:]):
411
+ for index in range(34):
412
+ t = index / 33
413
+ bow = math.sin(t * math.pi) * rng.uniform(-5, 5) * scale
414
+ points.append((a[0] + (b[0] - a[0]) * t + bow, a[1] + (b[1] - a[1]) * t - bow))
415
+ else:
416
+ baseline = y + height + 14 * scale
417
+ for stroke in range(2):
418
+ offset = stroke * 12 * scale
419
+ sign = 1 if stroke == 0 else -1
420
+ xs = [x - 8 * scale, x + width + 8 * scale][::sign]
421
+ for index in range(60):
422
+ t = index / 59
423
+ px = xs[0] + (xs[1] - xs[0]) * t
424
+ points.append((px, baseline + offset + math.sin(t * math.pi * 3 + stroke) * 3.5 * scale))
425
+ return points
426
+
427
+
428
+ def crayon_sprite(style, rect, scale, seed):
429
+ rng = random.Random(seed)
430
+ points = crayon_points(style, rect, scale, rng)
431
+ xs, ys = [p[0] for p in points], [p[1] for p in points]
432
+ margin = int(40 * scale)
433
+ left, top = int(min(xs)) - margin, int(min(ys)) - margin
434
+ width, height = int(max(xs)) - left + margin, int(max(ys)) - top + margin
435
+ factor = 2
436
+ mask = Image.new('L', (width * factor, height * factor), 0)
437
+ order = Image.new('L', (width * factor, height * factor), 0)
438
+ draw_mask, draw_order = ImageDraw.Draw(mask), ImageDraw.Draw(order)
439
+ total = len(points) - 1
440
+ phase = rng.uniform(0, math.tau)
441
+ for index in range(total):
442
+ taper = min(1.0, index / 8, (total - index) / 10)
443
+ pressure = (0.78 + 0.22 * math.sin(index * 0.21 + phase)) * (0.55 + 0.45 * taper)
444
+ stroke = max(2, int(12 * scale * factor * pressure))
445
+ a = ((points[index][0] - left) * factor, (points[index][1] - top) * factor)
446
+ b = ((points[index + 1][0] - left) * factor, (points[index + 1][1] - top) * factor)
447
+ value = int(255 * index / max(1, total))
448
+ for jitter in (0.0, 1.0):
449
+ dx, dy = jitter * 2.6 * scale * factor * math.cos(index * 0.4), jitter * 2.6 * scale * factor * math.sin(index * 0.33)
450
+ line = ((a[0] + dx, a[1] + dy), (b[0] + dx, b[1] + dy))
451
+ draw_mask.line(line, fill=255 if jitter == 0 else 150, width=stroke if jitter == 0 else max(2, stroke - 3))
452
+ draw_order.line(line, fill=value, width=stroke)
453
+ radius = stroke / 2
454
+ draw_mask.ellipse((a[0] - radius, a[1] - radius, a[0] + radius, a[1] + radius), fill=255)
455
+ draw_order.ellipse((a[0] - radius, a[1] - radius, a[0] + radius, a[1] + radius), fill=value)
456
+ mask = mask.filter(ImageFilter.GaussianBlur(0.8 * factor / 2)).resize((width, height), Image.LANCZOS)
457
+ order = order.resize((width, height), Image.NEAREST)
458
+ gen = np.random.default_rng(seed)
459
+ grain = gen.normal(0.0, 1.0, (height, width)).astype(np.float32)
460
+ soft = np.asarray(Image.fromarray(np.clip(grain * 40 + 128, 0, 255).astype(np.uint8)).filter(ImageFilter.GaussianBlur(1.6)), dtype=np.float32) / 255.0
461
+ texture = np.clip(0.8 + (soft - 0.5) * 2.4, 0.5, 1.0)
462
+ texture *= np.where(gen.random((height, width)) < 0.05, 0.45, 1.0)
463
+ alpha = np.asarray(mask, dtype=np.float32) / 255.0
464
+ return {'origin': (left, top), 'alpha': alpha * texture, 'order': np.asarray(order, dtype=np.float32) / 255.0}
465
+
466
+
467
+ def stroke_frame(sprite, progress, opacity, color=ACCENT):
468
+ reveal = np.clip((progress * 1.04 - sprite['order']) / 0.035, 0, 1)
469
+ alpha = sprite['alpha'] * reveal * opacity
470
+ height, width = alpha.shape
471
+ out = np.empty((height, width, 4), np.uint8)
472
+ out[:, :, 0], out[:, :, 1], out[:, :, 2] = color
473
+ out[:, :, 3] = np.clip(alpha * 255, 0, 255).astype(np.uint8)
474
+ return out
475
+
476
+
477
+ def cursor_sprite(scale):
478
+ size = int(100 * scale)
479
+ factor = 3
480
+ sheet = Image.new('RGBA', (size * 2 * factor, size * 2 * factor), (0, 0, 0, 0))
481
+ draw = ImageDraw.Draw(sheet)
482
+ unit = size * factor / 30
483
+ outline = [(0, 0), (0, 23), (6, 18), (10, 28), (14.5, 26), (10.5, 17), (18, 17)]
484
+ offset = size * factor * 0.25
485
+ shadow = Image.new('RGBA', sheet.size, (0, 0, 0, 0))
486
+ ImageDraw.Draw(shadow).polygon([(offset + 2.5 * unit + px * unit, offset + 3.5 * unit + py * unit) for px, py in outline], fill=(0, 0, 0, 120))
487
+ shadow = shadow.filter(ImageFilter.GaussianBlur(unit * 1.2))
488
+ sheet = Image.alpha_composite(shadow, sheet)
489
+ draw = ImageDraw.Draw(sheet)
490
+ points = [(offset + px * unit, offset + py * unit) for px, py in outline]
491
+ draw.polygon(points, fill=(255, 255, 255, 255), outline=(15, 20, 30, 255), width=max(2, int(unit * 1.5)))
492
+ return {'array': to_array(sheet.resize((size * 2, size * 2), Image.LANCZOS)), 'hot': (offset / factor, offset / factor)}
493
+
494
+
495
+ def ripple_sprite(radius, ring, alpha):
496
+ size = int(radius * 2 + ring * 4)
497
+ factor = 2
498
+ sheet = Image.new('RGBA', (size * factor, size * factor), (0, 0, 0, 0))
499
+ center = size * factor / 2
500
+ ImageDraw.Draw(sheet).ellipse((center - radius * factor, center - radius * factor, center + radius * factor, center + radius * factor),
501
+ outline=ACCENT + (int(255 * alpha),), width=max(2, int(ring * factor)))
502
+ return to_array(sheet.resize((size, size), Image.LANCZOS)), size / 2
503
+
504
+
505
+ class Reel:
506
+ def __init__(self, timeline, source, canvas, fonts, background=None):
507
+ self.timeline = timeline
508
+ self.canvas = canvas
509
+ self.lay = layout(canvas, source)
510
+ self.fonts = fonts
511
+ scale = self.lay['scale']
512
+ self.background = background if background is not None else gradient_background(canvas)
513
+ window = build_window(self.lay, fonts, timeline['tab_title'], timeline['url'])
514
+ self.base = shadowed(self.background, window, self.lay)
515
+ self.corner = self.corner_mask()
516
+ self.cursor = cursor_sprite(scale)
517
+ self.plan = plan_timeline(timeline['marks'], timeline['steps'])
518
+ self.strokes = [crayon_sprite(mark['style'], self.canvas_rect(mark['rect']), scale, 1000 + index) for index, mark in enumerate(timeline['marks'])]
519
+ self.labels = [self.label_sprite(mark) for mark in timeline['marks']]
520
+ self.captions = [self.caption_sprite(step, index) for index, step in enumerate(timeline['steps'])]
521
+ self.title = self.title_card()
522
+ self.waypoints = self.cursor_waypoints()
523
+ self.label_spots = {}
524
+
525
+ def corner_mask(self):
526
+ x, y, width, height = self.lay['content']
527
+ radius = self.lay['radius']
528
+ mask = np.asarray(rounded_mask((width, height), radius, (False, False, True, True)), dtype=np.float32) / 255.0
529
+ return mask
530
+
531
+ def to_canvas(self, point):
532
+ x, y, width, height = self.lay['content']
533
+ fit = self.lay['fit']
534
+ return (x + point[0] * fit, y + point[1] * fit)
535
+
536
+ def canvas_rect(self, rect):
537
+ x, y = self.to_canvas((rect[0], rect[1]))
538
+ fit = self.lay['fit']
539
+ return (x, y, rect[2] * fit, rect[3] * fit)
540
+
541
+ def label_sprite(self, mark):
542
+ if not mark['label']:
543
+ return None
544
+ scale = self.lay['scale']
545
+ return to_array(text_sprite(mark['label'], self.fonts, 'bold', 40 * scale, (255, 255, 255), (30 * scale, 17 * scale), INK + (238,), int(30 * scale), accent=ACCENT))
546
+
547
+ def caption_sprite(self, step, index):
548
+ scale = self.lay['scale']
549
+ total = len(self.timeline['steps'])
550
+ text = self.fonts.fit(step['caption'], 'bold', 58 * scale, self.lay['window'][2] - 360 * scale)
551
+ badge = f'{index + 1}' if total > 1 else None
552
+ return to_array(text_sprite(text, self.fonts, 'bold', 58 * scale, INK, (44 * scale, 26 * scale), (255, 255, 255, 240), int(54 * scale), badge=badge))
553
+
554
+ def title_card(self):
555
+ width, height = self.canvas
556
+ scale = self.lay['scale']
557
+ sheet = Image.new('RGBA', (width, height), (0, 0, 0, 0))
558
+ draw = ImageDraw.Draw(sheet)
559
+ badge = text_sprite(self.timeline['badge'], self.fonts, 'bold', 46 * scale, (255, 255, 255), (34 * scale, 16 * scale), (12, 102, 228, 255), int(40 * scale))
560
+ sheet.paste(badge, ((width - badge.width) // 2, int(height * 0.34)), badge)
561
+ title = self.fonts.fit(self.timeline['title'], 'bold', 170 * scale, width - 400 * scale)
562
+ draw.text((width / 2, height * 0.5), title, font=self.fonts.get('bold', int(170 * scale)), fill=INK + (255,), anchor='mm')
563
+ subtitle = self.fonts.fit(self.timeline['subtitle'], 'regular', 74 * scale, width - 600 * scale)
564
+ draw.text((width / 2, height * 0.5 + 170 * scale), subtitle, font=self.fonts.get('regular', int(74 * scale)), fill=MUTED + (255,), anchor='mm')
565
+ return to_array(sheet)
566
+
567
+ def cursor_waypoints(self):
568
+ points = []
569
+ for mark, when in zip(self.timeline['marks'], self.plan['marks']):
570
+ if mark['kind'] == 'click':
571
+ rect = self.canvas_rect(mark['rect'])
572
+ points.append((when['arrive'], when['click'], (rect[0] + rect[2] / 2, rect[1] + rect[3] / 2)))
573
+ return points
574
+
575
+ def cursor_position(self, t):
576
+ if not self.waypoints:
577
+ return None
578
+ x, y, width, height = self.lay['content']
579
+ previous = (x + width * 0.62, y + height * 0.82)
580
+ previous_click = self.waypoints[0][0] - 2.0
581
+ for index, (arrive, click, position) in enumerate(self.waypoints):
582
+ begin = min(arrive - 0.25, max(previous_click, arrive - CURSOR_TRAVEL))
583
+ if t < begin:
584
+ return previous, clamp((t - (begin - 0.4)) / 0.4) if index == 0 else 1.0
585
+ if t <= arrive:
586
+ progress = smooth((t - begin) / (arrive - begin))
587
+ dx, dy = position[0] - previous[0], position[1] - previous[1]
588
+ bend = 0.12 * progress * (1 - progress) * 4
589
+ return (previous[0] + dx * progress - dy * bend * 0.5, previous[1] + dy * progress + dx * bend * 0.5), 1.0
590
+ if t <= click:
591
+ return position, 1.0
592
+ previous_click, previous = click, position
593
+ return previous, 1.0
594
+
595
+ def draw_overlays(self, frame, t, content):
596
+ scale = self.lay['scale']
597
+ for index, mark in enumerate(self.timeline['marks']):
598
+ when = self.plan['marks'][index]
599
+ draw_time = draw_seconds(mark['style'])
600
+ start = when['draw']
601
+ if t < start or t > when['fade'] + CLICK_FADE:
602
+ continue
603
+ progress = ease_out((t - start) / draw_time)
604
+ opacity = 1.0 - smooth((t - when['fade']) / CLICK_FADE)
605
+ sprite = self.strokes[index]
606
+ blend(frame, stroke_frame(sprite, progress, 1.0), sprite['origin'][0], sprite['origin'][1], opacity)
607
+ label = self.labels[index]
608
+ if label is not None and t > start + 0.18:
609
+ rect = self.canvas_rect(mark['rect'])
610
+ appear = ease_out((t - start - 0.18) / 0.28)
611
+ lx, ly = self.label_position(index, rect, label, content)
612
+ blend(frame, label, lx, ly + (1 - appear) * 14 * scale, opacity * appear)
613
+ if self.waypoints:
614
+ placed = self.cursor_position(t)
615
+ if placed:
616
+ (px, py), alpha = placed
617
+ for _, tc, position in self.waypoints:
618
+ age = t - tc
619
+ if 0 <= age < 0.55:
620
+ radius = (10 + 70 * ease_out(age / 0.55)) * scale
621
+ ring, ring_origin = ripple_sprite(radius, 7 * scale, 0.85 * (1 - age / 0.55))
622
+ blend(frame, ring, position[0] - ring_origin, position[1] - ring_origin)
623
+ hot = self.cursor['hot']
624
+ blend(frame, self.cursor['array'], px - hot[0], py - hot[1], alpha)
625
+ for index in range(len(self.timeline['steps'])):
626
+ begin, finish = self.plan['starts'][index], self.plan['ends'][index]
627
+ if begin - 0.001 <= t and (finish is None or t <= finish + 0.3):
628
+ fade_in = ease_out((t - begin) / 0.32)
629
+ fade_out = 1.0 if finish is None else 1.0 - smooth((t - finish) / 0.3)
630
+ sprite = self.captions[index]
631
+ x = (self.canvas[0] - sprite.shape[1]) / 2
632
+ y = self.lay['caption_y'] - sprite.shape[0] / 2 + (1 - fade_in) * 26 * scale
633
+ blend(frame, sprite, x, y, fade_in * fade_out)
634
+
635
+ def label_position(self, index, rect, label, content):
636
+ if index in self.label_spots:
637
+ return self.label_spots[index]
638
+ scale = self.lay['scale']
639
+ cx, cy, cw, ch = self.lay['content']
640
+ height, width = label.shape[:2]
641
+ margin = 26 * scale
642
+ near, far = 52 * scale, 130 * scale
643
+ middle = rect[1] + rect[3] / 2 - height / 2
644
+ spread = rect[0] + rect[2] / 2 - width / 2
645
+ candidates = [(rect[0] + rect[2] + near, middle), (rect[0] - near - width, middle), (spread, rect[1] + rect[3] + near * 0.8),
646
+ (spread, rect[1] - near * 0.8 - height), (rect[0] + rect[2] + far, middle), (rect[0] - far - width, middle),
647
+ (spread, rect[1] + rect[3] + far), (spread, rect[1] - far - height)]
648
+ best, best_cost = None, None
649
+ for order, (x, y) in enumerate(candidates):
650
+ if x < cx + margin or y < cy + margin or x + width > cx + cw - margin or y + height > cy + ch - margin:
651
+ continue
652
+ region = content[int(y - cy):int(y - cy + height), int(x - cx):int(x - cx + width)]
653
+ ink = float((region.mean(axis=2) < 238).mean()) if region.size else 1.0
654
+ cost = ink + order * 0.01
655
+ if best_cost is None or cost < best_cost:
656
+ best, best_cost = (x, y), cost
657
+ if best is None:
658
+ best = (min(max(cx + margin, spread), cx + cw - width - margin), min(max(cy + margin, rect[1] + rect[3] + near), cy + ch - height - margin))
659
+ self.label_spots[index] = best
660
+ return best
661
+
662
+ def compose(self, content, t):
663
+ frame = self.base.copy()
664
+ x, y, width, height = self.lay['content']
665
+ radius = self.lay['radius']
666
+ frame[y:y + height - radius, x:x + width] = content[:height - radius]
667
+ corner_rows = slice(y + height - radius, y + height)
668
+ base_part = self.base[corner_rows, x:x + width].astype(np.float32)
669
+ mask = self.corner[height - radius:, :, None]
670
+ frame[corner_rows, x:x + width] = np.clip(base_part + (content[height - radius:].astype(np.float32) - base_part) * mask, 0, 255).astype(np.uint8)
671
+ self.draw_overlays(frame, t, content)
672
+ return frame
673
+
674
+ def intro_frame(self, content, k, intro_frames):
675
+ t = k / FPS
676
+ intro = intro_frames / FPS
677
+ reveal = smooth((t - (intro - 0.8)) / 0.8)
678
+ card = self.background.copy()
679
+ title_alpha = 1.0 - smooth((t - (intro - 1.15)) / 0.4)
680
+ enter = ease_out(t / 0.7)
681
+ shift = (1 - enter) * 40 * self.lay['scale'] - smooth((t - (intro - 1.0)) / 0.9) * 60 * self.lay['scale']
682
+ blend(card, self.title, 0, shift, title_alpha * enter)
683
+ if reveal <= 0:
684
+ return card
685
+ window = self.compose(content, -1.0)
686
+ zoom = 1.07 - 0.07 * reveal
687
+ if zoom > 1.0005:
688
+ image = Image.fromarray(window)
689
+ size = (int(self.canvas[0] * zoom), int(self.canvas[1] * zoom))
690
+ image = image.resize(size, Image.BILINEAR)
691
+ left, top = (size[0] - self.canvas[0]) // 2, (size[1] - self.canvas[1]) // 2
692
+ window = np.asarray(image.crop((left, top, left + self.canvas[0], top + self.canvas[1]))).copy()
693
+ mixed = card.astype(np.float32) + (window.astype(np.float32) - card.astype(np.float32)) * reveal
694
+ return np.clip(mixed, 0, 255).astype(np.uint8)
695
+
696
+
697
+ def run(arguments, **keywords):
698
+ return subprocess.run(arguments, check=True, capture_output=True, text=True, **keywords).stdout
699
+
700
+
701
+ def probe(video, ffprobe):
702
+ data = json.loads(run([ffprobe, '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height,avg_frame_rate:format=duration', '-of', 'json', str(video)]))
703
+ stream = data['streams'][0]
704
+ return int(stream['width']), int(stream['height']), float(data['format']['duration'])
705
+
706
+
707
+ def has_audio(video, ffprobe):
708
+ return bool(run([ffprobe, '-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=index', '-of', 'csv=p=0', str(video)]).strip())
709
+
710
+
711
+ def decoder(video, size, ffmpeg, start=None):
712
+ command = [ffmpeg, '-v', 'error', '-nostdin']
713
+ if start:
714
+ command += ['-ss', f'{start:.3f}']
715
+ command += ['-i', str(video), '-an', '-vf', f'fps={FPS}:round=near,scale={size[0]}:{size[1]}:flags=lanczos+accurate_rnd+full_chroma_int,unsharp=5:5:0.7:3:3:0.0',
716
+ '-f', 'rawvideo', '-pix_fmt', 'rgb24', '-']
717
+ return subprocess.Popen(command, stdout=subprocess.PIPE)
718
+
719
+
720
+ def read_frames(process, size):
721
+ length = size[0] * size[1] * 3
722
+ while True:
723
+ data = process.stdout.read(length)
724
+ if len(data) < length:
725
+ return
726
+ yield np.frombuffer(data, np.uint8).reshape(size[1], size[0], 3)
727
+
728
+
729
+ def encoder(out, canvas, ffmpeg, crf, sound=None, native=None, delay=0.0, preset='medium'):
730
+ command = [ffmpeg, '-v', 'error', '-y', '-f', 'rawvideo', '-pix_fmt', 'rgb24', '-s', f'{canvas[0]}x{canvas[1]}', '-r', str(FPS), '-i', '-']
731
+ for source in (sound, native):
732
+ if source:
733
+ command += ['-i', str(source)]
734
+ command += ['-map', '0:v']
735
+ if sound and native:
736
+ command += ['-filter_complex', f'[2:a]adelay={int(delay * 1000)}:all=1[native];[1:a][native]amix=inputs=2:duration=first:normalize=0[mix]', '-map', '[mix]']
737
+ elif sound:
738
+ command += ['-map', '1:a']
739
+ elif native:
740
+ command += ['-map', '1:a', '-af', f'adelay={int(delay * 1000)}:all=1']
741
+ if sound or native:
742
+ command += ['-c:a', 'aac', '-b:a', '160k', '-shortest']
743
+ command += ['-vf', 'scale=out_color_matrix=bt709:out_range=tv,format=yuv420p', '-c:v', 'libx264', '-preset', preset, '-crf', str(crf),
744
+ '-profile:v', 'high', '-r', str(FPS), '-colorspace', 'bt709', '-color_primaries', 'bt709', '-color_trc', 'bt709',
745
+ '-movflags', '+faststart', str(out)]
746
+ return subprocess.Popen(command, stdin=subprocess.PIPE)
747
+
748
+
749
+ def parse_size(text):
750
+ try:
751
+ width, height = (int(part) for part in text.lower().split('x'))
752
+ except ValueError as error:
753
+ raise ReelError('--size must look like 3840x2160.') from error
754
+ if width % 2 or height % 2 or width < 640 or height < 360 or width * 9 != height * 16:
755
+ raise ReelError('--size must be an even 16:9 size of at least 640x360.')
756
+ return width, height
757
+
758
+
759
+ def build(args):
760
+ need_imaging()
761
+ ffmpeg, ffprobe = args.ffmpeg, args.ffprobe
762
+ for tool in (ffmpeg, ffprobe):
763
+ if not shutil.which(tool):
764
+ raise ReelError(f'{tool} is not on PATH.')
765
+ timeline = load_timeline(args.timeline, args.events)
766
+ width, height, duration = probe(args.video, ffprobe)
767
+ source = timeline['source'] or (width, height)
768
+ canvas = parse_size(args.size)
769
+ reel = Reel(timeline, source, canvas, Fonts(args.font_dir))
770
+ return reel, (width, height, duration), canvas
771
+
772
+
773
+ def pick_music(choice):
774
+ if choice == 'off':
775
+ return None
776
+ if choice == 'random':
777
+ return random.SystemRandom().randrange(reel_sound.TRACKS)
778
+ try:
779
+ track = int(choice)
780
+ except ValueError as error:
781
+ raise ReelError('--music must be random, off or a track number.') from error
782
+ if not 0 <= track < reel_sound.TRACKS:
783
+ raise ReelError(f'--music track must be from 0 to {reel_sound.TRACKS - 1}.')
784
+ return track
785
+
786
+
787
+ def click_times(timeline, plan):
788
+ return [timeline['intro'] + when['click'] for mark, when in zip(timeline['marks'], plan['marks']) if mark['kind'] in ('click', 'type')]
789
+
790
+
791
+ def compose(args):
792
+ reel, (width, height, duration), canvas = build(args)
793
+ timeline = reel.timeline
794
+ intro_frames = int(round(timeline['intro'] * FPS))
795
+ outro_frames = int(round(timeline['outro'] * FPS))
796
+ holds = reel.plan['holds']
797
+ if (intro_frames + outro_frames + reel.plan['extra_frames']) / FPS + duration > MAX_FRAMES / FPS:
798
+ raise ReelError('The recording is too long to compose (limit one hour).')
799
+ audio = args.video if not holds and has_audio(args.video, args.ffprobe) else None
800
+ track = pick_music(args.music)
801
+ clicks = click_times(timeline, reel.plan) if track is not None else []
802
+ seconds = (intro_frames + outro_frames + reel.plan['extra_frames'] + int(round(duration * FPS))) / FPS
803
+ with tempfile.TemporaryDirectory() as scratch:
804
+ sound = None
805
+ if track is not None:
806
+ sound = Path(scratch) / 'sound.wav'
807
+ reel_sound.write_wav(sound, reel_sound.render(seconds, clicks, track))
808
+ return encode(args, reel, canvas, timeline, (intro_frames, outro_frames), holds, audio, sound, track)
809
+
810
+
811
+ def encode(args, reel, canvas, timeline, edges, holds, audio, sound, track):
812
+ intro_frames, outro_frames = edges
813
+ content_size = reel.lay['content'][2:]
814
+ source = decoder(args.video, content_size, args.ffmpeg)
815
+ out = encoder(args.out, canvas, args.ffmpeg, args.crf, sound, audio, timeline['intro'], args.preset)
816
+ frames = read_frames(source, content_size)
817
+ first = next(frames, None)
818
+ if first is None:
819
+ raise ReelError('The recording has no video frames.')
820
+ last = first
821
+ count = 0
822
+ try:
823
+ for k in range(intro_frames):
824
+ out.stdin.write(reel.intro_frame(first, k, intro_frames).tobytes())
825
+ count += 1
826
+ shown = 0
827
+ for raw, content in enumerate([first, *frames]):
828
+ for _ in range(1 + holds.get(raw, 0)):
829
+ out.stdin.write(reel.compose(content, shown / FPS).tobytes())
830
+ shown += 1
831
+ count += 1
832
+ last = content
833
+ for k in range(outro_frames):
834
+ out.stdin.write(reel.compose(last, (shown + k) / FPS).tobytes())
835
+ count += 1
836
+ finally:
837
+ out.stdin.close()
838
+ source.stdout.close()
839
+ source.wait()
840
+ if out.wait():
841
+ raise ReelError('ffmpeg could not encode the video.')
842
+ return {'out': str(args.out), 'frames': count, 'seconds': round(count / FPS, 2), 'size': f'{canvas[0]}x{canvas[1]}', 'fps': FPS,
843
+ 'intro_seconds': timeline['intro'], 'lead_gap': LEAD_GAP, 'holds': [[frame, count] for frame, count in sorted(holds.items())], 'audio_kept': audio is not None, 'music': track}
844
+
845
+
846
+ def frame(args):
847
+ reel, (width, height, duration), canvas = build(args)
848
+ content_size = reel.lay['content'][2:]
849
+ intro = reel.timeline['intro']
850
+ shown_t = max(0.0, args.time)
851
+ raw_t = raw_frame_for_output(reel.plan['holds'], frame_count(shown_t)) / FPS
852
+ process = decoder(args.video, content_size, args.ffmpeg, raw_t)
853
+ content = next(read_frames(process, content_size), None)
854
+ process.kill()
855
+ if content is None:
856
+ raise ReelError('No video frame at that time.')
857
+ image = reel.intro_frame(content, int(args.card * FPS), int(round(intro * FPS))) if args.card is not None else reel.compose(content, shown_t)
858
+ Image.fromarray(image).save(args.out)
859
+ return {'out': str(args.out), 'time': shown_t, 'source_time': raw_t}
860
+
861
+
862
+ def main():
863
+ parser = argparse.ArgumentParser(description='Compose a polished QA demo video from a clean browser recording.')
864
+ commands = parser.add_subparsers(dest='command', required=True)
865
+ for name in ('compose', 'frame'):
866
+ item = commands.add_parser(name)
867
+ item.add_argument('--video', required=True)
868
+ item.add_argument('--timeline')
869
+ item.add_argument('--events')
870
+ item.add_argument('--out', required=True)
871
+ item.add_argument('--size', default='3840x2160')
872
+ item.add_argument('--font-dir')
873
+ item.add_argument('--ffmpeg', default='ffmpeg')
874
+ item.add_argument('--ffprobe', default='ffprobe')
875
+ if name == 'compose':
876
+ item.add_argument('--crf', type=int, default=17)
877
+ item.add_argument('--preset', default='medium')
878
+ item.add_argument('--music', default='random')
879
+ else:
880
+ item.add_argument('--time', type=float, default=1.0)
881
+ item.add_argument('--card', type=float)
882
+ args = parser.parse_args()
883
+ try:
884
+ print(json.dumps({'compose': compose, 'frame': frame}[args.command](args)))
885
+ except (ReelError, OSError, subprocess.SubprocessError, KeyError) as error:
886
+ print(f'reel: {error}', file=sys.stderr)
887
+ return 1
888
+ return 0
889
+
890
+
891
+ if __name__ == '__main__':
892
+ raise SystemExit(main())
@@ -0,0 +1,156 @@
1
+ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
2
+ Upstream-Name: Noto
3
+ Upstream-Contact: https://github.com/googlei18n/noto-fonts/issues
4
+ http://groups.google.com/group/noto-font
5
+ Source: https://github.com/googlei18n/noto-fonts
6
+
7
+ Files: *
8
+ Copyright:
9
+ 2010,2012-2020, Google Inc.
10
+ 2015-2020, Google LLC.
11
+ License-Grant:
12
+ This Font Software is licensed under the SIL Open Font License,
13
+ Version 1.1.
14
+ License: OFL-1.1
15
+
16
+ Files:
17
+ debian/*
18
+ Copyright:
19
+ 2013-2020, Jonas Smedegaard <dr@jones.dk>
20
+ 2020, Purism, SPC
21
+ 2013, Vasudev Kamath <kamathvasudev@gmail.com>
22
+ 2026, Manuel Guerra <ar.manuelguerra@gmail.com>
23
+ License-Grant:
24
+ This program is free software;
25
+ you can redistribute it and/or modify it
26
+ under the terms of the GNU General Public License
27
+ as published by the Free Software Foundation;
28
+ either version 3, or (at your option) any later version.
29
+ License: GPL-3+
30
+
31
+ Files:
32
+ debian/NotoMono/NotoMono-Regular.ttf
33
+ Copyright:
34
+ 2007, Google Inc.
35
+ License-Grant:
36
+ This Font Software is licensed under the SIL Open Font License,
37
+ Version 1.1.
38
+ License: OFL-1.1
39
+
40
+ License: OFL-1.1
41
+ PREAMBLE
42
+ .
43
+ The goals of the Open Font License (OFL) are to stimulate
44
+ worldwide development of collaborative font projects,
45
+ to support the font creation efforts
46
+ of academic and linguistic communities,
47
+ and to provide a free and open framework
48
+ in which fonts may be shared and improved
49
+ in partnership with others.
50
+ .
51
+ The OFL allows the licensed fonts to be used, studied,
52
+ modified and redistributed freely
53
+ as long as they are not sold by themselves.
54
+ The fonts, including any derivative works,
55
+ can be bundled, embedded,
56
+ redistributed and/or sold with any software
57
+ provided that any reserved names are not used
58
+ by derivative works.
59
+ The fonts and derivatives, however,
60
+ cannot be released under any other type of license.
61
+ The requirement for fonts to remain under this license
62
+ does not apply to any document
63
+ created using the fonts or their derivatives.
64
+ .
65
+ DEFINITIONS
66
+ .
67
+ "Font Software" refers to the set of files
68
+ released by the Copyright Holder(s) under this license
69
+ and clearly marked as such.
70
+ This may include source files, build scripts and documentation.
71
+ .
72
+ "Reserved Font Name" refers to any names specified as such
73
+ after the copyright statement(s).
74
+ .
75
+ "Original Version" refers to the collection
76
+ of Font Software components
77
+ as distributed by the Copyright Holder(s).
78
+ .
79
+ "Modified Version" refers to any derivative
80
+ made by adding to, deleting, or substituting --
81
+ in part or in whole --
82
+ any of the components of the Original Version,
83
+ by changing formats
84
+ or by porting the Font Software to a new environment.
85
+ .
86
+ "Author" refers to any designer, engineer, programmer,
87
+ technical writer or other person
88
+ who contributed to the Font Software.
89
+ .
90
+ PERMISSION & CONDITIONS
91
+ .
92
+ Permission is hereby granted, free of charge,
93
+ to any person obtaining a copy of the Font Software,
94
+ to use, study, copy, merge, embed, modify, redistribute,
95
+ and sell modified and unmodified copies of the Font Software,
96
+ subject to the following conditions:
97
+ .
98
+ 1) Neither the Font Software nor any of its individual components,
99
+ in Original or Modified Versions,
100
+ may be sold by itself.
101
+ .
102
+ 2) Original or Modified Versions of the Font Software
103
+ may be bundled, redistributed and/or sold with any software,
104
+ provided that each copy contains the above copyright notice
105
+ and this license.
106
+ These can be included either as stand-alone text files,
107
+ human-readable headers or in the appropriate
108
+ machine-readable metadata fields within text or binary files
109
+ as long as those fields can be easily viewed by the user.
110
+ .
111
+ 3) No Modified Version of the Font Software may use
112
+ the Reserved Font Name(s)
113
+ unless explicit written permission is granted
114
+ by the corresponding Copyright Holder.
115
+ This restriction only applies to the primary font name
116
+ as presented to the users.
117
+ .
118
+ 4) The name(s) of the Copyright Holder(s)
119
+ or the Author(s) of the Font Software
120
+ shall not be used to promote,
121
+ endorse or advertise any Modified Version,
122
+ except to acknowledge the contribution(s)
123
+ of the Copyright Holder(s) and the Author(s)
124
+ or with their explicit written permission.
125
+ .
126
+ 5) The Font Software,
127
+ modified or unmodified, in part or in whole,
128
+ must be distributed entirely under this license,
129
+ and must not be distributed under any other license.
130
+ The requirement for fonts to remain under this license
131
+ does not apply to any document
132
+ created using the Font Software.
133
+ .
134
+ TERMINATION
135
+ .
136
+ This license becomes null and void
137
+ if any of the above conditions are not met.
138
+ .
139
+ DISCLAIMER
140
+ .
141
+ THE FONT SOFTWARE IS PROVIDED "AS IS",
142
+ WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
143
+ INCLUDING BUT NOT LIMITED TO
144
+ ANY WARRANTIES OF MERCHANTABILITY,
145
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
146
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT.
147
+ IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE
148
+ FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
149
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT,
150
+ INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
151
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
152
+ ARISING FROM, OUT OF THE USE OR INABILITY TO USE
153
+ THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
154
+
155
+ License: GPL-3+
156
+ License-Reference: /usr/share/common-licenses/GPL-3
@@ -0,0 +1,170 @@
1
+ import math
2
+ import wave
3
+
4
+ import numpy as np
5
+
6
+ SAMPLE_RATE = 44100
7
+ TRACKS = 20
8
+ CHORD_SECONDS = 4.0
9
+ CHORDS = 8
10
+ MUSIC_RMS = 0.04
11
+ CLICK_PEAK = 0.22
12
+ FADE_IN = 1.5
13
+ FADE_OUT = 2.5
14
+
15
+ SCALES = (
16
+ (0, 2, 4, 7, 9),
17
+ (0, 2, 4, 6, 7, 9, 11),
18
+ (0, 2, 4, 5, 7, 9, 10),
19
+ (0, 2, 3, 5, 7, 9, 10),
20
+ (0, 3, 5, 7, 10),
21
+ )
22
+ PROGRESSIONS = (
23
+ (0, 3, 4, 3, 0, 3, 5, 4),
24
+ (0, 5, 3, 4, 0, 5, 3, 1),
25
+ (0, 2, 3, 2, 0, 2, 4, 3),
26
+ (0, 4, 5, 3, 0, 4, 1, 3),
27
+ (0, 1, 3, 4, 0, 1, 5, 4),
28
+ (0, 3, 1, 4, 0, 3, 2, 4),
29
+ )
30
+ BELLS = (
31
+ {'ratios': ((1.0, 1.0), (2.76, 0.28), (5.4, 0.08)), 'decay': 1.6},
32
+ {'ratios': ((1.0, 1.0), (2.0, 0.3), (3.0, 0.1)), 'decay': 0.9},
33
+ {'ratios': ((1.0, 1.0), (2.0, 0.12)), 'decay': 1.3},
34
+ )
35
+
36
+
37
+ def frequency(note):
38
+ return 440.0 * 2.0 ** ((note - 69) / 12.0)
39
+
40
+
41
+ def add_circular(buffer, start, wave_form):
42
+ size = len(buffer)
43
+ start %= size
44
+ first = min(len(wave_form), size - start)
45
+ buffer[start:start + first] += wave_form[:first]
46
+ rest = len(wave_form) - first
47
+ while rest > 0:
48
+ part = min(rest, size)
49
+ buffer[:part] += wave_form[first:first + part]
50
+ first += part
51
+ rest -= part
52
+
53
+
54
+ def smooth(x):
55
+ x = np.clip(x, 0.0, 1.0)
56
+ return x * x * (3.0 - 2.0 * x)
57
+
58
+
59
+ def lowpass(signal, cutoff, rate):
60
+ spectrum = np.fft.rfft(signal)
61
+ freqs = np.fft.rfftfreq(len(signal), 1.0 / rate)
62
+ return np.fft.irfft(spectrum / (1.0 + (freqs / cutoff) ** 2), len(signal))
63
+
64
+
65
+ def chord_notes(scale, root, degree, seventh):
66
+ steps = [degree, degree + 2, degree + 4] + ([degree + 6] if seventh else [])
67
+ notes = []
68
+ for step in steps:
69
+ octave, index = divmod(step, len(scale))
70
+ note = root + scale[index] + 12 * octave
71
+ while note > 69:
72
+ note -= 12
73
+ notes.append(note)
74
+ return notes
75
+
76
+
77
+ def pad_voice(note, length, rate, detune):
78
+ t = np.arange(length) / rate
79
+ f = frequency(note) * (1.0 + detune)
80
+ return np.sin(2 * math.pi * f * t) + 0.35 * np.sin(4 * math.pi * f * t) + 0.12 * np.sin(6 * math.pi * f * t)
81
+
82
+
83
+ def bell_voice(note, bell, rate):
84
+ length = int(rate * bell['decay'] * 5)
85
+ t = np.arange(length) / rate
86
+ voice = sum(weight * np.sin(2 * math.pi * frequency(note) * ratio * t) for ratio, weight in bell['ratios'])
87
+ envelope = np.exp(-t / bell['decay']) * smooth(t / 0.006)
88
+ return voice * envelope
89
+
90
+
91
+ def render_loop(index, rate=SAMPLE_RATE):
92
+ rng = np.random.RandomState(7000 + index)
93
+ scale = SCALES[index % len(SCALES)]
94
+ progression = PROGRESSIONS[rng.randint(len(PROGRESSIONS))]
95
+ root = int(rng.choice((48, 50, 51, 53, 55, 57)))
96
+ bell = BELLS[rng.randint(len(BELLS))]
97
+ cutoff = float(rng.uniform(1400, 2600))
98
+ density = float(rng.uniform(0.25, 0.7))
99
+ bass = bool(rng.randint(2))
100
+ seventh = bool(rng.randint(2))
101
+ chord_len = int(CHORD_SECONDS * rate)
102
+ size = chord_len * CHORDS
103
+ low, high, bells = np.zeros(size), np.zeros(size), np.zeros(size)
104
+ attack = smooth(np.arange(int(1.4 * rate)) / (1.4 * rate))
105
+ tail = int(1.8 * rate)
106
+ for chord, degree in enumerate(progression):
107
+ start = chord * chord_len
108
+ length = chord_len + tail
109
+ t = np.arange(length) / rate
110
+ envelope = np.ones(length)
111
+ envelope[:len(attack)] = attack
112
+ envelope[chord_len:] = 1.0 - smooth((t[chord_len:] - CHORD_SECONDS) / 1.8)
113
+ notes = chord_notes(scale, root, degree, seventh)
114
+ for note in notes:
115
+ add_circular(low, start, pad_voice(note, length, rate, -0.0016) * envelope)
116
+ add_circular(high, start, pad_voice(note, length, rate, 0.0016) * envelope)
117
+ if bass:
118
+ add_circular(low, start, pad_voice(notes[0] - 12, length, rate, 0.0) * envelope * 0.9)
119
+ add_circular(high, start, pad_voice(notes[0] - 12, length, rate, 0.0) * envelope * 0.9)
120
+ for beat in range(8):
121
+ if rng.rand() < density:
122
+ step = int(rng.randint(len(scale) * 2))
123
+ octave, position = divmod(step, len(scale))
124
+ note = root + 24 + scale[position] + 12 * (octave - 1)
125
+ add_circular(bells, start + int(beat * 0.5 * rate), bell_voice(note, bell, rate) * 0.5)
126
+ echo_left = int(rng.choice((0.33, 0.375, 0.42)) * rate)
127
+ echo_right = int(rng.choice((0.5, 0.56, 0.62)) * rate)
128
+ wet_left = bells + 0.35 * np.roll(bells, echo_left) + 0.18 * np.roll(bells, 2 * echo_left)
129
+ wet_right = bells + 0.35 * np.roll(bells, echo_right) + 0.18 * np.roll(bells, 2 * echo_right)
130
+ left = lowpass(0.65 * low + 0.35 * high, cutoff, rate) + 0.6 * wet_left
131
+ right = lowpass(0.35 * low + 0.65 * high, cutoff, rate) + 0.6 * wet_right
132
+ stereo = np.stack([left, right], axis=1)
133
+ stereo -= stereo.mean(axis=0)
134
+ return (stereo * (MUSIC_RMS / math.sqrt(float(np.mean(stereo ** 2))))).astype(np.float32)
135
+
136
+
137
+ def click_sound(seed, rate=SAMPLE_RATE):
138
+ rng = np.random.RandomState(seed)
139
+ length = int(0.09 * rate)
140
+ t = np.arange(length) / rate
141
+ pitch = float(rng.uniform(1500, 1900))
142
+ tone = np.sin(2 * math.pi * pitch * t) * np.exp(-t / 0.011)
143
+ thump = np.sin(2 * math.pi * 190 * t) * np.exp(-t / 0.02) * 0.6
144
+ grit = np.diff(rng.uniform(-1, 1, length + 1)) * np.exp(-t / 0.003) * 0.2
145
+ sound = (tone + thump + grit) * smooth(t / 0.0006)
146
+ return (sound / np.max(np.abs(sound)) * CLICK_PEAK).astype(np.float32)
147
+
148
+
149
+ def render(seconds, clicks, track, rate=SAMPLE_RATE):
150
+ total = int(math.ceil(seconds * rate))
151
+ loop = render_loop(track, rate)
152
+ repeats = -(-total // len(loop))
153
+ mix = np.tile(loop, (repeats, 1))[:total].copy()
154
+ t = np.arange(total) / rate
155
+ mix *= (smooth(t / FADE_IN) * smooth((seconds - t) / FADE_OUT))[:, None]
156
+ for number, when in enumerate(clicks):
157
+ start = int(round(when * rate))
158
+ sound = click_sound(100 + number % 5, rate)
159
+ if 0 <= start < total:
160
+ part = sound[:total - start]
161
+ mix[start:start + len(part)] += part[:, None]
162
+ return np.clip(mix, -1.0, 1.0)
163
+
164
+
165
+ def write_wav(path, samples, rate=SAMPLE_RATE):
166
+ with wave.open(str(path), 'wb') as out:
167
+ out.setnchannels(samples.shape[1])
168
+ out.setsampwidth(2)
169
+ out.setframerate(rate)
170
+ out.writeframes((samples * 32767).astype('<i2').tobytes())