diff --git a/applications/visualizer/frontend/package.json b/applications/visualizer/frontend/package.json index 36c763a4..b93bdd28 100644 --- a/applications/visualizer/frontend/package.json +++ b/applications/visualizer/frontend/package.json @@ -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", diff --git a/applications/visualizer/frontend/src/components/viewers/ThreeD/Recorder.ts b/applications/visualizer/frontend/src/components/viewers/ThreeD/Recorder.ts new file mode 100644 index 00000000..e2171524 --- /dev/null +++ b/applications/visualizer/frontend/src/components/viewers/ThreeD/Recorder.ts @@ -0,0 +1,115 @@ +import { formatDate } from "../../../helpers/utils.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 Error(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); + } + } +} diff --git a/applications/visualizer/frontend/src/components/viewers/ThreeD/SceneControls.tsx b/applications/visualizer/frontend/src/components/viewers/ThreeD/SceneControls.tsx index d1866c96..baff46ed 100644 --- a/applications/visualizer/frontend/src/components/viewers/ThreeD/SceneControls.tsx +++ b/applications/visualizer/frontend/src/components/viewers/ThreeD/SceneControls.tsx @@ -6,12 +6,26 @@ import Tooltip from "@mui/material/Tooltip"; import { useRef, useState } from "react"; import { vars } from "../../../theme/variables.ts"; import CustomFormControlLabel from "./CustomFormControlLabel.tsx"; +import { Recorder } from "./Recorder.ts"; +import { downloadScreenshot } from "./Screenshoter.ts"; + const { gray500 } = vars; -function SceneControls({ cameraControlRef, isWireframe, setIsWireframe }) { +function SceneControls({ cameraControlRef, isWireframe, setIsWireframe, recorderRef }) { const [anchorEl, setAnchorEl] = useState(null); const rotateAnimationRef = useRef(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; @@ -43,6 +57,31 @@ function SceneControls({ cameraControlRef, isWireframe, setIsWireframe }) { setIsRotating(!isRotating); }; + const handleScreenshot = () => { + if (cameraControlRef.current) { + downloadScreenshot(document.getElementsByTagName("canvas")[0], 0.95, { width: 3840, height: 2160 }, 1, () => true, "screenshot.png"); + } + }; + + 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("CanvasRecording.webm", { type: "video/webm" }); + recorderRef.current = null; + } + }; + return ( - - - + + + - + diff --git a/applications/visualizer/frontend/src/components/viewers/ThreeD/Screenshoter.ts b/applications/visualizer/frontend/src/components/viewers/ThreeD/Screenshoter.ts new file mode 100644 index 00000000..4626555c --- /dev/null +++ b/applications/visualizer/frontend/src/components/viewers/ThreeD/Screenshoter.ts @@ -0,0 +1,49 @@ +import * as htmlToImage from "html-to-image"; +import { formatDate } from "../../../helpers/utils.ts"; + +function getOptions(htmlElement, targetResolution, quality, pixelRatio, filter) { + const resolution = getResolutionFixedRatio(htmlElement, targetResolution); + return { + quality: quality, + canvasWidth: resolution.width, + canvasHeight: resolution.height, + pixelRatio: pixelRatio, + filter: filter, + }; +} + +export function downloadScreenshot( + htmlElement, + quality = 0.95, + targetResolution = { width: 3840, height: 2160 }, + pixelRatio = 1, + filter = () => true, + filename = `Canvas_${formatDate(new Date())}.png`, +) { + const options = getOptions(htmlElement, targetResolution, quality, pixelRatio, filter); + + htmlToImage.toBlob(htmlElement, options).then((blob) => { + const link = document.createElement("a"); + link.download = filename; + link.href = window.URL.createObjectURL(blob); + link.click(); + }); +} + +function getResolutionFixedRatio(htmlElement, target) { + 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, + }; +} diff --git a/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx b/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx index 5aa9e3a4..e2dcdeec 100644 --- a/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx +++ b/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx @@ -17,6 +17,7 @@ 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"; @@ -37,6 +38,8 @@ function ThreeDViewer() { const cameraControlRef = useRef(null); + const recorderRef = useRef(null); + // @ts-expect-error 'setShowNeurons' is declared but its value is never read. // eslint-disable-next-line @typescript-eslint/no-unused-vars const [showNeurons, setShowNeurons] = useState(true); @@ -67,7 +70,8 @@ function ThreeDViewer() { return ( <> - + + }> - + ); } diff --git a/applications/visualizer/frontend/src/helpers/utils.ts b/applications/visualizer/frontend/src/helpers/utils.ts index 908da339..e650ceab 100644 --- a/applications/visualizer/frontend/src/helpers/utils.ts +++ b/applications/visualizer/frontend/src/helpers/utils.ts @@ -9,3 +9,15 @@ export function areSetsEqual(setA: Set, setB: Set): 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; +}