Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions bake/presently/export.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,44 @@ def initialize(context)
require "uri"
end

# Export narrated playback as a portable static HTML directory.
#
# The output contains `index.html`, all browser-side dependencies, slide
# assets, presentation-specific public assets, and one narration track per
# slide. Normalized recordings are preferred when available, with the source
# recording used as a fallback.
#
# Serve the resulting directory with any static HTTP server. Opening the file
# directly is not supported because browsers restrict JavaScript modules under
# `file://` URLs.
#
# @parameter output [String] Output directory. Default: `presentation`.
# @parameter slides_root [String] The slides directory. Default: `slides`.
# @parameter templates_root [String] Presentation-specific slide templates directory. Default: `templates`.
# @parameter recordings_root [String] Source recordings directory. Default: `audio`.
# @parameter playback_recordings_root [String] Normalized recordings directory. Default: `audio-normalized`.
# @parameter public_root [String] Presentation-specific public assets directory. Default: `public`.
# @parameter force [Boolean] Replace an existing output directory. Default: `false`.
def html(output: "presentation", slides_root: "slides", templates_root: "templates", recordings_root: "audio", playback_recordings_root: "audio-normalized", public_root: "public", force: false)
require "presently/html_export"
require "presently/presentation"

template_roots = [File.expand_path(templates_root)].select{|root| File.directory?(root)}
templates = Presently::Templates.for(template_roots)
presentation = Presently::Presentation.load(slides_root, templates)
public_roots = Presently::HTMLExport.public_roots(File.directory?(public_root) ? public_root : nil)
export = Presently::HTMLExport.new(
presentation: presentation,
recordings_root: recordings_root,
playback_recordings_root: playback_recordings_root,
public_roots: public_roots,
)

path = export.write(output, force: force)
puts "Exported static playback to #{path}"
return {path: path}
end

# Export the presentation to a PDF file.
#
# Starts a Presently server in-process, opens a headless Chrome browser,
Expand Down
1 change: 1 addition & 0 deletions lib/presently.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@
require_relative "presently/recordings/normalizer"
require_relative "presently/recording_view"
require_relative "presently/playback"
require_relative "presently/html_export"
require_relative "presently/application"
require_relative "presently/environment/application"
176 changes: 176 additions & 0 deletions lib/presently/html_export.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2026, by Samuel Williams.

require "fileutils"
require "tmpdir"
require "protocol/media/registry"

require_relative "playback"
require_relative "recordings"

module Presently
# Exports narrated playback as a directory of static HTML and supporting assets.
class HTMLExport
# Raised when one or more slides do not have narration to export.
class MissingRecordings < StandardError
# @parameter paths [Array(String)] Slide paths without narration.
def initialize(paths)
@paths = paths
super("Missing narration for: #{paths.join(", ")}")
end

# @attribute [Array(String)] Slide paths without narration.
attr :paths
end

# The public assets bundled with Presently.
PUBLIC_ROOT = File.expand_path("../../public", __dir__)

# Build the ordered public asset roots used by the Presently server.
# Later roots replace files from earlier roots.
# @parameter public_root [String | Nil] The presentation-specific public directory.
# @returns [Array(String)] Public directories in increasing precedence.
def self.public_roots(public_root = nil)
require "lively"

lively_root = File.join(Gem.loaded_specs.fetch("lively").full_gem_path, "public")
roots = [lively_root, PUBLIC_ROOT]
roots << File.expand_path(public_root) if public_root
roots
end

# @parameter presentation [Presentation] The presentation to export.
# @parameter recordings_root [String | Nil] Source narration directory.
# @parameter playback_recordings_root [String | Nil] Normalized narration directory.
# @parameter public_roots [Array(String)] Ordered public asset directories.
def initialize(presentation:, recordings_root: nil, playback_recordings_root: nil, public_roots: self.class.public_roots)
@presentation = presentation
@recordings = Recordings.new(recordings_root || File.expand_path("../audio", presentation.root))
@playback_recordings = Recordings.new(playback_recordings_root || File.expand_path("../audio-normalized", presentation.root))
@public_roots = public_roots.map{|root| File.expand_path(root)}
end

# Write the complete static playback directory.
# @parameter output [String] Destination directory.
# @parameter force [Boolean] Replace an existing destination.
# @returns [String] The absolute destination path.
def write(output, force: false)
output = File.expand_path(output)
recording_sources = recording_sources!
validate_destination!(output)

