diff --git a/record-and-playback/.rubocop.yml b/record-and-playback/.rubocop.yml index 368e11ac5e..9f078d4a64 100644 --- a/record-and-playback/.rubocop.yml +++ b/record-and-playback/.rubocop.yml @@ -1,5 +1,6 @@ AllCops: TargetRubyVersion: 2.5 # System ruby on Ubuntu 18.04 + NewCops: enable Layout/HashAlignment: EnforcedHashRocketStyle: [ key, table ] EnforcedColonStyle: [ key, table ] diff --git a/record-and-playback/core/scripts/README b/record-and-playback/core/scripts/README index df8adf4e6c..c011857e86 100644 --- a/record-and-playback/core/scripts/README +++ b/record-and-playback/core/scripts/README @@ -1,77 +1,132 @@ README -This instructions below are for testing by running scripts manually: +These instructions below are for testing by running scripts manually: 1. Create some temp scratch dirs: - mkdir -p ~/temp/log/presentation ~/temp/recording/{process,publish,raw} ~/temp/recording/status/{recorded,archived,processed,sanity} ~/temp/published -2. Edit core/scripts/bigbluebutton.yml and comment out the PRODUCTION dirs while uncommenting the DEVELOPMENT dir. The dir should match what you created above. - -raw_audio_src: /var/freeswitch/meetings -raw_video_src: /usr/share/red5/webapps/video/streams -raw_deskshare_src: /var/bigbluebutton/deskshare -raw_presentation_src: /var/bigbluebutton -redis_host: 127.0.0.1 -redis_port: 6379 +mkdir -p ~/temp/log/presentation ~/temp/recording/{process,publish,raw} ~/temp/recording/status/{recorded,archived,processed,sanity} ~/temp/published +2. Edit ~/dev/bigbluebutton/record-and-playback/core/scripts/bigbluebutton.yml and comment the PRODUCTION directories while uncommenting the DEVELOPMENT folders. +The path should match the ones created above. # For PRODUCTION -log_dir: /var/log/bigbluebutton -recording_dir: /var/bigbluebutton/recording -published_dir: /var/bigbluebutton/published -playback_host: 10.0.3.203 +# log_dir: /var/log/bigbluebutton +events_dir: /var/bigbluebutton/events +# recording_dir: /var/bigbluebutton/recording +# published_dir: /var/bigbluebutton/published +captions_dir: /var/bigbluebutton/captions +# playback_host: develop.distancelearning.cloud +playback_protocol: https # For DEVELOPMENT # This allows us to run the scripts manually -#scripts_dir: /home/ubuntu/dev/bigbluebutton/record-and-playback/core/scripts -#log_dir: /home/ubuntu/temp/log -#recording_dir: /home/ubuntu/temp/recording -#published_dir: /home/ubuntu/temp/published -#playback_host: 192.168.22.137 +scripts_dir: /home/ubuntu/dev/bigbluebutton/record-and-playback/core/scripts +log_dir: /home/ubuntu/temp/log +recording_dir: /home/ubuntu/temp/recording +published_dir: /home/ubuntu/temp/published +playback_host: 127.0.0.1 -3. Create a recording using BigBlueButton. After logging out, it should have created a .done file in - /var/bigbluebutton/recording/status/recorded dir. Make note of this meeting-id as we use that to tell the script +3. Stop the recording service: + + systemctl stop bbb-rap-starter + systemctl stop bbb-rap-resque-worker + +4. Create a recording using BigBlueButton. After logging out, it should have created a .done file in the + /var/bigbluebutton/recording/status/recorded directory. Make note of this meeting-id as we use that to tell the script which recording to process. -4. Before running the scripts, we have to make sure our scripts have the PATHs setup correctly. - Edit presentation/scripts/process/presentation.rb and uncomment the DEVELOPMENT PATH while - commenting the PRODUCTION PATH. We need to do this so the script will be able to find the - core library. +5. Before running the scripts, we have to make sure our scripts have the paths set up correctly. + In your development environment (~/dev/bigbluebutton/record-and-playback), edit -5. Now we run the archive step. Go to record-and-playback/core/scripts dir and type - ruby archive/archive.rb -m + - /core/scripts/archive/archive.rb + - /core/scripts/sanity/sanity.rb + - presentation/scripts/process/presentation.rb + - presentation/scripts/publish/presentation.rb + - presentation/scripts/presentation.yml -6. If everything goes well, you should have the raw files in ~/temp/recording/raw/ - You can also check the logs at ~/temp/log/archive-.log + and ensure the scripts are using the DEVELOPMENT PATH. We need to do this so the script will be able to find the core library. - You should also have an entry in ~/temp/recording/status/archived dir + # For DEVELOPMENT + # Allows us to run the script manually + require File.expand_path('../../../../core/lib/recordandplayback', __FILE__) -7. Then we need to do a sanity check if the raw recordings are complete. Type - ruby sanity/sanity.rb -m + # For PRODUCTION + # require File.expand_path('../../../lib/recordandplayback', __FILE__) - Check the log in ~/temp/log/sanity.log + # For PRODUCTION + # publish_dir: /var/bigbluebutton/published/presentation + video_formats: + - webm + # - mp4 - You should also have an entry in ~/temp/recording/status/sanity dir + # For DEVELOPMENT + publish_dir: /home/ubuntu/temp/published/presentation + +6. Now we run the archive step. Go to record-and-playback/core/scripts and type + + ruby archive/archive.rb -m + + If everything went well, you should now have the raw files in ~/temp/recording/raw/. + You can also check the logs at ~/temp/log/archive-.log, and have a .done entry in ~/temp/recording/status/archived. + +7. Now we need to do a sanity check to ensure the raw recordings are complete. Again in the scripts folder, type + + ruby sanity/sanity.rb -m + + Confirm everything went as intended by checking the logs at ~/temp/log/sanity.log and + that a .done entry in ~/temp/recording/status/sanity exists. 8. Assuming the recording passed the sanity check, it's time to process the recording. - cd record-and-playback/presentation/scripts - ruby process/presentation.rb -m - You can monitor the progress by tailing the log at ~/temp/log/presentation/process-.log + Set the path of the captions generation script by changing the line -9. Assuming that everything goes well. We can now run the publish script. However, we need to cheat a little bit. - The publish script will be looking for a "processing_time" file which contains information on how long the - processing took. Unfortunately, that file is created by the rap-worker.rb script which we don't run. + ret = BigBlueButton.exec_ret('utils/gen_webvtt', '-i', raw_archive_dir, '-o', target_dir) - So we create that file manually at - vi ~/temp/recording/process/presentation//processing_time + to + + ret = BigBlueButton.exec_ret('/home/ubuntu/dev/bigbluebutton/record-and-playback/core/scripts/utils/gen_webvtt', '-i', raw_archive_dir, '-o', target_dir) + + Now, you can run the presentation processing script: + + cd ~/dev/bigbluebutton/record-and-playback/presentation/scripts + ruby process/presentation.rb -m + + You can monitor the progress by tailing the log at ~/temp/log/presentation/process-.log. + + Note that each time one of the scripts fails, the corresponding directories for the recording at + + ~/temp/recording/process/presentation and ~/temp/recording/publish/presentation + + need to be deleted before executing them again. + +9. If everything went well, we can now run the publish script. However, we need to cheat a little bit. + + The publish script will be looking for a 'processing_time' file which contains information on how long the + processing took. Unfortunately, that file is created by the 'rap-worker.rb', a script which we don't run. + + Manually create the file at + + vi ~/temp/recording/process/presentation//processing_time Enter any number (e.g. 46843) and save the file. -10. Now run the publish script - ruby publish/presentation.rb -m -presentation + Once again, the path to the poll generation utility needs to be updated: + + Change - Notice we appended "presentation" to the meeting-id, this will tell the script to publish using the "presentation" format. + ret = BigBlueButton.exec_ret('utils/gen_poll_svg', ...) + to + ret = BigBlueButton.exec_ret('/home/ubuntu/dev/bigbluebutton/record-and-playback/core/scripts/utils/gen_webvtt', ...) + Finally, run the publish script: + + ruby publish/presentation.rb -m -presentation + + Notice we appended "presentation" to the meetingId, this will tell the script to publish using the "presentation" format. + +9. Deploy your changes with deploy.sh and restarting the recording-related services: + + systemctl restart bbb-rap-starter + systemctl restart bbb-rap-resque-worker diff --git a/record-and-playback/presentation/scripts/publish/presentation.rb b/record-and-playback/presentation/scripts/publish/presentation.rb index 7d370b3b5b..68ecaeeae8 100755 --- a/record-and-playback/presentation/scripts/publish/presentation.rb +++ b/record-and-playback/presentation/scripts/publish/presentation.rb @@ -1,5 +1,4 @@ -# Set encoding to utf-8 -# encoding: UTF-8 +# frozen_string_literal: false # # BigBlueButton open source conferencing system - http://www.bigbluebutton.org/ @@ -19,9 +18,13 @@ # with BigBlueButton; if not, see . # -performance_start = Time.now +# 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 File.expand_path('../../../lib/recordandplayback', __FILE__) require 'rubygems' require 'optimist' require 'yaml' @@ -31,126 +34,122 @@ require 'json' # This script lives in scripts/archive/steps while properties.yaml lives in scripts/ bbb_props = BigBlueButton.read_props -$presentation_props = YAML::load(File.read('presentation.yml')) +@presentation_props = YAML.safe_load(File.read('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 +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 +def scale_to_deskshare_video(width, height) + deskshare_video_height = 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 + scale_min = [deskshare_video_width / width, deskshare_video_height / height].min + video_width = width * scale_min + video_height = height * scale_min - return video_width.floor, video_height.floor + [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}" +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 = scaleToDeskshareVideo(video_info[:width], video_info[:height]) + 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 - return video_width, video_height + [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 +def calculate_record_events_offset 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] + 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_timestamp] + previous_stop_recording = event_stop 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| +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 - if timestamp <= event[:start_timestamp] - return event[:start_timestamp] - event[:offset] + 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 - elsif timestamp > event[:start_timestamp] and timestamp <= event[:stop_timestamp] - return timestamp - event[:offset] - end + 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 - return timestamp - $rec_events.last()[:offset] + $rec_events.last()[:duration] + 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) - return '0'*(6-color.length) + color + ('0' * (6 - color.length)) + color end def shape_scale_width(slide, x) - return (x / 100.0 * slide[:width]).round(5) + (x / 100.0 * slide[:width]).round(5) end + def shape_scale_height(slide, y) - return (y / 100.0 * slide[:height]).round(5) + (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]) + shape_thickness_percent = shape[:thickness_percent] + if shape_thickness_percent + shape_scale_width(slide, shape[:thickness_percent]) else - return shape[:thickness] + shape[:thickness] end end def svg_render_shape_pencil(g, slide, shape) - g['shape'] = "pencil#{shape[:shape_unique_id]}" + shape_unique_id = shape[:shape_unique_id] + g['shape'] = "pencil#{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") + 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 shape[:data_points].length == 2 - BigBlueButton.logger.info("Pencil #{shape[:shape_unique_id]}: Drawing single point") + if data_points_length == 2 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)) + 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 = 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| + 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) @@ -165,7 +164,7 @@ def svg_render_shape_pencil(g, slide, shape) 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}") + 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) @@ -179,12 +178,11 @@ def svg_render_shape_pencil(g, slide, shape) 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 + loop do x = shape_scale_width(slide, data_points.next) y = shape_scale_height(slide, data_points.next) path << "L#{x} #{y}" @@ -193,8 +191,9 @@ def svg_render_shape_pencil(g, slide, shape) 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" + 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 @@ -202,31 +201,28 @@ 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 + 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])) + 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:##{shape[:color]};stroke-width:#{shape_thickness(slide,shape)};visibility:hidden;fill:#{shape[:fill] ? '#'+shape[:color] : 'none'}" - if $version_atleast_2_0_0 - g['style'] += ";stroke-linejoin:miter" - else - g['style'] += ";stroke-linejoin:round" - end + g['style'] = "#{stroke_attributes(slide, shape)};stroke-linejoin:#{@version_atleast_2_0_0 ? 'miter' : 'round'}" doc = g.document data_points = shape[:data_points] @@ -236,7 +232,6 @@ def svg_render_shape_rect(g, slide, shape) 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. @@ -249,19 +244,14 @@ def svg_render_shape_rect(g, slide, shape) end end - path = doc.create_element('path', - d: "M#{x1} #{y1}L#{x2} #{y1}L#{x2} #{y2}L#{x1} #{y2}Z") + 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:#{shape[:fill] ? '#'+shape[:color] : 'none'}" - if $version_atleast_2_0_0 - g['style'] += ";stroke-linejoin:miter;stroke-miterlimit:8" - else - g['style'] += ";stroke-linejoin:round" - end + 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] @@ -272,14 +262,13 @@ def svg_render_shape_triangle(g, slide, shape) px = ((x1 + x2) / 2.0).round(5) - path = doc.create_element('path', - d: "M#{px} #{y1}L#{x2} #{y2}L#{x1} #{y2}Z") + 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:#{shape[:fill] ? '#'+shape[:color] : 'none'}" + g['style'] = stroke_attributes(slide, shape) doc = g.document data_points = shape[:data_points] @@ -313,12 +302,12 @@ def svg_render_shape_ellipse(g, slide, shape) # 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" + 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 @@ -335,18 +324,15 @@ def svg_render_shape_text(g, slide, shape) 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) + 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') + 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_element('br') if index.positive? p << doc.create_text_node(line.chomp) end fo << p @@ -374,39 +360,40 @@ def svg_render_shape_poll(g, slide, shape) 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" + 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 - IO.write(json_file, result) + 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}", '-h', "#{height.round}", '-n', "#{num_responders}", '-o', svg_file) - raise "Failed to generate poll svg" if ret != 0 + 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) + 'xlink:href' => "presentation/#{presentation}/poll_result#{poll_id}.svg", + width: width, height: height, x: x, y: y) end def svg_render_shape(canvas, slide, shape, image_id) - if shape[:in] == shape[:out] + 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] or - (!shape[:out].nil? and shape[:out] <= slide[:in]) + 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 - 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])) + 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' @@ -429,153 +416,131 @@ def svg_render_shape(canvas, slide, shape, image_id) g[:shape] = "image#{image_id}-#{g[:shape]}" - if g.element_children.length > 0 - canvas << g - end + canvas << g unless g.element_children.empty? end -$svg_image_id = 1 +@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)") + 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 - - BigBlueButton.logger.info("Image #{image_id}: Presentation #{slide[:presentation]} Slide #{slide[:slide]} Deskshare #{slide[:deskshare]} from #{slide[:in]} to #{slide[:out]}") + 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].nil? + 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 - if slide[:deskshare] or - shapes[slide[:presentation]].nil? or - shapes[slide[:presentation]][slide[:slide]].nil? - return - end - shapes = shapes[slide[:presentation]][slide[:slide]] + return if slide_deskshare || !shapes.dig(presentation, slide_number) + + shapes = shapes[presentation][slide_number] canvas = doc.create_element('g', - class: 'canvas', id: "canvas#{image_id}", - image: "image#{image_id}", display: 'none') + 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 + svg << canvas unless canvas.element_children.empty? 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 + 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) + 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] + [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 + panzoom_in = panzoom[:in] + return if panzoom_in == panzoom[:out] + + rec.event(timestamp: panzoom_in) do + x, y, w, h = panzoom_viewbox(panzoom) + rec.viewBox("#{x} #{y} #{w} #{h}") end +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 +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) - if cursor[:in] == cursor[:out] - BigBlueButton.logger.info("Cursor: not emitting, duration rounds to 0") - return - end + cursor_in = cursor[:in] + return if cursor_in == cursor[:out] - 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 + rec.event(timestamp: cursor_in) do + 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 = 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 - # Cursor position is relative to the visible area - x = cursor[:x].round(5) - y = cursor[:y].round(5) + x = y = -1.0 end - else - x = -1.0 - y = -1.0 + + rec.cursor("#{x} #{y}") 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 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_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 + + presentation = determine_presentation(presentation, current_presentation) + slide = determine_slide_number(slide, current_slide) # Set up the shapes data structures if needed - shapes[presentation] = {} if shapes[presentation].nil? - shapes[presentation][slide] = [] if shapes[presentation][slide].nil? + shapes[presentation] ||= {} + shapes[presentation][slide] ||= [] # We only need to deal with shapes for this slide shapes = shapes[presentation][slide] @@ -584,175 +549,148 @@ def events_parse_shape(shapes, event, current_presentation, current_slide, times 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 } + 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') - 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 + 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 shape[:type] == 'pencil' or shape[:type] == 'rectangle' or - shape[:type] == 'ellipse' or shape[:type] == 'triangle' or - shape[:type] == 'line' + 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 #{shape[:shape_id]} Shape #{shape[:shape_unique_id]} ID #{shape[:id]} is missing 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 + if @version_atleast_2_0_0 shape[:thickness_percent] = thickness.text.to_f else shape[:thickness] = thickness.text.to_i end end - if shape[:type] == 'rectangle' or - shape[:type] == 'ellipse' or shape[:type] == 'triangle' - fill = event.at_xpath('fill') - fill = fill.nil? ? "false" : fill.text + if %w[ellipse rectangle triangle].include?(shape_type) + fill = event.at_xpath('fill')&.text || 'false' shape[:fill] = fill =~ /true/ ? true : false 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' + + 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 - end - if shape[:type] == '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 #{shape[:shape_id]} Shape #{shape[:shape_unique_id]} ID #{shape[:id]} is missing calcedFontSize") + 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) - text = event.at_xpath('text') - if !text.nil? - shape[:text] = text.text - else - shape[:text] = '' - end + shape[:text] = event.at_xpath('text')&.text || '' end # Find the previous shape, for updates prev_shape = nil - if !shape[:id].nil? + if shape_id # If we have a shape ID, look up the previous shape by ID - prev_shape = shapes.find_all {|s| s[:id] == shape[:id] }.last + # 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 - 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] + 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.nil? + if prev_shape prev_shape[:out] = timestamp shape[:shape_unique_id] = prev_shape[:shape_unique_id] - if shape[:type] == 'pencil' and shape[:status] == 'DRAW_UPDATE' + 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] + 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 + 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 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 = 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 + 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') - if !shape_id.nil? - shape_id = shape_id.text - end + shape_id = event.at_xpath('shapeId')&.text # Set up the shapes data structures if needed - shapes[presentation] = {} if shapes[presentation].nil? - shapes[presentation][slide] = [] if shapes[presentation][slide].nil? + shapes[presentation] ||= {} + shapes[presentation][slide] ||= [] # We only need to deal with shapes for this slide shapes = shapes[presentation][slide] - if !shape_id.nil? + 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}") - shapes.each do |shape| - if shape[:id] == shape_id - if shape[:undo].nil? or shape[:undo] > timestamp - shape[:undo] = timestamp - end - end - end + 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_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}") + 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. - 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 + set_undo_helper(shapes, :shape_unique_id, undo_shape_unique_id, timestamp) else - BigBlueButton.logger.info("Undo: no applicable shapes found") + BigBlueButton.logger.info('Undo: no applicable shapes found') end end end @@ -760,36 +698,17 @@ 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 + 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 + # BigBlueButton 2.0 per-user clear features; default to full clear on older versions 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 + 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] = {} if shapes[presentation].nil? - shapes[presentation][slide] = [] if shapes[presentation][slide].nil? + shapes[presentation] ||= {} + shapes[presentation][slide] ||= [] # We only need to deal with shapes for this slide shapes = shapes[presentation][slide] @@ -801,8 +720,8 @@ def events_parse_clear(shapes, event, current_presentation, current_slide, times end shapes.each do |shape| - if full_clear or user_id == shape[:user_id] - if shape[:undo].nil? or shape[:undo] > timestamp + if full_clear || user_id == shape[:user_id] + if !shape[:undo] || shape[:undo] > timestamp shape[:undo] = timestamp end end @@ -810,25 +729,31 @@ def events_parse_clear(shapes, event, current_presentation, current_slide, times end def events_get_image_info(slide) - if slide[:deskshare] + slide_deskshare = slide[:deskshare] + slide_presentation = slide[:presentation] + + if slide_deskshare slide[:src] = 'presentation/deskshare.png' - elsif slide[:presentation] == '' + 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" + slide_nr = slide[:slide] + 1 + 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]}" + 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] + 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] + ['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 @@ -839,43 +764,34 @@ 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() +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') + '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') + 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') + 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 = 0.0 - current_y_offset = 0.0 - current_width_ratio = 100.0 - current_height_ratio = 100.0 + current_x_offset = current_y_offset = 0.0 + current_width_ratio = current_height_ratio = 100.0 # Current cursor status - cursor_x = -1.0 - cursor_y = -1.0 + cursor_x = cursor_y = -1.0 cursor_visible = false presenter = nil # Current deskshare state (affects presentation and pan/zoom) deskshare = false - slides = [] panzooms = [] cursors = [] @@ -884,11 +800,11 @@ def processPresentation(package_dir) # 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 = Nokogiri::XML(File.read("#{@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) + (translate_timestamp(event['timestamp']) / 1000.0).round(1) # Make sure to add initial entries to the slide & panzoom lists slide_changed = slides.empty? @@ -896,77 +812,74 @@ def processPresentation(package_dir) cursor_changed = cursors.empty? # Do event specific processing - if eventname == 'SharePresentationEvent' + case eventname + when 'SharePresentationEvent' current_presentation = event.at_xpath('presentationName').text current_slide = current_presentation_slide[current_presentation].to_i - slide_changed = true - panzoom_changed = true + slide_changed = panzoom_changed = true - elsif eventname == 'GotoSlideEvent' + when 'GotoSlideEvent' current_slide = event.at_xpath('slide').text.to_i current_presentation_slide[current_presentation] = current_slide - slide_changed = true - panzoom_changed = true + slide_changed = panzoom_changed = true - elsif eventname == 'ResizeAndMoveSlideEvent' + 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 - elsif $presentation_props['include_deskshare'] and (eventname == 'DeskshareStartedEvent' or eventname == 'StartWebRTCDesktopShareEvent') - deskshare = true - slide_changed = true + when 'DeskshareStartedEvent', 'StartWebRTCDesktopShareEvent' + deskshare = slide_changed = true if @presentation_props['include_deskshare'] - elsif $presentation_props['include_deskshare'] and (eventname == 'DeskshareStoppedEvent' or eventname == 'StopWebRTCDesktopShareEvent') - deskshare = false - slide_changed = true + when 'DeskshareStoppedEvent', 'StopWebRTCDesktopShareEvent' + if @presentation_props['include_deskshare'] + deskshare = false + slide_changed = true + end - elsif eventname == 'AddShapeEvent' or eventname == 'ModifyTextEvent' + when 'AddShapeEvent', 'ModifyTextEvent' events_parse_shape(shapes, event, current_presentation, current_slide, timestamp) - elsif eventname == 'UndoShapeEvent' or eventname == 'UndoAnnotationEvent' + when 'UndoShapeEvent', 'UndoAnnotationEvent' events_parse_undo(shapes, event, current_presentation, current_slide, timestamp) - elsif eventname == 'ClearPageEvent' or eventname == 'ClearWhiteboardEvent' + when 'ClearPageEvent', 'ClearWhiteboardEvent' events_parse_clear(shapes, event, current_presentation, current_slide, timestamp) - elsif eventname == 'AssignPresenterEvent' + 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 - elsif eventname == 'CursorMoveEvent' + when '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 + cursor_visible = cursor_changed = true - elsif eventname == 'WhiteboardCursorMoveEvent' - user_id = event.at_xpath('userId') + when 'WhiteboardCursorMoveEvent' + user_id = event.at_xpath('userId')&.text # Only draw cursor for current presentor. TODO multi-cursor support - if user_id.nil? or user_id.text == presenter + 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 = true - cursor_changed = true + cursor_visible = 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 + + if slide && + (slide[:presentation] == current_presentation) && + (slide[:slide] == current_slide) && + (slide[:deskshare] == deskshare) BigBlueButton.logger.info('Presentation/Slide: skipping, no changes') - slide_changed = false else - if !slide.nil? + if slide slide[:out] = timestamp svg_render_image(svg, slide, shapes) end @@ -976,7 +889,7 @@ def processPresentation(package_dir) presentation: current_presentation, slide: current_slide, in: timestamp, - deskshare: deskshare + deskshare: deskshare, } events_get_image_info(slide) slides << slide @@ -987,22 +900,24 @@ def processPresentation(package_dir) 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 + slide_width = slide[:width] + slide_height = slide[:height] + if panzoom && + (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) && + (panzoom[:deskshare] == deskshare) BigBlueButton.logger.info('Panzoom: skipping, no changes') panzoom_changed = false else - if !panzoom.nil? + if panzoom 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]})") + 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, @@ -1018,25 +933,24 @@ def processPresentation(package_dir) 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 + if cursor_changed || panzoom_changed + unless cursor_x >= 0 && cursor_x <= 100 && + cursor_y >= 0 && 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 + 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.nil? + if cursor 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, @@ -1060,115 +974,109 @@ def processPresentation(package_dir) cursor[:out] = last_timestamp cursors_emit_event(cursors_rec, cursor) + cursors_doc = Builder::XmlMarkup.new(indent: 2) + cursors_doc.instruct! + cursors_doc.recording(id: 'cursor_events') { |xml| xml << cursors_rec.target! } + + panzooms_doc = Builder::XmlMarkup.new(indent: 2) + panzooms_doc.instruct! + panzooms_doc.recording(id: 'panzoom_events') { |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.to_xml) - File.write("#{package_dir}/#{$cursor_xml_filename}", cursors_doc.to_xml) + 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!) end -def processChatMessages(events, bbb_props) - BigBlueButton.logger.info("Processing chat events") +def process_chat_messages(events, bbb_props) + BigBlueButton.logger.info('Processing chat events') # Create slides.xml and chat. - Nokogiri::XML::Builder.new do |xml| - xml.popcorn { - 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' - } - chattimeline[:out] = (chat[:out] / 1000.0).round(1) unless chat[:out].nil? - xml.chattimeline(**chattimeline) + 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 processDeskshareEvents(events) - BigBlueButton.logger.info("Processing deskshare events") +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 = 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 + @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 getPollQuestion(event) - question = "" - if not event.at_xpath("question").nil? - question = event.at_xpath("question").text - end - - question +def get_poll_question(event) + event.at_xpath('question')&.text || '' end -def getPollAnswers(event) +def get_poll_answers(event) answers = [] - if not event.at_xpath("answers").nil? - answers = JSON.load(event.at_xpath("answers").content) + answers_event = event.at_xpath('answers') + if (answers_event) + answers = JSON.parse(answers_event.content) end answers end -def getPollRespondents(event) - respondents = 0 - if not event.at_xpath("numRespondents").nil? - respondents = event.at_xpath("numRespondents").text.to_i - end - - respondents +def get_poll_respondents(event) + event.at_xpath('numRespondents')&.text.to_i || 0 end -def getPollResponders(event) - responders = 0 - if not event.at_xpath("numResponders").nil? - responders = event.at_xpath("numResponders").text.to_i - end - - responders +def get_poll_responders(event) + event.at_xpath('numResponders')&.text.to_i || 0 end -def getPollId(event) - id = "" - if not event.at_xpath("pollId").nil? - id = event.at_xpath("pollId").text - end - - id +def get_poll_id(event) + event.at_xpath('pollId')&.text || '' end -def getPollType(events, published_poll_event) - published_poll_id = getPollId(published_poll_event) +def get_poll_type(events, published_poll_event) + published_poll_id = get_poll_id(published_poll_event) - type = "" - events.xpath("//event[@eventname='PollStartedRecordEvent']").each do |event| - poll_id = getPollId(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 + type = event.at_xpath('type').text break end end @@ -1176,310 +1084,284 @@ def getPollType(events, published_poll_event) type end -def processPollEvents(events, package_dir) - BigBlueButton.logger.info("Processing poll events") - - published_polls = [] - $rec_events.each do |re| - events.xpath("//event[@eventname='PollPublishedRecordEvent']").each do |event| - if (event[:timestamp].to_i >= re[:start_timestamp] and event[:timestamp].to_i <= re[:stop_timestamp]) - published_polls << { - :timestamp => (translateTimestamp(event[:timestamp]) / 1000).to_i, - :type => getPollType(events, event), - :question => getPollQuestion(event), - :answers => getPollAnswers(event), - :respondents => getPollRespondents(event), - :responders => getPollResponders(event) - } - end - end - end - - if not published_polls.empty? - File.open("#{package_dir}/polls.json", "w") do |f| - f.puts(published_polls.to_json) - end - 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 processExternalVideoEvents(events, package_dir) - BigBlueButton.logger.info("Processing external video events") +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)) + BigBlueButton::Events.get_start_and_stop_external_video_events(@doc) + ) external_videos = [] - $rec_events.each do |re| + @rec_events.each do |re| external_video_events.each do |event| - #BigBlueButton.logger.info("Processing rec event #{re} and external video event #{event}") - timestamp = (translateTimestamp(event[:start_timestamp]) / 1000).to_i + 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 - if (external_videos.find {|ev| ev[:timestamp] == timestamp}.nil?) - if ((event[:start_timestamp] >= re[:start_timestamp] and event[:start_timestamp] <= re[:stop_timestamp]) || - (event[:start_timestamp] < re[:start_timestamp] and event[:stop_timestamp] >= re[:start_timestamp])) - external_videos << { - :timestamp => timestamp, - :external_video_url => event[:external_video_url] - } - end - end + 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 - if not external_videos.empty? - File.open("#{package_dir}/external_videos.json", "w") do |f| - f.puts(external_videos.to_json) - 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 -$shapes_svg_filename = 'shapes.svg' -$panzooms_xml_filename = 'panzooms.xml' -$cursor_xml_filename = 'cursor.xml' -$deskshare_xml_filename = 'deskshare.xml' +def copy_media_files_helper(media, media_files, package_dir) + BigBlueButton.logger.info("Making #{media} dir") + FileUtils.mkdir_p(media_dir = "#{package_dir}/#{media}") -opts = Optimist::options do - opt :meeting_id, "Meeting id to archive", :default => '58f4a6b3-cd07-444d-8564-59116cb53974', :type => String + 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 -$meeting_id = opts[:meeting_id] -puts $meeting_id -match = /(.*)-(.*)/.match $meeting_id -$meeting_id = match[1] -$playback = match[2] +@shapes_svg_filename = 'shapes.svg' +@panzooms_xml_filename = 'panzooms.xml' +@cursor_xml_filename = 'cursor.xml' +@deskshare_xml_filename = 'deskshare.xml' +@svg_shape_id = 1 +@svg_shape_unique_id = 1 -puts $meeting_id -puts $playback +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") - + if @playback == 'presentation' log_dir = bbb_props['log_dir'] - - logger = Logger.new("#{log_dir}/presentation/publish-#{$meeting_id}.log", 'daily' ) + 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") + 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 not FileTest.directory?(target_dir) - BigBlueButton.logger.info("Making dir target_dir") + 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") + 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_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 + 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 - 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") + copy_media_files_helper('video', 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| + 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 - 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 + presentation_text = "#{@process_dir}/presentation_text.json" + FileUtils.cp(presentation_text, package_dir) if File.exist?(presentation_text) - if File.exist?("#{$process_dir}/presentation_text.json") - FileUtils.cp("#{$process_dir}/presentation_text.json", package_dir) - end + notes = "#{@process_dir}/notes/notes.html" + FileUtils.cp(notes, package_dir) if File.exist?(notes) - if File.exist?("#{$process_dir}/notes/notes.html") - FileUtils.cp("#{$process_dir}/notes/notes.html", package_dir) - end + processing_time = File.read("#{@process_dir}/processing_time") - processing_time = File.read("#{$process_dir}/processing_time") - - @doc = Nokogiri::XML(File.read("#{$process_dir}/events.xml")) + @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)) + @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) + @recording_time = BigBlueButton::Events.get_recording_length(@doc) + @meeting_start = BigBlueButton::Events.first_event_timestamp(@doc) + @meeting_end = BigBlueButton::Events.last_event_timestamp(@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 + @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 # Copy metadata.xml from process_dir - FileUtils.cp("#{$process_dir}/metadata.xml", package_dir) - BigBlueButton.logger.info("Copied metadata.xml file") + 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" + 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 + metadata.search('recording/playback').each(&:remove) ## 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.3/#{$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") } + 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 - 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") + 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") + # Create slides.xml + BigBlueButton.logger.info('Generating xml for slides and chat') - calculateRecordEventsOffset() + calculate_record_events_offset # Write slides.xml to file - slides_doc = processChatMessages(@doc, bbb_props) - File.open("#{package_dir}/slides_new.xml", 'w') { |f| f.puts slides_doc.to_xml } + slides_doc = process_chat_messages(@doc, bbb_props) + File.open("#{package_dir}/slides_new.xml", 'w') { |f| f.puts slides_doc.target! } - processPresentation(package_dir) + process_presentation(package_dir) - processDeskshareEvents(@doc) + process_deskshare_events(@doc) - processPollEvents(@doc, package_dir) + process_poll_events(@doc, package_dir) - processExternalVideoEvents(@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.to_xml } + 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('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") + 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 + FileUtils.mkdir_p publish_dir unless FileTest.directory?(publish_dir) # Get raw size of presentation files - raw_dir = "#{recording_dir}/raw/#{$meeting_id}" + 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('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.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 - 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") + generate_done_or_fail_file(true) end end - - -rescue Exception => e +rescue StandardError => 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 + generate_done_or_fail_file(false) exit 1 end