| // Copyright 2022 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| // Enum class to identify horizontal or vertical flips |
| // |
| class FlipEnum { |
| static HorizontalFlip = new FlipEnum(1); |
| static VerticalFlip = new FlipEnum(2); |
| |
| constructor(id) { |
| this.id = id; |
| } |
| } |
| // Circular buffer to store the past X amount of frames. |
| // |
| class CircularBuffer { |
| constructor(size) { |
| this.instances = Array(size); |
| this.maxSize = size; |
| this.numFrames = 0; |
| } |
| |
| get(index) { |
| if (index < 0 || index < this.numFrames - this.maxSize || |
| index >= this.numFrames) { |
| return undefined; |
| } |
| return this.instances[index % this.maxSize]; |
| } |
| |
| push(frame) { |
| // Push frames into buffer |
| this.instances[this.numFrames % this.maxSize] = frame; |
| this.numFrames++; |
| } |
| |
| oldestIndex() { |
| if (this.numFrames <= this.maxSize) { |
| return 0; |
| } else { |
| return this.numFrames - this.maxSize; |
| } |
| } |
| |
| newestIndex() { |
| return this.numFrames - 1; |
| } |
| } |
| |
| // Represents a single frame, and contains all associated data. |
| // |
| class DrawFrame { |
| // Circular buffer supports 1 minute of frames. |
| static maxBufferNumFrames = 60*60; |
| static frameBuffer = new CircularBuffer(DrawFrame.maxBufferNumFrames); |
| static buffer_map = new Object(); |
| static demo_thread = { |
| thread_name: "demo thread", |
| thread_id: -1, |
| }; |
| static count() { return DrawFrame.frameBuffer.instances.length; } |
| |
| static get(index) { |
| return DrawFrame.frameBuffer.get(index); |
| } |
| |
| constructor(json) { |
| this.num_ = parseInt(json.frame); |
| this.size_ = { |
| width: parseInt(json.windowx), |
| height: parseInt(json.windowy), |
| }; |
| this.logs_ = json.logs; |
| this.drawCalls_ = json.drawcalls.map(c => new DrawCall(c)); |
| this.buffer_map = json.buff_map; |
| this.resetFilter(); |
| |
| this.threadMapping_ = {} |
| |
| if (!('threads' in json)) { |
| json.threads = [DrawFrame.demo_thread]; |
| } |
| json.threads.forEach(t => { |
| // If new thread has not been registered yet, then register it. |
| if (!(Thread.isThreadRegistered(t.thread_name))) { |
| new Thread(t); |
| }; |
| // Map thread id's to all the thread information. |
| // Values are set by default when frame first comes in. |
| this.threadMapping_[t.thread_id] = {threadName: t.thread_name, |
| threadEnabled: true, |
| overrideFilters: false, |
| threadColor: "#000000", |
| threadAlpha: "10"}; |
| }); |
| |
| if (json.new_sources) { |
| for (const s of json.new_sources) { |
| new Source(s); |
| notifyUiOfNewSource(s); |
| } |
| } |
| |
| for (let buff in this.buffer_map) { |
| // |buffer_map| contains data URIs, which we |fetch| to get a |Blob| to |
| // create an |ImageBitmap| with. |
| fetch(this.buffer_map[buff]) |
| .then((res) => res.blob()) |
| .then((blob) => createImageBitmap(blob)) |
| .then((res) => { |
| DrawFrame.buffer_map[buff] = res; |
| return res; |
| }); |
| } |
| |
| // Retain the original JSON, so that the file can be saved to local disk. |
| // Ideally, the JSON would be constructed on demand, but generating |
| // |new_sources| requires some work. So for now, do the easy thing. |
| this.json_ = json; |
| |
| DrawFrame.frameBuffer.push(this); |
| } |
| |
| submissionCount() { |
| return this.drawCalls_.length + this.logs_.length; |
| } |
| |
| updateCanvasSize(canvas, context, scale, orientationDeg, fcTransX, fcTransY) { |
| // Swap canvas width/height for 90 or 270 deg rotations |
| if (orientationDeg === 90 || orientationDeg === 270) { |
| canvas.width = this.size_.height * scale; |
| canvas.height = this.size_.width * scale; |
| } |
| // Restore original canvas width/height for 0 or 180 deg rotations |
| else { |
| canvas.width = this.size_.width * scale; |
| canvas.height = this.size_.height * scale; |
| } |
| // Some text can be drawn past the canvas boundaries, so add some padding on |
| // each side. |
| const padding = 20; |
| canvas.width += padding * 2; |
| canvas.height += padding * 2; |
| |
| // Fill the actual frame bounds to an opaque color. |
| context.save(); |
| context.fillStyle = "white"; |
| context.fillRect( |
| fcTransX+padding, // Translate to account for freecam. |
| fcTransY+padding, // Translate to account for freecam. |
| canvas.width - padding * 2, |
| canvas.height - padding * 2 |
| ); |
| context.restore(); |
| } |
| |
| getFilter(source_index) { |
| const filters = Filter.enabledInstances(); |
| let filter = undefined; |
| // TODO: multiple filters can match the same draw call. For now, let's just |
| // pick the earliest filter that matches, and let it decide what to do. |
| for (const f of filters) { |
| if (f.matches(Source.instances[source_index])) { |
| filter = f; |
| break; |
| } |
| } |
| |
| // No filters match this draw. So skip. |
| if (!filter) return undefined; |
| if (!filter.shouldDraw) return undefined; |
| |
| return filter; |
| } |
| |
| draw(canvas, context, scale, orientationDeg, fcTransX, fcTransY) { |
| // Look at global state of all threads and copy those states |
| // to the current frame's threadID-to-state mapping. |
| for (const threadId of Object.keys(this.threadMapping_)) { |
| const mappedThread = this.threadMapping_[threadId]; |
| mappedThread.threadEnabled = |
| Thread.getThread(mappedThread.threadName).enabled_; |
| mappedThread.threadColor = |
| Thread.getThread(mappedThread.threadName).drawColor_; |
| mappedThread.threadAlpha = |
| Thread.getThread(mappedThread.threadName).fillAlpha_; |
| mappedThread.overrideFilters = |
| Thread.getThread(mappedThread.threadName).overrideFilters_; |
| } |
| |
| // Translate accordingly for freecam zoom based on mouse pos. |
| // Translation values initlaized to 0 when not in freecam mode. |
| context.translate(fcTransX, fcTransY); |
| |
| // Generate a transform from frame space to canvas space. |
| context.translate(canvas.width / 2, canvas.height / 2); |
| if (orientationDeg === FlipEnum.HorizontalFlip.id) { |
| context.scale(-1, 1); |
| } else if (orientationDeg === FlipEnum.VerticalFlip.id) { |
| context.scale(1, -1); |
| } else { |
| context.rotate(orientationDeg * Math.PI / 180); |
| } |
| context.scale(scale, scale); |
| context.translate(-this.size_.width / 2, -this.size_.height / 2); |
| |
| for (const call of this.drawCalls_) { |
| // Assumed to be a positional text call. |
| if (call.text) { |
| continue; |
| } |
| if (!this.withinFilter(call.drawIndex_)) { |
| continue; |
| } |
| |
| // If thread not enabled, then skip draw call from this thread. |
| if (!this.threadMapping_[call.threadId_].threadEnabled) { |
| continue; |
| } |
| |
| call.draw(context, DrawFrame.buffer_map, |
| this.threadMapping_[call.threadId_]); |
| } |
| |
| // Get the current transform so that we can draw text in the right position |
| // without rotating or reflecting it. |
| const transformMatrix = context.getTransform(); |
| context.resetTransform(); |
| |
| context.font = "16px 'Courier bold', monospace"; |
| |
| // Draw the frame number |
| { |
| context.textBaseline = "bottom"; |
| context.fillStyle = "black"; |
| var newTextPos = transformMatrix.transformPoint(new DOMPoint(0, 0)); |
| context.fillText(this.num_, newTextPos.x, newTextPos.y); |
| } |
| |
| |
| for (const text of this.drawCalls_) { |
| // Not a positional text call. |
| if (!text.text) { |
| continue; |
| } |
| // If thread not enabled, then skip text calls from this thread. |
| if (!this.threadMapping_[text.threadId_].threadEnabled) { |
| continue; |
| } |
| if (!this.withinFilter(text.drawIndex_)) { |
| continue; |
| } |
| |
| var color; |
| // If thread is overriding, take thread color. |
| if (this.threadMapping_[text.threadId_].overrideFilters) { |
| color = this.threadMapping_[text.threadId_].threadColor; |
| } |
| // Otherwise, take filter's color. |
| else { |
| let filter = this.getFilter(text.sourceIndex_); |
| if (!filter) continue; |
| |
| color = (filter && filter.drawColor) ? |
| filter.drawColor : text.color_; |
| } |
| context.fillStyle = color; |
| // TODO: This should also create some DrawText object or something. |
| this.drawText(context, |
| text.text, |
| text.pos_.x, |
| text.pos_.y, |
| transformMatrix); |
| } |
| } |
| |
| // Draw text with a transformed position. |
| drawText(context, text, posX, posY, transformMatrix) { |
| // TODO: Set the text alignment based on the transform. |
| var newTextPos = transformMatrix.transformPoint(new DOMPoint(posX, posY)); |
| |
| // Make the origin of text the top-left, similar to rectangles. |
| context.textBaseline = "top"; |
| |
| // Fill a background rectangle behind the text with the current fill color. |
| const measure = context.measureText(text); |
| context.fillRect( |
| newTextPos.x, |
| newTextPos.y, |
| measure.width, |
| measure.actualBoundingBoxDescent - measure.actualBoundingBoxAscent |
| ); |
| |
| function perceptualBrightness(hexColor) { |
| const r = parseInt(hexColor.substr(1, 2), 16) / 255; |
| const g = parseInt(hexColor.substr(3, 2), 16) / 255; |
| const b = parseInt(hexColor.substr(5, 2), 16) / 255; |
| return Math.sqrt( |
| 0.299 * Math.pow(r, 2) + 0.587 * Math.pow(g, 2) + 0.114 * Math.pow(b, 2) |
| ); |
| } |
| |
| // Attempt to make the text contrast better against the background. |
| if (perceptualBrightness(context.fillStyle) > 0.65) { |
| context.fillStyle = "black"; |
| } else { |
| context.fillStyle = "white"; |
| } |
| |
| context.fillText(text, newTextPos.x, newTextPos.y); |
| } |
| |
| appendLogs(logContainer) { |
| for (const log of this.logs_) { |
| if (!this.withinFilter(log.drawindex)) { |
| continue; |
| } |
| |
| if (!('thread_id' in log)) { |
| log.thread_id = DrawFrame.demo_thread.thread_id; |
| } |
| // If thread not enabled, then skip draw call from this thread. |
| if (!this.threadMapping_[log.thread_id].threadEnabled) { |
| continue; |
| } |
| |
| var color; |
| let filter; |
| // If thread is overriding, take thread color. |
| if (this.threadMapping_[log.thread_id].overrideFilters) { |
| color = this.threadMapping_[log.thread_id].threadColor; |
| } |
| // Otherwise, take filter's color. |
| else { |
| filter = this.getFilter(log.source_index); |
| if (!filter) continue; |
| |
| color = (filter && filter.drawColor) ? |
| filter.drawColor : log.option.color; |
| } |
| |
| var container = document.createElement("span"); |
| var new_node = document.createTextNode(log.value); |
| container.style.color = color; |
| container.appendChild(new_node) |
| logContainer.appendChild(container); |
| logContainer.appendChild(document.createElement('br')); |
| } |
| } |
| |
| resetFilter() { |
| this.filter(-1, -1); |
| } |
| |
| filter(minIndex, maxIndex) { |
| this.minIndex_ = minIndex === -1 ? 0 : minIndex; |
| this.maxIndex_ = maxIndex === -1 ? this.submissionCount() : maxIndex; |
| } |
| |
| minIndex() { |
| return this.minIndex_; |
| } |
| |
| maxIndex() { |
| return this.maxIndex_; |
| } |
| |
| // True iff drawIndex is in [minIndex_, maxIndex). |
| withinFilter(drawIndex) { |
| return drawIndex >= this.minIndex_ && drawIndex < this.maxIndex_; |
| } |
| |
| toJSON() { |
| return this.json_; |
| } |
| } |
| |
| |
| // Controller for the viewer. |
| // |
| class Viewer { |
| constructor(canvas, log) { |
| this.canvas_ = canvas; |
| this.logContainer_ = log; |
| this.drawContext_ = this.canvas_.getContext("2d"); |
| |
| this.currentFrameIndex_ = -1; |
| this.viewScale = 1.0; |
| this.viewOrientation = 0; |
| this.freeCamTranslationX = 0; |
| this.freeCamTranslationY = 0; |
| } |
| |
| updateCurrentFrame() { |
| this.redrawCurrentFrame_(); |
| this.updateLogs_(); |
| } |
| |
| redrawCurrentFrame_() { |
| const frame = this.getCurrentFrame(); |
| if (!frame) return; |
| frame.updateCanvasSize(this.canvas_, |
| this.drawContext_, |
| this.viewScale, |
| this.viewOrientation, |
| this.freeCamTranslationX, |
| this.freeCamTranslationY); |
| frame.draw(this.canvas_, |
| this.drawContext_, |
| this.viewScale, |
| this.viewOrientation, |
| this.freeCamTranslationX, |
| this.freeCamTranslationY); |
| } |
| |
| updateLogs_() { |
| this.logContainer_.textContent = ''; |
| const frame = this.getCurrentFrame(); |
| if (!frame) return; |
| frame.appendLogs(this.logContainer_); |
| } |
| |
| getCurrentFrame() { |
| return DrawFrame.get(this.currentFrameIndex_); |
| } |
| |
| get currentFrameIndex() { return this.currentFrameIndex_; } |
| |
| setViewerScale(scaleAsInt) { |
| this.viewScale = scaleAsInt / 100.0; |
| } |
| |
| setViewerOrientation(orientationAsInt) { |
| this.viewOrientation = orientationAsInt; |
| } |
| |
| setFrame(frameIndex, minIndex = -1, maxIndex = -1) { |
| if (DrawFrame.get(frameIndex)) { |
| this.currentFrameIndex_ = frameIndex; |
| this.getCurrentFrame().filter(minIndex, maxIndex); |
| this.updateCurrentFrame(); |
| } |
| } |
| |
| resetTranslation() { |
| this.freeCamTranslationX = 0; |
| this.freeCamTranslationY = 0; |
| } |
| |
| zoomToMouse(currentMouseX, currentMouseY, delta) { |
| const factor = delta < 0 ? 1.05 : 0.95; |
| |
| const newScale = this.viewScale * factor; |
| |
| // Adjust translation to keep the zoom centered around the mouse. |
| const worldMouseX = (currentMouseX - this.freeCamTranslationX) / this.viewScale; |
| const worldMouseY = (currentMouseY - this.freeCamTranslationY) / this.viewScale; |
| |
| this.freeCamTranslationX -= worldMouseX * (newScale - this.viewScale); |
| this.freeCamTranslationY -= worldMouseY * (newScale - this.viewScale); |
| |
| this.viewScale = newScale; |
| this.updateCurrentFrame(); |
| } |
| }; |
| |
| // Controls the player. |
| // |
| class Player { |
| static instances = []; |
| |
| constructor(viewer, updateUi) { |
| this.viewer_ = viewer; |
| this.paused_ = false; |
| this.nextFrameScheduled_ = false; |
| this.live_ = true; |
| this.updateUi_ = updateUi; |
| |
| Player.instances[0] = this; |
| } |
| |
| play() { |
| this.paused_ = false; |
| if (this.nextFrameScheduled_) return; |
| |
| if (this.viewer_.currentFrameIndex == DrawFrame.frameBuffer.newestIndex()) { |
| return; |
| } |
| |
| if (this.live_) { |
| this.drawNewestFrame_(); |
| } else { |
| this.drawNextFrame_(); |
| } |
| |
| this.didDrawNewFrame_(); |
| |
| this.nextFrameScheduled_ = true; |
| requestAnimationFrame(() => { |
| this.nextFrameScheduled_ = false; |
| if (!this.paused_) |
| this.play(); |
| }); |
| } |
| |
| live() { |
| this.live_ = true; |
| this.play(); |
| } |
| |
| pause() { |
| this.paused_ = true; |
| this.live_ = false; |
| } |
| |
| rewind() { |
| this.pause(); |
| this.drawPreviousFrame_(); |
| this.didDrawNewFrame_(); |
| } |
| |
| forward() { |
| this.pause(); |
| this.drawNextFrame_(); |
| this.didDrawNewFrame_(); |
| } |
| |
| // Pauses after drawing at most |drawIndex| number of calls of the |
| // |frameIndex|-th frame. |
| // Draws all calls if |minIndex| and |maxIndex| are not set. |
| freezeFrame(frameIndex, minIndex = -1, maxIndex = -1) { |
| this.pause(); |
| this.viewer_.setFrame(frameIndex, minIndex, maxIndex); |
| this.didDrawNewFrame_(); |
| } |
| |
| setViewerScale(scaleAsString) { |
| this.viewer_.setViewerScale(parseInt(scaleAsString)); |
| this.refresh(); |
| } |
| |
| setViewerOrientation(orientationAsString) { |
| // Set orientationAsInt as selected orientation degree |
| // Horizontal Flip enum or Vertical Flip enum |
| const orientationAsInt = parseInt(orientationAsString) >= 0 ? |
| parseInt(orientationAsString) : |
| (orientationAsString === "Horizontal Flip" ? |
| FlipEnum.HorizontalFlip.id : FlipEnum.VerticalFlip.id); |
| |
| this.viewer_.setViewerOrientation(orientationAsInt); |
| this.refresh(); |
| } |
| |
| refresh() { |
| this.viewer_.updateCurrentFrame(); |
| } |
| |
| drawNewestFrame_() { |
| let newest = DrawFrame.frameBuffer.newestIndex(); |
| this.viewer_.setFrame(newest); |
| } |
| |
| drawNextFrame_() { |
| this.viewer_.setFrame(this.viewer_.currentFrameIndex + 1); |
| } |
| |
| drawPreviousFrame_() { |
| this.viewer_.setFrame(this.viewer_.currentFrameIndex - 1); |
| } |
| |
| didDrawNewFrame_() { |
| this.updateUi_(this.viewer_.getCurrentFrame()); |
| } |
| |
| get currentFrameIndex() { return this.viewer_.currentFrameIndex; } |
| |
| onNewFrame() { |
| let oldest = DrawFrame.frameBuffer.oldestIndex(); |
| if (this.currentFrameIndex < oldest) { |
| this.viewer_.setFrame(oldest, -1, -1); |
| } |
| this.didDrawNewFrame_(); |
| |
| // If the player is not paused, and a new frame is received, then make sure |
| // the next frame is drawn. |
| if (!this.paused_) { |
| this.play(); |
| } |
| } |
| |
| static get instance() { return Player.instances[0]; } |
| }; |