Skip to content

Commit

Permalink
Merge pull request #54 from MetaCell/feature/93-screenshot
Browse files Browse the repository at this point in the history
refactor screenshot code
  • Loading branch information
ddelpiano authored Oct 10, 2024
2 parents f8cf616 + 3e783f6 commit d68be55
Show file tree
Hide file tree
Showing 8 changed files with 311 additions and 14 deletions.
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
@@ -1,20 +1,45 @@
import { GetAppOutlined, HomeOutlined, PlayArrowOutlined, RadioButtonCheckedOutlined, SettingsOutlined, TonalityOutlined } from "@mui/icons-material";
import {
DarkModeOutlined,
GetAppOutlined,
HomeOutlined,
PlayArrowOutlined,
RadioButtonCheckedOutlined,
SettingsOutlined,
TonalityOutlined,
WbSunnyOutlined,
} from "@mui/icons-material";
import ZoomInIcon from "@mui/icons-material/ZoomIn";
import ZoomOutIcon from "@mui/icons-material/ZoomOut";
import { Box, Divider, IconButton, Popover, Typography } from "@mui/material";
import Tooltip from "@mui/material/Tooltip";
import { useEffect, useRef, useState } from "react";
import { useSelectedWorkspace } from "../../../hooks/useSelectedWorkspace.ts";
import { DARK_SCENE_BACKGROUND, LIGHT_SCENE_BACKGROUND } from "../../../settings/threeDSettings.ts";
import { vars } from "../../../theme/variables.ts";
import CustomFormControlLabel from "./CustomFormControlLabel.tsx";
import { Recorder } from "./Recorder.ts";

import { useGlobalContext } from "../../../contexts/GlobalContext.tsx";

const { gray500 } = vars;

function SceneControls({ cameraControlRef, isWireframe, setIsWireframe }) {
function SceneControls({ cameraControlRef, isWireframe, setIsWireframe, recorderRef, handleScreenshot, sceneColor, setSceneColor }) {
const { isGlobalRotating } = useGlobalContext();
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 @@ -60,6 +85,33 @@ function SceneControls({ cameraControlRef, isWireframe, setIsWireframe }) {
setIsRotating(isGlobalRotating);
}, [isGlobalRotating]);

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" });
recorderRef.current = null;
}
};

const handleSwichMode = () => {
if (sceneColor === LIGHT_SCENE_BACKGROUND) {
setSceneColor(DARK_SCENE_BACKGROUND);
} else {
setSceneColor(LIGHT_SCENE_BACKGROUND);
}
};

return (
<Box
sx={{
Expand All @@ -69,11 +121,24 @@ function SceneControls({ cameraControlRef, isWireframe, setIsWireframe }) {
position: "absolute",
top: ".5rem",
left: ".5rem",
backgroundColor: "#fff",
backgroundColor: sceneColor === LIGHT_SCENE_BACKGROUND ? "white" : "#393937",
borderRadius: "0.5rem",
border: "1px solid #ECECE9",
border: `1px solid ${sceneColor === LIGHT_SCENE_BACKGROUND ? "#ECECE9" : "#393937"}`,
boxShadow: "0px 1px 2px 0px rgba(16, 24, 40, 0.05)",
padding: "0.25rem",

"& .MuiDivider-root": {
borderColor: sceneColor === LIGHT_SCENE_BACKGROUND ? "#ECECE9" : "#535350",
},

"& .MuiButtonBase-root": {
"&:hover": {
backgroundColor: sceneColor === LIGHT_SCENE_BACKGROUND ? "#F6F5F4" : "#535350",
},
"& .MuiSvgIcon-root": {
color: sceneColor === LIGHT_SCENE_BACKGROUND ? "#757570" : "#ECECE9",
},
},
}}
>
<Tooltip title="Change settings" placement="right-start">
Expand Down Expand Up @@ -126,6 +191,9 @@ function SceneControls({ cameraControlRef, isWireframe, setIsWireframe }) {
<TonalityOutlined />
</IconButton>
</Tooltip>
<Tooltip title={sceneColor === LIGHT_SCENE_BACKGROUND ? "Switch to dark mode" : "Switch to light mode"} placement="right-start">
<IconButton onClick={handleSwichMode}>{sceneColor === LIGHT_SCENE_BACKGROUND ? <DarkModeOutlined /> : <WbSunnyOutlined />}</IconButton>
</Tooltip>
<Divider />
<Tooltip title="Zoom in" placement="right-start">
<IconButton
Expand Down Expand Up @@ -160,13 +228,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 !important" : "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}`);
}
}
Loading

0 comments on commit d68be55

Please sign in to comment.