presentation.rb 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881
  1. #!/usr/bin/env ruby
  2. # frozen_string_literal: false
  3. require 'English'
  4. require 'base64'
  5. require 'builder'
  6. require 'csv'
  7. require 'digest/bubblebabble'
  8. require 'fileutils'
  9. require 'json'
  10. require 'loofah'
  11. require 'nokogiri'
  12. require 'optimist'
  13. require 'yaml'
  14. require File.expand_path('../../../lib/recordandplayback', __FILE__)
  15. require File.expand_path('../../../lib/recordandplayback/interval_tree', __FILE__)
  16. include IntervalTree
  17. opts = Optimist.options do
  18. opt :meeting_id, 'Meeting id to archive', type: String
  19. opt :format, 'Playback format name', type: String
  20. opt :log_stdout, 'Log to STDOUT', type: :flag
  21. end
  22. meeting_id = opts[:meeting_id]
  23. playback = opts[:format]
  24. exit(0) if playback != 'presentation'
  25. logger = opts[:log_stdout] ? Logger.new($stdout) : Logger.new('/var/log/bigbluebutton/post_publish.log', 'weekly')
  26. logger.level = Logger::INFO
  27. BigBlueButton.logger = logger
  28. BigBlueButton.logger.info("Started exporting presentation for [#{meeting_id}]")
  29. @published_files = "/var/bigbluebutton/published/presentation/#{meeting_id}"
  30. # Creates scratch directories
  31. FileUtils.mkdir_p(["#{@published_files}/chats", "#{@published_files}/cursor", "#{@published_files}/frames",
  32. "#{@published_files}/timestamps", "/var/bigbluebutton/published/video/#{meeting_id}"])
  33. TEMPORARY_FILES_PERMISSION = 0o600
  34. # Setting the SVGZ option to true will write less data on the disk.
  35. SVGZ_COMPRESSION = true
  36. # Set this to true if you've recompiled FFmpeg to enable external references. Writes less data on disk
  37. FFMPEG_REFERENCE_SUPPORT = false
  38. BASE_URI = FFMPEG_REFERENCE_SUPPORT ? "-base_uri #{@published_files}" : ''
  39. # Set this to true if you've recompiled FFmpeg with the movtext codec enabled
  40. CAPTION_SUPPORT = false
  41. # Video output quality: 0 is lossless, 51 is the worst. Default 23, 18 - 28 recommended
  42. CONSTANT_RATE_FACTOR = 23
  43. SVG_EXTENSION = SVGZ_COMPRESSION ? 'svgz' : 'svg'
  44. VIDEO_EXTENSION = File.file?("#{@published_files}/video/webcams.mp4") ? 'mp4' : 'webm'
  45. # Set this to true if the whiteboard supports whiteboard animations
  46. REMOVE_REDUNDANT_SHAPES = false
  47. BENCHMARK_FFMPEG = false
  48. BENCHMARK = BENCHMARK_FFMPEG ? '-benchmark ' : ''
  49. THREADS = 4
  50. BACKGROUND_COLOR = 'white'.freeze
  51. CURSOR_RADIUS = 8
  52. # Output video size
  53. OUTPUT_WIDTH = 1920
  54. OUTPUT_HEIGHT = 1080
  55. # Playback layout
  56. WEBCAMS_WIDTH = 320
  57. WEBCAMS_HEIGHT = 240
  58. CHAT_WIDTH = WEBCAMS_WIDTH
  59. CHAT_HEIGHT = OUTPUT_HEIGHT - WEBCAMS_HEIGHT
  60. HIDE_CHAT = false
  61. HIDE_CHAT_NAMES = false
  62. HIDE_DESKSHARE = false
  63. # Assumes a monospaced font with a width to aspect ratio of 3:5
  64. CHAT_FONT_SIZE = 15
  65. CHAT_FONT_SIZE_X = (0.6 * CHAT_FONT_SIZE).to_i
  66. CHAT_STARTING_OFFSET = CHAT_HEIGHT + CHAT_FONT_SIZE
  67. # Max. dimensions supported: 8032 x 32767
  68. CHAT_CANVAS_WIDTH = (8032 / CHAT_WIDTH) * CHAT_WIDTH
  69. CHAT_CANVAS_HEIGHT = (32_767 / CHAT_FONT_SIZE) * CHAT_FONT_SIZE
  70. # Dimensions of the whiteboard area
  71. SLIDES_WIDTH = OUTPUT_WIDTH - WEBCAMS_WIDTH
  72. SLIDES_HEIGHT = OUTPUT_HEIGHT
  73. # Input deskshare dimensions. Is scaled to fit whiteboard area keeping aspect ratio
  74. DESKSHARE_INPUT_WIDTH = 1280
  75. DESKSHARE_INPUT_HEIGHT = 720
  76. # Center the deskshare
  77. DESKSHARE_Y_OFFSET = ((SLIDES_HEIGHT -
  78. ([SLIDES_WIDTH.to_f / DESKSHARE_INPUT_WIDTH,
  79. SLIDES_HEIGHT.to_f / DESKSHARE_INPUT_HEIGHT].min * DESKSHARE_INPUT_HEIGHT)) / 2).to_i
  80. WhiteboardElement = Struct.new(:begin, :end, :value, :id)
  81. WhiteboardSlide = Struct.new(:href, :begin, :end, :width, :height)
  82. def run_command(command, silent = false)
  83. BigBlueButton.logger.info("Running: #{command}") unless silent
  84. output = `#{command}`
  85. [$CHILD_STATUS.success?, output]
  86. end
  87. def add_captions
  88. json = JSON.parse(File.read("#{@published_files}/captions.json"))
  89. caption_amount = json.length
  90. return if caption_amount.zero?
  91. caption_input = ''
  92. maps = ''
  93. language_names = ''
  94. (0..caption_amount - 1).each do |i|
  95. caption = json[i]
  96. caption_input << "-i #{@published_files}/caption_#{caption['locale']}.vtt "
  97. maps << "-map #{i + 1} "
  98. language_names << "-metadata:s:s:#{i} language=#{caption['localeName'].downcase[0..2]} "
  99. end
  100. render = "ffmpeg -i #{@published_files}/meeting-tmp.mp4 #{caption_input} " \
  101. "-map 0:v -map 0:a #{maps} -c:v copy -c:a copy -c:s mov_text #{language_names} " \
  102. "-y #{@published_files}/meeting_captioned.mp4"
  103. success, = run_command(render)
  104. if success
  105. FileUtils.mv("#{@published_files}/meeting_captioned.mp4", "#{@published_files}/meeting-tmp.mp4")
  106. else
  107. warn('An error occurred adding the captions to the video.')
  108. exit(false)
  109. end
  110. end
  111. def add_chapters(duration, slides)
  112. # Extract metadata
  113. command = "ffmpeg -i #{@published_files}/meeting-tmp.mp4 -y -f ffmetadata #{@published_files}/meeting_metadata"
  114. success, = run_command(command)
  115. unless success
  116. warn("An error occurred extracting the video's metadata.")
  117. exit(false)
  118. end
  119. slide_number = 1
  120. deskshare_number = 1
  121. chapter = ''
  122. slides.each do |slide|
  123. chapter_start = slide.begin
  124. chapter_end = slide.end
  125. break if chapter_start >= duration
  126. next if (chapter_end - chapter_start) <= 0.25
  127. if slide.href.include?('deskshare')
  128. title = "Screen sharing #{deskshare_number}"
  129. deskshare_number += 1
  130. else
  131. title = "Slide #{slide_number}"
  132. slide_number += 1
  133. end
  134. chapter << "[CHAPTER]\nSTART=#{chapter_start * 1e9}\nEND=#{chapter_end * 1e9}\ntitle=#{title}\n\n"
  135. end
  136. File.open("#{@published_files}/meeting_metadata", 'a') do |file|
  137. file << chapter
  138. end
  139. render = "ffmpeg -i #{@published_files}/meeting-tmp.mp4 " \
  140. "-i #{@published_files}/meeting_metadata -map_metadata 1 " \
  141. "-map_chapters 1 -codec copy -y -t #{duration} #{@published_files}/meeting_chapters.mp4"
  142. success, = run_command(render)
  143. if success
  144. FileUtils.mv("#{@published_files}/meeting_chapters.mp4", "#{@published_files}/meeting-tmp.mp4")
  145. else
  146. warn('Failed to add the chapters to the video.')
  147. exit(false)
  148. end
  149. end
  150. def add_greenlight_buttons(metadata)
  151. bbb_props = File.open(File.join(__dir__, '../bigbluebutton.yml')) { |f| YAML.safe_load(f) }
  152. playback_protocol = bbb_props['playback_protocol']
  153. playback_host = bbb_props['playback_host']
  154. meeting_id = metadata.xpath('recording/id').inner_text
  155. metadata.xpath('recording/playback/format').children.first.content = 'video'
  156. metadata.xpath('recording/playback/link').children.first.content = "#{playback_protocol}://#{playback_host}/presentation/#{meeting_id}/meeting.mp4"
  157. File.open("/var/bigbluebutton/published/video/#{meeting_id}/metadata.xml", 'w') do |file|
  158. file.write(metadata)
  159. end
  160. end
  161. def base64_encode(path)
  162. return '' if File.directory?(path)
  163. data = File.open(path).read
  164. "data:image/#{File.extname(path).delete('.')};base64,#{Base64.strict_encode64(data)}"
  165. end
  166. def measure_string(s, font_size)
  167. # https://stackoverflow.com/a/4081370
  168. # DejaVuSans, the default truefont of Debian, can be used here
  169. # /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf
  170. # use ImageMagick to measure the string in pixels
  171. command = "convert xc: -font /usr/share/fonts/truetype/msttcorefonts/Arial.ttf -pointsize #{font_size} -debug annotate -annotate 0 #{Shellwords.escape(s)} null: 2>&1"
  172. _, output = run_command(command, true)
  173. output.match(/; width: (\d+);/)[1].to_f
  174. end
  175. def pack_up_string(s, separator, font_size, text_box_width)
  176. # split the line on whitespaces, and measure the line to fit into
  177. # the text_box_width
  178. line_breaks = []
  179. queued_words = []
  180. s.split(separator).each do |word|
  181. # first consider queued word and the current word in the line
  182. test_string = (queued_words + [word]).join(separator)
  183. width = measure_string(test_string, font_size)
  184. if width > text_box_width
  185. # line exceeded, so consider the queued words as a line break and
  186. # queue the current word
  187. line_breaks += [queued_words.join(separator)]
  188. if measure_string(word, font_size) > text_box_width
  189. # if the word alone exceeds the box width, then we pack the word
  190. # maximizing the amount of characters on each line
  191. res = pack_up_string(word, '', font_size, text_box_width)
  192. # queue last line break, other words might fit
  193. queued_words = [res.pop]
  194. line_breaks += res
  195. else
  196. queued_words = [word]
  197. end
  198. else
  199. # current word fits the text box, so keep enqueueing new words
  200. queued_words += [word]
  201. end
  202. end
  203. # make sure we release the final queued words as the final line break
  204. line_breaks += [queued_words.join(separator)] unless queued_words.empty?
  205. line_breaks
  206. end
  207. def convert_whiteboard_shapes(whiteboard)
  208. # Find shape elements
  209. whiteboard.xpath('svg/g/g').each do |annotation|
  210. # Make all annotations visible
  211. style = annotation.attr('style')
  212. style.sub! 'visibility:hidden', ''
  213. annotation.set_attribute('style', style)
  214. shape = annotation.attribute('shape').to_s
  215. # Convert polls to data schema
  216. if shape.include? 'poll'
  217. poll = annotation.element_children.first
  218. path = "#{@published_files}/#{poll.attribute('href')}"
  219. poll.remove_attribute('href')
  220. # Namespace xmlns:xlink is required by FFmpeg
  221. poll.add_namespace_definition('xlink', 'http://www.w3.org/1999/xlink')
  222. data = FFMPEG_REFERENCE_SUPPORT ? "file://#{path}" : base64_encode(path)
  223. poll.set_attribute('xlink:href', data)
  224. end
  225. # Convert XHTML to SVG so that text can be shown
  226. next unless shape.include? 'text'
  227. # Turn style attributes into a hash
  228. style_values = Hash[*CSV.parse(style, col_sep: ':', row_sep: ';').flatten]
  229. # The text_color variable may not be required depending on your FFmpeg version
  230. text_color = style_values['color']
  231. font_size = style_values['font-size'].to_f
  232. annotation.set_attribute('style', "#{style};fill:currentcolor")
  233. foreign_object = annotation.xpath('switch/foreignObject')
  234. # Obtain X and Y coordinates of the text
  235. x = foreign_object.attr('x').to_s
  236. y = foreign_object.attr('y').to_s
  237. text_box_width = foreign_object.attr('width').to_s.to_f
  238. text = foreign_object.children.children
  239. builder = Builder::XmlMarkup.new
  240. builder.text(x: x, y: y, fill: text_color, 'xml:space' => 'preserve') do
  241. previous_line_was_text = true
  242. text.each do |line|
  243. line = line.to_s
  244. if line == '<br/>'
  245. if previous_line_was_text
  246. previous_line_was_text = false
  247. else
  248. builder.tspan(x: x, dy: '1.0em') { builder << '<br/>' }
  249. end
  250. else
  251. line = Loofah.fragment(line).scrub!(:strip).text.unicode_normalize
  252. line_breaks = pack_up_string(line, ' ', font_size, text_box_width)
  253. line_breaks.each do |row|
  254. safe_message = Loofah.fragment(row).scrub!(:escape)
  255. builder.tspan(x: x, dy: '1.0em') { builder << safe_message }
  256. end
  257. previous_line_was_text = true
  258. end
  259. end
  260. end
  261. annotation.add_child(builder.target!)
  262. # Remove the <switch> tag
  263. annotation.xpath('switch').remove
  264. end
  265. # Save new shapes.svg copy
  266. File.open("#{@published_files}/shapes_modified.svg", 'w', TEMPORARY_FILES_PERMISSION) do |file|
  267. file.write(whiteboard)
  268. end
  269. end
  270. def parse_panzooms(pan_reader, timestamps)
  271. panzooms = []
  272. timestamp = 0
  273. pan_reader.each do |node|
  274. next unless node.node_type == Nokogiri::XML::Reader::TYPE_ELEMENT
  275. node_name = node.name
  276. timestamp = node.attribute('timestamp').to_f if node_name == 'event'
  277. if node_name == 'viewBox'
  278. panzooms << [timestamp, node.inner_xml]
  279. timestamps << timestamp
  280. end
  281. end
  282. [panzooms, timestamps]
  283. end
  284. def parse_whiteboard_shapes(shape_reader)
  285. slide_in = 0
  286. slide_out = 0
  287. shapes = []
  288. slides = []
  289. timestamps = []
  290. shape_reader.each do |node|
  291. next unless node.node_type == Nokogiri::XML::Reader::TYPE_ELEMENT
  292. node_name = node.name
  293. node_class = node.attribute('class')
  294. if node_name == 'image' && node_class == 'slide'
  295. slide_in = node.attribute('in').to_f
  296. slide_out = node.attribute('out').to_f
  297. timestamps << slide_in
  298. timestamps << slide_out
  299. # Image paths need to follow the URI Data Scheme (for slides and polls)
  300. path = "#{@published_files}/#{node.attribute('href')}"
  301. data = FFMPEG_REFERENCE_SUPPORT ? "file://#{path}" : base64_encode(path)
  302. slides << WhiteboardSlide.new(data, slide_in, slide_out, node.attribute('width').to_f, node.attribute('height'))
  303. end
  304. next unless node_name == 'g' && node_class == 'shape'
  305. shape_timestamp = node.attribute('timestamp').to_f
  306. shape_undo = node.attribute('undo').to_f
  307. shape_undo = slide_out if shape_undo.negative?
  308. shape_enter = [shape_timestamp, slide_in].max
  309. shape_leave = [[shape_undo, slide_in].max, slide_out].min
  310. timestamps << shape_enter
  311. timestamps << shape_leave
  312. xml = "<g style=\"#{node.attribute('style')}\">#{node.inner_xml}</g>"
  313. id = node.attribute('shape').split('-').last
  314. shapes << WhiteboardElement.new(shape_enter, shape_leave, xml, id)
  315. end
  316. [shapes, slides, timestamps]
  317. end
  318. def remove_adjacent(array)
  319. index = 0
  320. until array[index + 1].nil?
  321. array[index] = nil if array[index].id == array[index + 1].id
  322. index += 1
  323. end
  324. array.compact! || array
  325. end
  326. def parse_chat(chat_reader)
  327. messages = []
  328. salt = Time.now.nsec
  329. chat_reader.each do |node|
  330. unless node.name == 'chattimeline' &&
  331. node.attribute('target') == 'chat' &&
  332. node.node_type == Nokogiri::XML::Reader::TYPE_ELEMENT
  333. next
  334. end
  335. name = node.attribute('name')
  336. name = Digest::SHA1.bubblebabble(name << salt.to_s)[0..10] if HIDE_CHAT_NAMES
  337. messages << [node.attribute('in').to_f, name, node.attribute('message')]
  338. end
  339. messages
  340. end
  341. def render_chat(chat_reader)
  342. messages = parse_chat(chat_reader)
  343. return if messages.empty?
  344. # Text coordinates on the SVG file
  345. svg_x = 0
  346. svg_y = CHAT_STARTING_OFFSET
  347. # Chat viewbox coordinates
  348. chat_x = 0
  349. chat_y = 0
  350. overlay_position = []
  351. # Keep last n messages for seamless transitions between columns
  352. duplicates = Array.new((CHAT_HEIGHT / (3 * CHAT_FONT_SIZE)) + 1) { nil }
  353. # Create SVG chat with all messages
  354. # Add 'xmlns' => 'http://www.w3.org/2000/svg' for visual debugging
  355. builder = Builder::XmlMarkup.new
  356. builder.instruct!
  357. builder.svg(width: CHAT_CANVAS_WIDTH, height: CHAT_CANVAS_HEIGHT, 'xmlns' => 'http://www.w3.org/2000/svg') do
  358. builder.style { builder << "text{font-family: monospace; font-size: #{CHAT_FONT_SIZE}}" }
  359. messages.each do |timestamp, name, chat|
  360. # Strip HTML tags e.g. from links so it only displays the inner text
  361. chat = Loofah.fragment(chat).scrub!(:strip).text.unicode_normalize
  362. name = Loofah.fragment(name).scrub!(:strip).text.unicode_normalize
  363. max_message_length = (CHAT_WIDTH / CHAT_FONT_SIZE_X) - 1
  364. line_breaks = [-1]
  365. line_index = 0
  366. last_linebreak_pos = 0
  367. chat_length = chat.length - 1
  368. (0..chat_length).each do |chat_index|
  369. last_linebreak_pos = chat_index if chat[chat_index] == ' '
  370. if line_index >= max_message_length
  371. last_linebreak_pos = chat_index if last_linebreak_pos <= chat_index - max_message_length
  372. line_breaks << last_linebreak_pos
  373. line_index = chat_index - last_linebreak_pos - 1
  374. end
  375. line_index += 1
  376. end
  377. line_wraps = []
  378. line_breaks.each_cons(2) do |(a, b)|
  379. line_wraps << [a + 1, b]
  380. end
  381. line_wraps << [line_breaks.last + 1, chat_length]
  382. # Message height equals the line break amount + the line for the name / time + the empty line afterwards
  383. message_height = (line_wraps.size + 2) * CHAT_FONT_SIZE
  384. # Add message to a new column if it goes over the canvas height
  385. if svg_y + message_height > CHAT_CANVAS_HEIGHT
  386. # Insert duplicate messages when going to next column for a seamless transition
  387. duplicate_y = CHAT_HEIGHT
  388. duplicates.each do |header, duplicate_content, duplicate_x|
  389. break if header.nil? || duplicate_y.negative?
  390. duplicate_x += CHAT_WIDTH
  391. duplicate_content.each do |content|
  392. duplicate_y -= CHAT_FONT_SIZE
  393. builder.text(x: duplicate_x, y: duplicate_y) { builder << content }
  394. end
  395. duplicate_y -= CHAT_FONT_SIZE
  396. builder.text(x: duplicate_x, y: duplicate_y, 'font-weight' => 'bold') { builder << header }
  397. duplicate_y -= CHAT_FONT_SIZE
  398. end
  399. # Set coordinates to new column
  400. svg_y = CHAT_STARTING_OFFSET
  401. svg_x += CHAT_WIDTH
  402. chat_x += CHAT_WIDTH
  403. chat_y = message_height
  404. else
  405. chat_y += message_height
  406. end
  407. overlay_position << [timestamp, chat_x, chat_y]
  408. # Username and chat timestamp
  409. header = "#{name} #{Time.at(timestamp.to_f.round(0)).utc.strftime('%H:%M:%S')}"
  410. builder.text(x: svg_x, y: svg_y, 'font-weight' => 'bold') do
  411. builder << header
  412. end
  413. svg_y += CHAT_FONT_SIZE
  414. duplicate_content = []
  415. # Message text
  416. line_wraps.each do |a, b|
  417. safe_message = Loofah.fragment(chat[a..b]).scrub!(:escape)
  418. builder.text(x: svg_x, y: svg_y) { builder << safe_message }
  419. svg_y += CHAT_FONT_SIZE
  420. duplicate_content.unshift(safe_message)
  421. end
  422. duplicates.unshift([header, duplicate_content, svg_x])
  423. duplicates.pop
  424. svg_y += CHAT_FONT_SIZE
  425. end
  426. end
  427. # Dynamically adjust the chat canvas size for the fastest possible export
  428. cropped_chat_canvas_width = svg_x + CHAT_WIDTH
  429. cropped_chat_canvas_height = cropped_chat_canvas_width == CHAT_WIDTH ? svg_y : CHAT_CANVAS_HEIGHT
  430. builder = Nokogiri::XML(builder.target!)
  431. builder_root = builder.root
  432. builder_root.set_attribute('width', cropped_chat_canvas_width)
  433. builder_root.set_attribute('height', cropped_chat_canvas_height)
  434. # Saves chat as SVG / SVGZ file
  435. File.open("#{@published_files}/chats/chat.svg", 'w', TEMPORARY_FILES_PERMISSION) do |file|
  436. file.write(builder)
  437. end
  438. File.open("#{@published_files}/timestamps/chat_timestamps", 'w', TEMPORARY_FILES_PERMISSION) do |file|
  439. overlay_position.each do |timestamp, x, y|
  440. file.puts "#{timestamp} crop@c x #{x}, crop@c y #{y};"
  441. end
  442. end
  443. end
  444. def render_cursor(panzooms, cursor_reader)
  445. # Create the mouse pointer SVG
  446. builder = Builder::XmlMarkup.new
  447. # Add 'xmlns' => 'http://www.w3.org/2000/svg' for visual debugging, remove for faster exports
  448. builder.svg(width: CURSOR_RADIUS * 2, height: CURSOR_RADIUS * 2) do
  449. builder.circle(cx: CURSOR_RADIUS, cy: CURSOR_RADIUS, r: CURSOR_RADIUS, fill: 'red')
  450. end
  451. File.open("#{@published_files}/cursor/cursor.svg", 'w', TEMPORARY_FILES_PERMISSION) do |svg|
  452. svg.write(builder.target!)
  453. end
  454. cursor = []
  455. timestamps = []
  456. view_box = ''
  457. cursor_reader.each do |node|
  458. node_name = node.name
  459. next unless node.node_type == Nokogiri::XML::Reader::TYPE_ELEMENT
  460. timestamps << node.attribute('timestamp').to_f if node_name == 'event'
  461. cursor << node.inner_xml if node_name == 'cursor'
  462. end
  463. panzoom_index = 0
  464. File.open("#{@published_files}/timestamps/cursor_timestamps", 'w', TEMPORARY_FILES_PERMISSION) do |file|
  465. timestamps.each.with_index do |timestamp, frame_number|
  466. panzoom = panzooms[panzoom_index]
  467. if panzoom_index < panzooms.length && timestamp >= panzoom.first
  468. _, view_box = panzoom
  469. panzoom_index += 1
  470. view_box = view_box.split
  471. end
  472. # Get cursor coordinates
  473. pointer = cursor[frame_number].split
  474. width = view_box[2].to_f
  475. height = view_box[3].to_f
  476. # Calculate original cursor coordinates
  477. cursor_x = pointer[0].to_f * width
  478. cursor_y = pointer[1].to_f * height
  479. # Scaling required to reach target dimensions
  480. x_scale = SLIDES_WIDTH / width
  481. y_scale = SLIDES_HEIGHT / height
  482. # Keep aspect ratio
  483. scale_factor = [x_scale, y_scale].min
  484. # Scale
  485. cursor_x *= scale_factor
  486. cursor_y *= scale_factor
  487. # Translate given difference to new on-screen dimensions
  488. x_offset = (SLIDES_WIDTH - (scale_factor * width)) / 2
  489. y_offset = (SLIDES_HEIGHT - (scale_factor * height)) / 2
  490. # Center cursor
  491. cursor_x -= CURSOR_RADIUS
  492. cursor_y -= CURSOR_RADIUS
  493. cursor_x += x_offset
  494. cursor_y += y_offset
  495. # Move whiteboard to the right, making space for the chat and webcams
  496. cursor_x += WEBCAMS_WIDTH
  497. # Writes the timestamp and position down
  498. file.puts "#{timestamp} overlay@m x #{cursor_x.round(3)}, overlay@m y #{cursor_y.round(3)};"
  499. end
  500. end
  501. end
  502. def render_video(duration, meeting_name)
  503. # Determine if video had screensharing / chat messages
  504. deskshare = !HIDE_DESKSHARE && File.file?("#{@published_files}/deskshare/deskshare.#{VIDEO_EXTENSION}")
  505. chat = !HIDE_CHAT && File.file?("#{@published_files}/chats/chat.svg")
  506. render = "ffmpeg -f lavfi -i color=c=#{BACKGROUND_COLOR}:s=#{OUTPUT_WIDTH}x#{OUTPUT_HEIGHT} " \
  507. "-f concat -safe 0 #{BASE_URI} -i #{@published_files}/timestamps/whiteboard_timestamps " \
  508. "-framerate 10 -loop 1 -i #{@published_files}/cursor/cursor.svg "
  509. if chat
  510. render << "-framerate 1 -loop 1 -i #{@published_files}/chats/chat.svg " \
  511. "-i #{@published_files}/video/webcams.#{VIDEO_EXTENSION} "
  512. render << if deskshare
  513. "-i #{@published_files}/deskshare/deskshare.#{VIDEO_EXTENSION} -filter_complex " \
  514. "'[2]sendcmd=f=#{@published_files}/timestamps/cursor_timestamps[cursor];" \
  515. "[3]sendcmd=f=#{@published_files}/timestamps/chat_timestamps," \
  516. "crop@c=w=#{CHAT_WIDTH}:h=#{CHAT_HEIGHT}:x=0:y=0[chat];" \
  517. "[4]scale=w=#{WEBCAMS_WIDTH}:h=#{WEBCAMS_HEIGHT}[webcams];" \
  518. "[5]scale=w=#{SLIDES_WIDTH}:h=#{SLIDES_HEIGHT}:force_original_aspect_ratio=1[deskshare];" \
  519. "[0][deskshare]overlay=x=#{WEBCAMS_WIDTH}:y=#{DESKSHARE_Y_OFFSET}[screenshare];" \
  520. "[screenshare][1]overlay=x=#{WEBCAMS_WIDTH}[slides];" \
  521. '[slides][cursor]overlay@m[whiteboard];' \
  522. "[whiteboard][chat]overlay=y=#{WEBCAMS_HEIGHT}[chats];" \
  523. "[chats][webcams]overlay' "
  524. else
  525. "-filter_complex '[2]sendcmd=f=#{@published_files}/timestamps/cursor_timestamps[cursor];" \
  526. "[3]sendcmd=f=#{@published_files}/timestamps/chat_timestamps," \
  527. "crop@c=w=#{CHAT_WIDTH}:h=#{CHAT_HEIGHT}:x=0:y=0[chat];" \
  528. "[4]scale=w=#{WEBCAMS_WIDTH}:h=#{WEBCAMS_HEIGHT}[webcams];" \
  529. "[0][1]overlay=x=#{WEBCAMS_WIDTH}[slides];" \
  530. '[slides][cursor]overlay@m[whiteboard];' \
  531. "[whiteboard][chat]overlay=y=#{WEBCAMS_HEIGHT}[chats];[chats][webcams]overlay' "
  532. end
  533. else
  534. render << "-i #{@published_files}/video/webcams.#{VIDEO_EXTENSION} "
  535. render << if deskshare
  536. "-i #{@published_files}/deskshare/deskshare.#{VIDEO_EXTENSION} -filter_complex " \
  537. "'[2]sendcmd=f=#{@published_files}/timestamps/cursor_timestamps[cursor];" \
  538. "[3]scale=w=#{WEBCAMS_WIDTH}:h=#{WEBCAMS_HEIGHT}[webcams];" \
  539. "[4]scale=w=#{SLIDES_WIDTH}:h=#{SLIDES_HEIGHT}:force_original_aspect_ratio=1[deskshare];" \
  540. "[0][deskshare]overlay=x=#{WEBCAMS_WIDTH}:y=#{DESKSHARE_Y_OFFSET}[screenshare];" \
  541. "[screenshare][1]overlay=x=#{WEBCAMS_WIDTH}[slides];" \
  542. '[slides][cursor]overlay@m[whiteboard];' \
  543. "[whiteboard][webcams]overlay' "
  544. else
  545. "-filter_complex '[2]sendcmd=f=#{@published_files}/timestamps/cursor_timestamps[cursor];" \
  546. "[3]scale=w=#{WEBCAMS_WIDTH}:h=#{WEBCAMS_HEIGHT}[webcams];" \
  547. "[0][1]overlay=x=#{WEBCAMS_WIDTH}[slides];" \
  548. '[slides][cursor]overlay@m[whiteboard];' \
  549. "[whiteboard][webcams]overlay' "
  550. end
  551. end
  552. render << "-c:a aac -crf #{CONSTANT_RATE_FACTOR} -shortest -y -t #{duration} -threads #{THREADS} " \
  553. "-metadata title=#{Shellwords.escape("#{meeting_name}")} #{BENCHMARK} #{@published_files}/meeting-tmp.mp4"
  554. success, = run_command(render)
  555. unless success
  556. warn('An error occurred rendering the video.')
  557. exit(false)
  558. end
  559. end
  560. def render_whiteboard(panzooms, slides, shapes, timestamps)
  561. shapes_interval_tree = IntervalTree::Tree.new(shapes)
  562. # Create frame intervals with starting time 0
  563. intervals = timestamps.uniq.sort
  564. intervals = intervals.drop(1) if intervals.first == -1
  565. frame_number = 0
  566. # Render the visible frame for each interval
  567. File.open("#{@published_files}/timestamps/whiteboard_timestamps", 'w', TEMPORARY_FILES_PERMISSION) do |file|
  568. slide_number = 0
  569. slide = slides[slide_number]
  570. view_box = ''
  571. intervals.each_cons(2).each do |interval_start, interval_end|
  572. # Get view_box parameter of the current slide
  573. _, view_box = panzooms.shift if !panzooms.empty? && interval_start >= panzooms.first.first
  574. if slide_number < slides.size && interval_start >= slides[slide_number].begin
  575. slide = slides[slide_number]
  576. slide_number += 1
  577. end
  578. draw = shapes_interval_tree.search(interval_start, unique: false, sort: false)
  579. draw = [] if draw.nil?
  580. draw = remove_adjacent(draw) if REMOVE_REDUNDANT_SHAPES && !draw.empty?
  581. svg_export(draw, view_box, slide.href, slide.width, slide.height, frame_number)
  582. # Write the frame's duration down
  583. file.puts "file ../frames/frame#{frame_number}.#{SVG_EXTENSION}"
  584. file.puts "duration #{(interval_end - interval_start).round(1)}"
  585. frame_number += 1
  586. end
  587. # The last image needs to be specified twice, without specifying the duration (FFmpeg quirk)
  588. file.puts "file ../frames/frame#{frame_number - 1}.#{SVG_EXTENSION}" if frame_number.positive?
  589. end
  590. end
  591. def svg_export(draw, view_box, slide_href, width, height, frame_number)
  592. # Builds SVG frame
  593. builder = Builder::XmlMarkup.new
  594. _view_box_x, _view_box_y, view_box_width, view_box_height = view_box.split.map(&:to_f)
  595. view_box_aspect_ratio = view_box_width / view_box_height
  596. width = width.to_f
  597. height = height.to_f
  598. slide_aspect_ratio = width / height
  599. outer_viewbox_x = 0
  600. outer_viewbox_y = 0
  601. outer_viewbox_width = SLIDES_WIDTH
  602. outer_viewbox_height = SLIDES_HEIGHT
  603. if view_box_aspect_ratio > slide_aspect_ratio
  604. outer_viewbox_height = SLIDES_WIDTH / view_box_aspect_ratio
  605. else
  606. outer_viewbox_width = SLIDES_HEIGHT * view_box_aspect_ratio
  607. end
  608. outer_viewbox = "#{outer_viewbox_x} #{outer_viewbox_y} #{outer_viewbox_width} #{outer_viewbox_height}"
  609. builder.svg(width: SLIDES_WIDTH, height: SLIDES_HEIGHT, viewBox: outer_viewbox,
  610. 'xmlns:xlink' => 'http://www.w3.org/1999/xlink', 'xmlns' => 'http://www.w3.org/2000/svg') do
  611. # FFmpeg requires the xmlns:xmlink namespace. Add 'xmlns' => 'http://www.w3.org/2000/svg' for visual debugging
  612. builder.svg(viewBox: view_box,
  613. 'xmlns:xlink' => 'http://www.w3.org/1999/xlink', 'xmlns' => 'http://www.w3.org/2000/svg') do
  614. # Display background image
  615. builder.image('xlink:href': slide_href, width: width, height: height)
  616. # Adds annotations
  617. draw.each do |shape|
  618. builder << shape.value
  619. end
  620. end
  621. end
  622. File.open("#{@published_files}/frames/frame#{frame_number}.#{SVG_EXTENSION}", 'w',
  623. TEMPORARY_FILES_PERMISSION) do |svg|
  624. if SVGZ_COMPRESSION
  625. svgz = Zlib::GzipWriter.new(svg, Zlib::BEST_SPEED)
  626. svgz.write(builder.target!)
  627. svgz.close
  628. else
  629. svg.write(builder.target!)
  630. end
  631. end
  632. end
  633. def export_presentation
  634. # Benchmark
  635. start = Time.now
  636. # Convert whiteboard assets to a format compatible with FFmpeg
  637. convert_whiteboard_shapes(Nokogiri::XML(File.open("#{@published_files}/shapes.svg")).remove_namespaces!)
  638. metadata = Nokogiri::XML(File.open("#{@published_files}/metadata.xml"))
  639. # Playback duration in seconds
  640. duration = metadata.xpath('recording/playback/duration').inner_text.to_f / 1000
  641. meeting_name = metadata.xpath('recording/meta/meetingName').inner_text
  642. shapes, slides, timestamps =
  643. parse_whiteboard_shapes(Nokogiri::XML::Reader(File.read("#{@published_files}/shapes_modified.svg")))
  644. panzooms, timestamps = parse_panzooms(Nokogiri::XML::Reader(File.read("#{@published_files}/panzooms.xml")),
  645. timestamps)
  646. # Ensure correct recording length - shapes.svg may have incorrect slides after recording ends
  647. timestamps << duration
  648. timestamps = timestamps.select { |t| t <= duration }
  649. # Create video assets
  650. render_chat(Nokogiri::XML::Reader(File.open("#{@published_files}/slides_new.xml"))) unless HIDE_CHAT
  651. render_cursor(panzooms, Nokogiri::XML::Reader(File.open("#{@published_files}/cursor.xml")))
  652. render_whiteboard(panzooms, slides, shapes, timestamps)
  653. BigBlueButton.logger.info("Finished composing presentation. Time: #{Time.now - start}")
  654. start = Time.now
  655. BigBlueButton.logger.info('Starting to export video')
  656. render_video(duration, meeting_name)
  657. add_chapters(duration, slides)
  658. add_captions if CAPTION_SUPPORT
  659. FileUtils.mv("#{@published_files}/meeting-tmp.mp4", "#{@published_files}/meeting.mp4")
  660. BigBlueButton.logger.info("Exported recording available at #{@published_files}/meeting.mp4. Rendering took: #{Time.now - start}")
  661. add_greenlight_buttons(metadata)
  662. end
  663. export_presentation
  664. # Delete the contents of the scratch directories
  665. FileUtils.rm_rf(["#{@published_files}/chats", "#{@published_files}/cursor", "#{@published_files}/frames",
  666. "#{@published_files}/timestamps", "#{@published_files}/shapes_modified.svg",
  667. "#{@published_files}/meeting_metadata"])
  668. exit(0)