chromium/tools/visual_debugger/frame.js

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