bigbluebutton-Github/record-and-playback/presentation/scripts/publish/presentation.rb
germanocaumo 853d0dfd9b fix(whiteboard): tldraw recording processing/publishing
Changed the names of tldraw record events to differentiate from before.
Publish tldraw.json file with all shape information during the meeting to be used in playback.
Adapted cursor.xml and panzoom.xml to store tldraw data.
Publish slides svgs to be used by playback's tldraw component (otherwise we have different image sizes in pngs and thus messing the coordinates).
Retro-compatible with old recordings.
2022-06-23 17:04:09 +00:00

1495 lines
53 KiB
Ruby
Executable File

# frozen_string_literal: false
#
# BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
#
# Copyright (c) 2012 BigBlueButton Inc. and by respective authors (see below).
#
# This program is free software; you can redistribute it and/or modify it under the
# terms of the GNU Lesser General Public License as published by the Free Software
# Foundation; either version 3.0 of the License, or (at your option) any later
# version.
#
# BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
# PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
#
# For DEVELOPMENT
# Allows us to run the script manually
# require File.expand_path('../../../core/lib/recordandplayback', __dir__)
# For PRODUCTION
require File.expand_path('../../lib/recordandplayback', __dir__)
require 'rubygems'
require 'optimist'
require 'yaml'
require 'builder'
require 'fastimage' # require fastimage to get the image size of the slides (gem install fastimage)
require 'json'
# This script lives in scripts/archive/steps while properties.yaml lives in scripts/
bbb_props = BigBlueButton.read_props
@presentation_props = YAML.safe_load(File.read('presentation.yml'))
filepathPresOverride = "/etc/bigbluebutton/recording/presentation.yml"
hasOverride = File.file?(filepathPresOverride)
if (hasOverride)
presOverrideProps = YAML::load(File.open(filepathPresOverride))
$presentation_props = $presentation_props.merge(presOverrideProps)
end
# There's a couple of places where stuff is mysteriously divided or multiplied
# by 2. This is just here to call out how spooky that is.
MAGIC_MYSTERY_NUMBER = 2
def scale_to_deskshare_video(width, height)
deskshare_video_height = deskshare_video_width = @presentation_props['deskshare_output_height'].to_f
scale_min = [deskshare_video_width / width, deskshare_video_height / height].min
video_width = width * scale_min
video_height = height * scale_min
[video_width.floor, video_height.floor]
end
def get_deskshare_video_dimension(deskshare_stream_name)
video_width = video_height = @presentation_props['deskshare_output_height'].to_f
deskshare_video_filename = "#{@deskshare_dir}/#{deskshare_stream_name}"
if File.exist?(deskshare_video_filename)
video_info = BigBlueButton::EDL::Video.video_info(deskshare_video_filename)
video_width, video_height = scale_to_deskshare_video(video_info[:width], video_info[:height])
else
BigBlueButton.logger.error("Could not find deskshare video: #{deskshare_video_filename}")
end
[video_width, video_height]
end
#
# Calculate the offsets based on the start and stop recording events, so it's easier
# to translate the timestamps later based on these offsets
#
def calculate_record_events_offset
accumulated_duration = 0
previous_stop_recording = @meeting_start.to_f
@rec_events.each do |event|
event_start = event[:start_timestamp]
event_stop = event[:stop_timestamp]
event[:offset] = event_start - accumulated_duration
event[:duration] = event_stop - event_start
event[:accumulated_duration] = accumulated_duration
previous_stop_recording = event_stop
accumulated_duration += event[:duration]
end
end
#
# Translated an arbitrary Unix timestamp to the recording timestamp
#
def translate_timestamp(timestamp)
timestamp = timestamp.to_f
@rec_events.each do |event|
start_timestamp = event[:start_timestamp]
# if the timestamp comes before the start recording event, then the timestamp is translated to the moment it starts recording
return (start_timestamp - event[:offset]).to_f if timestamp <= start_timestamp
# if the timestamp is during the recording period, it is just translated to the new one using the offset
return (timestamp - event[:offset]).to_f if (timestamp > start_timestamp) && (timestamp <= event[:stop_timestamp])
end
# if the timestamp comes after the last stop recording event, then the timestamp is translated to the last stop recording event timestamp
last_rec_event = @rec_events.last
(timestamp - last_rec_event[:offset] + last_rec_event[:duration]).to_f
end
def color_to_hex(color)
color = color.to_i.to_s(16)
('0' * (6 - color.length)) + color
end
def shape_scale_width(slide, x)
(x / 100.0 * slide[:width]).round(5)
end
def shape_scale_height(slide, y)
(y / 100.0 * slide[:height]).round(5)
end
def shape_thickness(slide, shape)
shape_thickness_percent = shape[:thickness_percent]
if shape_thickness_percent
shape_scale_width(slide, shape[:thickness_percent])
else
shape[:thickness]
end
end
def svg_render_shape_pencil(g, slide, shape)
shape_unique_id = shape[:shape_unique_id]
g['shape'] = "pencil#{shape_unique_id}"
doc = g.document
data_points = shape[:data_points]
data_points_length = data_points.length
if data_points_length < 2
BigBlueButton.logger.warn("Pencil #{shape_unique_id} doesn't have enough points")
return
end
if data_points_length == 2
g['style'] = "stroke:none;fill:##{shape[:color]};visibility:hidden"
circle = doc.create_element('circle',
cx: shape_scale_width(slide, data_points[0]),
cy: shape_scale_height(slide, data_points[1]),
r: (shape_thickness(slide, shape) / 2.0).round(5))
g << circle
else
path = []
data_points = data_points.each
shape_commands = shape[:commands]
if shape_commands
shape_commands.each do |command|
case command
when 1 # MOVE_TO
x = shape_scale_width(slide, data_points.next)
y = shape_scale_height(slide, data_points.next)
path.push("M#{x} #{y}")
when 2 # LINE_TO
x = shape_scale_width(slide, data_points.next)
y = shape_scale_height(slide, data_points.next)
path.push("L#{x} #{y}")
when 3 # Q_CURVE_TO
cx1 = shape_scale_width(slide, data_points.next)
cy1 = shape_scale_height(slide, data_points.next)
x = shape_scale_width(slide, data_points.next)
y = shape_scale_height(slide, data_points.next)
path.push("Q#{cx1} #{cy1},#{x} #{y}")
when 4 # C_CURVE_TO
cx1 = shape_scale_width(slide, data_points.next)
cy1 = shape_scale_height(slide, data_points.next)
cx2 = shape_scale_width(slide, data_points.next)
cy2 = shape_scale_height(slide, data_points.next)
x = shape_scale_width(slide, data_points.next)
y = shape_scale_height(slide, data_points.next)
path.push("C#{cx1} #{cy1},#{cx2} #{cy2},#{x} #{y}")
else
raise "Unknown pencil command: #{command}"
end
end
else
x = shape_scale_width(slide, data_points.next)
y = shape_scale_height(slide, data_points.next)
path << "M#{x} #{y}"
begin
loop do
x = shape_scale_width(slide, data_points.next)
y = shape_scale_height(slide, data_points.next)
path << "L#{x} #{y}"
end
rescue StopIteration
end
end
path = path.join
g['style'] = "stroke:##{shape[:color]};stroke-linecap:round;stroke-linejoin:round;" \
"stroke-width:#{shape_thickness(slide, shape)};visibility:hidden;fill:none"
svg_path = doc.create_element('path', d: path)
g << svg_path
end
end
def svg_render_shape_line(g, slide, shape)
g['shape'] = "line#{shape[:shape_unique_id]}"
g['style'] =
"stroke:##{shape[:color]};stroke-width:#{shape_thickness(slide, shape)};" \
"visibility:hidden;fill:none;stroke-linecap:#{@version_atleast_2_0_0 ? 'butt' : 'round'}"
doc = g.document
data_points = shape[:data_points]
line = doc.create_element('line',
x1: shape_scale_width(slide, data_points[0]),
y1: shape_scale_height(slide, data_points[1]),
x2: shape_scale_width(slide, data_points[2]),
y2: shape_scale_height(slide, data_points[3]))
g << line
end
def stroke_attributes(slide, shape)
"stroke:##{shape_color = shape[:color]};stroke-width:#{shape_thickness(slide, shape)};" \
"visibility:hidden;fill:#{shape[:fill] ? "##{shape_color}" : 'none'}"
end
def svg_render_shape_rect(g, slide, shape)
g['shape'] = "rect#{shape[:shape_unique_id]}"
g['style'] = "#{stroke_attributes(slide, shape)};stroke-linejoin:#{@version_atleast_2_0_0 ? 'miter' : 'round'}"
doc = g.document
data_points = shape[:data_points]
x1 = shape_scale_width(slide, data_points[0])
y1 = shape_scale_height(slide, data_points[1])
x2 = shape_scale_width(slide, data_points[2])
y2 = shape_scale_height(slide, data_points[3])
width = (x2 - x1).abs
if shape[:square]
# Convert to a square, keeping aligned with the start point.
if x2 > x1
y2 = y1 + width
else
# This replicates a bug in the BigBlueButton flash client
BigBlueButton.logger.info("Rect #{shape[:shape_unique_id]} reversed square bug")
y2 = y1 - width
end
end
path = doc.create_element('path', d: "M#{x1} #{y1}L#{x2} #{y1}L#{x2} #{y2}L#{x1} #{y2}Z")
g << path
end
def svg_render_shape_triangle(g, slide, shape)
g['shape'] = "triangle#{shape[:shape_unique_id]}"
g['style'] = "#{stroke_attributes(slide, shape)};" \
"stroke-linejoin:#{@version_atleast_2_0_0 ? 'miter;stroke-miterlimit:8' : 'round'}"
doc = g.document
data_points = shape[:data_points]
x1 = shape_scale_width(slide, data_points[0])
y1 = shape_scale_height(slide, data_points[1])
x2 = shape_scale_width(slide, data_points[2])
y2 = shape_scale_height(slide, data_points[3])
px = ((x1 + x2) / 2.0).round(5)
path = doc.create_element('path', d: "M#{px} #{y1}L#{x2} #{y2}L#{x1} #{y2}Z")
g << path
end
def svg_render_shape_ellipse(g, slide, shape)
g['shape'] = "ellipse#{shape[:shape_unique_id]}"
g['style'] = stroke_attributes(slide, shape)
doc = g.document
data_points = shape[:data_points]
x1 = shape_scale_width(slide, data_points[0])
y1 = shape_scale_height(slide, data_points[1])
x2 = shape_scale_width(slide, data_points[2])
y2 = shape_scale_height(slide, data_points[3])
width_r = ((x2 - x1).abs / 2.0).round(5)
height_r = ((y2 - y1).abs / 2.0).round(5)
hx = ((x1 + x2) / 2.0).round(5)
hy = ((y1 + y2) / 2.0).round(5)
if shape[:circle]
# Convert to a circle, keeping aligned with the start point
height_r = width_r
if x2 > x1
y2 = y1 + height_r + height_r
else
# This replicates a bug in the BigBlueButton flash client
BigBlueButton.logger.info("Ellipse #{shape[:shape_unique_id]} reversed circle bug")
y2 = y1 - height_r - height_r
end
end
# Normalize the x,y coordinates
x1, x2 = x2, x1 if x1 > x2
y1, y2 = y2, y1 if y1 > y2
# SVG's ellipse element doesn't render if r_x or r_y is 0, but
# we want to display a line segment in that case. But the SVG
# path element's elliptical arc code renders r_x or r_y
# degenerate cases as line segments, so we can use that.
path = "M#{x1} #{hy}" \
"A#{width_r} #{height_r} 0 0 1 #{hx} #{y1}" \
"A#{width_r} #{height_r} 0 0 1 #{x2} #{hy}" \
"A#{width_r} #{height_r} 0 0 1 #{hx} #{y2}" \
"A#{width_r} #{height_r} 0 0 1 #{x1} #{hy}" \
'Z'
svg_path = doc.create_element('path', d: path)
g << svg_path
end
def svg_render_shape_text(g, slide, shape)
g['shape'] = "text#{shape[:shape_unique_id]}"
doc = g.document
data_points = shape[:data_points]
x = shape_scale_width(slide, data_points[0])
y = shape_scale_height(slide, data_points[1])
width = shape_scale_width(slide, shape[:text_box_width])
height = shape_scale_height(slide, shape[:text_box_height])
font_size = shape_scale_height(slide, shape[:calced_font_size])
g['style'] = "color:##{shape[:font_color]};word-wrap:break-word;visibility:hidden;font-family:Arial;font-size:#{font_size}px"
switch = doc.create_element('switch')
fo = doc.create_element('foreignObject',
width: width, height: height, x: x, y: y)
p = doc.create_element('p',
xmlns: 'http://www.w3.org/1999/xhtml', style: 'margin:0;padding:0')
shape[:text].each_line.with_index do |line, index|
p << doc.create_element('br') if index.positive?
p << doc.create_text_node(line.chomp)
end
fo << p
switch << fo
g << switch
end
def svg_render_shape_poll(g, slide, shape)
result = shape[:result]
if result == "[]"
BigBlueButton.logger.info("Poll #{shape[:shape_unique_id]} result is empty (no options/answers), ignoring...")
return
end
poll_id = shape[:shape_unique_id]
g['shape'] = "poll#{poll_id}"
g['style'] = 'visibility:hidden'
doc = g.document
data_points = shape[:data_points]
x = shape_scale_width(slide, data_points[0])
y = shape_scale_height(slide, data_points[1])
width = shape_scale_width(slide, data_points[2])
height = shape_scale_height(slide, data_points[3])
num_responders = shape[:num_responders]
presentation = slide[:presentation]
json_file = "#{@process_dir}/poll_result#{poll_id}.json"
svg_file = "#{@process_dir}/presentation/#{presentation}/poll_result#{poll_id}.svg"
# Save the poll json to a temp file
File.open(json_file, 'w') { |f| f.write result }
# Render the poll svg
ret = BigBlueButton.exec_ret('utils/gen_poll_svg', '-i', json_file, '-w', width.round.to_s, '-h', height.round.to_s,
'-n', num_responders.to_s, '-o', svg_file)
raise 'Failed to generate poll svg' if ret != 0
# Poll image
g << doc.create_element('image',
'xlink:href' => "presentation/#{presentation}/poll_result#{poll_id}.svg",
width: width, height: height, x: x, y: y)
end
def build_tldraw_shape(image_shapes, slide, shape)
shape_in = shape[:in]
shape_out = shape[:out]
if shape_in == shape_out
BigBlueButton.logger.info("Draw #{shape[:shape_id]} Shape #{shape[:shape_unique_id]} is never shown (duration rounds to 0)")
return
end
if (shape_in >= slide[:out]) || (!shape[:out].nil? && shape[:out] <= slide[:in])
BigBlueButton.logger.info("Draw #{shape[:shape_id]} Shape #{shape[:shape_unique_id]} is not visible during image time span")
return
end
tldraw_shape = {
id: shape[:shape_id],
timestamp: shape_in,
undo: (shape[:undo].nil? ? -1 : shape[:undo]),
shape_data: shape[:shape_data]
}
image_shapes.push(tldraw_shape)
end
def svg_render_shape(canvas, slide, shape, image_id)
shape_in = shape[:in]
shape_out = shape[:out]
if shape_in == shape_out
BigBlueButton.logger.info("Draw #{shape[:shape_id]} Shape #{shape[:shape_unique_id]} is never shown (duration rounds to 0)")
return
end
if (shape_in >= slide[:out]) || (!shape[:out].nil? && shape[:out] <= slide[:in])
BigBlueButton.logger.info("Draw #{shape[:shape_id]} Shape #{shape[:shape_unique_id]} is not visible during image time span")
return
end
doc = canvas.document
g = doc.create_element('g',
id: "image#{image_id}-draw#{shape[:shape_id]}", class: 'shape',
timestamp: shape_in, undo: (shape[:undo].nil? ? -1 : shape[:undo]))
case shape[:type]
when 'pencil'
svg_render_shape_pencil(g, slide, shape)
when 'line'
svg_render_shape_line(g, slide, shape)
when 'rectangle'
svg_render_shape_rect(g, slide, shape)
when 'triangle'
svg_render_shape_triangle(g, slide, shape)
when 'ellipse'
svg_render_shape_ellipse(g, slide, shape)
when 'text'
svg_render_shape_text(g, slide, shape)
when 'poll_result'
svg_render_shape_poll(g, slide, shape)
else
BigBlueButton.logger.warn("Ignoring unhandled shape type #{shape[:type]}")
end
g[:shape] = "image#{image_id}-#{g[:shape]}"
canvas << g unless g.element_children.empty?
end
@svg_image_id = 1
def svg_render_image(svg, slide, shapes, tldraw, tldraw_shapes)
slide_number = slide[:slide]
presentation = slide[:presentation]
slide_in = slide[:in]
slide_out = slide[:out]
if slide_in == slide_out || slide_in > (@recording_time / 1000)
BigBlueButton.logger.info("Presentation #{presentation} Slide #{slide_number} is never shown (duration rounds to 0)")
return
end
image_id = @svg_image_id
@svg_image_id += 1
slide_deskshare = slide[:deskshare]
BigBlueButton.logger.info("Image #{image_id}: Presentation #{presentation} Slide #{slide_number} Deskshare #{slide_deskshare} from #{slide_in} to #{slide_out}")
doc = svg.document
image = doc.create_element('image',
id: "image#{image_id}", class: 'slide',
in: slide_in, out: slide_out,
'xlink:href' => slide[:src],
width: slide[:width], height: slide[:height], x: 0, y: 0,
style: 'visibility:hidden')
image['text'] = slide[:text] if slide[:text]
svg << image
return if slide_deskshare || !shapes.dig(presentation, slide_number)
shapes = shapes[presentation][slide_number]
if !tldraw
canvas = doc.create_element('g',
class: 'canvas', id: "canvas#{image_id}",
image: "image#{image_id}", display: 'none')
shapes.each do |shape|
svg_render_shape(canvas, slide, shape, image_id)
end
svg << canvas unless canvas.element_children.empty?
else
image_shapes = []
shapes.each do |shape|
build_tldraw_shape(image_shapes, slide, shape)
end
tldraw_shapes[image_id] = { :shapes=>image_shapes, :timestamp=> slide_in}
end
end
def panzoom_viewbox(panzoom)
if panzoom[:deskshare]
panzoom[:x_offset] = panzoom[:y_offset] = 0.0
panzoom[:width_ratio] = panzoom[:height_ratio] = 100.0
end
x = (-panzoom[:x_offset] * MAGIC_MYSTERY_NUMBER / 100.0 * panzoom[:width]).round(5)
y = (-panzoom[:y_offset] * MAGIC_MYSTERY_NUMBER / 100.0 * panzoom[:height]).round(5)
w = shape_scale_width(panzoom, panzoom[:width_ratio])
h = shape_scale_height(panzoom, panzoom[:height_ratio])
[x, y, w, h]
end
def panzooms_emit_event(rec, panzoom, tldraw)
panzoom_in = panzoom[:in]
return if panzoom_in == panzoom[:out]
if !tldraw
rec.event(timestamp: panzoom_in) do
x, y, w, h = panzoom_viewbox(panzoom)
rec.viewBox("#{x} #{y} #{w} #{h}")
end
else
rec.event(timestamp: panzoom_in) do
rec.cameraAndZoom("#{panzoom[:x_camera]} #{panzoom[:y_camera]} #{panzoom[:zoom]}")
end
end
end
def convert_cursor_coordinate(cursor_coord, panzoom_offset, panzoom_ratio)
(((cursor_coord / 100.0) + (panzoom_offset * MAGIC_MYSTERY_NUMBER / 100.0)) / (panzoom_ratio / 100.0)).round(5)
end
def cursors_emit_event(rec, cursor, tldraw)
cursor_in = cursor[:in]
return if cursor_in == cursor[:out]
rec.event(timestamp: cursor_in) do
panzoom = cursor[:panzoom]
if cursor[:visible]
if @version_atleast_2_0_0 && !tldraw
# In BBB 2.0, the cursor now uses the same coordinate system as annotations
# Use the panzoom information to convert it to be relative to viewbox
x = convert_cursor_coordinate(cursor[:x], panzoom[:x_offset], panzoom[:width_ratio])
y = convert_cursor_coordinate(cursor[:y], panzoom[:y_offset], panzoom[:height_ratio])
x = y = -1.0 if (x < 0) || (x > 1) || (y < 0) || (y > 1)
else
# Cursor position is relative to the visible area
x = cursor[:x].round(5)
y = cursor[:y].round(5)
end
else
x = y = -1.0
end
rec.cursor("#{x} #{y}")
end
end
def determine_presentation(presentation, current_presentation)
presentation&.text || current_presentation
end
def determine_slide_number(slide, current_slide)
return current_slide unless slide
slide = slide.text.to_i
slide -= 1 unless @version_atleast_0_9_0
slide
end
def events_parse_tldraw_shape(shapes, event, current_presentation, current_slide, timestamp)
presentation = event.at_xpath('presentation')
slide = event.at_xpath('pageNumber')
presentation = determine_presentation(presentation, current_presentation)
slide = determine_slide_number(slide, current_slide)
# Set up the shapes data structures if needed
shapes[presentation] ||= {}
shapes[presentation][slide] ||= []
# We only need to deal with shapes for this slide
shapes = shapes[presentation][slide]
# Set up the structure for this shape
shape = {}
# Common properties
shape[:in] = timestamp
shape_data = shape[:shape_data] = JSON.parse(event.at_xpath('shapeData'))
user_id = event.at_xpath('userId')&.text
shape[:user_id] = user_id if user_id
shape_id = event.at_xpath('shapeId')&.text
shape[:id] = shape_id if shape_id
draw_id = shape[:shape_id] = @svg_shape_id
@svg_shape_id += 1
# Find the previous shape, for updates
prev_shape = nil
if shape_id
# If we have a shape ID, look up the previous shape by ID
prev_shape_pos = shapes.rindex { |s| s[:shade_id] == shape_id }
prev_shape = prev_shape_pos ? shapes[prev_shape_pos] : nil
end
if prev_shape
prev_shape[:out] = timestamp
shape[:shape_unique_id] = prev_shape[:shape_unique_id]
else
shape[:shape_unique_id] = @svg_shape_unique_id
@svg_shape_unique_id += 1
end
shapes << shape
end
def events_parse_shape(shapes, event, current_presentation, current_slide, timestamp)
# Figure out what presentation+slide this shape is for, with fallbacks
# for old BBB where this info isn't in the shape messages
presentation = event.at_xpath('presentation')
slide = event.at_xpath('pageNumber')
presentation = determine_presentation(presentation, current_presentation)
slide = determine_slide_number(slide, current_slide)
# Set up the shapes data structures if needed
shapes[presentation] ||= {}
shapes[presentation][slide] ||= []
# We only need to deal with shapes for this slide
shapes = shapes[presentation][slide]
# Set up the structure for this shape
shape = {}
# Common properties
shape[:in] = timestamp
shape_type = shape[:type] = event.at_xpath('type').text
shape_data_points = shape[:data_points] = event.at_xpath('dataPoints').text.split(',').map(&:to_f)
# These can be missing in old BBB versions, there are fallbacks
user_id = event.at_xpath('userId')&.text
shape[:user_id] = user_id if user_id
shape_id = event.at_xpath('id')&.text
shape[:id] = shape_id if shape_id
status = event.at_xpath('status')&.text
shape_status = shape[:status] = status if status
draw_id = shape[:shape_id] = @svg_shape_id
@svg_shape_id += 1
# Some shape-specific properties
if %w[ellipse line pencil rectangle triangle].include?(shape_type)
shape[:color] = color_to_hex(event.at_xpath('color').text)
thickness = event.at_xpath('thickness')
unless thickness
BigBlueButton.logger.warn("Draw #{draw_id} Shape #{shape[:shape_unique_id]} ID #{shape_id} is missing thickness")
return
end
if @version_atleast_2_0_0
shape[:thickness_percent] = thickness.text.to_f
else
shape[:thickness] = thickness.text.to_i
end
end
if %w[ellipse rectangle triangle].include?(shape_type)
fill = event.at_xpath('fill')&.text || 'false'
shape[:fill] = fill =~ /true/ ? true : false
end
case shape_type
when 'rectangle'
square = event.at_xpath('square')&.text
shape[:square] = (square == 'true') if square
when 'ellipse'
circle = event.at_xpath('circle')&.text
shape[:circle] = (circle == 'true') if circle
when 'pencil'
commands = event.at_xpath('commands')&.text
shape[:commands] = commands.split(',').map(&:to_i) if commands
when 'poll_result'
shape[:num_responders] = event.at_xpath('num_responders').text.to_i
shape[:num_respondents] = event.at_xpath('num_respondents').text.to_i
shape[:result] = event.at_xpath('result').text
when 'text'
shape[:text_box_width] = event.at_xpath('textBoxWidth').text.to_f
shape[:text_box_height] = event.at_xpath('textBoxHeight').text.to_f
calced_font_size = event.at_xpath('calcedFontSize')
unless calced_font_size
BigBlueButton.logger.warn("Draw #{draw_id} Shape #{shape[:shape_unique_id]} ID #{shape_id} is missing calcedFontSize")
return
end
shape[:calced_font_size] = calced_font_size.text.to_f
shape[:font_color] = color_to_hex(event.at_xpath('fontColor').text)
shape[:text] = event.at_xpath('text')&.text || ''
end
# Find the previous shape, for updates
prev_shape = nil
if shape_id
# If we have a shape ID, look up the previous shape by ID
# Don't look for updates if the drawing has ended
prev_shape_pos = shapes.rindex { |s| s[:id] == shape_id }
prev_shape = prev_shape_pos ? shapes[prev_shape_pos] : nil
else
# No shape ID, so do heuristic matching. If the previous shape had the
# same type and same first two data points, update it.
last_shape = shapes.last
last_shape_data_points = last_shape[:data_points]
if (last_shape[:type] == shape_type) &&
(last_shape_data_points[0] == shape_data_points[0]) &&
(last_shape_data_points[1] == shape_data_points[1])
prev_shape = last_shape
end
end
if prev_shape
prev_shape[:out] = timestamp
shape[:shape_unique_id] = prev_shape[:shape_unique_id]
if (shape_type == 'pencil') && (shape_status == 'DRAW_UPDATE')
# BigBlueButton 2.0 quirk - 'DRAW_UPDATE' events on pencil tool only
# include newly added points, rather than the full list.
shape[:data_points] = prev_shape[:data_points] + shape_data_points
end
else
shape[:shape_unique_id] = @svg_shape_unique_id
@svg_shape_unique_id += 1
end
shapes << shape
end
def set_undo_helper(shapes, key, id, timestamp)
shapes.each do |shape|
next unless shape[key] == id
shape[:undo] = timestamp if !shape[:undo] || (shape[:undo] > timestamp)
end
end
def events_parse_undo(shapes, event, current_presentation, current_slide, timestamp)
# Figure out what presentation+slide this undo is for, with fallbacks
# for old BBB where this info isn't in the undo messages
presentation = determine_presentation(event.at_xpath('presentation'), current_presentation)
slide = determine_slide_number(event.at_xpath('pageNumber'), current_slide)
# Newer undo messages have the shape id, making this a lot easier
shape_id = event.at_xpath('shapeId')&.text
# Set up the shapes data structures if needed
shapes[presentation] ||= {}
shapes[presentation][slide] ||= []
# We only need to deal with shapes for this slide
shapes = shapes[presentation][slide]
if shape_id
# If we have the shape id, we simply have to update the undo time on
# all the shapes with that id.
BigBlueButton.logger.info("Undo: removing shape with ID #{shape_id} at #{timestamp}")
set_undo_helper(shapes, :id, shape_id, timestamp)
else
# The undo command removes the most recently added shape that has not
# already been removed by another undo or clear. Find that shape.
undo_pos = shapes.rindex { |s| !s[:undo] }
undo_shape = undo_pos ? shapes[undo_pos] : nil
if undo_shape
undo_shape_unique_id = undo_shape[:shape_unique_id]
BigBlueButton.logger.info("Undo: removing Shape #{undo_shape_unique_id} at #{timestamp}")
# We have an id number assigned to associate all the updated versions
# of the same shape. Use that to determine which shapes to apply undo
# times to.
set_undo_helper(shapes, :shape_unique_id, undo_shape_unique_id, timestamp)
else
BigBlueButton.logger.info('Undo: no applicable shapes found')
end
end
end
def events_parse_clear(shapes, event, current_presentation, current_slide, timestamp)
# Figure out what presentation+slide this clear is for, with fallbacks
# for old BBB where this info isn't in the clear messages
presentation = determine_presentation(event.at_xpath('presentation'), current_presentation)
slide = determine_slide_number(event.at_xpath('pageNumber'), current_slide)
# BigBlueButton 2.0 per-user clear features; default to full clear on older versions
full_clear = event.at_xpath('fullClear')
full_clear = full_clear ? (full_clear.text == 'true') : true
user_id = event.at_xpath('userId')&.text
# Set up the shapes data structures if needed
shapes[presentation] ||= {}
shapes[presentation][slide] ||= []
# We only need to deal with shapes for this slide
shapes = shapes[presentation][slide]
if full_clear
BigBlueButton.logger.info("Clear: removing all shapes")
else
BigBlueButton.logger.info("Clear: removing shapes for User #{user_id}")
end
shapes.each do |shape|
if full_clear || user_id == shape[:user_id]
if !shape[:undo] || shape[:undo] > timestamp
shape[:undo] = timestamp
end
end
end
end
def events_get_image_info(slide, tldraw)
slide_deskshare = slide[:deskshare]
slide_presentation = slide[:presentation]
if slide_deskshare
slide[:src] = 'presentation/deskshare.png'
elsif slide_presentation == ''
slide[:src] = 'presentation/logo.png'
else
slide_nr = slide[:slide] + 1
tldraw ? slide[:src] = "presentation/#{slide_presentation}/svgs/slide#{slide_nr}.svg"
: slide[:src] = "presentation/#{slide_presentation}/slide-#{slide_nr}.png"
slide[:text] = "presentation/#{slide_presentation}/textfiles/slide-#{slide_nr}.txt"
end
image_path = "#{@process_dir}/#{slide[:src]}"
unless File.exist?(image_path)
BigBlueButton.logger.warn("Missing image file #{image_path}!")
# Emergency last-ditch blank image creation
FileUtils.mkdir_p(File.dirname(image_path))
command = \
if slide_deskshare
['convert', '-size',
"#{@presentation_props['deskshare_output_width']}x#{@presentation_props['deskshare_output_height']}", 'xc:transparent', '-background', 'transparent', image_path,]
else
['convert', '-size', '1600x1200', 'xc:transparent', '-background', 'transparent', '-quality', '90', '+dither',
'-depth', '8', '-colors', '256', image_path,]
end
BigBlueButton.exec_ret(*command) || raise("Unable to generate blank image for #{image_path}")
end
slide[:width], slide[:height] = FastImage.size(image_path)
BigBlueButton.logger.info("Image size is #{slide[:width]}x#{slide[:height]}")
end
# Create the shapes.svg, cursors.xml, and panzooms.xml files used for
# rendering the presentation area
def process_presentation(package_dir)
shapes_doc = Nokogiri::XML::Document.new
shapes_doc.create_internal_subset('svg', '-//W3C//DTD SVG 1.1//EN',
'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd')
svg = shapes_doc.root = shapes_doc.create_element('svg',
id: 'svgfile',
style: 'position:absolute;height:600px;width:800px',
xmlns: 'http://www.w3.org/2000/svg',
'xmlns:xlink' => 'http://www.w3.org/1999/xlink',
version: '1.1',
viewBox: '0 0 800 600')
panzooms_rec = Builder::XmlMarkup.new(indent: 2, margin: 1)
cursors_rec = Builder::XmlMarkup.new(indent: 2, margin: 1)
# Current presentation/slide state
current_presentation_slide = {}
current_presentation = ''
current_slide = 0
# Current pan/zoom state
current_x_offset = current_y_offset = 0.0
current_width_ratio = current_height_ratio = 100.0
current_x_camera = current_y_camera = current_zoom = 0.0
# Current cursor status
cursor_x = cursor_y = -1.0
cursor_visible = false
presenter = nil
# Current deskshare state (affects presentation and pan/zoom)
deskshare = false
slides = []
panzooms = []
cursors = []
shapes = {}
tldraw = BigBlueButton::Events.check_for_tldraw_events(@doc)
tldraw_shapes = {}
# Iterate through the events.xml and store the events, building the
# xml files as we go
last_timestamp = 0.0
events_xml = Nokogiri::XML(File.read("#{@process_dir}/events.xml"))
events_xml.xpath('/recording/event').each do |event|
eventname = event['eventname']
last_timestamp = timestamp =
(translate_timestamp(event['timestamp']) / 1000.0).round(1)
# Make sure to add initial entries to the slide & panzoom lists
slide_changed = slides.empty?
panzoom_changed = panzooms.empty?
cursor_changed = cursors.empty?
# Do event specific processing
case eventname
when 'SharePresentationEvent'
current_presentation = event.at_xpath('presentationName').text
current_slide = current_presentation_slide[current_presentation].to_i
slide_changed = panzoom_changed = true
when 'GotoSlideEvent'
current_slide = event.at_xpath('slide').text.to_i
current_presentation_slide[current_presentation] = current_slide
slide_changed = panzoom_changed = true
when 'ResizeAndMoveSlideEvent'
current_x_offset = event.at_xpath('xOffset').text.to_f
current_y_offset = event.at_xpath('yOffset').text.to_f
current_width_ratio = event.at_xpath('widthRatio').text.to_f
current_height_ratio = event.at_xpath('heightRatio').text.to_f
panzoom_changed = true
when 'TldrawCameraChangedEvent'
current_x_camera = event.at_xpath('xCamera').text.to_f
current_y_camera = event.at_xpath('yCamera').text.to_f
current_zoom = event.at_xpath('zoom').text.to_f
panzoom_changed = true
when 'DeskshareStartedEvent', 'StartWebRTCDesktopShareEvent'
deskshare = slide_changed = true if @presentation_props['include_deskshare']
when 'DeskshareStoppedEvent', 'StopWebRTCDesktopShareEvent'
if @presentation_props['include_deskshare']
deskshare = false
slide_changed = true
end
when 'AddShapeEvent', 'ModifyTextEvent'
events_parse_shape(shapes, event, current_presentation, current_slide, timestamp)
when 'AddTldrawShapeEvent'
events_parse_tldraw_shape(shapes, event, current_presentation, current_slide, timestamp)
when 'UndoShapeEvent', 'UndoAnnotationEvent', 'DeleteTldrawShapeEvent'
events_parse_undo(shapes, event, current_presentation, current_slide, timestamp)
when 'ClearPageEvent', 'ClearWhiteboardEvent'
events_parse_clear(shapes, event, current_presentation, current_slide, timestamp)
when 'AssignPresenterEvent'
# Move cursor offscreen on presenter switch, it'll reappear if the new
# presenter moves it
presenter = event.at_xpath('userid').text
cursor_visible = false
cursor_changed = true
when 'CursorMoveEvent'
cursor_x = event.at_xpath('xOffset').text.to_f
cursor_y = event.at_xpath('yOffset').text.to_f
cursor_visible = cursor_changed = true
when 'WhiteboardCursorMoveEvent'
user_id = event.at_xpath('userId')&.text
# Only draw cursor for current presentor. TODO multi-cursor support
if !user_id || user_id == presenter
cursor_x = event.at_xpath('xOffset').text.to_f
cursor_y = event.at_xpath('yOffset').text.to_f
cursor_visible = cursor_changed = true
end
end
# Perform slide finalization
if slide_changed
slide = slides.last
if slide &&
(slide[:presentation] == current_presentation) &&
(slide[:slide] == current_slide) &&
(slide[:deskshare] == deskshare)
BigBlueButton.logger.info('Presentation/Slide: skipping, no changes')
else
if slide
slide[:out] = timestamp
svg_render_image(svg, slide, shapes, tldraw, tldraw_shapes)
end
BigBlueButton.logger.info("Presentation #{current_presentation} Slide #{current_slide} Deskshare #{deskshare}")
slide = {
presentation: current_presentation,
slide: current_slide,
in: timestamp,
deskshare: deskshare,
}
events_get_image_info(slide, tldraw)
slides << slide
end
end
# Perform panzoom finalization
if panzoom_changed
slide = slides.last
panzoom = panzooms.last
slide_width = slide[:width]
slide_height = slide[:height]
if panzoom &&
(panzoom[:deskshare] == deskshare) &&
((!tldraw &&
(panzoom[:x_offset] == current_x_offset) &&
(panzoom[:y_offset] == current_y_offset) &&
(panzoom[:width_ratio] == current_width_ratio) &&
(panzoom[:height_ratio] == current_height_ratio) &&
(panzoom[:width] == slide_width) &&
(panzoom[:height] == slide_height)) ||
(tldraw &&
(panzoom[:x_camera] == current_x_camera) &&
(panzoom[:y_camera] == current_y_camera) &&
(panzoom[:zoom] == current_zoom))
)
BigBlueButton.logger.info('Panzoom: skipping, no changes')
panzoom_changed = false
else
if panzoom
panzoom[:out] = timestamp
panzooms_emit_event(panzooms_rec, panzoom, tldraw)
end
if !tldraw
BigBlueButton.logger.info("Panzoom: #{current_x_offset} #{current_y_offset} #{current_width_ratio} #{current_height_ratio} (#{slide_width}x#{slide_height})")
panzoom = {
x_offset: current_x_offset,
y_offset: current_y_offset,
width_ratio: current_width_ratio,
height_ratio: current_height_ratio,
width: slide[:width],
height: slide[:height],
in: timestamp,
deskshare: deskshare,
}
else
BigBlueButton.logger.info("Panzoom: #{current_x_camera} #{current_y_camera} #{current_zoom} (#{slide_width}x#{slide_height})")
panzoom = {
x_camera: current_x_camera,
y_camera: current_y_camera,
zoom: current_zoom,
in: timestamp,
deskshare: deskshare,
}
end
panzooms << panzoom
end
end
# Perform cursor finalization
if cursor_changed || panzoom_changed
if !tldraw
unless cursor_x >= 0 && cursor_x <= 100 &&
cursor_y >= 0 && cursor_y <= 100
cursor_visible = false
end
end
panzoom = panzooms.last
cursor = cursors.last
if cursor &&
((!cursor[:visible] && !cursor_visible) ||
(cursor[:x] == cursor_x && cursor[:y] == cursor_y)) &&
!panzoom_changed
BigBlueButton.logger.info('Cursor: skipping, no changes')
else
if cursor
cursor[:out] = timestamp
cursors_emit_event(cursors_rec, cursor, tldraw)
end
cursor = {
visible: cursor_visible,
x: cursor_x,
y: cursor_y,
panzoom: panzoom,
in: timestamp,
}
cursors << cursor
end
end
end
# Add the last slide, panzoom, and cursor
slide = slides.last
slide[:out] = last_timestamp
svg_render_image(svg, slide, shapes, tldraw, tldraw_shapes)
panzoom = panzooms.last
panzoom[:out] = last_timestamp
panzooms_emit_event(panzooms_rec, panzoom, tldraw)
cursor = cursors.last
cursor[:out] = last_timestamp
cursors_emit_event(cursors_rec, cursor, tldraw)
cursors_doc = Builder::XmlMarkup.new(indent: 2)
cursors_doc.instruct!
cursors_doc.recording(id: 'cursor_events', tldraw: tldraw) { |xml| xml << cursors_rec.target! }
panzooms_doc = Builder::XmlMarkup.new(indent: 2)
panzooms_doc.instruct!
panzooms_doc.recording(id: 'panzoom_events', tldraw: tldraw) { |xml| xml << panzooms_rec.target! }
# And save the result
File.write("#{package_dir}/#{@shapes_svg_filename}", shapes_doc.to_xml)
File.write("#{package_dir}/#{@panzooms_xml_filename}", panzooms_doc.target!)
File.write("#{package_dir}/#{@cursor_xml_filename}", cursors_doc.target!)
generate_json_file(package_dir, @tldraw_shapes_filename, tldraw_shapes) if tldraw
end
def process_chat_messages(events, bbb_props)
BigBlueButton.logger.info('Processing chat events')
# Create slides.xml and chat.
xml = Builder::XmlMarkup.new(indent: 2)
xml.instruct!
xml.popcorn do
BigBlueButton::Events.get_chat_events(events, @meeting_start.to_i, @meeting_end.to_i, bbb_props).each do |chat|
chattimeline = {
in: (chat[:in] / 1000.0).round(1),
direction: 'down',
name: chat[:sender],
chatEmphasizedText: chat[:chatEmphasizedText],
senderRole: chat[:senderRole],
message: chat[:message],
target: 'chat',
}
if (chat[:out])
chattimeline[:out] = (chat[:out] / 1000.0).round(1)
end
xml.chattimeline(**chattimeline)
end
end
xml
end
def process_deskshare_events(events)
BigBlueButton.logger.info('Processing deskshare events')
deskshare_matched_events = BigBlueButton::Events.get_matched_start_and_stop_deskshare_events(events)
@deskshare_xml = Builder::XmlMarkup.new(indent: 2)
@deskshare_xml.instruct!
@deskshare_xml.recording('id' => 'deskshare_events') do
deskshare_matched_events.each do |event|
start_timestamp = (translate_timestamp(event[:start_timestamp].to_f) / 1000).round(1)
stop_timestamp = (translate_timestamp(event[:stop_timestamp].to_f) / 1000).round(1)
next unless start_timestamp != stop_timestamp
video_info = BigBlueButton::EDL::Video.video_info("#{@deskshare_dir}/#{event_stream = event[:stream]}")
unless video_info[:video]
BigBlueButton.logger.warn("#{event_stream} is not a valid video file, skipping...")
next
end
video_width, video_height = get_deskshare_video_dimension(event_stream)
@deskshare_xml.event(start_timestamp: start_timestamp,
stop_timestamp: stop_timestamp,
video_width: video_width,
video_height: video_height)
end
end
end
def get_poll_question(event)
event.at_xpath('question')&.text || ''
end
def get_poll_answers(event)
answers = []
answers_event = event.at_xpath('answers')
if (answers_event)
answers = JSON.parse(answers_event.content)
end
answers
end
def get_poll_respondents(event)
event.at_xpath('numRespondents')&.text.to_i || 0
end
def get_poll_responders(event)
event.at_xpath('numResponders')&.text.to_i || 0
end
def get_poll_id(event)
event.at_xpath('pollId')&.text || ''
end
def get_poll_type(events, published_poll_event)
published_poll_id = get_poll_id(published_poll_event)
type = ''
events.xpath("recording/event[@eventname='PollStartedRecordEvent']").each do |event|
poll_id = get_poll_id(event)
if poll_id.eql?(published_poll_id)
type = event.at_xpath('type').text
break
end
end
type
end
def generate_json_file(package_dir, filename, contents)
File.open("#{package_dir}/#{filename}", 'w') { |f| f.puts(contents.to_json) } unless contents.empty?
end
def process_poll_events(events, package_dir)
BigBlueButton.logger.info('Processing poll events')
published_polls = []
@rec_events.each do |re|
events.xpath("recording/event[@eventname='PollPublishedRecordEvent']").each do |event|
timestamp = event[:timestamp]
next unless (timestamp.to_i >= re[:start_timestamp]) && (timestamp.to_i <= re[:stop_timestamp])
published_polls << {
timestamp: (translate_timestamp(timestamp) / 1000).to_i,
type: get_poll_type(events, event),
question: get_poll_question(event),
answers: get_poll_answers(event),
respondents: get_poll_respondents(event),
responders: get_poll_responders(event),
}
end
end
generate_json_file(package_dir, 'polls.json', published_polls)
end
def process_external_video_events(_events, package_dir)
BigBlueButton.logger.info('Processing external video events')
# Retrieve external video events
external_video_events = BigBlueButton::Events.match_start_and_stop_external_video_events(
BigBlueButton::Events.get_start_and_stop_external_video_events(@doc)
)
external_videos = []
@rec_events.each do |re|
external_video_events.each do |event|
BigBlueButton.logger.info("Processing rec event #{re} and external video event #{event}")
start_timestamp = event[:start_timestamp]
timestamp = (translate_timestamp(start_timestamp) / 1000).to_i
# do not add same external_video twice
next if external_videos.find { |ev| ev[:timestamp] == timestamp }
re_start_timestamp = re[:start_timestamp]
re_stop_timestamp = re[:stop_timestamp]
next unless ((start_timestamp >= re_start_timestamp) && (start_timestamp <= re_stop_timestamp)) ||
((start_timestamp < re_start_timestamp) && (re_stop_timestamp >= re_start_timestamp))
external_videos << {
timestamp: timestamp,
external_video_url: event[:external_video_url],
}
end
end
generate_json_file(package_dir, 'external_videos.json', external_videos)
end
def generate_done_or_fail_file(success)
File.open("#{@recording_dir}/status/published/#{@meeting_id}-presentation#{success ? '.done' : '.fail'}", 'w') do |file|
file.write("#{success ? 'Published' : 'Failed publishing'} #{@meeting_id}")
end
end
def copy_media_files_helper(media, media_files, package_dir)
BigBlueButton.logger.info("Making #{media} dir")
FileUtils.mkdir_p(media_dir = "#{package_dir}/#{media}")
media_files.each do |media_file|
BigBlueButton.logger.info("Made #{media} dir - copying: #{media_file} to -> #{media_dir}")
FileUtils.cp(media_file, media_dir)
BigBlueButton.logger.info("Copied #{File.extname(media_file)} file")
end
end
@shapes_svg_filename = 'shapes.svg'
@panzooms_xml_filename = 'panzooms.xml'
@cursor_xml_filename = 'cursor.xml'
@deskshare_xml_filename = 'deskshare.xml'
@tldraw_shapes_filename = 'tldraw.json'
@svg_shape_id = 1
@svg_shape_unique_id = 1
opts = Optimist.options do
opt :meeting_id, 'Meeting id to archive', default: '58f4a6b3-cd07-444d-8564-59116cb53974', type: String
end
@meeting_id = opts[:meeting_id]
match = /(.*)-(.*)/.match @meeting_id
@meeting_id = match[1]
@playback = match[2]
begin
if @playback == 'presentation'
log_dir = bbb_props['log_dir']
logger = Logger.new("#{log_dir}/presentation/publish-#{@meeting_id}.log", 'daily')
BigBlueButton.logger = logger
BigBlueButton.logger.info('Setting recording dir')
@recording_dir = bbb_props['recording_dir']
BigBlueButton.logger.info('Setting process dir')
@process_dir = "#{@recording_dir}/process/presentation/#{@meeting_id}"
BigBlueButton.logger.info('Setting publish dir')
publish_dir = @presentation_props['publish_dir']
BigBlueButton.logger.info('Setting playback url info')
playback_protocol = bbb_props['playback_protocol']
playback_host = bbb_props['playback_host']
BigBlueButton.logger.info('Setting target dir')
target_dir = "#{@recording_dir}/publish/presentation/#{@meeting_id}"
@deskshare_dir = "#{@recording_dir}/raw/#{@meeting_id}/deskshare"
if FileTest.directory?(target_dir)
BigBlueButton.logger.info("#{target_dir} is already there")
else
BigBlueButton.logger.info('Making dir target_dir')
FileUtils.mkdir_p target_dir
package_dir = "#{target_dir}/#{@meeting_id}"
BigBlueButton.logger.info('Making dir package_dir')
FileUtils.mkdir_p package_dir
begin
video_formats = @presentation_props['video_formats']
video_files = Dir.glob("#{@process_dir}/webcams.{#{video_formats.join(',')}}")
if video_files.empty?
copy_media_files_helper('audio', ["#{@process_dir}/audio.webm", "#{@process_dir}/audio.ogg"], package_dir)
else
copy_media_files_helper('video', video_files, package_dir)
end
video_files = Dir.glob("#{@process_dir}/deskshare.{#{video_formats.join(',')}}")
if video_files.empty?
BigBlueButton.logger.info("Could not copy deskshares.webm: file doesn't exist")
else
copy_media_files_helper('deskshare', video_files, package_dir)
end
if File.exist?("#{@process_dir}/captions.json")
BigBlueButton.logger.info('Copying caption files')
FileUtils.cp("#{@process_dir}/captions.json", package_dir)
Dir.glob("#{@process_dir}/caption_*.vtt").each do |caption|
BigBlueButton.logger.debug(caption)
FileUtils.cp(caption, package_dir)
end
end
presentation_text = "#{@process_dir}/presentation_text.json"
FileUtils.cp(presentation_text, package_dir) if File.exist?(presentation_text)
notes = "#{@process_dir}/notes/notes.html"
FileUtils.cp(notes, package_dir) if File.exist?(notes)
processing_time = File.read("#{@process_dir}/processing_time")
@doc = Nokogiri::XML(File.read("#{@process_dir}/events.xml"))
# Retrieve record events and calculate total recording duration.
@rec_events = BigBlueButton::Events.match_start_and_stop_rec_events(
BigBlueButton::Events.get_start_and_stop_rec_events(@doc)
)
@recording_time = BigBlueButton::Events.get_recording_length(@doc)
@meeting_start = BigBlueButton::Events.first_event_timestamp(@doc)
@meeting_end = BigBlueButton::Events.last_event_timestamp(@doc)
@version_atleast_0_9_0 = BigBlueButton::Events.bbb_version_compare(
@doc, 0, 9, 0
)
@version_atleast_2_0_0 = BigBlueButton::Events.bbb_version_compare(
@doc, 2, 0, 0
)
BigBlueButton.logger.info('Creating metadata.xml')
#### INSTEAD OF CREATING THE WHOLE metadata.xml FILE AGAIN, ONLY ADD <playback>
# Copy metadata.xml from process_dir
FileUtils.cp("#{@process_dir}/metadata.xml", package_dir)
BigBlueButton.logger.info('Copied metadata.xml file')
# Update state and add playback to metadata.xml
## Load metadata.xml
metadata = Nokogiri::XML(File.read("#{package_dir}/metadata.xml"))
## Update state
recording = metadata.root
state = recording.at_xpath('state')
state.content = 'published'
published = recording.at_xpath('published')
published.content = 'true'
## Remove empty playback
metadata.search('recording/playback').each(&:remove)
## Add the actual playback
presentation = BigBlueButton::Presentation.get_presentation_for_preview(@process_dir.to_s)
Nokogiri::XML::Builder.with(metadata.at('recording')) do |xml|
xml.playback do
xml.format('presentation')
xml.link("#{playback_protocol}://#{playback_host}/playback/presentation/2.3/#{@meeting_id}")
xml.processing_time(processing_time.to_s)
xml.duration(@recording_time.to_s)
unless presentation.empty?
xml.extensions do
xml.preview do
xml.images do
presentation[:slides].each do |key, val|
attributes = { width: '176', height: '136', alt: val[:alt]&.to_s || '' }
xml.image(attributes) do
xml.text("#{playback_protocol}://#{playback_host}/presentation/#{@meeting_id}/presentation/#{presentation[:id]}/thumbnails/thumb-#{key}.png")
end
end
end
end
end
end
end
end
## Write the new metadata.xml
File.open("#{package_dir}/metadata.xml", 'w') { |file| file.write(Nokogiri::XML(metadata.to_xml, &:noblanks).root) }
BigBlueButton.logger.info('Added playback to metadata.xml')
# Create slides.xml
BigBlueButton.logger.info('Generating xml for slides and chat')
calculate_record_events_offset
# Write slides.xml to file
slides_doc = process_chat_messages(@doc, bbb_props)
File.open("#{package_dir}/slides_new.xml", 'w') { |f| f.puts slides_doc.target! }
process_presentation(package_dir)
process_deskshare_events(@doc)
process_poll_events(@doc, package_dir)
process_external_video_events(@doc, package_dir)
# Write deskshare.xml to file
File.open("#{package_dir}/#{@deskshare_xml_filename}", 'w') { |f| f.puts @deskshare_xml.target! }
BigBlueButton.logger.info('Copying files to package dir')
FileUtils.cp_r("#{@process_dir}/presentation", package_dir)
BigBlueButton.logger.info('Copied files to package dir')
BigBlueButton.logger.info('Publishing slides')
# Now publish this recording files by copying them into the publish folder.
FileUtils.mkdir_p publish_dir unless FileTest.directory?(publish_dir)
# Get raw size of presentation files
raw_dir = "#{@recording_dir}/raw/#{@meeting_id}"
# After all the processing we'll add the published format and raw sizes to the metadata file
BigBlueButton.add_raw_size_to_metadata(package_dir, raw_dir)
BigBlueButton.add_playback_size_to_metadata(package_dir)
FileUtils.cp_r(package_dir, publish_dir) # Copy all the files.
BigBlueButton.logger.info('Finished publishing script presentation.rb successfully.')
BigBlueButton.logger.info('Removing processed and published files.')
FileUtils.rm_r([Dir.glob("#{@process_dir}/*"), Dir.glob("#{target_dir}/*")])
rescue StandardError => e
BigBlueButton.logger.error(e.message)
e.backtrace.each do |traceline|
BigBlueButton.logger.error(traceline)
end
exit 1
end
generate_done_or_fail_file(true)
end
end
rescue StandardError => e
BigBlueButton.logger.error(e.message)
e.backtrace.each do |traceline|
BigBlueButton.logger.error(traceline)
end
generate_done_or_fail_file(false)
exit 1
end