| // 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]; } | |
| }; |