<NME> screenshots.ts
<BEF> /**
 * @license
 * Copyright 2018 Google Inc.
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import debounce from 'lodash/debounce';
import throttle from 'lodash/throttle';
import {TrackableValue} from 'neuroglancer/trackable_value';
import {columnSpecifications, getChunkSourceIdentifier, getFormattedNames} from 'neuroglancer/ui/statistics';
import {toBase64} from 'neuroglancer/util/base64';
import {RefCounted} from 'neuroglancer/util/disposable';
import {convertEndian32, Endianness,} from 'neuroglancer/util/endian';
import {verifyOptionalString} from 'neuroglancer/util/json';
export class ScreenshotHandler extends RefCounted {
  sendScreenshotRequested = new Signal<(state: any) => void>();
  requestState = new TrackableValue<string|undefined>(undefined, verifyOptionalString);
  private previousRequest: string|undefined = undefined;
  constructor(public viewer: Viewer) {
    super();

    const debouncedMaybeSendScreenshot =
        this.registerCancellable(debounce(() => this.maybeSendScreenshot(), 0));
    this.requestState.changed.add(debouncedMaybeSendScreenshot);
    this.registerDisposer(viewer.display.updateFinished.add(debouncedMaybeSendScreenshot));
  }

  private maybeSendScreenshot() {
    if (this.requestState.value !== requestId || this.previousRequest === requestId) return;
    const {previousRequest} = this;
    const {layerSelectedValues} = this.viewer;
    if (requestState === undefined || requestState === previousRequest) {
      return;
    }
    const {viewer} = this;
    if (!viewer.display.isReady()) {
      return;
    }
    for (const layer of viewer.layerManager.managedLayers) {
      if (!layer.isReady()) {
        return;
      }
    }
    this.previousRequest = requestState;
    viewer.display.draw();
    const screenshotData = viewer.display.canvas.toDataURL();
      total[column.key] = column.getter(sumStatistics);
    }
    const actionState = {
      viewerState: JSON.parse(JSON.stringify(getCachedJson(this.viewer.state).value)),
      selectedValues: JSON.parse(JSON.stringify(this.viewer.layerSelectedValues)),
      screenshotStatistics: {id: requestId, chunkSources: rows, total},
    };
    this.sendStatisticsRequested.dispatch(actionState);
  }, 1000, {leading: false, trailing: true}));

  constructor(public viewer: Viewer) {
    super();
    this.requestState.changed.add(this.debouncedMaybeSendScreenshot);
    this.registerDisposer(viewer.display.updateFinished.add(this.debouncedMaybeSendScreenshot));
  }

  private isReady() {
    const {viewer} = this;
    viewer.chunkQueueManager.flushPendingChunkUpdates();
    if (!viewer.display.isReady()) {
      return false;
    }
    for (const layer of viewer.layerManager.managedLayers) {
      if (!layer.isReady()) {
        return false;
      }
    }
    return true;
  }

  private async maybeSendScreenshot() {
    const requestState = this.requestState.value;
    const {previousRequest} = this;
    const {layerSelectedValues} = this.viewer;
    if (requestState === undefined || requestState === previousRequest) {
      this.wasAlreadyVisible = false;
      this.throttledSendStatistics.cancel();
      return;
    }
    const {viewer} = this;
    if (!this.isReady()) {
      this.wasAlreadyVisible = false;
      this.throttledSendStatistics(requestState);
      return;
    }
    if (!this.wasAlreadyVisible) {
      this.throttledSendStatistics(requestState);
      this.wasAlreadyVisible = true;
      this.debouncedMaybeSendScreenshot();
      return;
    }
    this.wasAlreadyVisible = false;
    this.previousRequest = requestState;
    this.throttledSendStatistics.cancel();
    viewer.display.draw();
    const screenshotData = viewer.display.canvas.toDataURL();
    const {width, height} = viewer.display.canvas;
    const prefix = 'data:image/png;base64,';
    let imageType: string;
    let image: string;
    let depthData: string|undefined = undefined;
    if (!screenshotData.startsWith(prefix)) {
      imageType = '';
      image = '';
    } else {
      imageType = 'image/png';
      image = screenshotData.substring(prefix.length);
      if (requestState.endsWith('_includeDepth')) {
        const depthArray = viewer.display.getDepthArray();
        convertEndian32(depthArray, Endianness.LITTLE);
        depthData = await toBase64(depthArray);
      }
    }
    const actionState = {
      viewerState: JSON.parse(JSON.stringify(getCachedJson(this.viewer.state).value)),
      selectedValues: JSON.parse(JSON.stringify(layerSelectedValues)),
      screenshot: {id: requestState, image, imageType, depthData, width, height},
    };

    this.sendScreenshotRequested.dispatch(actionState);
  }
}

<MSG> fix: Improve reliability of screenshot capture

<DFF> @@ -25,14 +25,20 @@ import {Viewer} from 'neuroglancer/viewer';
 export class ScreenshotHandler extends RefCounted {
   sendScreenshotRequested = new Signal<(state: any) => void>();
   requestState = new TrackableValue<string|undefined>(undefined, verifyOptionalString);
+  /**
+   * To reduce the risk of taking a screenshot while deferred code is still registering layers,
+   * require that the viewer be in a ready state once, and still remain ready while all pending
+   * events are handled, before a screenshot is taken.
+   */
+  private wasAlreadyVisible = false;
   private previousRequest: string|undefined = undefined;
+  private debouncedMaybeSendScreenshot =
+      this.registerCancellable(debounce(() => this.maybeSendScreenshot(), 0));
+
   constructor(public viewer: Viewer) {
     super();
-
-    const debouncedMaybeSendScreenshot =
-        this.registerCancellable(debounce(() => this.maybeSendScreenshot(), 0));
-    this.requestState.changed.add(debouncedMaybeSendScreenshot);
-    this.registerDisposer(viewer.display.updateFinished.add(debouncedMaybeSendScreenshot));
+    this.requestState.changed.add(this.debouncedMaybeSendScreenshot);
+    this.registerDisposer(viewer.display.updateFinished.add(this.debouncedMaybeSendScreenshot));
   }
 
   private maybeSendScreenshot() {
@@ -40,17 +46,26 @@ export class ScreenshotHandler extends RefCounted {
     const {previousRequest} = this;
     const {layerSelectedValues} = this.viewer;
     if (requestState === undefined || requestState === previousRequest) {
+      this.wasAlreadyVisible = false;
       return;
     }
     const {viewer} = this;
     if (!viewer.display.isReady()) {
+      this.wasAlreadyVisible = false;
       return;
     }
     for (const layer of viewer.layerManager.managedLayers) {
       if (!layer.isReady()) {
+        this.wasAlreadyVisible = false;
         return;
       }
     }
+    if (!this.wasAlreadyVisible) {
+      this.wasAlreadyVisible = true;
+      this.debouncedMaybeSendScreenshot();
+      return;
+    }
+    this.wasAlreadyVisible = false;
     this.previousRequest = requestState;
     viewer.display.draw();
     const screenshotData = viewer.display.canvas.toDataURL();

fix: Improve reliability of screenshot capture