fix(whiteboard): tldraw recording processing/publishing

Changed the names of tldraw record events to differentiate from before.
Publish tldraw.json file with all shape information during the meeting to be used in playback.
Adapted cursor.xml and panzoom.xml to store tldraw data.
Publish slides svgs to be used by playback's tldraw component (otherwise we have different image sizes in pngs and thus messing the coordinates).
Retro-compatible with old recordings.
This commit is contained in:
germanocaumo 2022-06-23 17:04:09 +00:00
parent 75efa60f7d
commit 853d0dfd9b
9 changed files with 353 additions and 60 deletions

View File

@ -0,0 +1,65 @@
/**
* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
*
* Copyright (c) 2017 BigBlueButton Inc. and by respective authors (see below).
*
* This program is free software; you can redistribute it and/or modify it under the
* terms of the GNU Lesser General Public License as published by the Free Software
* Foundation; either version 3.0 of the License, or (at your option) any later
* version.
*
* BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License along
* with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
*
*/
package org.bigbluebutton.core.record.events
import org.bigbluebutton.common2.domain.SimpleVoteOutVO
import scala.collection.immutable.List
import scala.collection.Map
import scala.collection.mutable.ArrayBuffer
import spray.json._
import DefaultJsonProtocol._
class AddTldrawShapeWhiteboardRecordEvent extends AbstractWhiteboardRecordEvent {
import AddTldrawShapeWhiteboardRecordEvent._
implicit object AnyJsonFormat extends JsonFormat[Any] {
def write(x: Any) = x match {
case n: Int => JsNumber(n)
case s: String => JsString(s)
case d: Double => JsNumber(d)
case m: scala.collection.immutable.Map[String, _] => mapFormat[String, Any].write(m)
case l: List[_] => listFormat[Any].write(l)
case b: Boolean if b == true => JsTrue
case b: Boolean if b == false => JsFalse
}
def read(value: JsValue) = {}
}
setEvent("AddTldrawShapeEvent")
def setUserId(id: String) {
eventMap.put(USER_ID, id)
}
def setAnnotationId(id: String) {
eventMap.put(SHAPE_ID, id)
}
def addAnnotation(annotation: scala.collection.immutable.Map[String, Any]) {
eventMap.put(SHAPE_DATA, annotation.toJson.compactPrint)
}
}
object AddTldrawShapeWhiteboardRecordEvent {
protected final val USER_ID = "userId"
protected final val SHAPE_ID = "shapeId"
protected final val SHAPE_DATA = "shapeData"
}

View File

@ -0,0 +1,39 @@
/**
* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
*
* Copyright (c) 2017 BigBlueButton Inc. and by respective authors (see below).
*
* This program is free software; you can redistribute it and/or modify it under the
* terms of the GNU Lesser General Public License as published by the Free Software
* Foundation; either version 3.0 of the License, or (at your option) any later
* version.
*
* BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License along
* with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
*
*/
package org.bigbluebutton.core.record.events
class DeleteTldrawShapeRecordEvent extends AbstractWhiteboardRecordEvent {
import DeleteTldrawShapeRecordEvent._
setEvent("DeleteTldrawShapeEvent")
def setUserId(userId: String) {
eventMap.put(USER_ID, userId)
}
def setShapeId(shapeId: String) {
eventMap.put(SHAPE_ID, shapeId)
}
}
object DeleteTldrawShapeRecordEvent {
protected final val USER_ID = "userId"
protected final val SHAPE_ID = "shapeId"
}

View File