if File.exist?(output) || File.symlink?(output)
raise ArgumentError, "Output already exists: #{output} (pass force: true to replace it)" unless force
raise ArgumentError, "Refusing to replace a symbolic link: #{output}" if File.symlink?(output)
end

parent = File.dirname(output)
FileUtils.mkdir_p(parent)
staging = Dir.mktmpdir(".presently-html-", parent)

begin
copy_public_assets(staging)
copy_slide_assets(staging)
recording_urls = copy_recordings(staging, recording_sources)
write_playback(staging, recording_urls)

FileUtils.rm_rf(output) if File.exist?(output)
FileUtils.mv(staging, output)
ensure
FileUtils.rm_rf(staging) if File.exist?(staging)
end

output
end

private

def recording_sources!
missing = []

sources = @presentation.slides.map do |slide|
if @playback_recordings.exist?(slide)
[@playback_recordings, slide]
elsif @recordings.exist?(slide)
[@recordings, slide]
else
missing << slide.path
nil
end
end

raise MissingRecordings, missing unless missing.empty?

sources
end

def validate_destination!(output)
sources = [@presentation.root, @recordings.root, @playback_recordings.root, *@public_roots]
separator = File::SEPARATOR

sources.each do |source|
source = File.expand_path(source)
if source == output || source.start_with?(output + separator) || output.start_with?(source + separator)
raise ArgumentError, "Output must not overlap source directory: #{source}"
end
end
end

def copy_public_assets(output)
@public_roots.each do |root|
next unless File.directory?(root)

FileUtils.cp_r(File.join(root, "."), output, preserve: true)
end
end

def copy_slide_assets(output)
root = File.realpath(@presentation.root)
prefix = root.end_with?(File::SEPARATOR) ? root : root + File::SEPARATOR

Dir.glob("**/*", File::FNM_DOTMATCH, base: root).sort.each do |relative_path|
next if File.extname(relative_path) == ".md"
next unless Protocol::Media::Registry.for_path(relative_path)

source = File.join(root, relative_path)
next unless File.file?(source)

real_source = File.realpath(source)
next unless real_source.start_with?(prefix)

destination = File.join(output, "_slides", relative_path)
FileUtils.mkdir_p(File.dirname(destination))
FileUtils.copy_file(real_source, destination, true)
end

@presentation.stylesheets.each do |stylesheet|
destination = File.join(output, "_slides", stylesheet.path)
FileUtils.mkdir_p(File.dirname(destination))
File.write(destination, stylesheet.read)
end
end

def copy_recordings(output, sources)
sources.map do |recordings, slide|
relative_path = recordings.relative_path(slide)
destination = File.join(output, "audio", relative_path)
FileUtils.mkdir_p(File.dirname(destination))
FileUtils.copy_file(recordings.path(slide), destination, true)

"./audio/" + Stylesheet.encode_path(relative_path)
end
end

def write_playback(output, recording_urls)
playback = Playback.new(
presentation: @presentation,
recording_urls: recording_urls,
asset_prefix: ".",
)

File.write(File.join(output, "index.html"), playback.call)
end
end
end
25 changes: 23 additions & 2 deletions lib/presently/playback.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ def self.options_from_parameters(parameters)
# @parameter recording_urls [Array(String | Nil)] Narration URL for each slide.
# @parameter autoplay [Boolean] Whether playback should begin when ready.
# @parameter controls [Boolean] Whether playback controls should be visible.
def initialize(presentation:, recording_urls:, autoplay: false, controls: true)
# @parameter asset_prefix [String] Prefix for playback, component, and slide asset URLs.
def initialize(presentation:, recording_urls:, autoplay: false, controls: true, asset_prefix: "")
@presentation = presentation
@recording_urls = recording_urls
@autoplay = autoplay
@controls = controls
@asset_prefix = asset_prefix
@renderer = SlideRenderer.new(templates: presentation.templates)
end

Expand All @@ -55,11 +57,30 @@ def recording_url(index)
@recording_urls[index]
end

# Prefix an application-relative asset URL for the current playback target.
# @parameter path [String] An absolute application asset path.
# @returns [String] The prefixed asset URL.
def asset_url(path)
return path if @asset_prefix.empty?

@asset_prefix.sub(%r{/\z}, "") + "/" + path.sub(%r{\A/}, "")
end

# Resolve a presentation stylesheet URL for the current playback target.
# @parameter stylesheet [Stylesheet] The presentation stylesheet.
# @returns [String] The prefixed stylesheet URL.
def stylesheet_url(stylesheet)
asset_url(stylesheet.url)
end

