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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/bake/presently/export.rb +187 -45
  4. data/bake/presently/recordings.rb +72 -0
  5. data/context/animating-slides.md +25 -1
  6. data/lib/presently/application.rb +157 -30
  7. data/lib/presently/display_view.rb +7 -7
  8. data/lib/presently/environment/application.rb +17 -1
  9. data/lib/presently/export.rb +15 -13
  10. data/lib/presently/export.xrb +4 -0
  11. data/lib/presently/home_view.rb +65 -0
  12. data/lib/presently/page.rb +29 -22
  13. data/lib/presently/playback.rb +71 -0
  14. data/lib/presently/playback.xrb +59 -0
  15. data/lib/presently/presentation.rb +5 -0
  16. data/lib/presently/presenter_view.rb +6 -6
  17. data/lib/presently/recording_view.rb +158 -0
  18. data/lib/presently/recordings/normalizer.rb +183 -0
  19. data/lib/presently/recordings.rb +100 -0
  20. data/lib/presently/slide.rb +35 -3
  21. data/lib/presently/slide_assets.rb +101 -0
  22. data/lib/presently/slide_renderer.rb +2 -1
  23. data/lib/presently/slide_view.rb +23 -0
  24. data/lib/presently/stylesheet.rb +112 -0
  25. data/lib/presently/version.rb +1 -1
  26. data/lib/presently.rb +5 -0
  27. data/public/{code-focus.js → _components/@socketry/presently/Presently/CodeFocus.js} +1 -0
  28. data/public/{slide-scripts.js → _components/@socketry/presently/Presently/Scripts.js} +1 -1
  29. data/public/{slide.js → _components/@socketry/presently/Presently/Slide.js} +70 -13
  30. data/public/_components/@socketry/presently/Presently/SlideRendering.js +109 -0
  31. data/public/_components/@socketry/presently/Presently.js +4 -0
  32. data/public/_components/@socketry/syntax/Syntax/CodeElement.js +116 -37
  33. data/public/_components/@socketry/syntax/Syntax/Language.js +5 -2
  34. data/public/_components/@socketry/syntax/Syntax/Match.js +8 -2
  35. data/public/_components/@socketry/syntax/Syntax/Rule.js +21 -5
  36. data/public/_components/@socketry/syntax/Syntax.js +2 -2
  37. data/public/_components/@socketry/syntax/package.json +6 -5
  38. data/public/_components/@socketry/syntax/readme.md +7 -1
  39. data/public/_components/@socketry/syntax/themes/base/syntax.css +11 -0
  40. data/public/_components/@socketry/syntax/themes/theming.md +11 -0
  41. data/public/_static/index.css +381 -0
  42. data/public/application.js +29 -64
  43. data/public/export.js +1 -3
  44. data/public/playback.js +266 -0
  45. data/public/recorder.js +282 -0
  46. data/readme.md +38 -0
  47. data/releases.md +8 -0
  48. data.tar.gz.sig +0 -0
  49. metadata +34 -8
  50. metadata.gz.sig +0 -0
  51. data/bake/presently/node.rb +0 -47
  52. data/lib/presently/page.xrb +0 -32
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3c2eaa843e93a1ef3f3c97b0e89c80a3ee4484ddbee4c105ca39bc7c324671ea
4
- data.tar.gz: bc27aa664ca531b38da8b807e3fcb438a41c4338a180c1ddd39fc1daa0c549ed
3
+ metadata.gz: 532d867fea4dad3add38479e76432b7eabfe6769d1f45930cf6c3be61174648e
4
+ data.tar.gz: e197012bf34d8708eeb012205e07289a392a2263b29045a4d052239d578c961f
5
5
  SHA512:
6
- metadata.gz: 98959ac207c29fa8a4bf444e0d1e476e584183615d04abed09c8f74cda3a338ed61f9517f67d5e7e5bb40aa624c352100bf1c2f36c61663a81f61ec509fe52d8
7
- data.tar.gz: 30ca2a30e72d1f0c0dbc71b31786349ecd8d8c70eae5d69f29e701e77cd2f39487909a71a4699b2be9b70169cc170065313da35e46394ab92d0b378f3849e38a
6
+ metadata.gz: 5d2bbb05d71ba4939e5a4491ce039bff503fd276f7a18d2d13cfb0e21d07259d4f3748d3c7fb630ae53544afafa0807c688f95e8b2c3954870620600f72692ba
7
+ data.tar.gz: 5d6052ff6a37f309cefc28fd6d9fe5fb15a46fbdf08bd1981959546e398a3a5536b7fdf6377af9459fc613d2d42cdb69ac8f610f64828985afb3884a7d144c48
checksums.yaml.gz.sig CHANGED
Binary file
@@ -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
- # PDF export always requires an HTTP server, regardless of the configured
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
- # Derive the actual base URL from the bound socket address.
67
- base_url = nil
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
- session = client.session(bridge.default_capabilities)
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 = timeout * 1000
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 {path: output}
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
@@ -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 from `slide.js` — scoped to the current slide's body.
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 "uri"
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
- # Handles routing for the display view (`/`), presenter view (`/presenter`),
21
- # and WebSocket connections (`/live`). Creates a shared {PresentationController}
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
- def initialize(delegate, slides_root: "slides", templates_roots: [])
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
- super(delegate)
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 the body view for the given request path.
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 [Live::View | Nil] The view for the path, or `nil` for unknown paths.
67
- def body(request)
68
- case request.path
69
- when "/"
70
- DisplayView.new(controller: controller)
71
- when "/presenter"
72
- PresenterView.new(controller: controller)
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
- # Handle an HTTP request by rendering the appropriate page.
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
- # @returns [Protocol::HTTP::Response] The HTTP response.
79
- def handle(request)
80
- path, query = request.path.split("?", 2)
81
-
82
- if path == "/export"
83
- options = Export.options_from_query(query)
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
- if body = self.body(request)
90
- page = Page.new(title: title, body: body)
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