headless-screenshot.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. /**
  2. * Copyright (c) 2019-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  3. *
  4. * @author Alexander Rose <alexander.rose@weirdbyte.de>
  5. * @author Jesse Liang <jesse.liang@rcsb.org>
  6. * @author Sebastian Bittrich <sebastian.bittrich@rcsb.org>
  7. * @author Ke Ma <mark.ma@rcsb.org>
  8. * @author Adam Midlik <midlik@gmail.com>
  9. */
  10. import fs from 'fs';
  11. import path from 'path';
  12. import { type BufferRet as JpegBufferRet } from 'jpeg-js'; // Only import type here, the actual import must be provided by the caller
  13. import { type PNG } from 'pngjs'; // Only import type here, the actual import must be provided by the caller
  14. import { Canvas3D, Canvas3DContext, Canvas3DProps, DefaultCanvas3DParams } from '../../mol-canvas3d/canvas3d';
  15. import { ImagePass, ImageProps } from '../../mol-canvas3d/passes/image';
  16. import { Passes } from '../../mol-canvas3d/passes/passes';
  17. import { PostprocessingParams, PostprocessingProps } from '../../mol-canvas3d/passes/postprocessing';
  18. import { createContext } from '../../mol-gl/webgl/context';
  19. import { AssetManager } from '../../mol-util/assets';
  20. import { ColorNames } from '../../mol-util/color/names';
  21. import { PixelData } from '../../mol-util/image';
  22. import { InputObserver } from '../../mol-util/input/input-observer';
  23. import { ParamDefinition } from '../../mol-util/param-definition';
  24. export interface ExternalModules {
  25. 'gl': typeof import('gl'),
  26. 'jpeg-js'?: typeof import('jpeg-js'),
  27. 'pngjs'?: typeof import('pngjs'),
  28. }
  29. export type HeadlessScreenshotHelperOptions = {
  30. webgl?: WebGLContextAttributes,
  31. canvas?: Partial<Canvas3DProps>,
  32. imagePass?: Partial<ImageProps>,
  33. }
  34. export type RawImageData = {
  35. data: Uint8ClampedArray,
  36. width: number,
  37. height: number,
  38. }
  39. /** To render Canvas3D when running in Node.js (without DOM) */
  40. export class HeadlessScreenshotHelper {
  41. readonly canvas3d: Canvas3D;
  42. readonly imagePass: ImagePass;
  43. constructor(readonly externalModules: ExternalModules, readonly canvasSize: { width: number, height: number }, canvas3d?: Canvas3D, options?: HeadlessScreenshotHelperOptions) {
  44. if (canvas3d) {
  45. this.canvas3d = canvas3d;
  46. } else {
  47. const glContext = this.externalModules.gl(this.canvasSize.width, this.canvasSize.height, options?.webgl ?? defaultWebGLAttributes());
  48. const webgl = createContext(glContext);
  49. const input = InputObserver.create();
  50. const attribs = { ...Canvas3DContext.DefaultAttribs };
  51. const assetManager = new AssetManager();
  52. const passes = new Passes(webgl, assetManager, attribs);
  53. const dispose = () => {
  54. input.dispose();
  55. webgl.destroy();
  56. };
  57. this.canvas3d = Canvas3D.create({ webgl, input, passes, attribs, assetManager, dispose }, options?.canvas ?? defaultCanvas3DParams());
  58. }
  59. this.imagePass = this.canvas3d.getImagePass(options?.imagePass ?? defaultImagePassParams());
  60. this.imagePass.setSize(this.canvasSize.width, this.canvasSize.height);
  61. }
  62. private getImageData(width: number, height: number): RawImageData {
  63. this.imagePass.setSize(width, height);
  64. this.imagePass.render();
  65. this.imagePass.colorTarget.bind();
  66. const array = new Uint8Array(width * height * 4);
  67. this.canvas3d.webgl.readPixels(0, 0, width, height, array);
  68. const pixelData = PixelData.create(array, width, height);
  69. PixelData.flipY(pixelData);
  70. PixelData.divideByAlpha(pixelData);
  71. // ImageData is not defined in Node.js
  72. return { data: new Uint8ClampedArray(array), width, height };
  73. }
  74. async getImageRaw(imageSize?: { width: number, height: number }, postprocessing?: Partial<PostprocessingProps>): Promise<RawImageData> {
  75. const width = imageSize?.width ?? this.canvasSize.width;
  76. const height = imageSize?.height ?? this.canvasSize.height;
  77. this.canvas3d.commit(true);
  78. this.imagePass.setProps({
  79. postprocessing: ParamDefinition.merge(PostprocessingParams, this.canvas3d.props.postprocessing, postprocessing),
  80. });
  81. return this.getImageData(width, height);
  82. }
  83. async getImagePng(imageSize?: { width: number, height: number }, postprocessing?: Partial<PostprocessingProps>): Promise<PNG> {
  84. const imageData = await this.getImageRaw(imageSize, postprocessing);
  85. if (!this.externalModules.pngjs) {
  86. throw new Error("External module 'pngjs' was not provided. If you want to use getImagePng, you must import 'pngjs' and provide it to the HeadlessPluginContext/HeadlessScreenshotHelper constructor.");
  87. }
  88. const generatedPng = new this.externalModules.pngjs.PNG({ width: imageData.width, height: imageData.height });
  89. generatedPng.data = Buffer.from(imageData.data.buffer);
  90. return generatedPng;
  91. }
  92. async getImageJpeg(imageSize?: { width: number, height: number }, postprocessing?: Partial<PostprocessingProps>, jpegQuality: number = 90): Promise<JpegBufferRet> {
  93. const imageData = await this.getImageRaw(imageSize, postprocessing);
  94. if (!this.externalModules['jpeg-js']) {
  95. throw new Error("External module 'jpeg-js' was not provided. If you want to use getImageJpeg, you must import 'jpeg-js' and provide it to the HeadlessPluginContext/HeadlessScreenshotHelper constructor.");
  96. }
  97. const generatedJpeg = this.externalModules['jpeg-js'].encode(imageData, jpegQuality);
  98. return generatedJpeg;
  99. }
  100. async saveImage(outPath: string, imageSize?: { width: number, height: number }, postprocessing?: Partial<PostprocessingProps>, format?: 'png' | 'jpeg', jpegQuality = 90) {
  101. if (!format) {
  102. const extension = path.extname(outPath).toLowerCase();
  103. if (extension === '.png') format = 'png';
  104. else if (extension === '.jpg' || extension === '.jpeg') format = 'jpeg';
  105. else throw new Error(`Cannot guess image format from file path '${outPath}'. Specify format explicitly or use path with one of these extensions: .png, .jpg, .jpeg`);
  106. }
  107. if (format === 'png') {
  108. const generatedPng = await this.getImagePng(imageSize, postprocessing);
  109. await writePngFile(generatedPng, outPath);
  110. } else if (format === 'jpeg') {
  111. const generatedJpeg = await this.getImageJpeg(imageSize, postprocessing, jpegQuality);
  112. await writeJpegFile(generatedJpeg, outPath);
  113. } else {
  114. throw new Error(`Invalid format: ${format}`);
  115. }
  116. }
  117. }
  118. async function writePngFile(png: PNG, outPath: string) {
  119. await new Promise<void>(resolve => {
  120. png.pack().pipe(fs.createWriteStream(outPath)).on('finish', resolve);
  121. });
  122. }
  123. async function writeJpegFile(jpeg: JpegBufferRet, outPath: string) {
  124. await new Promise<void>(resolve => {
  125. fs.writeFile(outPath, jpeg.data, () => resolve());
  126. });
  127. }
  128. export function defaultCanvas3DParams(): Partial<Canvas3DProps> {
  129. return {
  130. camera: {
  131. mode: 'orthographic',
  132. helper: {
  133. axes: { name: 'off', params: {} }
  134. },
  135. stereo: {
  136. name: 'off', params: {}
  137. },
  138. fov: 90,
  139. manualReset: false,
  140. },
  141. cameraResetDurationMs: 0,
  142. cameraFog: {
  143. name: 'on',
  144. params: {
  145. intensity: 50
  146. }
  147. },
  148. renderer: {
  149. ...DefaultCanvas3DParams.renderer,
  150. backgroundColor: ColorNames.white,
  151. },
  152. postprocessing: {
  153. occlusion: {
  154. name: 'off', params: {}
  155. },
  156. outline: {
  157. name: 'off', params: {}
  158. },
  159. antialiasing: {
  160. name: 'fxaa',
  161. params: {
  162. edgeThresholdMin: 0.0312,
  163. edgeThresholdMax: 0.063,
  164. iterations: 12,
  165. subpixelQuality: 0.3
  166. }
  167. },
  168. background: { variant: { name: 'off', params: {} } },
  169. shadow: { name: 'off', params: {} },
  170. }
  171. };
  172. }
  173. export function defaultWebGLAttributes(): WebGLContextAttributes {
  174. return {
  175. antialias: true,
  176. preserveDrawingBuffer: true,
  177. alpha: true, // the renderer requires an alpha channel
  178. depth: true, // the renderer requires a depth buffer
  179. premultipliedAlpha: true, // the renderer outputs PMA
  180. };
  181. }
  182. export function defaultImagePassParams(): Partial<ImageProps> {
  183. return {
  184. cameraHelper: {
  185. axes: { name: 'off', params: {} },
  186. },
  187. multiSample: {
  188. mode: 'on',
  189. sampleLevel: 4
  190. }
  191. };
  192. }
  193. export const STYLIZED_POSTPROCESSING: Partial<PostprocessingProps> = {
  194. occlusion: {
  195. name: 'on' as const, params: {
  196. samples: 32,
  197. multiScale: { name: 'off', params: {} },
  198. radius: 5,
  199. bias: 0.8,
  200. blurKernelSize: 15,
  201. resolutionScale: 1,
  202. color: ColorNames.black,
  203. }
  204. }, outline: {
  205. name: 'on' as const, params: {
  206. scale: 1,
  207. threshold: 0.95,
  208. color: ColorNames.black,
  209. includeTransparent: true,
  210. }
  211. }
  212. };