// 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) {
// 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(
padding,
padding,
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) {
// 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_;
}
// 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.translationX = 0;
this.translationY = 0;
}
updateCurrentFrame() {
this.redrawCurrentFrame_();
this.updateLogs_();
}
redrawCurrentFrame_() {
const frame = this.getCurrentFrame();
if (!frame) return;
frame.updateCanvasSize(this.canvas_,
this.drawContext_,
this.viewScale,
this.viewOrientation);
frame.draw(this.canvas_,
this.drawContext_,
this.viewScale,
this.viewOrientation);
}
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();
}
}
zoomToMouse(currentMouseX, currentMouseY, delta) {
var factor = 1.1;
if (delta > 0) {
factor = 0.9;
}
// this.translationX = currentMouseX;
// this.translationY = currentMouseY;
// this.updateCurrentFrame();
this.viewScale *= factor;
this.updateCurrentFrame();
// this.translationX = -currentMouseX;
// this.translationY = -currentMouseY;
// 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]; }
};