presently 0.16.0 → 0.17.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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/bake/presently/export.rb +187 -45
- data/bake/presently/recordings.rb +72 -0
- data/context/animating-slides.md +25 -1
- data/lib/presently/application.rb +157 -30
- data/lib/presently/display_view.rb +7 -7
- data/lib/presently/environment/application.rb +17 -1
- data/lib/presently/export.rb +15 -13
- data/lib/presently/export.xrb +4 -0
- data/lib/presently/home_view.rb +65 -0
- data/lib/presently/page.rb +29 -22
- data/lib/presently/playback.rb +71 -0
- data/lib/presently/playback.xrb +59 -0
- data/lib/presently/presentation.rb +5 -0
- data/lib/presently/presenter_view.rb +6 -6
- data/lib/presently/recording_view.rb +158 -0
- data/lib/presently/recordings/normalizer.rb +183 -0
- data/lib/presently/recordings.rb +100 -0
- data/lib/presently/slide.rb +35 -3
- data/lib/presently/slide_assets.rb +101 -0
- data/lib/presently/slide_renderer.rb +2 -1
- data/lib/presently/slide_view.rb +23 -0
- data/lib/presently/stylesheet.rb +112 -0
- data/lib/presently/version.rb +1 -1
- data/lib/presently.rb +5 -0
- data/public/{code-focus.js → _components/@socketry/presently/Presently/CodeFocus.js} +1 -0
- data/public/{slide-scripts.js → _components/@socketry/presently/Presently/Scripts.js} +1 -1
- data/public/{slide.js → _components/@socketry/presently/Presently/Slide.js} +70 -13
- data/public/_components/@socketry/presently/Presently/SlideRendering.js +109 -0
- data/public/_components/@socketry/presently/Presently.js +4 -0
- data/public/_components/@socketry/syntax/Syntax/CodeElement.js +116 -37
- data/public/_components/@socketry/syntax/Syntax/Language.js +5 -2
- data/public/_components/@socketry/syntax/Syntax/Match.js +8 -2
- data/public/_components/@socketry/syntax/Syntax/Rule.js +21 -5
- data/public/_components/@socketry/syntax/Syntax.js +2 -2
- data/public/_components/@socketry/syntax/package.json +6 -5
- data/public/_components/@socketry/syntax/readme.md +7 -1
- data/public/_components/@socketry/syntax/themes/base/syntax.css +11 -0
- data/public/_components/@socketry/syntax/themes/theming.md +11 -0
- data/public/_static/index.css +381 -0
- data/public/application.js +29 -64
- data/public/export.js +1 -3
- data/public/playback.js +266 -0
- data/public/recorder.js +282 -0
- data/readme.md +38 -0
- data/releases.md +8 -0
- data.tar.gz.sig +0 -0
- metadata +34 -8
- metadata.gz.sig +0 -0
- data/bake/presently/node.rb +0 -47
- data/lib/presently/page.xrb +0 -32
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 532d867fea4dad3add38479e76432b7eabfe6769d1f45930cf6c3be61174648e
|
|
4
|
+
data.tar.gz: e197012bf34d8708eeb012205e07289a392a2263b29045a4d052239d578c961f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5d2bbb05d71ba4939e5a4491ce039bff503fd276f7a18d2d13cfb0e21d07259d4f3748d3c7fb630ae53544afafa0807c688f95e8b2c3954870620600f72692ba
|
|
7
|
+
data.tar.gz: 5d6052ff6a37f309cefc28fd6d9fe5fb15a46fbdf08bd1981959546e398a3a5536b7fdf6377af9459fc613d2d42cdb69ac8f610f64828985afb3884a7d144c48
|
checksums.yaml.gz.sig
CHANGED
|
Binary file
|
data/bake/presently/export.rb
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
def initialize(context)
|
|
7
7
|
super
|
|
8
8
|
|
|
9
|
+
require "base64"
|
|
9
10
|
require "fileutils"
|
|
10
11
|
require "uri"
|
|
11
12
|
end
|
|
@@ -23,12 +24,7 @@ end
|
|
|
23
24
|
# @parameter timing [Boolean] Include slide duration and elapsed time. Default: `true`.
|
|
24
25
|
# @parameter timeout [Integer] Seconds to wait for the page to signal readiness. Default: `60`.
|
|
25
26
|
def pdf(output: "presentation.pdf", slides_root: "slides", notes: true, speaker: true, timing: true, slide_width_px: 1920, slide_height_px: 1080, notes_height_px: 300, timeout: 60)
|
|
26
|
-
require "async"
|
|
27
|
-
require "async/http/endpoint"
|
|
28
|
-
require "async/service"
|
|
29
|
-
require "async/webdriver"
|
|
30
27
|
require "presently"
|
|
31
|
-
require "presently/environment/application"
|
|
32
28
|
|
|
33
29
|
page_size = Presently::Export::PageSize.new(
|
|
34
30
|
slide_width_px: slide_width_px,
|
|
@@ -43,6 +39,121 @@ def pdf(output: "presentation.pdf", slides_root: "slides", notes: true, speaker:
|
|
|
43
39
|
slide_width_px: slide_width_px, slide_height_px: slide_height_px, notes_height_px: notes_height_px
|
|
44
40
|
)
|
|
45
41
|
|
|
42
|
+
with_browser_session(slides_root: slides_root, timeout: timeout) do |session, base_url|
|
|
43
|
+
session.resize_window(
|
|
44
|
+
page_size.slide_width_px,
|
|
45
|
+
page_size.slide_height_px
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
session.navigate_to("#{base_url}/export?#{query}")
|
|
49
|
+
|
|
50
|
+
# Block until export.js dispatches presently:ready.
|
|
51
|
+
session.execute_async(<<~JS)
|
|
52
|
+
const done = arguments[arguments.length - 1];
|
|
53
|
+
if (window.__PRESENTLY_READY) { done(); return; }
|
|
54
|
+
document.addEventListener('presently:ready', () => done(), {once: true});
|
|
55
|
+
JS
|
|
56
|
+
|
|
57
|
+
pdf_data = session.print(
|
|
58
|
+
background: true,
|
|
59
|
+
margin: {top: 0, bottom: 0, left: 0, right: 0},
|
|
60
|
+
page: {width: page_size.slide_width_cm, height: page_height_cm},
|
|
61
|
+
shrink_to_fit: false
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
FileUtils.mkdir_p(File.dirname(File.expand_path(output)))
|
|
65
|
+
File.write(output, pdf_data, mode: "wb")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
return {path: output}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Export the narrated presentation to an MP4 video.
|
|
72
|
+
#
|
|
73
|
+
# This uses Chromium's experimental `Page.startScreenRecording` DevTools
|
|
74
|
+
# command. Until that command reaches Chrome for Testing, pass matching
|
|
75
|
+
# Chromium and ChromeDriver paths explicitly (or through the
|
|
76
|
+
# `PRESENTLY_CHROME_PATH` and `PRESENTLY_CHROMEDRIVER_PATH` environment
|
|
77
|
+
# variables).
|
|
78
|
+
#
|
|
79
|
+
# @parameter output [String] Output MP4 path. Default: `presentation.mp4`.
|
|
80
|
+
# @parameter slides_root [String] The slides directory. Default: `slides`.
|
|
81
|
+
# @parameter width [Integer] Maximum video width. Default: `1920`.
|
|
82
|
+
# @parameter height [Integer] Maximum video height. Default: `1080`.
|
|
83
|
+
# @parameter frame_rate [Integer] Maximum video frame rate. Default: `30`.
|
|
84
|
+
# @parameter timeout [Integer] Seconds to wait for the playback page to load. Default: `60`.
|
|
85
|
+
# @parameter duration_limit [Integer] Maximum presentation duration in seconds. Default: `3600`.
|
|
86
|
+
# @parameter browser_version [String] Chrome for Testing version used when explicit paths are omitted. Default: `canary`.
|
|
87
|
+
# @parameter browser_path [String | Nil] Path to a Chromium executable supporting screen recording.
|
|
88
|
+
# @parameter driver_path [String | Nil] Path to a matching ChromeDriver executable.
|
|
89
|
+
def video(output: "presentation.mp4", slides_root: "slides", width: 1920, height: 1080, frame_rate: 30, timeout: 60, duration_limit: 3600, browser_version: "canary", browser_path: nil, driver_path: nil)
|
|
90
|
+
browser_path ||= ENV["PRESENTLY_CHROME_PATH"]
|
|
91
|
+
driver_path ||= ENV["PRESENTLY_CHROMEDRIVER_PATH"]
|
|
92
|
+
|
|
93
|
+
with_browser_session(
|
|
94
|
+
slides_root: slides_root,
|
|
95
|
+
timeout: timeout,
|
|
96
|
+
script_timeout: duration_limit,
|
|
97
|
+
browser_version: browser_version,
|
|
98
|
+
browser_path: browser_path,
|
|
99
|
+
driver_path: driver_path,
|
|
100
|
+
chrome_arguments: ["--autoplay-policy=no-user-gesture-required", "--hide-scrollbars"]
|
|
101
|
+
) do |session, base_url|
|
|
102
|
+
resize_viewport(session, width, height)
|
|
103
|
+
session.navigate_to("#{base_url}/playback?autoplay=false&controls=false")
|
|
104
|
+
|
|
105
|
+
ready = session.execute_async(<<~JS)
|
|
106
|
+
const done = arguments[arguments.length - 1];
|
|
107
|
+
if (window.__PRESENTLY_PLAYBACK_ERROR) { done({error: window.__PRESENTLY_PLAYBACK_ERROR}); return; }
|
|
108
|
+
if (window.__PRESENTLY_PLAYBACK_READY) { done({ready: true}); return; }
|
|
109
|
+
document.addEventListener('presently:playback-error', event => done({error: event.detail.message}), {once: true});
|
|
110
|
+
document.addEventListener('presently:playback-ready', () => done({ready: true}), {once: true});
|
|
111
|
+
JS
|
|
112
|
+
|
|
113
|
+
raise "Playback preparation failed: #{ready["error"]}" if ready["error"]
|
|
114
|
+
|
|
115
|
+
recording_started = false
|
|
116
|
+
stream = nil
|
|
117
|
+
playback = nil
|
|
118
|
+
|
|
119
|
+
begin
|
|
120
|
+
devtools(session, "Page.startScreenRecording", audio: true, maxWidth: width, maxHeight: height, frameRate: frame_rate)
|
|
121
|
+
recording_started = true
|
|
122
|
+
|
|
123
|
+
session.execute("window.__PRESENTLY_PLAYBACK_START();")
|
|
124
|
+
playback = session.execute_async(<<~JS)
|
|
125
|
+
const done = arguments[arguments.length - 1];
|
|
126
|
+
if (window.__PRESENTLY_PLAYBACK_ERROR) { done({error: window.__PRESENTLY_PLAYBACK_ERROR}); return; }
|
|
127
|
+
if (window.__PRESENTLY_PLAYBACK_FINISHED) { done({finished: true}); return; }
|
|
128
|
+
document.addEventListener('presently:playback-error', event => done({error: event.detail.message}), {once: true});
|
|
129
|
+
document.addEventListener('presently:playback-finished', () => done({finished: true}), {once: true});
|
|
130
|
+
JS
|
|
131
|
+
ensure
|
|
132
|
+
if recording_started
|
|
133
|
+
stream = devtools(session, "Page.stopScreenRecording")["stream"]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
raise "Playback failed: #{playback["error"]}" if playback&.dig("error")
|
|
138
|
+
raise "Chromium did not return the recorded video stream!" unless stream
|
|
139
|
+
|
|
140
|
+
write_devtools_stream(session, stream, output)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
return {path: output}
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
def with_browser_session(slides_root:, timeout:, script_timeout: timeout, browser_version: "stable", browser_path: nil, driver_path: nil, chrome_arguments: [])
|
|
149
|
+
require "async"
|
|
150
|
+
require "async/http/endpoint"
|
|
151
|
+
require "async/service"
|
|
152
|
+
require "async/webdriver"
|
|
153
|
+
require "presently/environment/application"
|
|
154
|
+
|
|
155
|
+
result = nil
|
|
156
|
+
|
|
46
157
|
Async do |task|
|
|
47
158
|
# Bind port 0 first so we know the actual address before starting the server.
|
|
48
159
|
bound_endpoint = Async::HTTP::Endpoint.parse("http://localhost:0").bound
|
|
@@ -55,7 +166,7 @@ def pdf(output: "presentation.pdf", slides_root: "slides", notes: true, speaker:
|
|
|
55
166
|
endpoint: Async::HTTP::Endpoint.parse("http://localhost", bound_endpoint)
|
|
56
167
|
)
|
|
57
168
|
|
|
58
|
-
#
|
|
169
|
+
# Export always requires an HTTP server, regardless of the configured
|
|
59
170
|
# Lively transport:
|
|
60
171
|
transport = environment.with(environment.evaluator.http_environment)
|
|
61
172
|
evaluator = transport.evaluator
|
|
@@ -63,53 +174,22 @@ def pdf(output: "presentation.pdf", slides_root: "slides", notes: true, speaker:
|
|
|
63
174
|
server_task = task.async{server.run}
|
|
64
175
|
|
|
65
176
|
begin
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
bound_endpoint.local_address_endpoint.each do |endpoint|
|
|
69
|
-
address = endpoint.address
|
|
70
|
-
host = address.ipv6? ? "[#{address.ip_address}]" : address.ip_address
|
|
71
|
-
base_url = "http://#{host}:#{address.ip_port}"
|
|
72
|
-
break
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
export_url = "#{base_url}/export?#{query}"
|
|
76
|
-
|
|
77
|
-
bridge = Async::WebDriver::Bridge::Chrome.for(:stable)
|
|
177
|
+
base_url = export_base_url(bound_endpoint)
|
|
178
|
+
bridge = chrome_bridge(browser_version, browser_path, driver_path)
|
|
78
179
|
driver = bridge.start
|
|
79
180
|
|
|
80
181
|
begin
|
|
81
182
|
client = Async::WebDriver::Client.open(driver.endpoint)
|
|
82
183
|
|
|
83
184
|
begin
|
|
84
|
-
|
|
185
|
+
capabilities = bridge.default_capabilities
|
|
186
|
+
capabilities[:alwaysMatch][:"goog:chromeOptions"][:args].concat(chrome_arguments)
|
|
187
|
+
session = client.session(capabilities)
|
|
85
188
|
|
|
86
189
|
begin
|
|
87
|
-
session.resize_window(
|
|
88
|
-
page_size.slide_width_px,
|
|
89
|
-
page_size.slide_height_px
|
|
90
|
-
)
|
|
91
|
-
|
|
92
190
|
session.page_load_timeout = timeout * 1000
|
|
93
|
-
session.script_timeout =
|
|
94
|
-
|
|
95
|
-
session.navigate_to(export_url)
|
|
96
|
-
|
|
97
|
-
# Block until export.js dispatches presently:ready.
|
|
98
|
-
session.execute_async(<<~JS)
|
|
99
|
-
const done = arguments[arguments.length - 1];
|
|
100
|
-
if (window.__PRESENTLY_READY) { done(); return; }
|
|
101
|
-
document.addEventListener('presently:ready', () => done(), {once: true});
|
|
102
|
-
JS
|
|
103
|
-
|
|
104
|
-
pdf_data = session.print(
|
|
105
|
-
background: true,
|
|
106
|
-
margin: {top: 0, bottom: 0, left: 0, right: 0},
|
|
107
|
-
page: {width: page_size.slide_width_cm, height: page_height_cm},
|
|
108
|
-
shrink_to_fit: false
|
|
109
|
-
)
|
|
110
|
-
|
|
111
|
-
FileUtils.mkdir_p(File.dirname(File.expand_path(output)))
|
|
112
|
-
File.write(output, pdf_data, mode: "wb")
|
|
191
|
+
session.script_timeout = script_timeout * 1000
|
|
192
|
+
result = yield(session, base_url)
|
|
113
193
|
ensure
|
|
114
194
|
session.close
|
|
115
195
|
end
|
|
@@ -128,5 +208,67 @@ def pdf(output: "presentation.pdf", slides_root: "slides", notes: true, speaker:
|
|
|
128
208
|
end
|
|
129
209
|
end
|
|
130
210
|
|
|
131
|
-
return
|
|
211
|
+
return result
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def chrome_bridge(browser_version, browser_path, driver_path)
|
|
215
|
+
if browser_path || driver_path
|
|
216
|
+
options = {}
|
|
217
|
+
options[:browser_path] = File.expand_path(browser_path) if browser_path
|
|
218
|
+
options[:driver_path] = File.expand_path(driver_path) if driver_path
|
|
219
|
+
|
|
220
|
+
Async::WebDriver::Bridge::Chrome.new(**options)
|
|
221
|
+
else
|
|
222
|
+
Async::WebDriver::Bridge::Chrome.for(browser_version.to_sym)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def export_base_url(bound_endpoint)
|
|
227
|
+
bound_endpoint.local_address_endpoint.each do |endpoint|
|
|
228
|
+
address = endpoint.address
|
|
229
|
+
host = address.ipv6? ? "[#{address.ip_address}]" : address.ip_address
|
|
230
|
+
return "http://#{host}:#{address.ip_port}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
raise "Could not determine the export server address!"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def resize_viewport(session, width, height)
|
|
237
|
+
# WebDriver sizes the outer window, while screen recording captures the page
|
|
238
|
+
# viewport. Measure the browser decoration after an initial resize and
|
|
239
|
+
# compensate so the recorded video has the requested dimensions.
|
|
240
|
+
session.resize_window(width, height)
|
|
241
|
+
dimensions = session.execute(<<~JS)
|
|
242
|
+
return {outerWidth, outerHeight, innerWidth, innerHeight};
|
|
243
|
+
JS
|
|
244
|
+
|
|
245
|
+
session.resize_window(
|
|
246
|
+
width + dimensions["outerWidth"] - dimensions["innerWidth"],
|
|
247
|
+
height + dimensions["outerHeight"] - dimensions["innerHeight"]
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def devtools(session, command, **parameters)
|
|
252
|
+
session.post("goog/cdp/execute", {cmd: command, params: parameters})
|
|
253
|
+
rescue Async::WebDriver::Error => error
|
|
254
|
+
raise "Chromium does not support #{command}: #{error.message}"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def write_devtools_stream(session, stream, output)
|
|
258
|
+
path = File.expand_path(output)
|
|
259
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
260
|
+
|
|
261
|
+
begin
|
|
262
|
+
File.open(path, "wb") do |file|
|
|
263
|
+
loop do
|
|
264
|
+
chunk = devtools(session, "IO.read", handle: stream, size: 1024 * 1024)
|
|
265
|
+
data = chunk["data"]
|
|
266
|
+
data = Base64.strict_decode64(data) if chunk["base64Encoded"]
|
|
267
|
+
file.write(data)
|
|
268
|
+
break if chunk["eof"]
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
ensure
|
|
272
|
+
devtools(session, "IO.close", handle: stream)
|
|
273
|
+
end
|
|
132
274
|
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
def initialize(context)
|
|
7
|
+
super
|
|
8
|
+
|
|
9
|
+
require "pathname"
|
|
10
|
+
require "presently/recordings/normalizer"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Normalize all completed slide recordings to a consistent loudness.
|
|
14
|
+
#
|
|
15
|
+
# Original recordings are preserved under `input_root`. Normalized WebM/Opus
|
|
16
|
+
# files are written to the corresponding paths under `output_root`. Existing
|
|
17
|
+
# outputs newer than their source are skipped unless `force` is enabled.
|
|
18
|
+
#
|
|
19
|
+
# @parameter input_root [String] Source recordings directory. Default: `audio`.
|
|
20
|
+
# @parameter output_root [String] Normalized recordings directory. Default: `audio-normalized`.
|
|
21
|
+
# @parameter integrated_loudness [Float] Target integrated loudness in LUFS. Default: `-16.0`.
|
|
22
|
+
# @parameter true_peak [Float] Maximum true peak in dBTP. Default: `-1.5`.
|
|
23
|
+
# @parameter loudness_range [Float] Target loudness range in LU. Default: `11.0`.
|
|
24
|
+
# @parameter bitrate [String] Output Opus bitrate. Default: `128k`.
|
|
25
|
+
# @parameter force [Boolean] Normalize recordings even when output is current. Default: `false`.
|
|
26
|
+
# @parameter command [String] FFmpeg executable. Default: `ffmpeg`.
|
|
27
|
+
def normalize(input_root: "audio", output_root: "audio-normalized", integrated_loudness: -16.0, true_peak: -1.5, loudness_range: 11.0, bitrate: "128k", force: false, command: "ffmpeg")
|
|
28
|
+
input_root = File.expand_path(input_root)
|
|
29
|
+
output_root = File.expand_path(output_root)
|
|
30
|
+
|
|
31
|
+
if input_root == output_root
|
|
32
|
+
raise ArgumentError, "output_root must be different from input_root so originals are preserved!"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
files = Dir.glob(File.join(input_root, "**", "*.webm")).sort
|
|
36
|
+
if files.empty?
|
|
37
|
+
puts "No WebM recordings found under #{input_root}."
|
|
38
|
+
return {processed: 0, skipped: 0, output_root: output_root}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
normalizer = Presently::Recordings::Normalizer.new(command: command)
|
|
42
|
+
processed = 0
|
|
43
|
+
skipped = 0
|
|
44
|
+
|
|
45
|
+
files.each do |input_path|
|
|
46
|
+
relative_path = Pathname.new(input_path).relative_path_from(Pathname.new(input_root)).to_s
|
|
47
|
+
output_path = File.join(output_root, relative_path)
|
|
48
|
+
|
|
49
|
+
if !force && File.file?(output_path) && File.mtime(output_path) >= File.mtime(input_path)
|
|
50
|
+
puts "Skipping #{relative_path} (already normalized)."
|
|
51
|
+
skipped += 1
|
|
52
|
+
next
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
print "Normalizing #{relative_path}... "
|
|
56
|
+
measurements = normalizer.normalize(
|
|
57
|
+
input_path,
|
|
58
|
+
output_path,
|
|
59
|
+
integrated_loudness: integrated_loudness,
|
|
60
|
+
true_peak: true_peak,
|
|
61
|
+
loudness_range: loudness_range,
|
|
62
|
+
bitrate: bitrate,
|
|
63
|
+
)
|
|
64
|
+
input = measurements.fetch(:input)
|
|
65
|
+
output = measurements.fetch(:output)
|
|
66
|
+
puts "#{input.fetch("input_i")} LUFS → #{output.fetch("input_i")} LUFS, #{output.fetch("input_tp")} dBTP"
|
|
67
|
+
processed += 1
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
puts "Normalized #{processed} recording#{"s" unless processed == 1}; skipped #{skipped}."
|
|
71
|
+
return {processed: processed, skipped: skipped, output_root: output_root}
|
|
72
|
+
end
|
data/context/animating-slides.md
CHANGED
|
@@ -25,7 +25,7 @@ slide.find("li").show(1)
|
|
|
25
25
|
```
|
|
26
26
|
~~~
|
|
27
27
|
|
|
28
|
-
The script receives a `slide` object — an instance of the `Slide` class
|
|
28
|
+
The script receives a `slide` object — an instance of the `Slide` class exported by `@socketry/presently` — scoped to the current slide's body.
|
|
29
29
|
|
|
30
30
|
If the script contains a syntax error or throws an exception, the error is logged to the browser console and the presentation continues unaffected.
|
|
31
31
|
|
|
@@ -194,6 +194,30 @@ All timeouts registered via `slide.after()` (and the underlying `slide.setTimeou
|
|
|
194
194
|
|
|
195
195
|
The global `setTimeout` in slide scripts is also automatically tracked — you can use it directly and it will be cancelled on slide change.
|
|
196
196
|
|
|
197
|
+
## Slide Lifetime and Cleanup
|
|
198
|
+
|
|
199
|
+
Slide scripts can start resources other than timers, including audio, video, observers, animations, and event listeners. Use `slide.defer(callback)` to release those resources when the slide is deactivated:
|
|
200
|
+
|
|
201
|
+
``` javascript
|
|
202
|
+
const audio = new Audio("introduction.mp3")
|
|
203
|
+
audio.play()
|
|
204
|
+
|
|
205
|
+
slide.defer(() => {
|
|
206
|
+
audio.pause()
|
|
207
|
+
})
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Deferred callbacks run in reverse registration order when the slide changes. Registering a callback after the slide has already been deactivated runs it immediately, preventing asynchronously-created resources from escaping the slide's lifetime.
|
|
211
|
+
|
|
212
|
+
The `slide.signal` getter exposes an `AbortSignal` which is aborted at the same time. Browser APIs which support abort signals can therefore be bound directly to the slide:
|
|
213
|
+
|
|
214
|
+
``` javascript
|
|
215
|
+
const button = slide.element.querySelector("button")
|
|
216
|
+
button.addEventListener("click", handleClick, {signal: slide.signal})
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Aborting the signal removes this event listener; it does not invoke `handleClick`. Use `slide.defer(...)` for resources which do not accept an `AbortSignal`.
|
|
220
|
+
|
|
197
221
|
## Looping Animations with `slide.loop()`
|
|
198
222
|
|
|
199
223
|
To repeat an animation indefinitely, use `slide.loop()`. The callback receives a fresh `SlideContext` each iteration and can use `after()` to schedule steps in the same way as a regular chain. The loop waits for all steps to complete and then restarts, with an optional extra pause between iterations.
|
|
@@ -4,12 +4,17 @@
|
|
|
4
4
|
# Copyright, 2026, by Samuel Williams.
|
|
5
5
|
|
|
6
6
|
require "lively"
|
|
7
|
-
require "
|
|
7
|
+
require "protocol/url"
|
|
8
8
|
|
|
9
9
|
require_relative "presentation"
|
|
10
10
|
require_relative "presentation_controller"
|
|
11
|
+
require_relative "home_view"
|
|
11
12
|
require_relative "display_view"
|
|
12
13
|
require_relative "presenter_view"
|
|
14
|
+
require_relative "recording_view"
|
|
15
|
+
require_relative "recordings"
|
|
16
|
+
require_relative "slide_assets"
|
|
17
|
+
require_relative "playback"
|
|
13
18
|
require_relative "export"
|
|
14
19
|
require_relative "page"
|
|
15
20
|
require_relative "state"
|
|
@@ -17,25 +22,29 @@ require_relative "state"
|
|
|
17
22
|
module Presently
|
|
18
23
|
# Represents the main Presently application middleware.
|
|
19
24
|
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
# that keeps all connected clients in sync.
|
|
25
|
+
# Routes the presentation, recording, playback, export, and live interfaces.
|
|
26
|
+
# Creates a shared {PresentationController} that keeps connected clients in sync.
|
|
23
27
|
class Application < Lively::Application
|
|
24
28
|
# Initialize a new Presently application.
|
|
25
29
|
# @parameter delegate [Protocol::HTTP::Middleware] The next middleware in the chain.
|
|
26
30
|
# @parameter slides_root [String] The directory containing slide files.
|
|
27
31
|
# @parameter templates_roots [Array(String)] Additional directories to search for templates.
|
|
28
|
-
|
|
32
|
+
# @parameter recordings_root [String | Nil] Directory where source narration is stored. Defaults to `audio` beside the slides directory.
|
|
33
|
+
# @parameter playback_recordings_root [String | Nil] Directory containing normalized narration. Defaults to `audio-normalized` beside the slides directory.
|
|
34
|
+
def initialize(delegate, slides_root: "slides", templates_roots: [], recordings_root: nil, playback_recordings_root: nil)
|
|
29
35
|
@slides_root = slides_root
|
|
30
36
|
@templates_roots = templates_roots
|
|
37
|
+
@recordings = Recordings.new(recordings_root || File.expand_path("../audio", slides_root))
|
|
38
|
+
@playback_recordings = Recordings.new(playback_recordings_root || File.expand_path("../audio-normalized", slides_root))
|
|
31
39
|
|
|
32
|
-
|
|
40
|
+
slide_assets = SlideAssets.new(delegate, root: @slides_root, stylesheets: ->{controller.presentation.stylesheets})
|
|
41
|
+
super(slide_assets)
|
|
33
42
|
end
|
|
34
43
|
|
|
35
44
|
# The view classes that this application allows.
|
|
36
45
|
# @returns [Array(Class)] The allowed view classes.
|
|
37
46
|
def allowed_views
|
|
38
|
-
[DisplayView, PresenterView]
|
|
47
|
+
[HomeView, DisplayView, PresenterView, RecordingView]
|
|
39
48
|
end
|
|
40
49
|
|
|
41
50
|
# The shared state passed to all views via the resolver.
|
|
@@ -61,37 +70,155 @@ module Presently
|
|
|
61
70
|
"Presently"
|
|
62
71
|
end
|
|
63
72
|
|
|
64
|
-
# Create
|
|
73
|
+
# Create a Presently page with the presentation-specific stylesheets.
|
|
74
|
+
# @parameter view [Live::View] The root view for the page.
|
|
75
|
+
# @returns [Page] The presentation page.
|
|
76
|
+
def make_page(view)
|
|
77
|
+
stylesheets = controller.presentation.stylesheets.map(&:url)
|
|
78
|
+
Page.new(title: title, body: view, stylesheets: stylesheets)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Add Presently's application routes.
|
|
82
|
+
# @parameter router [Lively::Router::Builder] The router to configure.
|
|
83
|
+
def configure_routes(router)
|
|
84
|
+
router.get("/") do
|
|
85
|
+
body = resolver.root(HomeView)
|
|
86
|
+
Page.new(title: title, body: body).call
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
router.get("/display"){make_page(resolver.root(DisplayView)).call}
|
|
90
|
+
router.get("/presenter"){make_page(resolver.root(PresenterView)).call}
|
|
91
|
+
router.get("/record"){make_page(resolver.root(RecordingView)).call}
|
|
92
|
+
|
|
93
|
+
router.route("/recordings", methods: ["GET", "HEAD", "PUT"]) do |request|
|
|
94
|
+
handle_recording(request, request_parameters(request))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
router.route("/playback/recordings", methods: ["GET", "HEAD"]) do |request|
|
|
98
|
+
handle_playback_recording(request, request_parameters(request))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
router.get("/playback") do |request|
|
|
102
|
+
render_playback(request_parameters(request))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
router.get("/export") do |request|
|
|
106
|
+
render_export(request_parameters(request))
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# Parse query parameters from the incoming request target.
|
|
65
113
|
# @parameter request [Protocol::HTTP::Request] The incoming request.
|
|
66
|
-
# @returns [
|
|
67
|
-
def
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
114
|
+
# @returns [Hash] The decoded query parameters.
|
|
115
|
+
def request_parameters(request)
|
|
116
|
+
Protocol::URL::Reference[request.path].parse_query!
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Render the narrated playback interface.
|
|
120
|
+
def render_playback(parameters)
|
|
121
|
+
presentation = Presentation.load(@slides_root, controller.templates)
|
|
122
|
+
recording_urls = presentation.slides.each_index.map do |index|
|
|
123
|
+
slide = presentation.slides[index]
|
|
124
|
+
if @playback_recordings.exist?(slide) || @recordings.exist?(slide)
|
|
125
|
+
"/playback/recordings?index=#{index}"
|
|
126
|
+
end
|
|
73
127
|
end
|
|
128
|
+
|
|
129
|
+
playback = Playback.new(
|
|
130
|
+
presentation: presentation,
|
|
131
|
+
recording_urls: recording_urls,
|
|
132
|
+
**Playback.options_from_parameters(parameters),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
Protocol::HTTP::Response[200, [["content-type", "text/html"]], [playback.call]]
|
|
74
136
|
end
|
|
75
137
|
|
|
76
|
-
#
|
|
138
|
+
# Render the printable export interface.
|
|
139
|
+
def render_export(parameters)
|
|
140
|
+
presentation = Presentation.load(@slides_root, controller.templates)
|
|
141
|
+
export = Export.new(presentation: presentation, **Export.options_from_parameters(parameters))
|
|
142
|
+
|
|
143
|
+
Protocol::HTTP::Response[200, [["content-type", "text/html"]], [export.call]]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Handle reading and writing a slide recording.
|
|
147
|
+
# @parameter request [Protocol::HTTP::Request] The incoming request.
|
|
148
|
+
# @parameter parameters [Hash] The decoded query parameters.
|
|
149
|
+
# @returns [Protocol::HTTP::Response]
|
|
150
|
+
def handle_recording(request, parameters)
|
|
151
|
+
index = recording_index(parameters)
|
|
152
|
+
return Protocol::HTTP::Response[400, [], ["A valid slide index is required."]] unless index
|
|
153
|
+
|
|
154
|
+
slide = controller.slides[index]
|
|
155
|
+
return Protocol::HTTP::Response[404, [], ["Slide not found."]] unless slide
|
|
156
|
+
|
|
157
|
+
case request.method
|
|
158
|
+
when "GET", "HEAD"
|
|
159
|
+
serve_recording(request, slide, @recordings)
|
|
160
|
+
when "PUT"
|
|
161
|
+
store_recording(request, slide)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Serve normalized narration for playback, falling back to the source take.
|
|
166
|
+
def handle_playback_recording(request, parameters)
|
|
167
|
+
index = recording_index(parameters)
|
|
168
|
+
return Protocol::HTTP::Response[400, [], ["A valid slide index is required."]] unless index
|
|
169
|
+
|
|
170
|
+
slide = controller.slides[index]
|
|
171
|
+
return Protocol::HTTP::Response[404, [], ["Slide not found."]] unless slide
|
|
172
|
+
|
|
173
|
+
recordings = @playback_recordings.exist?(slide) ? @playback_recordings : @recordings
|
|
174
|
+
serve_recording(request, slide, recordings)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Extract the slide index from decoded query parameters.
|
|
178
|
+
# @parameter parameters [Hash] The decoded query parameters.
|
|
179
|
+
# @returns [Integer | Nil]
|
|
180
|
+
def recording_index(parameters)
|
|
181
|
+
Integer(parameters.fetch("index"))
|
|
182
|
+
rescue ArgumentError, KeyError
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Serve an existing recording.
|
|
187
|
+
# @parameter request [Protocol::HTTP::Request] The incoming request.
|
|
188
|
+
# @parameter slide [Slide] The requested slide.
|
|
189
|
+
# @returns [Protocol::HTTP::Response]
|
|
190
|
+
def serve_recording(request, slide, recordings)
|
|
191
|
+
unless recordings.exist?(slide)
|
|
192
|
+
return Protocol::HTTP::Response[404, [], ["Recording not found."]]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
headers = [
|
|
196
|
+
["content-type", Recordings::CONTENT_TYPE],
|
|
197
|
+
["cache-control", "no-store"],
|
|
198
|
+
]
|
|
199
|
+
body = recordings.read(slide) unless request.method == "HEAD"
|
|
200
|
+
|
|
201
|
+
Protocol::HTTP::Response[200, headers, body]
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Store an uploaded recording.
|
|
77
205
|
# @parameter request [Protocol::HTTP::Request] The incoming request.
|
|
78
|
-
# @
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
presentation = Presentation.load(@slides_root, controller.templates)
|
|
85
|
-
export = Export.new(presentation: presentation, **options)
|
|
86
|
-
return Protocol::HTTP::Response[200, [["content-type", "text/html"]], [export.call]]
|
|
206
|
+
# @parameter slide [Slide] The slide being recorded.
|
|
207
|
+
# @returns [Protocol::HTTP::Response]
|
|
208
|
+
def store_recording(request, slide)
|
|
209
|
+
content_type = request.headers["content-type"]&.split(";", 2)&.first
|
|
210
|
+
unless content_type == Recordings::CONTENT_TYPE
|
|
211
|
+
return Protocol::HTTP::Response[415, [], ["Expected #{Recordings::CONTENT_TYPE}."]]
|
|
87
212
|
end
|
|
88
213
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return Protocol::HTTP::Response[200, [], [page.call]]
|
|
92
|
-
else
|
|
93
|
-
return Protocol::HTTP::Response[404, [], ["Not Found"]]
|
|
214
|
+
unless request.body
|
|
215
|
+
return Protocol::HTTP::Response[400, [], ["A recording body is required."]]
|
|
94
216
|
end
|
|
217
|
+
|
|
218
|
+
@recordings.write(slide, request.body)
|
|
219
|
+
Protocol::HTTP::Response[201, [["content-type", "application/json"]], ["{\"saved\":true}"]]
|
|
220
|
+
rescue Recordings::TooLarge => error
|
|
221
|
+
Protocol::HTTP::Response[413, [], [error.message]]
|
|
95
222
|
end
|
|
96
223
|
end
|
|
97
224
|
end
|