#!/usr/bin/env ruby # frozen_string_literal: false require 'English' require 'base64' require 'builder' require 'csv' require 'digest/bubblebabble' require 'fileutils' require 'json' require 'loofah' require 'nokogiri' require 'optimist' require 'yaml' require File.expand_path('../../../lib/recordandplayback', __FILE__) require File.expand_path('../../../lib/recordandplayback/interval_tree', __FILE__) include IntervalTree opts = Optimist.options do opt :meeting_id, 'Meeting id to archive', type: String opt :format, 'Playback format name', type: String opt :log_stdout, 'Log to STDOUT', type: :flag end meeting_id = opts[:meeting_id] playback = opts[:format] exit(0) if playback != 'presentation' logger = opts[:log_stdout] ? Logger.new($stdout) : Logger.new('/var/log/bigbluebutton/post_publish.log', 'weekly') logger.level = Logger::INFO BigBlueButton.logger = logger BigBlueButton.logger.info("Started exporting presentation for [#{meeting_id}]") @published_files = "/var/bigbluebutton/published/presentation/#{meeting_id}" # Creates scratch directories FileUtils.mkdir_p(["#{@published_files}/chats", "#{@published_files}/cursor", "#{@published_files}/frames", "#{@published_files}/timestamps", "/var/bigbluebutton/published/video/#{meeting_id}"]) TEMPORARY_FILES_PERMISSION = 0o600 # Setting the SVGZ option to true will write less data on the disk. SVGZ_COMPRESSION = true # Set this to true if you've recompiled FFmpeg to enable external references. Writes less data on disk FFMPEG_REFERENCE_SUPPORT = false BASE_URI = FFMPEG_REFERENCE_SUPPORT ? "-base_uri #{@published_files}" : '' # Set this to true if you've recompiled FFmpeg with the movtext codec enabled CAPTION_SUPPORT = false # Video output quality: 0 is lossless, 51 is the worst. Default 23, 18 - 28 recommended CONSTANT_RATE_FACTOR = 23 SVG_EXTENSION = SVGZ_COMPRESSION ? 'svgz' : 'svg' VIDEO_EXTENSION = File.file?("#{@published_files}/video/webcams.mp4") ? 'mp4' : 'webm' # Set this to true if the whiteboard supports whiteboard animations REMOVE_REDUNDANT_SHAPES = false BENCHMARK_FFMPEG = false BENCHMARK = BENCHMARK_FFMPEG ? '-benchmark ' : '' THREADS = 4 BACKGROUND_COLOR = 'white'.freeze CURSOR_RADIUS = 8 # Output video size OUTPUT_WIDTH = 1920 OUTPUT_HEIGHT = 1080 # Playback layout WEBCAMS_WIDTH = 320 WEBCAMS_HEIGHT = 240 CHAT_WIDTH = WEBCAMS_WIDTH CHAT_HEIGHT = OUTPUT_HEIGHT - WEBCAMS_HEIGHT HIDE_CHAT = false HIDE_CHAT_NAMES = false HIDE_DESKSHARE = false # Assumes a monospaced font with a width to aspect ratio of 3:5 CHAT_FONT_SIZE = 15 CHAT_FONT_SIZE_X = (0.6 * CHAT_FONT_SIZE).to_i CHAT_STARTING_OFFSET = CHAT_HEIGHT + CHAT_FONT_SIZE # Max. dimensions supported: 8032 x 32767 CHAT_CANVAS_WIDTH = (8032 / CHAT_WIDTH) * CHAT_WIDTH CHAT_CANVAS_HEIGHT = (32_767 / CHAT_FONT_SIZE) * CHAT_FONT_SIZE # Dimensions of the whiteboard area SLIDES_WIDTH = OUTPUT_WIDTH - WEBCAMS_WIDTH SLIDES_HEIGHT = OUTPUT_HEIGHT # Input deskshare dimensions. Is scaled to fit whiteboard area keeping aspect ratio DESKSHARE_INPUT_WIDTH = 1280 DESKSHARE_INPUT_HEIGHT = 720 # Center the deskshare DESKSHARE_Y_OFFSET = ((SLIDES_HEIGHT - ([SLIDES_WIDTH.to_f / DESKSHARE_INPUT_WIDTH, SLIDES_HEIGHT.to_f / DESKSHARE_INPUT_HEIGHT].min * DESKSHARE_INPUT_HEIGHT)) / 2).to_i WhiteboardElement = Struct.new(:begin, :end, :value, :id) WhiteboardSlide = Struct.new(:href, :begin, :end, :width, :height) def run_command(command, silent = false) BigBlueButton.logger.info("Running: #{command}") unless silent output = `#{command}` [$CHILD_STATUS.success?, output] end def add_captions json = JSON.parse(File.read("#{@published_files}/captions.json")) caption_amount = json.length return if caption_amount.zero? caption_input = '' maps = '' language_names = '' (0..caption_amount - 1).each do |i| caption = json[i] caption_input << "-i #{@published_files}/caption_#{caption['locale']}.vtt " maps << "-map #{i + 1} " language_names << "-metadata:s:s:#{i} language=#{caption['localeName'].downcase[0..2]} " end render = "ffmpeg -i #{@published_files}/meeting-tmp.mp4 #{caption_input} " \ "-map 0:v -map 0:a #{maps} -c:v copy -c:a copy -c:s mov_text #{language_names} " \ "-y #{@published_files}/meeting_captioned.mp4" success, = run_command(render) if success FileUtils.mv("#{@published_files}/meeting_captioned.mp4", "#{@published_files}/meeting-tmp.mp4") else warn('An error occurred adding the captions to the video.') exit(false) end end def add_chapters(duration, slides) # Extract metadata command = "ffmpeg -i #{@published_files}/meeting-tmp.mp4 -y -f ffmetadata #{@published_files}/meeting_metadata" success, = run_command(command) unless success warn("An error occurred extracting the video's metadata.") exit(false) end slide_number = 1 deskshare_number = 1 chapter = '' slides.each do |slide| chapter_start = slide.begin chapter_end = slide.end break if chapter_start >= duration next if (chapter_end - chapter_start) <= 0.25 if slide.href.include?('deskshare') title = "Screen sharing #{deskshare_number}" deskshare_number += 1 else title = "Slide #{slide_number}" slide_number += 1 end chapter << "[CHAPTER]\nSTART=#{chapter_start * 1e9}\nEND=#{chapter_end * 1e9}\ntitle=#{title}\n\n" end File.open("#{@published_files}/meeting_metadata", 'a') do |file| file << chapter end render = "ffmpeg -i #{@published_files}/meeting-tmp.mp4 " \ "-i #{@published_files}/meeting_metadata -map_metadata 1 " \ "-map_chapters 1 -codec copy -y -t #{duration} #{@published_files}/meeting_chapters.mp4" success, = run_command(render) if success FileUtils.mv("#{@published_files}/meeting_chapters.mp4", "#{@published_files}/meeting-tmp.mp4") else warn('Failed to add the chapters to the video.') exit(false) end end def add_greenlight_buttons(metadata) bbb_props = File.open(File.join(__dir__, '../bigbluebutton.yml')) { |f| YAML.safe_load(f) } playback_protocol = bbb_props['playback_protocol'] playback_host = bbb_props['playback_host'] meeting_id = metadata.xpath('recording/id').inner_text metadata.xpath('recording/playback/format').children.first.content = 'video' metadata.xpath('recording/playback/link').children.first.content = "#{playback_protocol}://#{playback_host}/presentation/#{meeting_id}/meeting.mp4" File.open("/var/bigbluebutton/published/video/#{meeting_id}/metadata.xml", 'w') do |file| file.write(metadata) end end def base64_encode(path) return '' if File.directory?(path) data = File.open(path).read "data:image/#{File.extname(path).delete('.')};base64,#{Base64.strict_encode64(data)}" end def measure_string(s, font_size) # https://stackoverflow.com/a/4081370 # DejaVuSans, the default truefont of Debian, can be used here # /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf # use ImageMagick to measure the string in pixels command = "convert xc: -font /usr/share/fonts/truetype/msttcorefonts/Arial.ttf -pointsize #{font_size} -debug annotate -annotate 0 #{Shellwords.escape(s)} null: 2>&1" _, output = run_command(command, true) output.match(/; width: (\d+);/)[1].to_f end def pack_up_string(s, separator, font_size, text_box_width) # split the line on whitespaces, and measure the line to fit into # the text_box_width line_breaks = [] queued_words = [] s.split(separator).each do |word| # first consider queued word and the current word in the line test_string = (queued_words + [word]).join(separator) width = measure_string(test_string, font_size) if width > text_box_width # line exceeded, so consider the queued words as a line break and # queue the current word line_breaks += [queued_words.join(separator)] if measure_string(word, font_size) > text_box_width # if the word alone exceeds the box width, then we pack the word # maximizing the amount of characters on each line res = pack_up_string(word, '', font_size, text_box_width) # queue last line break, other words might fit queued_words = [res.pop] line_breaks += res else queued_words = [word] end else # current word fits the text box, so keep enqueueing new words queued_words += [word] end end # make sure we release the final queued words as the final line break line_breaks += [queued_words.join(separator)] unless queued_words.empty? line_breaks end def convert_whiteboard_shapes(whiteboard) # Find shape elements whiteboard.xpath('svg/g/g').each do |annotation| # Make all annotations visible style = annotation.attr('style') style.sub! 'visibility:hidden', '' annotation.set_attribute('style', style) shape = annotation.attribute('shape').to_s # Convert polls to data schema if shape.include? 'poll' poll = annotation.element_children.first path = "#{@published_files}/#{poll.attribute('href')}" poll.remove_attribute('href') # Namespace xmlns:xlink is required by FFmpeg poll.add_namespace_definition('xlink', 'http://www.w3.org/1999/xlink') data = FFMPEG_REFERENCE_SUPPORT ? "file://#{path}" : base64_encode(path) poll.set_attribute('xlink:href', data) end # Convert XHTML to SVG so that text can be shown next unless shape.include? 'text' # Turn style attributes into a hash style_values = Hash[*CSV.parse(style, col_sep: ':', row_sep: ';').flatten] # The text_color variable may not be required depending on your FFmpeg version text_color = style_values['color'] font_size = style_values['font-size'].to_f annotation.set_attribute('style', "#{style};fill:currentcolor") foreign_object = annotation.xpath('switch/foreignObject') # Obtain X and Y coordinates of the text x = foreign_object.attr('x').to_s y = foreign_object.attr('y').to_s text_box_width = foreign_object.attr('width').to_s.to_f text = foreign_object.children.children builder = Builder::XmlMarkup.new builder.text(x: x, y: y, fill: text_color, 'xml:space' => 'preserve') do previous_line_was_text = true text.each do |line| line = line.to_s if line == '
' if previous_line_was_text previous_line_was_text = false else builder.tspan(x: x, dy: '1.0em') { builder << '
' } end else line = Loofah.fragment(line).scrub!(:strip).text.unicode_normalize line_breaks = pack_up_string(line, ' ', font_size, text_box_width) line_breaks.each do |row| safe_message = Loofah.fragment(row).scrub!(:escape) builder.tspan(x: x, dy: '1.0em') { builder << safe_message } end previous_line_was_text = true end end end annotation.add_child(builder.target!) # Remove the tag annotation.xpath('switch').remove end # Save new shapes.svg copy File.open("#{@published_files}/shapes_modified.svg", 'w', TEMPORARY_FILES_PERMISSION) do |file| file.write(whiteboard) end end def parse_panzooms(pan_reader, timestamps) panzooms = [] timestamp = 0 pan_reader.each do |node| next unless node.node_type == Nokogiri::XML::Reader::TYPE_ELEMENT node_name = node.name timestamp = node.attribute('timestamp').to_f if node_name == 'event' if node_name == 'viewBox' panzooms << [timestamp, node.inner_xml] timestamps << timestamp end end [panzooms, timestamps] end def parse_whiteboard_shapes(shape_reader) slide_in = 0 slide_out = 0 shapes = [] slides = [] timestamps = [] shape_reader.each do |node| next unless node.node_type == Nokogiri::XML::Reader::TYPE_ELEMENT node_name = node.name node_class = node.attribute('class') if node_name == 'image' && node_class == 'slide' slide_in = node.attribute('in').to_f slide_out = node.attribute('out').to_f timestamps << slide_in timestamps << slide_out # Image paths need to follow the URI Data Scheme (for slides and polls) path = "#{@published_files}/#{node.attribute('href')}" data = FFMPEG_REFERENCE_SUPPORT ? "file://#{path}" : base64_encode(path) slides << WhiteboardSlide.new(data, slide_in, slide_out, node.attribute('width').to_f, node.attribute('height')) end next unless node_name == 'g' && node_class == 'shape' shape_timestamp = node.attribute('timestamp').to_f shape_undo = node.attribute('undo').to_f shape_undo = slide_out if shape_undo.negative? shape_enter = [shape_timestamp, slide_in].max shape_leave = [[shape_undo, slide_in].max, slide_out].min timestamps << shape_enter timestamps << shape_leave xml = "#{node.inner_xml}" id = node.attribute('shape').split('-').last shapes << WhiteboardElement.new(shape_enter, shape_leave, xml, id) end [shapes, slides, timestamps] end def remove_adjacent(array) index = 0 until array[index + 1].nil? array[index] = nil if array[index].id == array[index + 1].id index += 1 end array.compact! || array end def parse_chat(chat_reader) messages = [] salt = Time.now.nsec chat_reader.each do |node| unless node.name == 'chattimeline' && node.attribute('target') == 'chat' && node.node_type == Nokogiri::XML::Reader::TYPE_ELEMENT next end name = node.attribute('name') name = Digest::SHA1.bubblebabble(name << salt.to_s)[0..10] if HIDE_CHAT_NAMES messages << [node.attribute('in').to_f, name, node.attribute('message')] end messages end def render_chat(chat_reader) messages = parse_chat(chat_reader) return if messages.empty? # Text coordinates on the SVG file svg_x = 0 svg_y = CHAT_STARTING_OFFSET # Chat viewbox coordinates chat_x = 0 chat_y = 0 overlay_position = [] # Keep last n messages for seamless transitions between columns duplicates = Array.new((CHAT_HEIGHT / (3 * CHAT_FONT_SIZE)) + 1) { nil } # Create SVG chat with all messages # Add 'xmlns' => 'http://www.w3.org/2000/svg' for visual debugging builder = Builder::XmlMarkup.new builder.instruct! builder.svg(width: CHAT_CANVAS_WIDTH, height: CHAT_CANVAS_HEIGHT, 'xmlns' => 'http://www.w3.org/2000/svg') do builder.style { builder << "text{font-family: monospace; font-size: #{CHAT_FONT_SIZE}}" } messages.each do |timestamp, name, chat| # Strip HTML tags e.g. from links so it only displays the inner text chat = Loofah.fragment(chat).scrub!(:strip).text.unicode_normalize name = Loofah.fragment(name).scrub!(:strip).text.unicode_normalize max_message_length = (CHAT_WIDTH / CHAT_FONT_SIZE_X) - 1 line_breaks = [-1] line_index = 0 last_linebreak_pos = 0 chat_length = chat.length - 1 (0..chat_length).each do |chat_index| last_linebreak_pos = chat_index if chat[chat_index] == ' ' if line_index >= max_message_length last_linebreak_pos = chat_index if last_linebreak_pos <= chat_index - max_message_length line_breaks << last_linebreak_pos line_index = chat_index - last_linebreak_pos - 1 end line_index += 1 end line_wraps = [] line_breaks.each_cons(2) do |(a, b)| line_wraps << [a + 1, b] end line_wraps << [line_breaks.last + 1, chat_length] # Message height equals the line break amount + the line for the name / time + the empty line afterwards message_height = (line_wraps.size + 2) * CHAT_FONT_SIZE # Add message to a new column if it goes over the canvas height if svg_y + message_height > CHAT_CANVAS_HEIGHT # Insert duplicate messages when going to next column for a seamless transition duplicate_y = CHAT_HEIGHT duplicates.each do |header, duplicate_content, duplicate_x| break if header.nil? || duplicate_y.negative? duplicate_x += CHAT_WIDTH duplicate_content.each do |content| duplicate_y -= CHAT_FONT_SIZE builder.text(x: duplicate_x, y: duplicate_y) { builder << content } end duplicate_y -= CHAT_FONT_SIZE builder.text(x: duplicate_x, y: duplicate_y, 'font-weight' => 'bold') { builder << header } duplicate_y -= CHAT_FONT_SIZE end # Set coordinates to new column svg_y = CHAT_STARTING_OFFSET svg_x += CHAT_WIDTH chat_x += CHAT_WIDTH chat_y = message_height else chat_y += message_height end overlay_position << [timestamp, chat_x, chat_y] # Username and chat timestamp header = "#{name} #{Time.at(timestamp.to_f.round(0)).utc.strftime('%H:%M:%S')}" builder.text(x: svg_x, y: svg_y, 'font-weight' => 'bold') do builder << header end svg_y += CHAT_FONT_SIZE duplicate_content = [] # Message text line_wraps.each do |a, b| safe_message = Loofah.fragment(chat[a..b]).scrub!(:escape) builder.text(x: svg_x, y: svg_y) { builder << safe_message } svg_y += CHAT_FONT_SIZE duplicate_content.unshift(safe_message) end duplicates.unshift([header, duplicate_content, svg_x]) duplicates.pop svg_y += CHAT_FONT_SIZE end end # Dynamically adjust the chat canvas size for the fastest possible export cropped_chat_canvas_width = svg_x + CHAT_WIDTH cropped_chat_canvas_height = cropped_chat_canvas_width == CHAT_WIDTH ? svg_y : CHAT_CANVAS_HEIGHT builder = Nokogiri::XML(builder.target!) builder_root = builder.root builder_root.set_attribute('width', cropped_chat_canvas_width) builder_root.set_attribute('height', cropped_chat_canvas_height) # Saves chat as SVG / SVGZ file File.open("#{@published_files}/chats/chat.svg", 'w', TEMPORARY_FILES_PERMISSION) do |file| file.write(builder) end File.open("#{@published_files}/timestamps/chat_timestamps", 'w', TEMPORARY_FILES_PERMISSION) do |file| overlay_position.each do |timestamp, x, y| file.puts "#{timestamp} crop@c x #{x}, crop@c y #{y};" end end end def render_cursor(panzooms, cursor_reader) # Create the mouse pointer SVG builder = Builder::XmlMarkup.new # Add 'xmlns' => 'http://www.w3.org/2000/svg' for visual debugging, remove for faster exports builder.svg(width: CURSOR_RADIUS * 2, height: CURSOR_RADIUS * 2) do builder.circle(cx: CURSOR_RADIUS, cy: CURSOR_RADIUS, r: CURSOR_RADIUS, fill: 'red') end File.open("#{@published_files}/cursor/cursor.svg", 'w', TEMPORARY_FILES_PERMISSION) do |svg| svg.write(builder.target!) end cursor = [] timestamps = [] view_box = '' cursor_reader.each do |node| node_name = node.name next unless node.node_type == Nokogiri::XML::Reader::TYPE_ELEMENT timestamps << node.attribute('timestamp').to_f if node_name == 'event' cursor << node.inner_xml if node_name == 'cursor' end panzoom_index = 0 File.open("#{@published_files}/timestamps/cursor_timestamps", 'w', TEMPORARY_FILES_PERMISSION) do |file| timestamps.each.with_index do |timestamp, frame_number| panzoom = panzooms[panzoom_index] if panzoom_index < panzooms.length && timestamp >= panzoom.first _, view_box = panzoom panzoom_index += 1 view_box = view_box.split end # Get cursor coordinates pointer = cursor[frame_number].split width = view_box[2].to_f height = view_box[3].to_f # Calculate original cursor coordinates cursor_x = pointer[0].to_f * width cursor_y = pointer[1].to_f * height # Scaling required to reach target dimensions x_scale = SLIDES_WIDTH / width y_scale = SLIDES_HEIGHT / height # Keep aspect ratio scale_factor = [x_scale, y_scale].min # Scale cursor_x *= scale_factor cursor_y *= scale_factor # Translate given difference to new on-screen dimensions x_offset = (SLIDES_WIDTH - (scale_factor * width)) / 2 y_offset = (SLIDES_HEIGHT - (scale_factor * height)) / 2 # Center cursor cursor_x -= CURSOR_RADIUS cursor_y -= CURSOR_RADIUS cursor_x += x_offset cursor_y += y_offset # Move whiteboard to the right, making space for the chat and webcams cursor_x += WEBCAMS_WIDTH # Writes the timestamp and position down file.puts "#{timestamp} overlay@m x #{cursor_x.round(3)}, overlay@m y #{cursor_y.round(3)};" end end end def render_video(duration, meeting_name) # Determine if video had screensharing / chat messages deskshare = !HIDE_DESKSHARE && File.file?("#{@published_files}/deskshare/deskshare.#{VIDEO_EXTENSION}") chat = !HIDE_CHAT && File.file?("#{@published_files}/chats/chat.svg") render = "ffmpeg -f lavfi -i color=c=#{BACKGROUND_COLOR}:s=#{OUTPUT_WIDTH}x#{OUTPUT_HEIGHT} " \ "-f concat -safe 0 #{BASE_URI} -i #{@published_files}/timestamps/whiteboard_timestamps " \ "-framerate 10 -loop 1 -i #{@published_files}/cursor/cursor.svg " if chat render << "-framerate 1 -loop 1 -i #{@published_files}/chats/chat.svg " \ "-i #{@published_files}/video/webcams.#{VIDEO_EXTENSION} " render << if deskshare "-i #{@published_files}/deskshare/deskshare.#{VIDEO_EXTENSION} -filter_complex " \ "'[2]sendcmd=f=#{@published_files}/timestamps/cursor_timestamps[cursor];" \ "[3]sendcmd=f=#{@published_files}/timestamps/chat_timestamps," \ "crop@c=w=#{CHAT_WIDTH}:h=#{CHAT_HEIGHT}:x=0:y=0[chat];" \ "[4]scale=w=#{WEBCAMS_WIDTH}:h=#{WEBCAMS_HEIGHT}[webcams];" \ "[5]scale=w=#{SLIDES_WIDTH}:h=#{SLIDES_HEIGHT}:force_original_aspect_ratio=1[deskshare];" \ "[0][deskshare]overlay=x=#{WEBCAMS_WIDTH}:y=#{DESKSHARE_Y_OFFSET}[screenshare];" \ "[screenshare][1]overlay=x=#{WEBCAMS_WIDTH}[slides];" \ '[slides][cursor]overlay@m[whiteboard];' \ "[whiteboard][chat]overlay=y=#{WEBCAMS_HEIGHT}[chats];" \ "[chats][webcams]overlay' " else "-filter_complex '[2]sendcmd=f=#{@published_files}/timestamps/cursor_timestamps[cursor];" \ "[3]sendcmd=f=#{@published_files}/timestamps/chat_timestamps," \ "crop@c=w=#{CHAT_WIDTH}:h=#{CHAT_HEIGHT}:x=0:y=0[chat];" \ "[4]scale=w=#{WEBCAMS_WIDTH}:h=#{WEBCAMS_HEIGHT}[webcams];" \ "[0][1]overlay=x=#{WEBCAMS_WIDTH}[slides];" \ '[slides][cursor]overlay@m[whiteboard];' \ "[whiteboard][chat]overlay=y=#{WEBCAMS_HEIGHT}[chats];[chats][webcams]overlay' " end else render << "-i #{@published_files}/video/webcams.#{VIDEO_EXTENSION} " render << if deskshare "-i #{@published_files}/deskshare/deskshare.#{VIDEO_EXTENSION} -filter_complex " \ "'[2]sendcmd=f=#{@published_files}/timestamps/cursor_timestamps[cursor];" \ "[3]scale=w=#{WEBCAMS_WIDTH}:h=#{WEBCAMS_HEIGHT}[webcams];" \ "[4]scale=w=#{SLIDES_WIDTH}:h=#{SLIDES_HEIGHT}:force_original_aspect_ratio=1[deskshare];" \ "[0][deskshare]overlay=x=#{WEBCAMS_WIDTH}:y=#{DESKSHARE_Y_OFFSET}[screenshare];" \ "[screenshare][1]overlay=x=#{WEBCAMS_WIDTH}[slides];" \ '[slides][cursor]overlay@m[whiteboard];' \ "[whiteboard][webcams]overlay' " else "-filter_complex '[2]sendcmd=f=#{@published_files}/timestamps/cursor_timestamps[cursor];" \ "[3]scale=w=#{WEBCAMS_WIDTH}:h=#{WEBCAMS_HEIGHT}[webcams];" \ "[0][1]overlay=x=#{WEBCAMS_WIDTH}[slides];" \ '[slides][cursor]overlay@m[whiteboard];' \ "[whiteboard][webcams]overlay' " end end render << "-c:a aac -crf #{CONSTANT_RATE_FACTOR} -shortest -y -t #{duration} -threads #{THREADS} " \ "-metadata title=#{Shellwords.escape("#{meeting_name}")} #{BENCHMARK} #{@published_files}/meeting-tmp.mp4" success, = run_command(render) unless success warn('An error occurred rendering the video.') exit(false) end end def render_whiteboard(panzooms, slides, shapes, timestamps) shapes_interval_tree = IntervalTree::Tree.new(shapes) # Create frame intervals with starting time 0 intervals = timestamps.uniq.sort intervals = intervals.drop(1) if intervals.first == -1 frame_number = 0 # Render the visible frame for each interval File.open("#{@published_files}/timestamps/whiteboard_timestamps", 'w', TEMPORARY_FILES_PERMISSION) do |file| slide_number = 0 slide = slides[slide_number] view_box = '' intervals.each_cons(2).each do |interval_start, interval_end| # Get view_box parameter of the current slide _, view_box = panzooms.shift if !panzooms.empty? && interval_start >= panzooms.first.first if slide_number < slides.size && interval_start >= slides[slide_number].begin slide = slides[slide_number] slide_number += 1 end draw = shapes_interval_tree.search(interval_start, unique: false, sort: false) draw = [] if draw.nil? draw = remove_adjacent(draw) if REMOVE_REDUNDANT_SHAPES && !draw.empty? svg_export(draw, view_box, slide.href, slide.width, slide.height, frame_number) # Write the frame's duration down file.puts "file ../frames/frame#{frame_number}.#{SVG_EXTENSION}" file.puts "duration #{(interval_end - interval_start).round(1)}" frame_number += 1 end # The last image needs to be specified twice, without specifying the duration (FFmpeg quirk) file.puts "file ../frames/frame#{frame_number - 1}.#{SVG_EXTENSION}" if frame_number.positive? end end def svg_export(draw, view_box, slide_href, width, height, frame_number) # Builds SVG frame builder = Builder::XmlMarkup.new _view_box_x, _view_box_y, view_box_width, view_box_height = view_box.split.map(&:to_f) view_box_aspect_ratio = view_box_width / view_box_height width = width.to_f height = height.to_f slide_aspect_ratio = width / height outer_viewbox_x = 0 outer_viewbox_y = 0 outer_viewbox_width = SLIDES_WIDTH outer_viewbox_height = SLIDES_HEIGHT if view_box_aspect_ratio > slide_aspect_ratio outer_viewbox_height = SLIDES_WIDTH / view_box_aspect_ratio else outer_viewbox_width = SLIDES_HEIGHT * view_box_aspect_ratio end outer_viewbox = "#{outer_viewbox_x} #{outer_viewbox_y} #{outer_viewbox_width} #{outer_viewbox_height}" builder.svg(width: SLIDES_WIDTH, height: SLIDES_HEIGHT, viewBox: outer_viewbox, 'xmlns:xlink' => 'http://www.w3.org/1999/xlink', 'xmlns' => 'http://www.w3.org/2000/svg') do # FFmpeg requires the xmlns:xmlink namespace. Add 'xmlns' => 'http://www.w3.org/2000/svg' for visual debugging builder.svg(viewBox: view_box, 'xmlns:xlink' => 'http://www.w3.org/1999/xlink', 'xmlns' => 'http://www.w3.org/2000/svg') do # Display background image builder.image('xlink:href': slide_href, width: width, height: height) # Adds annotations draw.each do |shape| builder << shape.value end end end File.open("#{@published_files}/frames/frame#{frame_number}.#{SVG_EXTENSION}", 'w', TEMPORARY_FILES_PERMISSION) do |svg| if SVGZ_COMPRESSION svgz = Zlib::GzipWriter.new(svg, Zlib::BEST_SPEED) svgz.write(builder.target!) svgz.close else svg.write(builder.target!) end end end def export_presentation # Benchmark start = Time.now # Convert whiteboard assets to a format compatible with FFmpeg convert_whiteboard_shapes(Nokogiri::XML(File.open("#{@published_files}/shapes.svg")).remove_namespaces!) metadata = Nokogiri::XML(File.open("#{@published_files}/metadata.xml")) # Playback duration in seconds duration = metadata.xpath('recording/playback/duration').inner_text.to_f / 1000 meeting_name = metadata.xpath('recording/meta/meetingName').inner_text shapes, slides, timestamps = parse_whiteboard_shapes(Nokogiri::XML::Reader(File.read("#{@published_files}/shapes_modified.svg"))) panzooms, timestamps = parse_panzooms(Nokogiri::XML::Reader(File.read("#{@published_files}/panzooms.xml")), timestamps) # Ensure correct recording length - shapes.svg may have incorrect slides after recording ends timestamps << duration timestamps = timestamps.select { |t| t <= duration } # Create video assets render_chat(Nokogiri::XML::Reader(File.open("#{@published_files}/slides_new.xml"))) unless HIDE_CHAT render_cursor(panzooms, Nokogiri::XML::Reader(File.open("#{@published_files}/cursor.xml"))) render_whiteboard(panzooms, slides, shapes, timestamps) BigBlueButton.logger.info("Finished composing presentation. Time: #{Time.now - start}") start = Time.now BigBlueButton.logger.info('Starting to export video') render_video(duration, meeting_name) add_chapters(duration, slides) add_captions if CAPTION_SUPPORT FileUtils.mv("#{@published_files}/meeting-tmp.mp4", "#{@published_files}/meeting.mp4") BigBlueButton.logger.info("Exported recording available at #{@published_files}/meeting.mp4. Rendering took: #{Time.now - start}") add_greenlight_buttons(metadata) end export_presentation # Delete the contents of the scratch directories FileUtils.rm_rf(["#{@published_files}/chats", "#{@published_files}/cursor", "#{@published_files}/frames", "#{@published_files}/timestamps", "#{@published_files}/shapes_modified.svg", "#{@published_files}/meeting_metadata"]) exit(0)