headless-screenshot.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  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 passes = new Passes(webgl, new AssetManager(), attribs);
  52. this.canvas3d = Canvas3D.create({ webgl, input, passes, attribs } as Canvas3DContext, options?.canvas ?? defaultCanvas3DParams());
  53. }
  54. this.imagePass = this.canvas3d.getImagePass(options?.imagePass ?? defaultImagePassParams());
  55. this.imagePass.setSize(this.canvasSize.width, this.canvasSize.height);
  56. }
  57. private getImageData(width: number, height: number): RawImageData {
  58. this.imagePass.setSize(width, height);
  59. this.imagePass.render();
  60. this.imagePass.colorTarget.bind();
  61. const array = new Uint8Array(width * height * 4);
  62. this.canvas3d.webgl.readPixels(0, 0, width, height, array);
  63. const pixelData = PixelData.create(array, width, height);
  64. PixelData.flipY(pixelData);
  65. PixelData.divideByAlpha(pixelData);
  66. // ImageData is not defined in Node.js
  67. return { data: new Uint8ClampedArray(array), width, height };
  68. }
  69. async getImageRaw(imageSize?: { width: number, height: number }, postprocessing?: Partial<PostprocessingProps>): Promise<RawImageData> {
  70. const width = imageSize?.width ?? this.canvasSize.width;
  71. const height = imageSize?.height ?? this.canvasSize.height;
  72. this.canvas3d.commit(true);
  73. this.imagePass.setProps({
  74. postprocessing: ParamDefinition.merge(PostprocessingParams, this.canvas3d.props.postprocessing, postprocessing),
  75. });
  76. return this.getImageData(width, height);
  77. }
  78. async getImagePng(imageSize?: { width: number, height: number }, postprocessing?: Partial<PostprocessingProps>): Promise<PNG> {
  79. const imageData = await this.getImageRaw(imageSize, postprocessing);
  80. if (!this.externalModules.pngjs) {
  81. 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.");
  82. }
  83. const generatedPng = new this.externalModules.pngjs.PNG({ width: imageData.width, height: imageData.height });
  84. generatedPng.data = Buffer.from(imageData.data.buffer);
  85. return generatedPng;
  86. }
  87. async getImageJpeg(imageSize?: { width: number, height: number }, postprocessing?: Partial<PostprocessingProps>, jpegQuality: number = 90): Promise<JpegBufferRet> {
  88. const imageData = await this.getImageRaw(imageSize, postprocessing);
  89. if (!this.externalModules['jpeg-js']) {
  90. 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.");
  91. }
  92. const generatedJpeg = this.externalModules['jpeg-js'].encode(imageData, jpegQuality);
  93. return generatedJpeg;
  94. }
  95. async saveImage(outPath: string, imageSize?: { width: number, height: number }, postprocessing?: Partial<PostprocessingProps>, format?: 'png' | 'jpeg', jpegQuality = 90) {
  96. if (!format) {
  97. const extension = path.extname(outPath).toLowerCase();
  98. if (extension === '.png') format = 'png';
  99. else if (extension === '.jpg' || extension === '.jpeg') format = 'jpeg';
  100. 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`);
  101. }
  102. if (format === 'png') {
  103. const generatedPng = await this.getImagePng(imageSize, postprocessing);
  104. await writePngFile(generatedPng, outPath);
  105. } else if (format === 'jpeg') {
  106. const generatedJpeg = await this.getImageJpeg(imageSize, postprocessing, jpegQuality);
  107. await writeJpegFile(generatedJpeg, outPath);
  108. } else {
  109. throw new Error(`Invalid format: ${format}`);
  110. }
  111. }
  112. }
  113. async function writePngFile(png: PNG, outPath: string) {
  114. await new Promise<void>(resolve => {
  115. png.pack().pipe(fs.createWriteStream(outPath)).on('finish', resolve);
  116. });
  117. }
  118. async function writeJpegFile(jpeg: JpegBufferRet, outPath: string) {
  119. await new Promise<void>(resolve => {
  120. fs.writeFile(outPath, jpeg.data, () => resolve());
  121. });
  122. }
  123. export function defaultCanvas3DParams(): Partial<Canvas3DProps> {
  124. return {
  125. camera: {
  126. mode: 'orthographic',
  127. helper: {
  128. axes: { name: 'off', params: {} }
  129. },
  130. stereo: {
  131. name: 'off', params: {}
  132. },
  133. fov: 90,
  134. manualReset: false,
  135. },
  136. cameraResetDurationMs: 0,
  137. cameraFog: {
  138. name: 'on',
  139. params: {
  140. intensity: 50
  141. }
  142. },
  143. renderer: {
  144. ...DefaultCanvas3DParams.renderer,
  145. backgroundColor: ColorNames.white,
  146. },
  147. postprocessing: {
  148. occlusion: {
  149. name: 'off', params: {}
  150. },
  151. outline: {
  152. name: 'off', params: {}
  153. },
  154. antialiasing: {
  155. name: 'fxaa',
  156. params: {
  157. edgeThresholdMin: 0.0312,
  158. edgeThresholdMax: 0.063,
  159. iterations: 12,
  160. subpixelQuality: 0.3
  161. }
  162. },
  163. background: { variant: { name: 'off', params: {} } },
  164. shadow: { name: 'off', params: {} },
  165. }
  166. };
  167. }
  168. export function defaultWebGLAttributes(): WebGLContextAttributes {
  169. return {
  170. antialias: true,
  171. preserveDrawingBuffer: true,
  172. alpha: true, // the renderer requires an alpha channel
  173. depth: true, // the renderer requires a depth buffer
  174. premultipliedAlpha: true, // the renderer outputs PMA
  175. };
  176. }
  177. export function defaultImagePassParams(): Partial<ImageProps> {
  178. return {
  179. cameraHelper: {
  180. axes: { name: 'off', params: {} },
  181. },
  182. multiSample: {
  183. mode: 'on',
  184. sampleLevel: 4
  185. }
  186. };
  187. }
  188. export const STYLIZED_POSTPROCESSING: Partial<PostprocessingProps> = {
  189. occlusion: {
  190. name: 'on' as const, params: {
  191. samples: 32,
  192. multiScale: { name: 'off', params: {} },
  193. radius: 5,
  194. bias: 0.8,
  195. blurKernelSize: 15,
  196. resolutionScale: 1,
  197. color: ColorNames.black,
  198. }
  199. }, outline: {
  200. name: 'on' as const, params: {
  201. scale: 1,
  202. threshold: 0.95,
  203. color: ColorNames.black,
  204. includeTransparent: true,
  205. }
  206. }
  207. };