@ -32,23 +32,28 @@ class ResizeAndMoveSlideRecordEvent extends AbstractPresentationRecordEvent {
eventMap.put(ID, id)
}
def setXCamera(xCamera: Double) {
eventMap.put(X_CAMERA, xCamera.toString)
def setXOffset(offset: Double) {
eventMap.put(X_OFFSET, offset.toString)
}
def setYCamera(yCamera: Double) {
eventMap.put(Y_CAMERA, yCamera.toString)
def setYOffset(offset: Double) {
eventMap.put(Y_OFFSET, offset.toString)
}
def setZoom(zoom: Double) {
eventMap.put(ZOOM, zoom.toString)
def setWidthRatio(ratio: Double) {
eventMap.put(WIDTH_RATIO, ratio.toString)
}
def setHeightRatio(ratio: Double) {
eventMap.put(HEIGHT_RATIO, ratio.toString)
}
}
object ResizeAndMoveSlideRecordEvent {
protected final val PRES_NAME = "presentationName"
protected final val ID = "id"
protected final val X_CAMERA = "xCamera"
protected final val Y_CAMERA = "yCamera"
protected final val ZOOM = "zoom"
protected final val X_OFFSET = "xOffset"
protected final val Y_OFFSET = "yOffset"
protected final val WIDTH_RATIO = "widthRatio"
protected final val HEIGHT_RATIO = "heightRatio"
}

View File

@ -0,0 +1,54 @@
/**
* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
*
* Copyright (c) 2017 BigBlueButton Inc. and by respective authors (see below).
*
* This program is free software; you can redistribute it and/or modify it under the
* terms of the GNU Lesser General Public License as published by the Free Software
* Foundation; either version 3.0 of the License, or (at your option) any later
* version.
*
* BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License along
* with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
*
*/
package org.bigbluebutton.core.record.events
class TldrawCameraChangedRecordEvent extends AbstractPresentationRecordEvent {
import TldrawCameraChangedRecordEvent._
setEvent("TldrawCameraChangedEvent")
def setPresentationName(name: String) {
eventMap.put(PRES_NAME, name)
}
def setId(id: String) {
eventMap.put(ID, id)
}
def setXCamera(xCamera: Double) {
eventMap.put(X_CAMERA, xCamera.toString)
}
def setYCamera(yCamera: Double) {
eventMap.put(Y_CAMERA, yCamera.toString)
}
def setZoom(zoom: Double) {
eventMap.put(ZOOM, zoom.toString)
}
}
object TldrawCameraChangedRecordEvent {
protected final val PRES_NAME = "presentationName"
protected final val ID = "id"
protected final val X_CAMERA = "xCamera"
protected final val Y_CAMERA = "yCamera"
protected final val ZOOM = "zoom"
}

View File

@ -184,7 +184,7 @@ class RedisRecorderActor(
}
private def handleResizeAndMovePageEvtMsg(msg: ResizeAndMovePageEvtMsg) {
val ev = new ResizeAndMoveSlideRecordEvent()
val ev = new TldrawCameraChangedRecordEvent()
ev.setMeetingId(msg.header.meetingId)
ev.setPodId(msg.body.podId)
ev.setPresentationName(msg.body.presentationId)
@ -280,7 +280,7 @@ class RedisRecorderActor(
private def handleSendWhiteboardAnnotationsEvtMsg(msg: SendWhiteboardAnnotationsEvtMsg) {
msg.body.annotations.foreach(annotation => {
val ev = new AddShapeWhiteboardRecordEvent()
val ev = new AddTldrawShapeWhiteboardRecordEvent()
ev.setMeetingId(msg.header.meetingId)
ev.setPresentation(getPresentationId(annotation.wbId))
ev.setPageNumber(getPageNum(annotation.wbId))
@ -320,7 +320,7 @@ class RedisRecorderActor(
private def handleDeleteWhiteboardAnnotationsEvtMsg(msg: DeleteWhiteboardAnnotationsEvtMsg) {
msg.body.annotationsIds.foreach(annotationId => {
val ev = new UndoAnnotationRecordEvent()
val ev = new DeleteTldrawShapeRecordEvent()
ev.setMeetingId(msg.header.meetingId)
ev.setPresentation(getPresentationId(msg.body.whiteboardId))
ev.setPageNumber(getPageNum(msg.body.whiteboardId))

View File

@ -1,6 +1,5 @@
import * as React from "react";
import ReactCursorPosition from "react-cursor-position";
import Vec from "@tldraw/vec";
import { _ } from "lodash";
function usePrevious(value) {
@ -79,7 +78,7 @@ const PositionLabel = (props) => {
whiteboardId,
} = props;
const { name, color, userId, presenter } = currentUser;
const { name, color } = currentUser;
const prevCurrentPoint = usePrevious(currentPoint);
React.useEffect(() => {
@ -128,8 +127,8 @@ export default function Cursors(props) {
!cursorWrapper.hasOwnProperty("mouseleave") &&
cursorWrapper?.addEventListener("mouseleave", (event) => {
publishCursorUpdate({
xPercent: null,
yPercent: null,
xPercent: -1.0,
yPercent: -1.0,
whiteboardId: whiteboardId,
});
setActive(false);

View File

@ -960,7 +960,7 @@ module BigBlueButton
# The following events are considered to indicate that the presentation
# area was actively used during the session.
when 'AddShapeEvent', 'ModifyTextEvent', 'UndoShapeEvent',
'ClearPageEvent'
'ClearPageEvent', 'AddTldrawShapeEvent', 'DeleteTldrawShapeEvent'
BigBlueButton.logger.debug("Seen a #{event['eventname']} event, presentation area used.")
return true
# We ignore the first SharePresentationEvent, since it's the default
@ -1093,5 +1093,11 @@ module BigBlueButton
return false
end
# Check if doc has tldraw events
def self.check_for_tldraw_events(events)
return !(events.xpath("recording/event[@eventname='TldrawCameraChangedEvent']").empty? &&
events.xpath("recording/event[@eventname='AddTldrawShapeEvent']").empty?)
end
end
end

View File

@ -192,6 +192,10 @@ unless FileTest.directory?(target_dir)
# Copy thumbnails from raw files
FileUtils.cp_r("#{pres_dir}/thumbnails", "#{target_pres_dir}/thumbnails") if File.exist?("#{pres_dir}/thumbnails")
tldraw = BigBlueButton::Events.check_for_tldraw_events(@doc);
if (tldraw)
FileUtils.cp_r("#{pres_dir}/svgs", "#{target_pres_dir}/svgs") if File.exist?("#{pres_dir}/svgs")
end
end
BigBlueButton.logger.info('Generating closed captions')

View File

@ -382,6 +382,30 @@ def svg_render_shape_poll(g, slide, shape)
width: width, height: height, x: x, y: y)
end
def build_tldraw_shape(image_shapes, slide, shape)
shape_in = shape[:in]
shape_out = shape[:out]
if shape_in == shape_out
BigBlueButton.logger.info("Draw #{shape[:shape_id]} Shape #{shape[:shape_unique_id]} is never shown (duration rounds to 0)")
return
end
if (shape_in >= slide[:out]) || (!shape[:out].nil? && shape[:out] <= slide[:in])
BigBlueButton.logger.info("Draw #{shape[:shape_id]} Shape #{shape[:shape_unique_id]} is not visible during image time span")
return
end
tldraw_shape = {
id: shape[:shape_id],
timestamp: shape_in,
undo: (shape[:undo].nil? ? -1 : shape[:undo]),
shape_data: shape[:shape_data]
}
image_shapes.push(tldraw_shape)
end
def svg_render_shape(canvas, slide, shape, image_id)
shape_in = shape[:in]
shape_out = shape[:out]
@ -426,7 +450,7 @@ def svg_render_shape(canvas, slide, shape, image_id)
end
@svg_image_id = 1
def svg_render_image(svg, slide, shapes)
def svg_render_image(svg, slide, shapes, tldraw, tldraw_shapes)
slide_number = slide[:slide]
presentation = slide[:presentation]
slide_in = slide[:in]
@ -458,6 +482,7 @@ def svg_render_image(svg, slide, shapes)
shapes = shapes[presentation][slide_number]
if !tldraw
canvas = doc.create_element('g',
class: 'canvas', id: "canvas#{image_id}",
image: "image#{image_id}", display: 'none')
@ -467,6 +492,15 @@ def svg_render_image(svg, slide, shapes)
end
svg << canvas unless canvas.element_children.empty?
else
image_shapes = []
shapes.each do |shape|
build_tldraw_shape(image_shapes, slide, shape)
end
tldraw_shapes[image_id] = { :shapes=>image_shapes, :timestamp=> slide_in}
end
end
def panzoom_viewbox(panzoom)
@ -483,28 +517,34 @@ def panzoom_viewbox(panzoom)
[x, y, w, h]
end
def panzooms_emit_event(rec, panzoom)
def panzooms_emit_event(rec, panzoom, tldraw)
panzoom_in = panzoom[:in]
return if panzoom_in == panzoom[:out]
if !tldraw
rec.event(timestamp: panzoom_in) do
x, y, w, h = panzoom_viewbox(panzoom)
rec.viewBox("#{x} #{y} #{w} #{h}")
end
else
rec.event(timestamp: panzoom_in) do
rec.cameraAndZoom("#{panzoom[:x_camera]} #{panzoom[:y_camera]} #{panzoom[:zoom]}")
end
end
end
def convert_cursor_coordinate(cursor_coord, panzoom_offset, panzoom_ratio)
(((cursor_coord / 100.0) + (panzoom_offset * MAGIC_MYSTERY_NUMBER / 100.0)) / (panzoom_ratio / 100.0)).round(5)
end
def cursors_emit_event(rec, cursor)
def cursors_emit_event(rec, cursor, tldraw)
cursor_in = cursor[:in]
return if cursor_in == cursor[:out]
rec.event(timestamp: cursor_in) do
panzoom = cursor[:panzoom]
if cursor[:visible]
if @version_atleast_2_0_0
if @version_atleast_2_0_0 && !tldraw
# In BBB 2.0, the cursor now uses the same coordinate system as annotations
# Use the panzoom information to convert it to be relative to viewbox
x = convert_cursor_coordinate(cursor[:x], panzoom[:x_offset], panzoom[:width_ratio])
@ -535,6 +575,53 @@ def determine_slide_number(slide, current_slide)
slide
end
def events_parse_tldraw_shape(shapes, event, current_presentation, current_slide, timestamp)
presentation = event.at_xpath('presentation')
slide = event.at_xpath('pageNumber')
presentation = determine_presentation(presentation, current_presentation)
slide = determine_slide_number(slide, current_slide)
# Set up the shapes data structures if needed
shapes[presentation] ||= {}
shapes[presentation][slide] ||= []
# We only need to deal with shapes for this slide
shapes = shapes[presentation][slide]
# Set up the structure for this shape
shape = {}
# Common properties
shape[:in] = timestamp
shape_data = shape[:shape_data] = JSON.parse(event.at_xpath('shapeData'))
user_id = event.at_xpath('userId')&.text
shape[:user_id] = user_id if user_id
shape_id = event.at_xpath('shapeId')&.text
shape[:id] = shape_id if shape_id
draw_id = shape[:shape_id] = @svg_shape_id
@svg_shape_id += 1
# Find the previous shape, for updates
prev_shape = nil
if shape_id
# If we have a shape ID, look up the previous shape by ID
prev_shape_pos = shapes.rindex { |s| s[:shade_id] == shape_id }
prev_shape = prev_shape_pos ? shapes[prev_shape_pos] : nil
end
if prev_shape
prev_shape[:out] = timestamp
shape[:shape_unique_id] = prev_shape[:shape_unique_id]
else
shape[:shape_unique_id] = @svg_shape_unique_id
@svg_shape_unique_id += 1
end
shapes << shape
end
def events_parse_shape(shapes, event, current_presentation, current_slide, timestamp)
# Figure out what presentation+slide this shape is for, with fallbacks
# for old BBB where this info isn't in the shape messages
@ -734,7 +821,7 @@ def events_parse_clear(shapes, event, current_presentation, current_slide, times
end
end
def events_get_image_info(slide)
def events_get_image_info(slide, tldraw)
slide_deskshare = slide[:deskshare]
slide_presentation = slide[:presentation]
@ -744,7 +831,8 @@ def events_get_image_info(slide)
slide[:src] = 'presentation/logo.png'
else
slide_nr = slide[:slide] + 1
slide[:src] = "presentation/#{slide_presentation}/slide-#{slide_nr}.png"
tldraw ? slide[:src] = "presentation/#{slide_presentation}/svgs/slide#{slide_nr}.svg"
: slide[:src] = "presentation/#{slide_presentation}/slide-#{slide_nr}.png"
slide[:text] = "presentation/#{slide_presentation}/textfiles/slide-#{slide_nr}.txt"
end
image_path = "#{@process_dir}/#{slide[:src]}"
@ -792,6 +880,7 @@ def process_presentation(package_dir)
# Current pan/zoom state
current_x_offset = current_y_offset = 0.0
current_width_ratio = current_height_ratio = 100.0
current_x_camera = current_y_camera = current_zoom = 0.0
# Current cursor status
cursor_x = cursor_y = -1.0
cursor_visible = false
@ -802,6 +891,8 @@ def process_presentation(package_dir)
panzooms = []
cursors = []
shapes = {}
tldraw = BigBlueButton::Events.check_for_tldraw_events(@doc)
tldraw_shapes = {}
# Iterate through the events.xml and store the events, building the
# xml files as we go
@ -836,6 +927,12 @@ def process_presentation(package_dir)
current_height_ratio = event.at_xpath('heightRatio').text.to_f
panzoom_changed = true
when 'TldrawCameraChangedEvent'
current_x_camera = event.at_xpath('xCamera').text.to_f
current_y_camera = event.at_xpath('yCamera').text.to_f
current_zoom = event.at_xpath('zoom').text.to_f
panzoom_changed = true
when 'DeskshareStartedEvent', 'StartWebRTCDesktopShareEvent'
deskshare = slide_changed = true if @presentation_props['include_deskshare']
@ -848,7 +945,10 @@ def process_presentation(package_dir)
when 'AddShapeEvent', 'ModifyTextEvent'
events_parse_shape(shapes, event, current_presentation, current_slide, timestamp)
when 'UndoShapeEvent', 'UndoAnnotationEvent'
when 'AddTldrawShapeEvent'
events_parse_tldraw_shape(shapes, event, current_presentation, current_slide, timestamp)
when 'UndoShapeEvent', 'UndoAnnotationEvent', 'DeleteTldrawShapeEvent'
events_parse_undo(shapes, event, current_presentation, current_slide, timestamp)
when 'ClearPageEvent', 'ClearWhiteboardEvent'
@ -887,7 +987,7 @@ def process_presentation(package_dir)
else
if slide
slide[:out] = timestamp
svg_render_image(svg, slide, shapes)
svg_render_image(svg, slide, shapes, tldraw, tldraw_shapes)
end
BigBlueButton.logger.info("Presentation #{current_presentation} Slide #{current_slide} Deskshare #{deskshare}")
@ -897,7 +997,7 @@ def process_presentation(package_dir)
in: timestamp,
deskshare: deskshare,
}
events_get_image_info(slide)
events_get_image_info(slide, tldraw)
slides << slide
end
end
@ -909,20 +1009,27 @@ def process_presentation(package_dir)
slide_width = slide[:width]
slide_height = slide[:height]
if panzoom &&
(panzoom[:deskshare] == deskshare) &&
((!tldraw &&
(panzoom[:x_offset] == current_x_offset) &&
(panzoom[:y_offset] == current_y_offset) &&
(panzoom[:width_ratio] == current_width_ratio) &&
(panzoom[:height_ratio] == current_height_ratio) &&
(panzoom[:width] == slide_width) &&
(panzoom[:height] == slide_height) &&
(panzoom[:deskshare] == deskshare)
(panzoom[:height] == slide_height)) ||
(tldraw &&
(panzoom[:x_camera] == current_x_camera) &&
(panzoom[:y_camera] == current_y_camera) &&
(panzoom[:zoom] == current_zoom))
)
BigBlueButton.logger.info('Panzoom: skipping, no changes')
panzoom_changed = false
else
if panzoom
panzoom[:out] = timestamp
panzooms_emit_event(panzooms_rec, panzoom)
panzooms_emit_event(panzooms_rec, panzoom, tldraw)
end
if !tldraw
BigBlueButton.logger.info("Panzoom: #{current_x_offset} #{current_y_offset} #{current_width_ratio} #{current_height_ratio} (#{slide_width}x#{slide_height})")
panzoom = {
x_offset: current_x_offset,
@ -934,16 +1041,28 @@ def process_presentation(package_dir)
in: timestamp,
deskshare: deskshare,
}
else
BigBlueButton.logger.info("Panzoom: #{current_x_camera} #{current_y_camera} #{current_zoom} (#{slide_width}x#{slide_height})")
panzoom = {
x_camera: current_x_camera,
y_camera: current_y_camera,
zoom: current_zoom,
in: timestamp,
deskshare: deskshare,
}
end
panzooms << panzoom
end
end
# Perform cursor finalization
if cursor_changed || panzoom_changed
if !tldraw
unless cursor_x >= 0 && cursor_x <= 100 &&
cursor_y >= 0 && cursor_y <= 100
cursor_visible = false
end
end
panzoom = panzooms.last
cursor = cursors.last
@ -955,7 +1074,7 @@ def process_presentation(package_dir)
else
if cursor
cursor[:out] = timestamp
cursors_emit_event(cursors_rec, cursor)
cursors_emit_event(cursors_rec, cursor, tldraw)
end
cursor = {
visible: cursor_visible,
@ -972,26 +1091,27 @@ def process_presentation(package_dir)
# Add the last slide, panzoom, and cursor
slide = slides.last
slide[:out] = last_timestamp
svg_render_image(svg, slide, shapes)
svg_render_image(svg, slide, shapes, tldraw, tldraw_shapes)
panzoom = panzooms.last
panzoom[:out] = last_timestamp
panzooms_emit_event(panzooms_rec, panzoom)
panzooms_emit_event(panzooms_rec, panzoom, tldraw)
cursor = cursors.last
cursor[:out] = last_timestamp
cursors_emit_event(cursors_rec, cursor)
cursors_emit_event(cursors_rec, cursor, tldraw)
cursors_doc = Builder::XmlMarkup.new(indent: 2)
cursors_doc.instruct!
cursors_doc.recording(id: 'cursor_events') { |xml| xml << cursors_rec.target! }
cursors_doc.recording(id: 'cursor_events', tldraw: tldraw) { |xml| xml << cursors_rec.target! }
panzooms_doc = Builder::XmlMarkup.new(indent: 2)
panzooms_doc.instruct!
panzooms_doc.recording(id: 'panzoom_events') { |xml| xml << panzooms_rec.target! }
panzooms_doc.recording(id: 'panzoom_events', tldraw: tldraw) { |xml| xml << panzooms_rec.target! }
# And save the result
File.write("#{package_dir}/#{@shapes_svg_filename}", shapes_doc.to_xml)
File.write("#{package_dir}/#{@panzooms_xml_filename}", panzooms_doc.target!)
File.write("#{package_dir}/#{@cursor_xml_filename}", cursors_doc.target!)
generate_json_file(package_dir, @tldraw_shapes_filename, tldraw_shapes) if tldraw
end
def process_chat_messages(events, bbb_props)
@ -1170,6 +1290,7 @@ end
@panzooms_xml_filename = 'panzooms.xml'
@cursor_xml_filename = 'cursor.xml'
@deskshare_xml_filename = 'deskshare.xml'
@tldraw_shapes_filename = 'tldraw.json'
@svg_shape_id = 1
@svg_shape_unique_id = 1