# Set encoding to utf-8 # encoding: UTF-8 # # 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 . # performance_start = Time.now require '../../core/lib/recordandplayback' require 'rubygems' require 'trollop' require 'yaml' require 'builder' require 'fastimage' # require fastimage to get the image size of the slides (gem install fastimage) # This script lives in scripts/archive/steps while properties.yaml lives in scripts/ bbb_props = YAML::load(File.open('../../core/scripts/bigbluebutton.yml')) $presentation_props = YAML::load(File.open('presentation.yml')) # 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 scaleToDeskshareVideo(width, height) deskshare_video_height = $presentation_props['deskshare_output_height'].to_f deskshare_video_width = $presentation_props['deskshare_output_height'].to_f scale = [deskshare_video_width/width, deskshare_video_height/height] video_width = width * scale.min video_height = height * scale.min return video_width.floor, video_height.floor end def getDeskshareVideoDimension(deskshare_stream_name) video_width = $presentation_props['deskshare_output_height'].to_f 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 = scaleToDeskshareVideo(video_info[:width], video_info[:height]) else BigBlueButton.logger.error("Could not find deskshare video: #{deskshare_video_filename}") end return 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 calculateRecordEventsOffset accumulated_duration = 0 previous_stop_recording = $meeting_start.to_f $rec_events.each do |event| event[:offset] = event[:start_timestamp] - accumulated_duration event[:duration] = event[:stop_timestamp] - event[:start_timestamp] event[:accumulated_duration] = accumulated_duration previous_stop_recording = event[:stop_timestamp] accumulated_duration += event[:duration] end end # # Translated an arbitrary Unix timestamp to the recording timestamp. This is the # function that others will call # def translateTimestamp(timestamp) new_timestamp = translateTimestamp_helper(timestamp.to_f).to_f # BigBlueButton.logger.info("Translating #{timestamp}, old value=#{timestamp.to_f-$meeting_start.to_f}, new value=#{new_timestamp}") new_timestamp end # # Translated an arbitrary Unix timestamp to the recording timestamp # def translateTimestamp_helper(timestamp) $rec_events.each do |event| # if the timestamp comes before the start recording event, then the timestamp is translated to the moment it starts recording if timestamp <= event[:start_timestamp] return event[:start_timestamp] - event[:offset] # if the timestamp is during the recording period, it is just translated to the new one using the offset elsif timestamp > event[:start_timestamp] and timestamp <= event[:stop_timestamp] return timestamp - event[:offset] end end # if the timestamp comes after the last stop recording event, then the timestamp is translated to the last stop recording event timestamp return timestamp - $rec_events.last()[:offset] + $rec_events.last()[:duration] end def color_to_hex(color) color = color.to_i.to_s(16) return '0'*(6-color.length) + color end def shape_scale_width(slide, x) return (x / 100.0 * slide[:width]).round(5) end def shape_scale_height(slide, y) return (y / 100.0 * slide[:height]).round(5) end def shape_thickness(slide, shape) if !shape[:thickness_percent].nil? return shape_scale_width(slide, shape[:thickness_percent]) else return shape[:thickness] end end def svg_render_shape_pencil(g, slide, shape) g['shape'] = "pencil#{shape[:shape_unique_id]}" doc = g.document if shape[:data_points].length < 2 BigBlueButton.logger.warn("Pencil #{shape[:shape_unique_id]} doesn't have enough points") return end if shape[:data_points].length == 2 BigBlueButton.logger.info("Pencil #{shape[:shape_unique_id]}: Drawing single point") g['style'] = "stroke:none;fill:##{shape[:color]};visibility:hidden" circle = doc.create_element('circle', cx: shape_scale_width(slide, shape[:data_points][0]), cy: shape_scale_height(slide, shape[:data_points][1]), r: (shape_thickness(slide, shape) / 2.0).round(5)) g << circle else path = [] data_points = shape[:data_points].each if !shape[:commands].nil? BigBlueButton.logger.info("Pencil #{shape[:shape_unique_id]}: Drawing from command string (#{shape[:commands].length} 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} #{cy2},#{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 BigBlueButton.logger.info("Pencil #{shape[:shape_unique_id]}: Drawing simple line (#{shape[:data_points].length / 2} points)") x = shape_scale_width(slide, data_points.next) y = shape_scale_height(slide, data_points.next) path << "M#{x} #{y}" begin while true 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" if $version_atleast_2_0_0 g['style'] += ";stroke-linecap:butt" else g['style'] += ";stroke-linecap:round" end 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 svg_render_shape_rect(g, slide, shape) g['shape'] = "rect#{shape[:shape_unique_id]}" g['style'] = "stroke:##{shape[:color]};stroke-width:#{shape_thickness(slide,shape)};visibility:hidden;fill:none" if $version_atleast_2_0_0 g['style'] += ";stroke-linejoin:miter" else g['style'] += ";stroke-linejoin:round" end 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 height = (y2 - y1).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:##{shape[:color]};stroke-width:#{shape_thickness(slide,shape)};visibility:hidden;fill:none" if $version_atleast_2_0_0 g['style'] += ";stroke-linejoin:miter;stroke-miterlimit:8" else g['style'] += ";stroke-linejoin:round" end 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:##{shape[:color]};stroke-width:#{shape_thickness(slide,shape)};visibility:hidden;fill:none" 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}" path += "A#{width_r} #{height_r} 0 0 1 #{hx} #{y1}" path += "A#{width_r} #{height_r} 0 0 1 #{x2} #{hy}" path += "A#{width_r} #{height_r} 0 0 1 #{hx} #{y2}" path += "A#{width_r} #{height_r} 0 0 1 #{x1} #{hy}" path += "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]) BigBlueButton.logger.info("Text #{shape[:shape_unique_id]} width #{width} height #{height} font size #{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| if index > 0 p << doc.create_element('br') end p << doc.create_text_node(line.chomp) end fo << p switch << fo g << switch end def svg_render_shape_poll(g, slide, shape) 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]) result = JSON.load(shape[:result]) num_responders = shape[:num_responders] presentation = slide[:presentation] max_num_votes = result.map{ |r| r['num_votes'] }.max dat_file = "#{$process_dir}/poll_result#{poll_id}.dat" gpl_file = "#{$process_dir}/poll_result#{poll_id}.gpl" pdf_file = "#{$process_dir}/poll_result#{poll_id}.pdf" svg_file = "#{$process_dir}/presentation/#{presentation}/poll_result#{poll_id}.svg" # Use gnuplot to generate an SVG image for the graph File.open(dat_file, 'w') do |d| result.each do |r| d.puts("#{r['id']} #{r['num_votes']}") end end File.open(dat_file, 'r') do |d| BigBlueButton.logger.debug("gnuplot data:") BigBlueButton.logger.debug(d.readlines(nil)[0]) end File.open(gpl_file, 'w') do |g| g.puts('reset') g.puts("set term pdfcairo size #{height / 72}, #{width / 72} font \"Arial,48\" noenhanced") g.puts('set lmargin 0.5') g.puts('set rmargin 0.5') g.puts('unset key') g.puts('set style data boxes') g.puts('set style fill solid border -1') g.puts('set boxwidth 0.9 relative') g.puts('set yrange [0:*]') g.puts('unset border') g.puts('unset ytics') xtics = result.map{ |r| "#{r['key'].gsub('%', '%%').inspect} #{r['id']}" }.join(', ') g.puts("set xtics rotate by 90 scale 0 right (#{xtics})") if num_responders > 0 x2tics = result.map{ |r| "\"#{(r['num_votes'].to_f / num_responders * 100).to_i}%%\" #{r['id']}" }.join(', ') g.puts("set x2tics rotate by 90 scale 0 left (#{x2tics})") end g.puts('set linetype 1 linewidth 1 linecolor rgb "black"') result.each do |r| if r['num_votes'] == 0 or r['num_votes'].to_f / max_num_votes <= 0.5 g.puts("set label \"#{r['num_votes']}\" at #{r['id']},#{r['num_votes']} left rotate by 90 offset 0,character 0.5 front") else g.puts("set label \"#{r['num_votes']}\" at #{r['id']},#{r['num_votes']} right rotate by 90 offset 0,character -0.5 textcolor rgb \"white\" front") end end g.puts("set output \"#{pdf_file}\"") g.puts("plot \"#{dat_file}\"") end File.open(gpl_file, 'r') do |d| BigBlueButton.logger.debug("gnuplot script:") BigBlueButton.logger.debug(d.readlines(nil)[0]) end # gnuplot svg rendering has issues, so we render to pdf... ret = BigBlueButton.exec_ret('gnuplot', '-d', gpl_file) raise "Failed to generate plot pdf" if ret != 0 # then use pdftocairo to turn it into svg ret = BigBlueButton.exec_ret('pdftocairo', '-svg', pdf_file, svg_file) raise "Failed to convert poll to svg" if ret != 0 # Outer box to act as a poll result backdrop g << doc.create_element('rect', x: x + 2, y: y + 2, width: width - 4, height: height - 4, fill: 'white', stroke: 'black', 'stroke-width' => 4) # Poll image (note that the image is sideways and has to be rotated) g << doc.create_element('image', 'xlink:href' => "presentation/#{presentation}/poll_result#{poll_id}.svg", height: width, width: height, x: slide[:width], y: y, transform: "rotate(90, #{slide[:width]}, #{y})") end def svg_render_shape(canvas, slide, shape, image_id) 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] or (!shape[:out].nil? and 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 BigBlueButton.logger.info("Draw #{shape[:shape_id]} Shape #{shape[:shape_unique_id]} Type #{shape[:type]} from #{shape[:in]} to #{shape[:out]} undo #{shape[:undo]}") 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]}" if g.element_children.length > 0 canvas << g end end $svg_image_id = 1 def svg_render_image(svg, slide, shapes) if slide[:in] == slide[:out] BigBlueButton.logger.info("Presentation #{slide[:presentation]} Slide #{slide[:slide]} is never shown (duration rounds to 0)") return end image_id = $svg_image_id $svg_image_id += 1 BigBlueButton.logger.info("Image #{image_id}: Presentation #{slide[:presentation]} Slide #{slide[:slide]} 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].nil? svg << image if slide[:deskshare] or shapes[slide[:presentation]].nil? or shapes[slide[:presentation]][slide[:slide]].nil? return end shapes = shapes[slide[:presentation]][slide[:slide]] 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 if canvas.element_children.length > 0 svg << canvas end end def panzoom_viewbox(panzoom) if panzoom[:deskshare] panzoom[:x_offset] = 0.0 panzoom[:y_offset] = 0.0 panzoom[:width_ratio] = 100.0 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]) return [x, y, w, h] end def panzooms_emit_event(rec, panzoom) if panzoom[:in] == panzoom[:out] BigBlueButton.logger.info("Panzoom: not emitting, duration rounds to 0") return end doc = rec.document event = doc.create_element('event', timestamp: panzoom[:in]) x, y, w, h = panzoom_viewbox(panzoom) viewbox = doc.create_element('viewBox') viewbox.content = "#{x} #{y} #{w} #{h}" BigBlueButton.logger.info("Panzoom viewbox #{viewbox.content} at #{panzoom[:in]}") event << viewbox rec << event end def cursors_emit_event(rec, cursor) if cursor[:in] == cursor[:out] BigBlueButton.logger.info("Cursor: not emitting, duration rounds to 0") return end doc = rec.document event = doc.create_element('event', timestamp: cursor[:in]) panzoom = cursor[:panzoom] if cursor[:visible] if $version_atleast_2_0_0 # 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 = (((cursor[:x] / 100.0) + (panzoom[:x_offset] * $magic_mystery_number / 100.0)) / (panzoom[:width_ratio] / 100.0)).round(5) y = (((cursor[:y] / 100.0) + (panzoom[:y_offset] * $magic_mystery_number / 100.0)) / (panzoom[:height_ratio] / 100.0)).round(5) if x < 0 or x > 1 or y < 0 or y > 1 x = -1.0 y = -1.0 end else # Cursor position is relative to the visible area x = cursor[:x].round(5) y = cursor[:y].round(5) end else x = -1.0 y = -1.0 end cursor_e = doc.create_element('cursor') cursor_e.content = "#{x} #{y}" BigBlueButton.logger.info("Cursor #{cursor_e.content} at #{cursor[:in]}") event << cursor_e rec << event end $svg_shape_id = 1 $svg_shape_unique_id = 1 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') if presentation.nil? presentation = current_presentation else presentation = presentation.text end if slide.nil? slide = current_slide else slide = slide.text.to_i slide -=1 unless $version_atleast_0_9_0 end # Set up the shapes data structures if needed shapes[presentation] = {} if shapes[presentation].nil? shapes[presentation][slide] = [] if shapes[presentation][slide].nil? # 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] = event.at_xpath('type').text shape[:data_points] = event.at_xpath('dataPoints').text.split(',').map { |p| p.to_f } # These can be missing in old BBB versions, there are fallbacks user_id = event.at_xpath('userId') shape[:user_id] = user_id.text if !user_id.nil? shape_id = event.at_xpath('id') shape[:id] = shape_id.text if !shape_id.nil? status = event.at_xpath('status') shape[:status] = status.text if !status.nil? shape[:shape_id] = $svg_shape_id $svg_shape_id += 1 # Some shape-specific properties if shape[:type] == 'pencil' or shape[:type] == 'rectangle' or shape[:type] == 'ellipse' or shape[:type] == 'triangle' or shape[:type] == 'line' shape[:color] = color_to_hex(event.at_xpath('color').text) thickness = event.at_xpath('thickness').text if $version_atleast_2_0_0 shape[:thickness_percent] = thickness.to_f else shape[:thickness] = thickness.to_i end end if shape[:type] == 'rectangle' square = event.at_xpath('square') if !square.nil? shape[:square] = (square.text == 'true') end end if shape[:type] == 'ellipse' circle = event.at_xpath('circle') if !circle.nil? shape[:circle] = (circle.text == 'true') end end if shape[:type] == 'pencil' commands = event.at_xpath('commands') if !commands.nil? shape[:commands] = commands.text.split(',').map { |c| c.to_i } end end if shape[:type] == '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 end if shape[:type] == 'text' shape[:text_box_width] = event.at_xpath('textBoxWidth').text.to_f shape[:text_box_height] = event.at_xpath('textBoxHeight').text.to_f shape[:calced_font_size] = event.at_xpath('calcedFontSize').text.to_f shape[:font_color] = color_to_hex(event.at_xpath('fontColor').text) text = event.at_xpath('text') if !text.nil? shape[:text] = text.text else shape[:text] = '' end end # Find the previous shape, for updates prev_shape = nil if !shape[:id].nil? # If we have a shape ID, look up the previous shape by ID prev_shape = shapes.find_all {|s| s[:id] == shape[:id] }.last 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 if last_shape[:type] == shape[:type] and last_shape[:data_points][0] == shape[:data_points][0] and last_shape[:data_points][1] == shape[:data_points][1] prev_shape = last_shape end end if !prev_shape.nil? prev_shape[:out] = timestamp shape[:shape_unique_id] = prev_shape[:shape_unique_id] if shape[:type] == 'pencil' and 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 BigBlueButton.logger.info("Draw #{shape[:shape_id]} Shape #{shape[:shape_unique_id]} ID #{shape[:id]} Type #{shape[:type]}") shapes << shape 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 = event.at_xpath('presentation') slide = event.at_xpath('pageNumber') if presentation.nil? presentation = current_presentation else presentation = presentation.text end if slide.nil? slide = current_slide else slide = slide.text.to_i slide -=1 unless $version_atleast_0_9_0 end # Newer undo messages have the shape id, making this a lot easier shape_id = event.at_xpath('shapeId') if !shape_id.nil? shape_id = shape_id.text end # Set up the shapes data structures if needed shapes[presentation] = {} if shapes[presentation].nil? shapes[presentation][slide] = [] if shapes[presentation][slide].nil? # We only need to deal with shapes for this slide shapes = shapes[presentation][slide] if !shape_id.nil? # 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}") shapes.each do |shape| if shape[:id] == shape_id if shape[:undo].nil? or shape[:undo] > timestamp shape[:undo] = timestamp end end end 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_shape = shapes.select { |s| s[:undo].nil? }.last if !undo_shape.nil? BigBlueButton.logger.info("Undo: removing Shape #{undo_shape[: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. shapes.each do |shape| if shape[:shape_unique_id] == undo_shape[:shape_unique_id] if shape[:undo].nil? or shape[:undo] > timestamp shape[:undo] = timestamp end end end 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 = event.at_xpath('presentation') slide = event.at_xpath('pageNumber') if presentation.nil? presentation = current_presentation else presentation = presentation.text end if slide.nil? slide = current_slide else slide = slide.text.to_i slide -=1 unless $version_atleast_0_9_0 end # BigBlueButton 2.0 per-user clear features full_clear = event.at_xpath('fullClear') if !full_clear.nil? full_clear = (full_clear.text == 'true') else # Default to full clear on older versions full_clear = true end user_id = event.at_xpath('userId') if !user_id.nil? user_id = user_id.text end # Set up the shapes data structures if needed shapes[presentation] = {} if shapes[presentation].nil? shapes[presentation][slide] = [] if shapes[presentation][slide].nil? # 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 or user_id == shape[:user_id] if shape[:undo].nil? or shape[:undo] > timestamp shape[:undo] = timestamp end end end end def events_get_image_info(slide) if slide[:deskshare] slide[:src] = 'presentation/deskshare.png' elsif slide[:presentation] == '' slide[:src] = 'presentation/logo.png' else slide[:src] = "presentation/#{slide[:presentation]}/slide-#{slide[:slide] + 1}.png" slide[:text] = "presentation/#{slide[:presentation]}/textfiles/slide-#{slide[:slide] + 1}.txt" end image_path = "#{$process_dir}/#{slide[:src]}" if !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)) if slide[:deskshare] command = "convert -size #{$presentation_props['deskshare_output_width']}x#{$presentation_props['deskshare_output_height']} xc:transparent -background transparent #{image_path}" else command = "convert -size 1600x1200 xc:transparent -background transparent -quality 90 +dither -depth 8 -colors 256 #{image_path}" end BigBlueButton.execute(command) 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 processPresentation(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_doc = Nokogiri::XML::Document.new() panzooms_rec = panzooms_doc.root = panzooms_doc.create_element('recording', id: 'panzoom_events') cursors_doc = Nokogiri::XML::Document.new() cursors_rec = cursors_doc.root = cursors_doc.create_element('recording', id: 'cursor_events') # Current presentation/slide state current_presentation_slide = {} current_presentation = '' current_slide = 0 # Current pan/zoom state current_x_offset = 0.0 current_y_offset = 0.0 current_width_ratio = 100.0 current_height_ratio = 100.0 # Current cursor status cursor_x = -1.0 cursor_y = -1.0 cursor_visible = false presenter = nil # Current deskshare state (affects presentation and pan/zoom) deskshare = false slides = [] panzooms = [] cursors = [] 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.open("#{$process_dir}/events.xml")) events_xml.xpath('/recording/event').each do |event| eventname = event['eventname'] last_timestamp = timestamp = (translateTimestamp(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 if eventname == 'SharePresentationEvent' current_presentation = event.at_xpath('presentationName').text current_slide = current_presentation_slide[current_presentation].to_i slide_changed = true panzoom_changed = true elsif eventname == 'GotoSlideEvent' current_slide = event.at_xpath('slide').text.to_i current_presentation_slide[current_presentation] = current_slide slide_changed = true panzoom_changed = true elsif eventname == '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 elsif $presentation_props['include_deskshare'] and (eventname == 'DeskshareStartedEvent' or eventname == 'StartWebRTCDesktopShareEvent') deskshare = true slide_changed = true elsif $presentation_props['include_deskshare'] and (eventname == 'DeskshareStoppedEvent' or eventname == 'StopWebRTCDesktopShareEvent') deskshare = false slide_changed = true elsif eventname == 'AddShapeEvent' or eventname == 'ModifyTextEvent' events_parse_shape(shapes, event, current_presentation, current_slide, timestamp) elsif eventname == 'UndoShapeEvent' or eventname == 'UndoAnnotationEvent' events_parse_undo(shapes, event, current_presentation, current_slide, timestamp) elsif eventname == 'ClearPageEvent' or eventname == 'ClearWhiteboardEvent' events_parse_clear(shapes, event, current_presentation, current_slide, timestamp) elsif eventname == '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 elsif eventname == 'CursorMoveEvent' cursor_x = event.at_xpath('xOffset').text.to_f cursor_y = event.at_xpath('yOffset').text.to_f cursor_visible = true cursor_changed = true elsif eventname == 'WhiteboardCursorMoveEvent' user_id = event.at_xpath('userId') # Only draw cursor for current presentor. TODO multi-cursor support if user_id.nil? or user_id.text == presenter cursor_x = event.at_xpath('xOffset').text.to_f cursor_y = event.at_xpath('yOffset').text.to_f cursor_visible = true cursor_changed = true end end # Perform slide finalization if slide_changed slide = slides.last if !slide.nil? and slide[:presentation] == current_presentation and slide[:slide] == current_slide and slide[:deskshare] == deskshare BigBlueButton.logger.info('Presentation/Slide: skipping, no changes') slide_changed = false else if !slide.nil? slide[:out] = timestamp svg_render_image(svg, slide, 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) slides << slide end end # Perform panzoom finalization if panzoom_changed slide = slides.last panzoom = panzooms.last if !panzoom.nil? and panzoom[:x_offset] == current_x_offset and panzoom[:y_offset] == current_y_offset and panzoom[:width_ratio] == current_width_ratio and panzoom[:height_ratio] == current_height_ratio and panzoom[:width] == slide[:width] and panzoom[:height] == slide[:height] and panzoom[:deskshare] == deskshare BigBlueButton.logger.info('Panzoom: skipping, no changes') panzoom_changed = false else if !panzoom.nil? panzoom[:out] = timestamp panzooms_emit_event(panzooms_rec, panzoom) end 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, } panzooms << panzoom end end # Perform cursor finalization if cursor_changed or panzoom_changed unless cursor_x >= 0 and cursor_x <= 100 and cursor_y >= 0 and cursor_y <= 100 cursor_visible = false end panzoom = panzooms.last cursor = cursors.last if !cursor.nil? and ((!cursor[:visible] and !cursor_visible) or (cursor[:x] == cursor_x and cursor[:y] == cursor_y)) and !panzoom_changed BigBlueButton.logger.info('Cursor: skipping, no changes') else if !cursor.nil? cursor[:out] = timestamp cursors_emit_event(cursors_rec, cursor) end BigBlueButton.logger.info("Cursor: visible #{cursor_visible}, #{cursor_x} #{cursor_y} (#{panzoom[:width]}x#{panzoom[:height]})") 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) panzoom = panzooms.last panzoom[:out] = last_timestamp panzooms_emit_event(panzooms_rec, panzoom) cursor = cursors.last cursor[:out] = last_timestamp cursors_emit_event(cursors_rec, cursor) # And save the result File.write("#{package_dir}/#{$shapes_svg_filename}", shapes_doc.to_xml) File.write("#{package_dir}/#{$panzooms_xml_filename}", panzooms_doc.to_xml) File.write("#{package_dir}/#{$cursor_xml_filename}", cursors_doc.to_xml) end def processChatMessages BigBlueButton.logger.info("Processing chat events") # Create slides.xml and chat. $slides_doc = Nokogiri::XML::Builder.new do |xml| $xml = xml $xml.popcorn { # Process chat events. current_time = 0 $rec_events.each do |re| $chat_events.each do |node| if (node[:timestamp].to_i >= re[:start_timestamp] and node[:timestamp].to_i <= re[:stop_timestamp]) chat_timestamp = node[:timestamp] chat_sender = node.xpath(".//sender")[0].text() chat_message = BigBlueButton::Events.linkify(node.xpath(".//message")[0].text()) chat_start = ( translateTimestamp(chat_timestamp) / 1000).to_i # Creates a list of the clear timestamps that matter for this message next_clear_timestamps = $clear_chat_timestamps.select{ |e| e >= node[:timestamp] } # If there is none we skip it, or else we add the out time that will remove a message if next_clear_timestamps.empty? $xml.chattimeline(:in => chat_start, :direction => :down, :name => chat_sender, :message => chat_message, :target => :chat ) else chat_end = ( translateTimestamp( next_clear_timestamps.first ) / 1000).to_i $xml.chattimeline(:in => chat_start, :out => chat_end, :direction => :down, :name => chat_sender, :message => chat_message, :target => :chat ) end end end current_time += re[:stop_timestamp] - re[:start_timestamp] end } end end def processDeskshareEvents(events) BigBlueButton.logger.info("Processing deskshare events") deskshare_matched_events = BigBlueButton::Events.get_matched_start_and_stop_deskshare_events(events) $deskshare_xml = Nokogiri::XML::Builder.new do |xml| $xml = xml $xml.recording('id' => 'deskshare_events') do deskshare_matched_events.each do |event| start_timestamp = (translateTimestamp(event[:start_timestamp].to_f) / 1000).round(1) stop_timestamp = (translateTimestamp(event[:stop_timestamp].to_f) / 1000).round(1) if (start_timestamp != stop_timestamp) video_info = BigBlueButton::EDL::Video.video_info("#{$deskshare_dir}/#{event[:stream]}") if !video_info[:video] BigBlueButton.logger.warn("#{event[:stream]} is not a valid video file, skipping...") next end video_width, video_height = getDeskshareVideoDimension(event[:stream]) $xml.event(:start_timestamp => start_timestamp, :stop_timestamp => stop_timestamp, :video_width => video_width, :video_height => video_height) end end end end end $shapes_svg_filename = 'shapes.svg' $panzooms_xml_filename = 'panzooms.xml' $cursor_xml_filename = 'cursor.xml' $deskshare_xml_filename = 'deskshare.xml' opts = Trollop::options do opt :meeting_id, "Meeting id to archive", :default => '58f4a6b3-cd07-444d-8564-59116cb53974', :type => String end $meeting_id = opts[:meeting_id] puts $meeting_id match = /(.*)-(.*)/.match $meeting_id $meeting_id = match[1] $playback = match[2] puts $meeting_id puts $playback 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 captions dir") captions_dir = bbb_props['captions_dir'] captions_publish_dir = "#{captions_dir}/#{$meeting_id}" 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 not FileTest.directory?(target_dir) 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? BigBlueButton.logger.info("Making video dir") video_dir = "#{package_dir}/video" FileUtils.mkdir_p video_dir video_files.each do |video_file| BigBlueButton.logger.info("Made video dir - copying: #{video_file} to -> #{video_dir}") FileUtils.cp(video_file, video_dir) BigBlueButton.logger.info("Copied #{File.extname(video_file)} file") end else audio_dir = "#{package_dir}/audio" BigBlueButton.logger.info("Making audio dir") FileUtils.mkdir_p audio_dir BigBlueButton.logger.info("Made audio dir - copying: #{$process_dir}/audio.webm to -> #{audio_dir}") FileUtils.cp("#{$process_dir}/audio.webm", audio_dir) BigBlueButton.logger.info("Copied audio.webm file - copying: #{$process_dir}/audio.ogg to -> #{audio_dir}") FileUtils.cp("#{$process_dir}/audio.ogg", audio_dir) BigBlueButton.logger.info("Copied audio.ogg file") end if File.exist?("#{$process_dir}/captions.json") BigBlueButton.logger.info("Copying caption files to #{captions_publish_dir}") FileUtils.mkdir_p captions_publish_dir FileUtils.cp("#{$process_dir}/captions.json", captions_publish_dir) Dir.glob("#{$process_dir}/caption_*.vtt").each do |caption| BigBlueButton.logger.debug(caption) FileUtils.cp(caption, captions_publish_dir) end end video_files = Dir.glob("#{$process_dir}/deskshare.{#{video_formats.join(',')}}") if ! video_files.empty? BigBlueButton.logger.info("Making deskshare dir") deskshare_dir = "#{package_dir}/deskshare" FileUtils.mkdir_p deskshare_dir video_files.each do |video_file| BigBlueButton.logger.info("Made deskshare dir - copying: #{video_file} to -> #{deskshare_dir}") FileUtils.cp(video_file, deskshare_dir) BigBlueButton.logger.info("Copied #{File.extname(video_file)} file") end else BigBlueButton.logger.info("Could not copy deskshares.webm: file doesn't exist") end if File.exist?("#{$process_dir}/presentation_text.json") FileUtils.cp("#{$process_dir}/presentation_text.json", package_dir) end processing_time = File.read("#{$process_dir}/processing_time") @doc = Nokogiri::XML(File.open("#{$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) # presentation_url = "/slides/" + $meeting_id + "/presentation" $meeting_start = @doc.xpath("//event")[0][:timestamp] $meeting_end = @doc.xpath("//event").last()[:timestamp] $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") # Get the real-time start and end timestamp match = /.*-(\d+)$/.match($meeting_id) real_start_time = match[1] real_end_time = (real_start_time.to_i + ($meeting_end.to_i - $meeting_start.to_i)).to_s #### INSTEAD OF CREATING THE WHOLE metadata.xml FILE AGAIN, ONLY ADD # 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.open("#{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 do |playback| playback.remove end ## Add the actual playback presentation = BigBlueButton::Presentation.get_presentation_for_preview("#{$process_dir}") metadata_with_playback = Nokogiri::XML::Builder.with(metadata.at('recording')) do |xml| xml.playback { xml.format("presentation") xml.link("#{playback_protocol}://#{playback_host}/playback/presentation/2.0/playback.html?meetingId=#{$meeting_id}") xml.processing_time("#{processing_time}") xml.duration("#{recording_time}") unless presentation.empty? xml.extensions { xml.preview { xml.images { presentation[:slides].each do |key,val| attributes = {:width => "176", :height => "136", :alt => (val[:alt] != nil)? "#{val[:alt]}": ""} xml.image(attributes){ xml.text("#{playback_protocol}://#{playback_host}/presentation/#{$meeting_id}/presentation/#{presentation[:id]}/thumbnails/thumb-#{key}.png") } end } } } end } end ## Write the new metadata.xml metadata_file = File.new("#{package_dir}/metadata.xml","w") metadata = Nokogiri::XML(metadata.to_xml) { |x| x.noblanks } metadata_file.write(metadata.root) metadata_file.close BigBlueButton.logger.info("Added playback to metadata.xml") #Create slides.xml BigBlueButton.logger.info("Generating xml for slides and chat") # Gathering all the events from the events.xml $chat_events = @doc.xpath("//event[@eventname='PublicChatEvent']") # Create a list of timestamps when the moderator cleared the public chat $clear_chat_timestamps = [ ] clear_chat_events = @doc.xpath("//event[@eventname='ClearPublicChatEvent']") clear_chat_events.each { |clear| $clear_chat_timestamps << clear[:timestamp] } $clear_chat_timestamps.sort! calculateRecordEventsOffset() processChatMessages() processPresentation(package_dir) processDeskshareEvents(@doc) # Write slides.xml to file File.open("#{package_dir}/slides_new.xml", 'w') { |f| f.puts $slides_doc.to_xml } # Write deskshare.xml to file File.open("#{package_dir}/#{$deskshare_xml_filename}", 'w') { |f| f.puts $deskshare_xml.to_xml } 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. if not FileTest.directory?(publish_dir) FileUtils.mkdir_p publish_dir end # 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 files.") FileUtils.rm_r(Dir.glob("#{$process_dir}/*")) BigBlueButton.logger.info("Removing published files.") FileUtils.rm_r(Dir.glob("#{target_dir}/*")) rescue Exception => e BigBlueButton.logger.error(e.message) e.backtrace.each do |traceline| BigBlueButton.logger.error(traceline) end exit 1 end publish_done = File.new("#{recording_dir}/status/published/#{$meeting_id}-presentation.done", "w") publish_done.write("Published #{$meeting_id}") publish_done.close else BigBlueButton.logger.info("#{target_dir} is already there") end end rescue Exception => e BigBlueButton.logger.error(e.message) e.backtrace.each do |traceline| BigBlueButton.logger.error(traceline) end publish_done = File.new("#{recording_dir}/status/published/#{$meeting_id}-presentation.fail", "w") publish_done.write("Failed Publishing #{$meeting_id}") publish_done.close exit 1 end