2023-01-20 04:56:15 +08:00
|
|
|
#!/usr/bin/env ruby
|
|
|
|
# frozen_string_literal: true
|
|
|
|
|
|
|
|
# This file is part of BigBlueButton.
|
|
|
|
#
|
|
|
|
# Copyright © BigBlueButton Inc. and by respective authors.
|
|
|
|
#
|
|
|
|
# BigBlueButton 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 <https://www.gnu.org/licenses>.
|
|
|
|
|
|
|
|
require File.expand_path('../../lib/recordandplayback', __dir__)
|
|
|
|
require File.expand_path('../../lib/recordandplayback/edl', __dir__)
|
|
|
|
require 'active_support/core_ext/hash'
|
|
|
|
require 'optimist'
|
|
|
|
require 'yaml'
|
|
|
|
require 'nokogiri'
|
|
|
|
require 'erb'
|
|
|
|
require 'cgi'
|
|
|
|
|
|
|
|
opts = Optimist.options do
|
|
|
|
opt :meeting_id, 'Meeting id to process', type: String
|
|
|
|
opt :stderr, 'Log output to stderr'
|
|
|
|
end
|
|
|
|
Optimist.die :meeting_id, 'must be provided' unless opts[:meeting_id]
|
|
|
|
meeting_id = opts[:meeting_id]
|
|
|
|
|
|
|
|
start_real_time = nil
|
|
|
|
begin
|
|
|
|
m = /-(\d+)$/.match(meeting_id)
|
|
|
|
start_real_time = m[1].to_i
|
|
|
|
end
|
|
|
|
|
|
|
|
# Load parameters and set up paths
|
|
|
|
props = YAML.safe_load(File.open(File.expand_path('../bigbluebutton.yml', __dir__)))
|
|
|
|
video_props = YAML.safe_load(File.open(File.expand_path('../video.yml', __dir__)))
|
|
|
|
video_props['audio_offset'] = 0 if video_props['audio_offset'].nil?
|
|
|
|
|
|
|
|
recording_dir = props['recording_dir']
|
|
|
|
playback_dir = video_props['playback_dir']
|
|
|
|
process_dir = "#{recording_dir}/process/video/#{meeting_id}"
|
|
|
|
raw_archive_dir = "#{recording_dir}/raw/#{meeting_id}"
|
|
|
|
donefile = "#{recording_dir}/status/processed/#{meeting_id}-video.done"
|
|
|
|
log_file = "#{props['log_dir']}/video/process-#{meeting_id}.log"
|
|
|
|
|
|
|
|
logger = opts[:stderr] ? Logger.new($stderr) : Logger.new(log_file)
|
|
|
|
BigBlueButton.logger = logger
|
|
|
|
|
|
|
|
if File.exist?(donefile)
|
|
|
|
logger.warn 'This processing script has already been run'
|
|
|
|
exit 0
|
|
|
|
end
|
|
|
|
|
|
|
|
FileUtils.mkdir_p process_dir
|
|
|
|
|
|
|
|
logger.info 'Reading basic recording information'
|
|
|
|
events = Nokogiri::XML(File.open("#{raw_archive_dir}/events.xml"))
|
|
|
|
initial_timestamp = BigBlueButton::Events.first_event_timestamp(events)
|
|
|
|
final_timestamp = BigBlueButton::Events.last_event_timestamp(events)
|
|
|
|
duration = BigBlueButton::Events.get_recording_length(events)
|
|
|
|
metadata = events.at_xpath('/recording/metadata')
|
|
|
|
|
|
|
|
logger.info 'Generating video events list'
|
|
|
|
|
|
|
|
# Webcams
|
2023-02-11 01:21:27 +08:00
|
|
|
webcam_edl = BigBlueButton::Events.create_webcam_edl(events, raw_archive_dir, props['show_moderator_viewpoint'])
|
2023-01-20 04:56:15 +08:00
|
|
|
logger.debug 'Webcam EDL:'
|
|
|
|
BigBlueButton::EDL::Video.dump(webcam_edl)
|
|
|
|
|
|
|
|
# Deskshare
|
|
|
|
deskshare_edl = BigBlueButton::Events.create_deskshare_edl(events, raw_archive_dir)
|
|
|
|
logger.debug 'Deskshare EDL:'
|
|
|
|
BigBlueButton::EDL::Video.dump(deskshare_edl)
|
|
|
|
|
|
|
|
video_edl = BigBlueButton::EDL::Video.merge(webcam_edl, deskshare_edl)
|
|
|
|
|
|
|
|
logger.debug 'Merged Video EDL:'
|
|
|
|
BigBlueButton::EDL::Video.dump(video_edl)
|
|
|
|
|
|
|
|
logger.info 'Applying recording start/stop events to video'
|
|
|
|
video_edl = BigBlueButton::Events.edl_match_recording_marks_video(video_edl, events, initial_timestamp, final_timestamp)
|
|
|
|
logger.debug 'Trimmed Video EDL:'
|
|
|
|
BigBlueButton::EDL::Video.dump(video_edl)
|
|
|
|
|
|
|
|
logger.info 'Checking whether webcams were used'
|
|
|
|
have_webcams = BigBlueButton::Events.have_webcam_events(events)
|
|
|
|
if have_webcams
|
|
|
|
logger.info('Webcams were use in this session')
|
|
|
|
else
|
|
|
|
logger.info('No webcams were used in this session')
|
|
|
|
end
|
|
|
|
|
2023-02-11 01:21:27 +08:00
|
|
|
logger.info('Checking whether desktop sharing was used')
|
|
|
|
have_deskshare = BigBlueButton::Events.have_deskshare_events(events)
|
|
|
|
if have_deskshare
|
|
|
|
logger.info('Desktop sharing was used in this session')
|
|
|
|
else
|
|
|
|
logger.info('No desktop sharing was used in this session')
|
|
|
|
end
|
|
|
|
|
2023-01-20 04:56:15 +08:00
|
|
|
logger.info 'Checking whether the presentation area was used'
|
|
|
|
have_presentation = BigBlueButton::Events.have_presentation_events(events)
|
|
|
|
if have_presentation
|
|
|
|
logger.info('Have presentation events, rendering presentation area')
|
|
|
|
else
|
|
|
|
logger.info('No presentation events found')
|
|
|
|
end
|
|
|
|
|
|
|
|
if !have_presentation && !have_webcams
|
|
|
|
logger.info('This recording has neither webcams or presentation')
|
|
|
|
logger.info("Re-enabling presentation area, so the video isn't blank...")
|
|
|
|
have_presentation = true
|
|
|
|
end
|
|
|
|
|
|
|
|
presentation_edl = nil
|
|
|
|
if have_presentation
|
|
|
|
# The presentation video gets special treatment
|
|
|
|
presentation_video = "#{process_dir}/presentation.mkv"
|
|
|
|
presentation_edl = [
|
|
|
|
{
|
|
|
|
timestamp: 0,
|
|
|
|
areas: { presentation: [{ filename: presentation_video, timestamp: 0 }] }
|
|
|
|
},
|
|
|
|
{
|
|
|
|
timestamp: duration,
|
|
|
|
areas: { presentation: [] }
|
|
|
|
}
|
|
|
|
]
|
|
|
|
else
|
|
|
|
presentation_edl = [
|
|
|
|
{
|
|
|
|
timestamp: 0,
|
|
|
|
areas: { presentation: [] }
|
|
|
|
}
|
|
|
|
]
|
|
|
|
end
|
|
|
|
logger.debug 'Presentation EDL:'
|
|
|
|
BigBlueButton::EDL::Video.dump(presentation_edl)
|
|
|
|
|
|
|
|
video_edl = BigBlueButton::EDL::Video.merge(presentation_edl, video_edl)
|
|
|
|
logger.debug 'Merged Video EDL with Presentation:'
|
|
|
|
BigBlueButton::EDL::Video.dump(video_edl)
|
|
|
|
|
|
|
|
logger.info 'Generating audio events list'
|
|
|
|
audio_edl = BigBlueButton::AudioEvents.create_audio_edl(events, raw_archive_dir)
|
|
|
|
logger.debug 'Audio EDL:'
|
|
|
|
BigBlueButton::EDL::Audio.dump(audio_edl)
|
|
|
|
|
|
|
|
logger.info 'Applying recording start/stop events to audio'
|
|
|
|
audio_edl = BigBlueButton::Events.edl_match_recording_marks_audio(audio_edl, events, initial_timestamp, final_timestamp)
|
|
|
|
logger.debug 'Trimmed Audio EDL:'
|
|
|
|
BigBlueButton::EDL::Audio.dump(audio_edl)
|
|
|
|
|
|
|
|
logger.info 'Rendering audio'
|
|
|
|
audio = BigBlueButton::EDL::Audio.render(audio_edl, "#{process_dir}/audio")
|
|
|
|
|
|
|
|
if BigBlueButton::Events.screenshare_has_audio?(events, "#{raw_archive_dir}/deskshare")
|
|
|
|
logger.info('Generating audio events list for deskshare')
|
|
|
|
deskshare_audio_edl = BigBlueButton::AudioEvents.create_deskshare_audio_edl(events, "#{raw_archive_dir}/deskshare")
|
|
|
|
logger.debug('Deskshare audio EDL:')
|
|
|
|
BigBlueButton::EDL::Audio.dump(deskshare_audio_edl)
|
|
|
|
|
|
|
|
logger.info('Applying recording start/stop events to deskshare audio')
|
|
|
|
deskshare_audio_edl = BigBlueButton::Events.edl_match_recording_marks_audio(
|
|
|
|
deskshare_audio_edl, events, initial_timestamp, final_timestamp
|
|
|
|
)
|
|
|
|
logger.debug('Trimmed deskshare audio EDL:')
|
|
|
|
BigBlueButton::EDL::Audio.dump(deskshare_audio_edl)
|
|
|
|
|
|
|
|
logger.info('Rendering deskshare audio')
|
|
|
|
deskshare_audio = BigBlueButton::EDL::Audio.render(deskshare_audio_edl, "#{process_dir}/deskshare_audio")
|
|
|
|
|
|
|
|
logger.info('Mixing meeting audio and deskshare audio')
|
|
|
|
audio = BigBlueButton::EDL::Audio.mixer([audio, deskshare_audio], "#{process_dir}/mixed_audio")
|
|
|
|
end
|
|
|
|
|
|
|
|
# Select the layout based on what video sections are available
|
|
|
|
layout = \
|
2023-02-11 01:21:27 +08:00
|
|
|
if have_webcams
|
|
|
|
if have_presentation || have_deskshare
|
|
|
|
video_props['layout']
|
|
|
|
else
|
|
|
|
video_props['nopresentation_layout']
|
|
|
|
end
|
2023-01-20 04:56:15 +08:00
|
|
|
else
|
2023-02-11 01:21:27 +08:00
|
|
|
video_props['nowebcam_layout']
|
2023-01-20 04:56:15 +08:00
|
|
|
end
|
|
|
|
|
|
|
|
layout.symbolize_keys!
|
|
|
|
layout[:areas].each do |area|
|
|
|
|
area.symbolize_keys!
|
|
|
|
area[:name] = area[:name].to_sym
|
|
|
|
end
|
|
|
|
|
|
|
|
if have_presentation
|
|
|
|
logger.info 'Creating presentation area video'
|
|
|
|
presentation_area = layout[:areas].detect { |area| area[:name] == :presentation }
|
|
|
|
|
|
|
|
BigBlueButton.execute(
|
|
|
|
[
|
|
|
|
'bbb-presentation-video',
|
|
|
|
'-i', raw_archive_dir,
|
|
|
|
'-w', presentation_area[:width].to_s, '-h', presentation_area[:height].to_s, '-r', layout[:framerate].to_s,
|
|
|
|
'-o', presentation_video
|
|
|
|
],
|
|
|
|
true
|
|
|
|
)
|
|
|
|
end
|
|
|
|
|
|
|
|
logger.info 'Rendering video'
|
|
|
|
video = BigBlueButton::EDL::Video.render(video_edl, layout, "#{process_dir}/video")
|
|
|
|
|
|
|
|
logger.info "Encoding output files to #{video_props['formats'].length} formats"
|
|
|
|
video_props['formats'].each_with_index do |format, i|
|
|
|
|
format.symbolize_keys!
|
|
|
|
logger.info " #{format[:mimetype]}"
|
|
|
|
BigBlueButton::EDL.encode(audio, video, format, "#{process_dir}/video-#{i}", video_props['audio_offset'])
|
|
|
|
end
|
|
|
|
|
|
|
|
logger.info('Generating closed captions')
|
|
|
|
ret = BigBlueButton.exec_ret('utils/gen_webvtt', '-i', raw_archive_dir, '-o', process_dir)
|
|
|
|
raise 'Generating closed caption files failed' if ret != 0
|
|
|
|
|
|
|
|
captions = JSON.parse(File.read("#{process_dir}/captions.json"))
|
|
|
|
|
|
|
|
# Generate the support files for chat events
|
|
|
|
have_chat = false
|
|
|
|
begin
|
|
|
|
logger.info 'Processing chat events'
|
|
|
|
chats = BigBlueButton::Events.get_chat_events(events, initial_timestamp, final_timestamp, props)
|
|
|
|
have_chat = true unless chats.empty?
|
|
|
|
|
|
|
|
# Output the chat events to the popcorn events file
|
|
|
|
popcorn_events = Nokogiri::XML::Builder.new do |xml|
|
|
|
|
xml.popcorn do
|
|
|
|
chats.each do |chat|
|
|
|
|
chattimeline = {
|
|
|
|
in: (chat[:in] / 1000.0).round(1),
|
|
|
|
direction: 'down',
|
|
|
|
name: chat[:sender],
|
|
|
|
message: chat[:message]
|
|
|
|
}
|
|
|
|
chattimeline[:out] = (chat[:out] / 1000.0).round(1) unless chat[:out].nil?
|
|
|
|
xml.chattimeline(**chattimeline)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
File.write("#{process_dir}/video.xml", popcorn_events.to_xml)
|
|
|
|
end
|
|
|
|
|
|
|
|
# Publishing support files
|
|
|
|
|
|
|
|
logger.info 'Generating index page'
|
|
|
|
index_template = "#{playback_dir}/index.html.erb"
|
|
|
|
index_erb = ERB.new(File.read(index_template))
|
|
|
|
index_erb.filename = index_template
|
|
|
|
File.write(
|
|
|
|
"#{process_dir}/index.html",
|
|
|
|
index_erb.result_with_hash(
|
|
|
|
captions: captions,
|
|
|
|
haveChat: have_chat,
|
|
|
|
meetingName: metadata['meetingName'],
|
|
|
|
video_props: video_props
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
logger.info 'Generating metadata xml'
|
|
|
|
metadata_xml = Nokogiri::XML::Builder.new do |xml|
|
|
|
|
xml.recording do
|
|
|
|
xml.id(meeting_id)
|
|
|
|
xml.state('available')
|
|
|
|
xml.published('true')
|
|
|
|
xml.start_time(start_real_time)
|
|
|
|
xml.end_time(start_real_time + final_timestamp - initial_timestamp)
|
|
|
|
xml.playback do
|
|
|
|
xml.format('video')
|
2023-02-11 01:21:27 +08:00
|
|
|
xml.link("#{props['playback_protocol']}://#{props['playback_host']}/playback/video/#{meeting_id}/")
|
2023-01-20 04:56:15 +08:00
|
|
|
xml.duration(duration)
|
|
|
|
end
|
|
|
|
xml.meta do
|
|
|
|
metadata.attributes.each do |k, v|
|
|
|
|
xml.method_missing(k, v)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
File.write("#{process_dir}/metadata.xml", metadata_xml.to_xml)
|
|
|
|
|
|
|
|
logger.info 'Copying css and js support files'
|
|
|
|
FileUtils.cp_r("#{playback_dir}/css", process_dir)
|
|
|
|
FileUtils.cp_r("#{playback_dir}/js", process_dir)
|
|
|
|
FileUtils.cp_r("#{playback_dir}/video-js", process_dir)
|
|
|
|
|
|
|
|
logger.info 'Processing successfully completed, writing done file'
|
|
|
|
|
|
|
|
File.write(donefile, "Processed #{meeting_id}")
|