# Render one slide as HTML.
# @parameter slide [Slide] The slide to render.
# @returns [XRB::MarkupString]
def render_slide(slide)
@renderer.render_to_html(slide)
html = @renderer.render_to_html(slide)
return html if @asset_prefix.empty?

XRB::MarkupString.raw(html.to_s.gsub(Stylesheet::PREFIX, asset_url(Stylesheet::PREFIX)))
end

# Render the complete playback page.
Expand Down
24 changes: 12 additions & 12 deletions lib/presently/playback.xrb
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,28 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />

<link rel="icon" type="image/png" href="/_static/icon.png" />
<link rel="stylesheet" href="/_static/index.css" type="text/css" media="screen" />
<link rel="stylesheet" href="/_static/slides.css" type="text/css" media="screen" />
<link rel="stylesheet" href="/_static/playback.css" type="text/css" media="screen" />
<link rel="stylesheet" href="/_static/custom.css" type="text/css" media="screen" />
<link rel="stylesheet" href="/_components/@socketry/syntax/themes/base/syntax.css" type="text/css" media="screen" />
<link rel="icon" type="image/png" href="#{self.asset_url("/_static/icon.png")}" />
<link rel="stylesheet" href="#{self.asset_url("/_static/index.css")}" type="text/css" media="screen" />
<link rel="stylesheet" href="#{self.asset_url("/_static/slides.css")}" type="text/css" media="screen" />
<link rel="stylesheet" href="#{self.asset_url("/_static/playback.css")}" type="text/css" media="screen" />
<link rel="stylesheet" href="#{self.asset_url("/_static/custom.css")}" type="text/css" media="screen" />
<link rel="stylesheet" href="#{self.asset_url("/_components/@socketry/syntax/themes/base/syntax.css")}" type="text/css" media="screen" />
<?r self.stylesheets.each do |stylesheet| ?>
<link rel="stylesheet" href="#{stylesheet.url}" type="text/css" media="screen" />
<link rel="stylesheet" href="#{self.stylesheet_url(stylesheet)}" type="text/css" media="screen" />
<?r end ?>

<script type="importmap">
{
"imports": {
"morphdom": "/_components/morphdom/morphdom-esm.js",
"@socketry/presently": "/_components/@socketry/presently/Presently.js",
"@socketry/syntax": "/_components/@socketry/syntax/Syntax.js",
"animejs": "/_components/animejs/dist/bundles/anime.esm.min.js"
"morphdom": "#{self.asset_url("/_components/morphdom/morphdom-esm.js")}",
"@socketry/presently": "#{self.asset_url("/_components/@socketry/presently/Presently.js")}",
"@socketry/syntax": "#{self.asset_url("/_components/@socketry/syntax/Syntax.js")}",
"animejs": "#{self.asset_url("/_components/animejs/dist/bundles/anime.esm.min.js")}"
}
}
</script>

<script type="module" src="/playback.js"></script>
<script type="module" src="#{self.asset_url("/playback.js")}"></script>
</head>

<body class="playback" data-autoplay="#{self.autoplay}" data-controls="#{self.controls}">
Expand Down
14 changes: 14 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ Open `http://localhost:9292/playback` to watch the narrated presentation. Playba

For automated capture, use `http://localhost:9292/playback?autoplay=true&controls=false`. The page exposes `window.__PRESENTLY_PLAYBACK_READY` and `window.__PRESENTLY_PLAYBACK_FINISHED`, and dispatches matching `presently:playback-ready` and `presently:playback-finished` events.

To export narrated playback as static HTML with its audio and browser assets:

``` shell
bundle exec bake presently:export:html output=presentation
```

The output is a portable directory which can be deployed to a static web host. Preview it through an HTTP server because browsers restrict JavaScript modules loaded directly from `file://` URLs:

``` shell
ruby -run -e httpd presentation -p 8000
```

Open `http://localhost:8000/` to review the export. The task requires narration for every slide, prefers files under `audio-normalized/`, and falls back to the corresponding recording under `audio/`. Pass `force=true` to replace an existing output directory.

To export playback directly to an MP4 file using a Chromium build that supports `Page.startScreenRecording`:

``` shell
Expand Down
1 change: 1 addition & 0 deletions releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased

- Match slide duration to saved narration by default in the recorder.
- Export narrated playback, audio, and browser assets as a portable static HTML directory with `bake presently:export:html`.

## v0.23.0

Expand Down
Loading
Loading