<!DOCTYPE html>
<html lang="en">
<!--
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.
-->
<head>
<style>
#scrubberframe::-webkit-slider-thumb {
appearance: none;
width: 20px;
height: 20px;
background-color: grey;
}
#scrubberframe {
margin-top: 10px;
flex-grow: 1;
appearance: none;
background-color: #f0f0f0;
width: 100%;
}
.scrubber::-webkit-slider-thumb {
appearance: none;
width: 20px;
height: 20px;
background-color: grey;
}
.minMaxScrubber {
width: 100%;
display: flex;
}
.minMaxScrubber > .scrubber {
appearance: none;
background-color: #f0f0f0;
margin: 0px;
flex-grow: 1;
flex-basis: 20px; /* width of slider-thumb */
min-width: 0px;
}
#url {
font-family: monospace;
font-size: smaller;
}
#controls>#buttons {
display: flex;
}
#log {
min-height: 100px;
max-height: 100%;
font-family: monospace;
overflow: auto;
}
#connectionPanel,
#saveload {
display: inline-block;
}
#connectionPanel,
#saveload,
#topPanel {
padding-bottom: 10px;
}
#topPanel {
display: flex;
}
#settings {
display: flex;
flex-direction: column;
font-size: small;
}
.panelSection {
margin-right: 20px;
}
.panelSection:last-child {
margin-right: 0px;
flex-grow: 1;
}
#connection-status {
color: limegreen;
font-size: large;
padding-right: 5px;
}
#connection-status.disconnected {
color: orange;
}
</style>
<link href="https://unpkg.com/material-components-web@latest/dist/material-components-web.min.css" rel="stylesheet">
<script src="https://unpkg.com/material-components-web@latest/dist/material-components-web.min.js"></script>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" />
<link rel='stylesheet' href='style.css'>
<script src='filter.js'></script>
<script src='filter-ui.js'></script>
<script src='frame.js'></script>
<script src='connection.js'></script>
<script src='thread.js'></script>
<script src='thread-ui.js'></script>
</head>
<div id='connectionPanel'>
<div class='sectionTitle'>
<font class='disconnected' id='connection-status'>●</font>Connection
</div>
<div class='section'>
<div title="Expert usage only. Autoconnect should provide the dev tools websocket through /json/version discovery.">
<input id='url' name='url' size=60 type="text"
placeholder='WebSocket URL or leave empty for autoconnect...'></input>
</div>
<button class="mdc-button mdc-button--outline" id='connect'>
<div class="mdc-button__ripple"></div>
<span class="mdc-button__label">Connect</span>
</button>
<button class="mdc-button mdc-button--outline" id='disconnect' disabled=true>
<div class="mdc-button__ripple"></div>
<span class="mdc-button__label">Disconnect</span>
</button>
<input type="checkbox" id="autoconnect" name="autoconnect" checked="true">
<label for="autoconnect" style="font-size:small; font:Roboto" > Autoconnect</label><br>
</div>
</div>
<div id='saveload'>
<div class='sectionTitle'>Save/Load ...</div>
<div class='section'>
<button id='demo' class="mdc-button mdc-button--outline">
<div class="mdc-button__ripple"></div>
<span class="mdc-button__label">Load demo data</span>
</button>
<button id='savedata' class="mdc-button mdc-button--outline"
title="Serializes current session stream to json file.">
<div class="mdc-button__ripple"></div>
<span class="mdc-button__label">Save to disk</span>
</button>
<button id='loaddata' class="mdc-button mdc-button--outline"
title="Deserializes json file of previous session and imports these frames into the App.">
<div class="mdc-button__ripple"></div>
<span class="mdc-button__label">Load from disk</span>
</button>
</div>
</div>
<div id='logsPanel'>
<div class='panelSection collapsible'>
<div class='sectionTitle'>
Logs
[<span class="plus">+</span><span class="minus">-</span>]
<!--input size=40 placeholder='Comma separated filter ...'>(TODO)</input-->
</div>
<div class='section'>
<div id='log'></div>
</div>
</div>
</div>
<div class='panelSection collapsible'>
<div class='sectionTitle'
title="Filter debug data stream. Filter operations occur in a left to right order with first match being applied.">
<i class="material-icons-outlined">filter_list</i> Filters
[<span class="plus">+</span><span class="minus">-</span>]
</div>
<div class='section'>
<div id='filters' class="mdc-chip-set mdc-chip-set--filter" role="grid"
title="Filter debug data stream. Filter operations occur in a left to right order with first match being applied.">
</div>
<button class="mdc-button mdc-button--outline" id='createfilter'>
<div class="mdc-button__ripple"></div>
<span class="mdc-button__label"><i class="material-icons-outlined">add_box</i> Add new filter</span>
</button>
</div>
</div>
<div class='panelSection collapsible'>
<div class='sectionTitle'>
<i class="material-symbols-outlined">
airwave
</i>
Threads
[<span class="plus">+</span><span class="minus">-</span>]
</div>
<div id='threads' class='section'></div>
</div>
<div class='panelSection collapsible'>
<div class='sectionTitle'>
Viewer Controls
[<span class="plus">+</span><span class="minus">-</span>]
</div>
<div class='section' id='controls'>
<div id='buttons'>
<button class="mdc-button mdc-button--outline" id='prev'>
<span class="mdc-button__label"><i class="material-icons-outlined">skip_previous</i> Previous frame</span>
</button>
<button class="mdc-button mdc-button--outline" id='play'>
<div class="mdc-button__ripple"></div>
<span class="mdc-button__label"><i class="material-icons-outlined">play_circle_outline</i> Play</span>
</button>
<button class="mdc-button mdc-button--outline" id='pause'>
<div class="mdc-button__ripple"></div>
<span class="mdc-button__label"><i class="material-icons-outlined">pause_circle_outline</i> Pause</span>
</button>
<button class="mdc-button mdc-button--outline" id='next'>
<span class="mdc-button__label">Next frame <i class="material-icons-outlined">skip_next</i></span>
</button>
<button class="mdc-button mdc-button--raised" id='live'>
<div class="mdc-button__ripple"></div>
<span class="mdc-button__label">Live <i class="material-icons-outlined">fast_forward</i></span>
</button>
</div>
<div>
<div class='sectionTitle'>Frame Selection</div>
<input type='range' min='0' max='0' value='0' id='scrubberframe'></input>
</div>
<div>
<div class='sectionTitle'>Draw Selection<span id="drawRange"></span></div>
<div class="minMaxScrubber">
<input type="range" id='minDrawScrubber' class="scrubber"
min="0" max="0" value="0" step="1"></input>
<input type="range" id='maxDrawScrubber' class="scrubber"
min="0" max="1" value="1" step="1"></input>
</div>
</div>
</div>
</div>
<div class="panelSection collapsible">
<div class='sectionTitle'>
Viewer
[<span class="plus">+</span><span class="minus">-</span>]
</div>
<div style='float: right' class='sectionTitle'>
Scale
<select id="viewerscale">
<option id="100pct">100%</option>
<option id="50pct">50%</option>
<option id="200pct">200%</option>
<option id="freeCam" disabled>Free Camera</option>
</select>
</div>
<div style='float: right' class='sectionTitle'>
Orientation
<select id="viewerorientation">
<option id="0deg">0 deg clockwise</option>
<option id="90deg">90 deg clockwise</option>
<option id="180deg">180 deg clockwise</option>
<option id="270deg">270 deg clockwise</option>
<option id="hFlip">Horizontal Flip</option>
<option id="vFlip">Vertical Flip</option>
</select>
</div>
<div class='section'>
<!--We need to fix viewer size to avoid scroll position change when
multiple displays are present. crbug.com/1358526-->
<div style="height:4000px; border: 1px dotted gray; background-color: #f0f0f0">
<canvas id='canvas' style="top :0px"></canvas>
</div>
</div>
<div class='modalContainer'></div>
</div>
<div id='importtracing'>
<div class='sectionTitle'>Tracing (Prototype)...</div>
<div class='section'>
<button id='importtracebutton' class="mdc-button mdc-button--outline"
title="Import tracing data (json) into visual debugger app.">
<div class="mdc-button__ripple"></div>
<span class="mdc-button__label">Import Trace</span>
</button>
</div>
</div>
<script>
function processIncomingFrame(json) {
if (!json) return;
new DrawFrame(json);
Player.instance.onNewFrame();
}
async function testAnimate() {
const f = await fetch('demo.json');
const text = await f.text();
const json = JSON.parse(text);
for (const frame of json) {
processIncomingFrame(frame);
}
}
async function saveDemoDataToDisk() {
const text = JSON.stringify(DrawFrame.frameBuffer.instances.map(d => d.toJSON()));
const blob = new Blob([text], { type: 'text/plain' });
const link = document.createElement('a');
link.download = 'cvd-stream.json';
link.href = window.URL.createObjectURL(blob);
link.click();
}
window.onload = function () {
Connection.initialize();
const addFilterButton = document.querySelector('#createfilter');
addFilterButton.addEventListener('click', () => {
showCreateFilterPopup(addFilterButton);
});
const container = document.querySelector('.modalContainer');
container.addEventListener('click', (event) => {
if (event.target == container)
hideModal();
});
const demo = document.querySelector('#demo');
demo.addEventListener('click', testAnimate);
const savedata = document.querySelector('#savedata');
savedata.addEventListener('click', saveDemoDataToDisk);
const loaddata = document.querySelector('#loaddata');
loaddata.addEventListener('click', () => {
const f = document.createElement('input');
f.type = 'file';
f.addEventListener('change', () => {
const file = new FileReader(f.files[0]);
file.addEventListener('load', () => {
const json = JSON.parse(file.result);
for (const frame of json) {
processIncomingFrame(frame);
}
});
file.readAsText(f.files[0]);
});
f.click();
});
const importtracedata = document.querySelector('#importtracebutton');
importtracedata.addEventListener('click', () => {
const f = document.createElement('input');
f.type = 'file';
f.addEventListener('change', () => {
async function handleBlob(blob) {
if (blob.type === 'application/x-gzip') {
// If the blob is gzipped, decompress it and recurse.
const ds = new DecompressionStream("gzip");
const decompressedBlob = blob.stream().pipeThrough(ds);
handleBlob(await new Response(decompressedBlob).blob());
} else {
try {
const json = JSON.parse(await blob.text());
handleImportTraceJson(json);
} catch (e) {
if (e instanceof SyntaxError) {
// Not all JSON blobs have the right mimetype, so we just try
// and fail to check.
alert("Not valid JSON: " + blob.message);
} else {
throw e;
}
}
}
}
handleBlob(f.files[0]);
});
f.click();
function handleImportTraceJson(json) {
const traceEvents = json.traceEvents;
curr_frame = "0";
curr_draws = [];
sources_this_frame = []
global_sources_mapping = []
// Get the source index for an annotation, updating `global_sources_mapping` as needed.
let getSourceIndex = (anno) => {
let found_source_index = global_sources_mapping.indexOf(anno);
if (found_source_index === -1) {
global_sources_mapping.push(anno);
found_source_index = global_sources_mapping.length - 1;
sources_this_frame.push({
"anno": anno,
"file": "none",
"func": "none",
"index": found_source_index,
"line": -1,
});
}
return found_source_index;
};
threads_this_frame = new Set();
global_threads = {};
global_processes = {};
// Return a faux thread ID that also includes the process ID.
// VizDebugger only tracks a thread ID, but we know the process ID as well in this case.
let trackThreadAndProcessId = (event) => {
// We're assuming the thread ID is not going to exceed u16.
let thread_id = (event.pid * 65536) + event.tid;
threads_this_frame.add({
"thread_id": thread_id,
"thread_name": `${global_processes[event.pid]}/${global_threads[event.tid]}`,
})
return thread_id;
}
let resolveThreadNamesAndResetThreadIdsThisFrame = () => {
let threads = [];
for (const thread of threads_this_frame) {
threads.push(thread);
}
threads_this_frame.clear();
return threads;
};
for (const event of traceEvents) {
if (event.name === "thread_name") {
global_threads[event.tid] = event.args.name;
} else if (event.name === "process_name") {
global_processes[event.pid] = event.args.name;
} else if (event.cat.includes("viz.visual_debugger")) {
if (event.name == "visual_debugger_sync") {
single_frame = { "drawcalls": [], "frame": "none", "logs": [], "new_sources": [], "time": "0", "version": 1, "windowx": 2400, "windowy": 1600, "threads": [{ "thread_id": "1", "thread_name": "allthreads" }] };
single_frame.drawcalls = curr_draws;
curr_frame = event.args.last_presented_trace_id;
single_frame.frame = curr_frame;
single_frame.new_sources = sources_this_frame;
single_frame.windowx = parseInt(event.args.display_size.split("x")[0]);
single_frame.windowy = parseInt(event.args.display_size.split("x")[1]);
processIncomingFrame(single_frame);
curr_draws = [];
sources_this_frame = [];
threads_this_frame.clear();
}
else {
const single_call = { "drawindex": curr_draws.length, "option": { "alpha": 5, "color": "#ff0000" }, "pos": [-1, -1], "size": [-1, -1], "source_index": -1, "thread_id": 1, "buff_id": -1 };
single_call.pos[0] = parseFloat(event.args.args.pos_x);
single_call.pos[1] = parseFloat(event.args.args.pos_y);
single_call.size[0] = parseFloat(event.args.args.size_x);
single_call.size[1] = parseFloat(event.args.args.size_y);
single_call.text = event.args.args.text;
single_call.source_index = getSourceIndex(event.name);
curr_draws.push(single_call);
}
} else if (event.cat.includes("viz.quads")) {
if (event.name === "cc::LayerTreeHostImpl") {
let draw_calls = [];
let logs = [];
let render_pass_count = event.args.snapshot.frame.render_passes.length;
for (let i = 0; i < render_pass_count; i++) {
let render_pass = event.args.snapshot.frame.render_passes[i];
logs.push({
"source_index": getSourceIndex("frame.render_pass.meta"),
"drawindex": draw_calls.length + logs.length,
"option": { "alpha": 0, "color": "#0000ff" },
"thread_id": trackThreadAndProcessId(event),
"value": `Render pass id=${render_pass.id}, output_rect=${render_pass.output_rect}, damage_rect=${render_pass.damage_rect}, quad_list.size=${render_pass.quad_list.length}, copy_requests=${render_pass.copy_requests}`,
});
if (i < render_pass_count - 1) {
// Skip non-root render pass quads to reduce visual noise.
continue;
}
draw_calls.push({
"source_index": getSourceIndex("frame.render_pass.output_rect"),
"drawindex": draw_calls.length + logs.length,
"option": { "alpha": 5, "color": "#000000" },
"pos": [render_pass.output_rect[0], render_pass.output_rect[1]],
"size": [render_pass.output_rect[2], render_pass.output_rect[3]],
"thread_id": trackThreadAndProcessId(event),
"buff_id": -1,
});
draw_calls.push({
"source_index": getSourceIndex("frame.render_pass.damage"),
"drawindex": draw_calls.length + logs.length,
"option": { "alpha": 5, "color": "#000000" },
"pos": [render_pass.damage_rect[0], render_pass.damage_rect[1]],
"size": [render_pass.damage_rect[2], render_pass.damage_rect[3]],
"thread_id": trackThreadAndProcessId(event),
"buff_id": -1,
});
for (let quad of render_pass.quad_list) {
draw_calls.push({
"source_index": getSourceIndex("frame.render_pass.quad"),
"drawindex": draw_calls.length + logs.length,
"option": { "alpha": 5, "color": "#000000" },
"pos": [
quad.rect_as_target_space_quad[0],
quad.rect_as_target_space_quad[1],
],
"size": [
quad.rect_as_target_space_quad[2] - quad.rect_as_target_space_quad[0],
quad.rect_as_target_space_quad[5] - quad.rect_as_target_space_quad[1],
],
"thread_id": trackThreadAndProcessId(event),
"buff_id": -1,
});
draw_calls.push({
"source_index": getSourceIndex("frame.render_pass.material"),
"drawindex": draw_calls.length + logs.length,
"option": { "alpha": 0, "color": "#00ff00" },
"pos": [
quad.rect_as_target_space_quad[0],
quad.rect_as_target_space_quad[1],
],
"size": [0, 0],
"text": `${quad.material}`,
"thread_id": trackThreadAndProcessId(event),
"buff_id": -1,
});
}
}
// This event contains a snapshot of the layer tree, so we can process a full frame immediately.
processIncomingFrame({
"drawcalls": draw_calls,
"frame": event.tts,
"logs": logs,
"new_sources": sources_this_frame,
"time": event.ts,
"version": 1,
"windowx": event.args.snapshot.device_viewport_size.width,
"windowy": event.args.snapshot.device_viewport_size.height,
"threads": resolveThreadNamesAndResetThreadIdsThisFrame(),
});
sources_this_frame = [];
}
}
}
}
});
document.querySelectorAll('.panelSection.collapsible').forEach(
(section) => {
let title = section.querySelector('.sectionTitle');
title.addEventListener('click', () => {
section.classList.toggle('collapsed');
});
});
setUpPlayer();
restoreFilters();
}
/**
* Gets value from localStorage if it exists, and sets the <option> with that
* id to be selected in options_el.
* @param {string} key: The localStorage key to get if present.
* @param {Element} options_el: The <select> Element to be updated.
* @returns {boolean} Whether a value from localStorage was used to change the
* the selected option.
*/
function getStoredOptionsValue(key, options_el) {
let storedId = localStorage.getItem(key);
if (storedId != null) {
let option = options_el.namedItem(storedId);
if (option == null) {
console.error(`No option with id ${storedId} found on ${options_el.id}`);
localStorage.removeItem(key);
} else {
options_el.selectedIndex = option.index;
return true;
}
}
return false;
}
function updateDrawScrubberSizes(minIndex, maxIndex, nDraws) {
const scrubberMin = document.querySelector('#minDrawScrubber');
const scrubberMax = document.querySelector('#maxDrawScrubber');
const drawRange = document.querySelector('#drawRange');
// The point where the sliders meet will be halway between the two values.
// This means the slider you select when clicking will always be the one
// closest to your mouse.
let mid = Math.floor((minIndex + maxIndex) / 2);
scrubberMin.max = mid;
scrubberMax.min = mid;
// Update the size of each slider to its fraction of the total range.
scrubberMin.style = `flex-grow: ${mid}`;
scrubberMax.style = `flex-grow: ${nDraws - mid}`;
}
function setDrawScrubbers(minIndex, maxIndex, nDraws) {
const scrubberMin = document.querySelector('#minDrawScrubber');
const scrubberMax = document.querySelector('#maxDrawScrubber');
const drawRange = document.querySelector('#drawRange');
scrubberMax.max = nDraws;
scrubberMin.value = minIndex;
scrubberMax.value = maxIndex;
drawRange.textContent = ` [${minIndex}, ${maxIndex})`;
updateDrawScrubberSizes(minIndex, maxIndex, nDraws);
}
function updateFrameScrubber(oldest, newest, current) {
const scrubberFrame = document.querySelector('#scrubberframe');
scrubberFrame.min = oldest;
scrubberFrame.max = newest;
scrubberFrame.value = current;
}
function setUpPlayer() {
// First, set up the viewer.
const canvas = document.querySelector('#canvas');
const logContainer = document.querySelector('#log');
const viewer = new Viewer(canvas, logContainer);
// Now create the player for the viewer.
const player = new Player(viewer, (frame) => {
updateFrameScrubber(
DrawFrame.frameBuffer.oldestIndex(),
DrawFrame.frameBuffer.newestIndex(),
player.currentFrameIndex);
setDrawScrubbers(
frame.minIndex(), frame.maxIndex(), frame.submissionCount());
});
document.querySelector('#pause').addEventListener('click', () => {
player.pause();
pause.setAttribute('style', 'background:#000000;color:white');
});
document.querySelector('#play').addEventListener('click', () => {
player.play();
pause.removeAttribute('style');
});
document.querySelector('#prev').addEventListener('click', () => {
player.rewind();
});
document.querySelector('#next').addEventListener('click', () => {
player.forward();
});
document.querySelector('#live').addEventListener('click', () => {
player.live();
has_disconnected = false;
pause.removeAttribute('style');
});
const scrubberFrame = document.querySelector('#scrubberframe');
scrubberFrame.addEventListener('input', () => {
player.freezeFrame(parseInt(scrubberFrame.value));
pause.setAttribute('style', 'background:#000000;color:white');
});
const scrubberDrawMin = document.querySelector('#minDrawScrubber');
const scrubberDrawMax = document.querySelector('#maxDrawScrubber');
function drawScrubberChanged() {
let min = parseInt(scrubberDrawMin.value);
let max = parseInt(scrubberDrawMax.value);
player.freezeFrame(parseInt(scrubberFrame.value), min, max);
updateDrawScrubberSizes(min, max, parseInt(scrubberDrawMax.max));
}
scrubberDrawMin.addEventListener('input', () => {
drawScrubberChanged();
});
scrubberDrawMax.addEventListener('input', () => {
drawScrubberChanged()
});
let currentMouseX = 0;
let currentMouseY = 0;
let zoom = 100;
const viewerScale = document.querySelector("#viewerscale");
function scaleChanged() {
let selected = viewerScale.selectedOptions[0];
if (selected.id != "freeCam") {
player.setViewerScale(viewerScale.value);
zoom = parseInt(viewerScale.value);
}
else {
player.setViewerScale(zoom);
canvas.addEventListener('mouseenter', () => {
canvas.addEventListener('mousemove', function (event) {
currentMouseX = Math.round(event.offsetX);
currentMouseY = Math.round(event.offsetY);
});
});
canvas.addEventListener('wheel', function(e) {
e.preventDefault();
var delta = e.deltaY;
viewer.zoomToMouse(currentMouseX, currentMouseY, delta);
}, {passive:false});
}
}
const scaleKey = "scale";
if (getStoredOptionsValue(scaleKey, viewerScale)) {
scaleChanged();
}
viewerScale.addEventListener('input', () => {
scaleChanged();
localStorage.setItem(scaleKey, viewerScale.selectedOptions[0].id);
});
const viewerOrientation = document.querySelector("#viewerorientation");
function orientationChanged() {
let selected = viewerOrientation.selectedOptions[0];
// Change dropdown style when setting non-zero orientation.
if (selected.id != '0deg') {
viewerOrientation.classList.add("nonzero-orientation");
} else {
viewerOrientation.classList.remove("nonzero-orientation");
}
player.setViewerOrientation(viewerOrientation.value);
}
const orientationKey = 'orientation';
if (getStoredOptionsValue(orientationKey, viewerOrientation)) {
orientationChanged();
}
viewerOrientation.addEventListener('change', () => {
orientationChanged();
localStorage.setItem(orientationKey, viewerOrientation.selectedOptions[0].id);
});
}
function showModal(element, focusSelector) {
const container = document.querySelector('.modalContainer');
container.appendChild(element);
container.style.display = 'block';
element.querySelector(focusSelector).focus();
}
function hideModal() {
const container = document.querySelector('.modalContainer');
container.style.display = 'none';
container.textContent = '';
}
</script>
</html>