Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor screenshot code #54

Merged
merged 18 commits into from
Oct 10, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions applications/visualizer/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"cytoscape-dagre": "^2.5.0",
"cytoscape-fcose": "^2.2.0",
"file-saver": "^2.0.5",
"html-to-image": "^1.11.11",
"html2canvas": "^1.4.1",
"ol": "^9.1.0",
"pako": "^2.1.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { formatDate } from "../../../helpers/utils.ts";
import { GlobalError } from "../../../models/Error.ts";
export class Recorder {
private mediaRecorder: MediaRecorder | null = null;
private recordedBlobs: Blob[] = [];
private stream: MediaStream;
private ctx: WebGLRenderingContext;
// @ts-ignore
private options: { mediaRecorderOptions?: MediaRecorderOptions; blobOptions?: BlobPropertyBag } = {
mediaRecorderOptions: { mimeType: "video/webm" },
blobOptions: { type: "video/webm" },
};
private blobOptions: BlobPropertyBag = { type: "video/webm" };

constructor(canvas: HTMLCanvasElement, recorderOptions: { mediaRecorderOptions?: MediaRecorderOptions; blobOptions?: BlobPropertyBag }) {
this.stream = canvas.captureStream();
const { mediaRecorderOptions, blobOptions } = recorderOptions;
this.setupMediaRecorder(mediaRecorderOptions);
this.recordedBlobs = [];
this.blobOptions = blobOptions;
this.ctx = canvas.getContext("webgl");
}

handleDataAvailable(event) {
if (event.data && event.data.size > 0) {
this.recordedBlobs.push(event.data);
}
}

setupMediaRecorder(options) {
let error = "";

if (options == null) {
options = { mimeType: "video/webm" };
}
let mediaRecorder;
try {
mediaRecorder = new MediaRecorder(this.stream, options);
} catch (e0) {
error = `Unable to create MediaRecorder with options Object: ${e0}`;
try {
options = { mimeType: "video/webm,codecs=vp9" };
mediaRecorder = new MediaRecorder(this.stream, options);
} catch (e1) {
error = `Unable to create MediaRecorder with options Object: ${e1}`;
try {
options = { mimeType: "video/webm,codecs=vp8" }; // Chrome 47
mediaRecorder = new MediaRecorder(this.stream, options);
} catch (e2) {
error =
"MediaRecorder is not supported by this browser.\n\n" +
"Try Firefox 29 or later, or Chrome 47 or later, " +
"with Enable experimental Web Platform features enabled from chrome://flags." +
`Exception while creating MediaRecorder: ${e2}`;
}
}
}

if (!mediaRecorder) {
throw new GlobalError(error);
}

mediaRecorder.ondataavailable = (evt) => this.handleDataAvailable(evt);
mediaRecorder.onstart = () => this.animationLoop();

this.mediaRecorder = mediaRecorder;
this.options = options;
if (!this.blobOptions) {
const { mimeType } = options;
this.blobOptions = { type: mimeType };
}
}

startRecording() {
this.recordedBlobs = [];
this.mediaRecorder.start(100);
}

stopRecording(options) {
this.mediaRecorder.stop();
return this.getRecordingBlob(options);
}

download(filename, options) {
if (!filename) {
filename = `CanvasRecording_${formatDate(new Date())}.webm`;
}
const blob = this.getRecordingBlob(options);
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.style.display = "none";
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}, 100);
return blob;
}

getRecordingBlob(options) {
if (!options) {
options = this.blobOptions;
}
return new Blob(this.recordedBlobs, options);
}

