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 +21 -0
- package/bin/qareel +0 -0
- package/package.json +23 -0
- package/share/qareel/reel/reel.py +892 -0
- package/share/qareel/reel/reel_assets/LICENSE.txt +156 -0
- package/share/qareel/reel/reel_assets/NotoSans-Bold.ttf +0 -0
- package/share/qareel/reel/reel_assets/NotoSans-Regular.ttf +0 -0
- package/share/qareel/reel/reel_sound.py +170 -0
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
|
|
Binary file
|
|
Binary file
|
|
@@ -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())
|