From e6642fc8ec78b105652c715a0da36eb351cb4cb8 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 16 Sep 2026 14:48:59 +1200 Subject: [PATCH] Export narrated playback as static HTML Assisted-By: devx/e18eafa2-2efa-46c8-8c94-fb459f0d557f --- bake/presently/export.rb | 38 ++++++++ lib/presently.rb | 1 + lib/presently/html_export.rb | 176 ++++++++++++++++++++++++++++++++++ lib/presently/playback.rb | 25 ++++- lib/presently/playback.xrb | 24 ++--- readme.md | 14 +++ releases.md | 1 + test/presently/html_export.rb | 121 +++++++++++++++++++++++ test/presently/playback.rb | 14 +++ 9 files changed, 400 insertions(+), 14 deletions(-) create mode 100644 lib/presently/html_export.rb create mode 100644 test/presently/html_export.rb diff --git a/bake/presently/export.rb b/bake/presently/export.rb index 32c8e17..ca73459 100644 --- a/bake/presently/export.rb +++ b/bake/presently/export.rb @@ -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, diff --git a/lib/presently.rb b/lib/presently.rb index f955c2e..5264730 100644 --- a/lib/presently.rb +++ b/lib/presently.rb @@ -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" diff --git a/lib/presently/html_export.rb b/lib/presently/html_export.rb new file mode 100644 index 0000000..9df0b9d --- /dev/null +++ b/lib/presently/html_export.rb @@ -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 diff --git a/lib/presently/playback.rb b/lib/presently/playback.rb index aecd300..4baeb64 100644 --- a/lib/presently/playback.rb +++ b/lib/presently/playback.rb @@ -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 @@ -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. diff --git a/lib/presently/playback.xrb b/lib/presently/playback.xrb index bd5c7de..4d9cb4f 100644 --- a/lib/presently/playback.xrb +++ b/lib/presently/playback.xrb @@ -6,28 +6,28 @@ - - - - - - + + + + + + - + - + diff --git a/readme.md b/readme.md index 78cc883..90e4fd4 100644 --- a/readme.md +++ b/readme.md @@ -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 diff --git a/releases.md b/releases.md index 86c0855..4af159a 100644 --- a/releases.md +++ b/releases.md @@ -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 diff --git a/test/presently/html_export.rb b/test/presently/html_export.rb new file mode 100644 index 0000000..b72d6f8 --- /dev/null +++ b/test/presently/html_export.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "presently/html_export" +require "presently/presentation" +require "tmpdir" +require "fileutils" + +describe Presently::HTMLExport do + let(:root) {Dir.mktmpdir} + let(:slides_root) {File.join(root, "slides")} + let(:recordings_root) {File.join(root, "audio")} + let(:playback_recordings_root) {File.join(root, "audio-normalized")} + let(:bundled_public_root) {File.join(root, "bundled-public")} + let(:presentation_public_root) {File.join(root, "public")} + let(:output) {File.join(root, "presentation")} + + after do + FileUtils.remove_entry(root) + end + + before do + FileUtils.mkdir_p(slides_root) + FileUtils.mkdir_p(recordings_root) + FileUtils.mkdir_p(playback_recordings_root) + FileUtils.mkdir_p(File.join(bundled_public_root, "_static")) + FileUtils.mkdir_p(File.join(presentation_public_root, "_static")) + + File.write(File.join(slides_root, "010-first.md"), "# First\n\n![Diagram](diagram.svg)\n") + File.write(File.join(slides_root, "020-second.md"), "# Second\n") + File.write(File.join(slides_root, "010-first.css"), ".slide-body { color: blue; }\n") + File.write(File.join(slides_root, "diagram.svg"), "diagram\n") + File.write(File.join(recordings_root, "010-first.webm"), "source-first") + File.write(File.join(recordings_root, "020-second.webm"), "source-second") + File.write(File.join(playback_recordings_root, "010-first.webm"), "normalized-first") + + File.write(File.join(bundled_public_root, "playback.js"), "// playback\n") + File.write(File.join(bundled_public_root, "_static", "custom.css"), "/* bundled */\n") + File.write(File.join(presentation_public_root, "_static", "custom.css"), "/* presentation */\n") + File.write(File.join(presentation_public_root, "logo.svg"), "logo\n") + end + + let(:presentation) {Presently::Presentation.load(slides_root)} + let(:export) do + subject.new( + presentation: presentation, + recordings_root: recordings_root, + playback_recordings_root: playback_recordings_root, + public_roots: [bundled_public_root, presentation_public_root], + ) + end + + with ".public_roots" do + it "orders Lively, Presently, and presentation assets by precedence" do + roots = subject.public_roots(presentation_public_root) + + expect(roots.first).to be == File.join(Gem.loaded_specs.fetch("lively").full_gem_path, "public") + expect(roots[-2]).to be == subject::PUBLIC_ROOT + expect(roots.last).to be == File.expand_path(presentation_public_root) + end + end + + with "#write" do + it "writes portable playback HTML, assets, and narration" do + path = export.write(output) + html = File.read(File.join(path, "index.html")) + + expect(path).to be == File.expand_path(output) + expect(html).to be(:include?, 'src="./playback.js"') + expect(html).to be(:include?, 'href="./_static/playback.css"') + expect(html).to be(:include?, 'src="./_slides/diagram.svg"') + expect(html).to be(:include?, 'src="./audio/010-first.webm"') + expect(html).to be(:include?, 'src="./audio/020-second.webm"') + + expect(File.read(File.join(path, "audio", "010-first.webm"))).to be == "normalized-first" + expect(File.read(File.join(path, "audio", "020-second.webm"))).to be == "source-second" + expect(File.read(File.join(path, "_static", "custom.css"))).to be == "/* presentation */\n" + expect(File.read(File.join(path, "logo.svg"))).to be == "logo\n" + expect(File.read(File.join(path, "_slides", "diagram.svg"))).to be == "diagram\n" + expect(File.read(File.join(path, "_slides", "010-first.css"))).to be(:include?, '@scope (.slide[data-slide-path="010-first.md"])') + expect(File).not.to be(:file?, File.join(path, "_slides", "010-first.md")) + end + + it "requires narration for every slide" do + FileUtils.rm(File.join(recordings_root, "020-second.webm")) + + expect do + export.write(output) + end.to raise_exception(subject::MissingRecordings, message: be(:include?, "020-second.md")) + expect(File).not.to be(:exist?, output) + end + + it "does not replace an existing output by default" do + FileUtils.mkdir_p(output) + File.write(File.join(output, "keep.txt"), "keep") + + expect do + export.write(output) + end.to raise_exception(ArgumentError, message: be(:include?, "pass force: true")) + expect(File.read(File.join(output, "keep.txt"))).to be == "keep" + end + + it "replaces an existing output when forced" do + FileUtils.mkdir_p(output) + File.write(File.join(output, "stale.txt"), "stale") + + export.write(output, force: true) + + expect(File).not.to be(:exist?, File.join(output, "stale.txt")) + expect(File).to be(:file?, File.join(output, "index.html")) + end + + it "refuses destinations which overlap source directories" do + expect do + export.write(File.join(slides_root, "export")) + end.to raise_exception(ArgumentError, message: be(:include?, "must not overlap")) + end + end +end diff --git a/test/presently/playback.rb b/test/presently/playback.rb index 1fcb707..02c753b 100644 --- a/test/presently/playback.rb +++ b/test/presently/playback.rb @@ -84,5 +84,19 @@ expect(playback.call).to be(:include?, 'data-autoplay="true"') expect(playback.call).to be(:include?, 'data-controls="false"') end + + it "prefixes assets for a static playback target" do + File.write(File.join(dir, "style.css"), ".slide { color: blue; }\n") + File.write(File.join(dir, "diagram.svg"), "\n") + File.write(File.join(dir, "01.md"), "![Diagram](diagram.svg)\n") + playback = subject.new(presentation: Presently::Presentation.load(dir), recording_urls: recording_urls, asset_prefix: ".") + html = playback.call + + expect(html).to be(:include?, 'src="./playback.js"') + expect(html).to be(:include?, 'href="./_static/playback.css"') + expect(html).to be(:include?, 'href="./_slides/style.css"') + expect(html).to be(:include?, 'src="./_slides/diagram.svg"') + expect(html).to be(:include?, '"morphdom": "./_components/morphdom/morphdom-esm.js"') + end end end