animationLoop() {
this.ctx.drawArrays(this.ctx.POINTS, 0, 0);
if (this.mediaRecorder.state !== "inactive") {
requestAnimationFrame(this.animationLoop);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,29 @@ import ZoomOutIcon from "@mui/icons-material/ZoomOut";
import { Box, Divider, IconButton, Popover, Typography } from "@mui/material";
import Tooltip from "@mui/material/Tooltip";
import { useRef, useState } from "react";
import { useSelectedWorkspace } from "../../../hooks/useSelectedWorkspace.ts";
import { vars } from "../../../theme/variables.ts";
import CustomFormControlLabel from "./CustomFormControlLabel.tsx";
import { Recorder } from "./Recorder.ts";

const { gray500 } = vars;

function SceneControls({ cameraControlRef, isWireframe, setIsWireframe }) {
function SceneControls({ cameraControlRef, isWireframe, setIsWireframe, recorderRef, handleScreenshot }) {
const workspace = useSelectedWorkspace();
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const rotateAnimationRef = useRef<number | null>(null);
const [isRotating, setIsRotating] = useState(false);
const [isRecording, setIsRecording] = useState(false);

const handleRecordClick = () => {
if (isRecording) {
stopRecording();
} else {
startRecording();
}
setIsRecording(!isRecording);
};

const open = Boolean(anchorEl);
const id = open ? "settings-popover" : undefined;

Expand Down Expand Up @@ -42,6 +57,24 @@ function SceneControls({ cameraControlRef, isWireframe, setIsWireframe }) {

setIsRotating(!isRotating);
};
const startRecording = () => {
if (recorderRef.current === null) {
const canvas = document.getElementsByTagName("canvas")[0];
recorderRef.current = new Recorder(canvas, {
mediaRecorderOptions: { mimeType: "video/webm" },
blobOptions: { type: "video/webm" },
});
recorderRef.current.startRecording();
}
};

const stopRecording = async () => {
if (recorderRef.current) {
recorderRef.current.stopRecording({ type: "video/webm" });
recorderRef.current.download(workspace.name + ".webm", { type: "video/webm" });
aranega marked this conversation as resolved.
Show resolved Hide resolved
recorderRef.current = null;
}
};

return (
<Box
Expand Down Expand Up @@ -143,13 +176,17 @@ function SceneControls({ cameraControlRef, isWireframe, setIsWireframe }) {
<PlayArrowOutlined />
</IconButton>
</Tooltip>
<Tooltip title="Record viewer" placement="right-start">
<IconButton>
<RadioButtonCheckedOutlined />
<Tooltip title={isRecording ? "Stop recording" : "Record viewer"} placement="right-start">
<IconButton onClick={handleRecordClick}>
<RadioButtonCheckedOutlined
sx={{
color: isRecording ? "red" : "inherit",
}}
/>
</IconButton>
</Tooltip>
<Tooltip title="Download graph" placement="right-start">
<IconButton>
<IconButton onClick={handleScreenshot}>
<GetAppOutlined />
</IconButton>
</Tooltip>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import * as THREE from "three";
import { GlobalError } from "../../../models/Error.ts";

function getResolutionFixedRatio(htmlElement: HTMLElement, target: { width: number; height: number }) {
const current = {
height: htmlElement.clientHeight,
width: htmlElement.clientWidth,
};

if ((Math.abs(target.width - current.width) * 9) / 16 > Math.abs(target.height - current.height)) {
return {
height: target.height,
width: Math.round((current.width * target.height) / current.height),
};
}
return {
height: Math.round((current.height * target.width) / current.width),
width: target.width,
};
}

function getOptions(htmlElement: HTMLCanvasElement, targetResolution: { width: number; height: number }, pixelRatio: number) {
const resolution = getResolutionFixedRatio(htmlElement, targetResolution);
return {
canvasWidth: resolution.width,
canvasHeight: resolution.height,
pixelRatio: pixelRatio,
};
}

export function downloadScreenshot(
canvasRef: React.RefObject<HTMLCanvasElement>,
sceneRef: React.RefObject<THREE.Scene>,
cameraRef: React.RefObject<THREE.PerspectiveCamera>,
filename?: string,
) {
if (!sceneRef.current || !cameraRef.current || !canvasRef.current) return;

const options = getOptions(canvasRef.current, { width: 3840, height: 2160 }, 1);

try {
const tempRenderer = new THREE.WebGLRenderer({ preserveDrawingBuffer: true });
tempRenderer.setSize(options.canvasWidth, options.canvasHeight);
tempRenderer.setPixelRatio(options.pixelRatio); // Set the resolution scaling

cameraRef.current.aspect = options.canvasWidth / options.canvasHeight;
cameraRef.current.updateProjectionMatrix();

tempRenderer.render(sceneRef.current, cameraRef.current);

tempRenderer.domElement.toBlob((blob) => {
if (blob) {
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename || "screenshot.png";
link.click();
URL.revokeObjectURL(link.href);
}
}, "image/png");

tempRenderer.dispose();
} catch (e) {
throw new GlobalError(`Error saving image: ${e}`);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { CameraControls, PerspectiveCamera } from "@react-three/drei";
import { Canvas } from "@react-three/fiber";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import type * as THREE from "three";
import { useSelectedWorkspace } from "../../../hooks/useSelectedWorkspace.ts";
import { ViewerType, getNeuronUrlForDataset } from "../../../models/models.ts";
import { type Dataset, OpenAPI } from "../../../rest";
Expand All @@ -17,9 +18,10 @@ import {
import DatasetPicker from "./DatasetPicker.tsx";
import Gizmo from "./Gizmo.tsx";
import Loader from "./Loader.tsx";
import type { Recorder } from "./Recorder";
import STLViewer from "./STLViewer.tsx";
import SceneControls from "./SceneControls.tsx";

import { downloadScreenshot } from "./Screenshoter.ts";
export interface Instance {
id: string;
url: string;
Expand All @@ -36,6 +38,11 @@ function ThreeDViewer() {
const [isWireframe, setIsWireframe] = useState<boolean>(false);

const cameraControlRef = useRef<CameraControls | null>(null);
const recorderRef = useRef<Recorder | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const sceneRef = useRef<THREE.Scene | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const glRef = useRef<THREE.WebGLRenderer | null>(null);

// @ts-expect-error 'setShowNeurons' is declared but its value is never read.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand Down Expand Up @@ -64,10 +71,22 @@ function ThreeDViewer() {
setInstances(newInstances);
}, [selectedDataset, workspace.availableNeurons, workspace.visibilities]);

const handleScreenshot = () => {
downloadScreenshot(canvasRef, sceneRef, cameraRef, workspace.name);
};

const onCreated = (state) => {
canvasRef.current = state.gl.domElement;
sceneRef.current = state.scene;
cameraRef.current = state.camera;
glRef.current = state.gl;
};

return (
<>
<DatasetPicker datasets={dataSets} selectedDataset={selectedDataset} onDatasetChange={setSelectedDataset} />
<Canvas style={{ backgroundColor: SCENE_BACKGROUND }} frameloop={"demand"}>
<Canvas style={{ backgroundColor: SCENE_BACKGROUND }} frameloop={"demand"} gl={{ preserveDrawingBuffer: false }} onCreated={onCreated}>
<color attach="background" args={["#F6F5F4"]} />
<Suspense fallback={<Loader />}>
<PerspectiveCamera
makeDefault
Expand All @@ -76,6 +95,7 @@ function ThreeDViewer() {
position={CAMERA_POSITION}
near={CAMERA_NEAR}
far={CAMERA_FAR}
ref={cameraRef}
/>
<CameraControls ref={cameraControlRef} />

Expand All @@ -87,7 +107,13 @@ function ThreeDViewer() {
<STLViewer instances={instances} isWireframe={isWireframe} />
</Suspense>
</Canvas>
<SceneControls cameraControlRef={cameraControlRef} isWireframe={isWireframe} setIsWireframe={setIsWireframe} />
<SceneControls
cameraControlRef={cameraControlRef}
isWireframe={isWireframe}
setIsWireframe={setIsWireframe}
recorderRef={recorderRef}
handleScreenshot={handleScreenshot}
/>
</>
);
}
Expand Down
12 changes: 12 additions & 0 deletions applications/visualizer/frontend/src/helpers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,15 @@ export function areSetsEqual<T>(setA: Set<T>, setB: Set<T>): boolean {
}
return true;
}

export function formatDate(d) {
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}-${pad(d.getHours(), 2)}${pad(d.getMinutes(), 2)}${pad(d.getSeconds(), 2)}`;
}

function pad(num, size) {
let s = num + "";
while (s.length < size) {
s = "0" + s;
}
return s;
}