viewport-screenshot.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. /**
  2. * Copyright (c) 2019-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
  3. *
  4. * @author David Sehnal <david.sehnal@gmail.com>
  5. * @author Alexander Rose <alexander.rose@weirdbyte.de>
  6. */
  7. import { Viewport } from '../../mol-canvas3d/camera/util';
  8. import { CameraHelperParams } from '../../mol-canvas3d/helper/camera-helper';
  9. import { ImagePass } from '../../mol-canvas3d/passes/image';
  10. import { canvasToBlob } from '../../mol-canvas3d/util';
  11. import { equalEps } from '../../mol-math/linear-algebra/3d/common';
  12. import { PluginComponent } from '../../mol-plugin-state/component';
  13. import { PluginStateObject } from '../../mol-plugin-state/objects';
  14. import { StateSelection } from '../../mol-state';
  15. import { RuntimeContext, Task } from '../../mol-task';
  16. import { Color } from '../../mol-util/color';
  17. import { download } from '../../mol-util/download';
  18. import { ParamDefinition as PD } from '../../mol-util/param-definition';
  19. import { SetUtils } from '../../mol-util/set';
  20. import { PluginContext } from '../context';
  21. export { ViewportScreenshotHelper, ViewportScreenshotHelperParams };
  22. namespace ViewportScreenshotHelper {
  23. export type ResolutionSettings = PD.Values<ReturnType<ViewportScreenshotHelper['createParams']>>['resolution']
  24. export type ResolutionTypes = ResolutionSettings['name']
  25. }
  26. type ViewportScreenshotHelperParams = PD.Values<ReturnType<ViewportScreenshotHelper['createParams']>>
  27. class ViewportScreenshotHelper extends PluginComponent {
  28. private createParams() {
  29. const max = Math.min(this.plugin.canvas3d ? this.plugin.canvas3d.webgl.maxRenderbufferSize : 4096, 4096);
  30. return {
  31. resolution: PD.MappedStatic('viewport', {
  32. viewport: PD.Group({}),
  33. hd: PD.Group({}),
  34. 'full-hd': PD.Group({}),
  35. 'ultra-hd': PD.Group({}),
  36. custom: PD.Group({
  37. width: PD.Numeric(1920, { min: 128, max, step: 1 }),
  38. height: PD.Numeric(1080, { min: 128, max, step: 1 }),
  39. }, { isFlat: true })
  40. }, {
  41. options: [
  42. ['viewport', 'Viewport'],
  43. ['hd', 'HD (1280 x 720)'],
  44. ['full-hd', 'Full HD (1920 x 1080)'],
  45. ['ultra-hd', 'Ultra HD (3840 x 2160)'],
  46. ['custom', 'Custom']
  47. ]
  48. }),
  49. transparent: PD.Boolean(false),
  50. axes: CameraHelperParams.axes,
  51. };
  52. }
  53. private _params: ReturnType<ViewportScreenshotHelper['createParams']> = void 0 as any;
  54. get params() {
  55. if (this._params) return this._params;
  56. return this._params = this.createParams();
  57. }
  58. readonly behaviors = {
  59. values: this.ev.behavior<ViewportScreenshotHelperParams>({
  60. transparent: this.params.transparent.defaultValue,
  61. axes: { name: 'off', params: {} },
  62. resolution: this.params.resolution.defaultValue
  63. }),
  64. cropParams: this.ev.behavior<{ auto: boolean, relativePadding: number }>({ auto: true, relativePadding: 0.1 }),
  65. relativeCrop: this.ev.behavior<Viewport>({ x: 0, y: 0, width: 1, height: 1 }),
  66. };
  67. readonly events = {
  68. previewed: this.ev<any>()
  69. };
  70. get values() {
  71. return this.behaviors.values.value;
  72. }
  73. get cropParams() {
  74. return this.behaviors.cropParams.value;
  75. }
  76. get relativeCrop() {
  77. return this.behaviors.relativeCrop.value;
  78. }
  79. private getCanvasSize() {
  80. return {
  81. width: this.plugin.canvas3d?.webgl.gl.drawingBufferWidth || 0,
  82. height: this.plugin.canvas3d?.webgl.gl.drawingBufferHeight || 0
  83. };
  84. }
  85. private getSize() {
  86. const values = this.values;
  87. switch (values.resolution.name) {
  88. case 'viewport': return this.getCanvasSize();
  89. case 'hd': return { width: 1280, height: 720 };
  90. case 'full-hd': return { width: 1920, height: 1080 };
  91. case 'ultra-hd': return { width: 3840, height: 2160 };
  92. default: return { width: values.resolution.params.width, height: values.resolution.params.height };
  93. }
  94. }
  95. private createPass(mutlisample: boolean) {
  96. const c = this.plugin.canvas3d!;
  97. const { colorBufferFloat, textureFloat } = c.webgl.extensions;
  98. const aoProps = c.props.postprocessing.occlusion;
  99. return c.getImagePass({
  100. transparentBackground: this.values.transparent,
  101. cameraHelper: { axes: this.values.axes },
  102. multiSample: {
  103. mode: mutlisample ? 'on' : 'off',
  104. sampleLevel: colorBufferFloat && textureFloat ? 4 : 2
  105. },
  106. postprocessing: {
  107. ...c.props.postprocessing,
  108. occlusion: aoProps.name === 'on'
  109. ? { name: 'on', params: { ...aoProps.params, samples: 128 } }
  110. : aoProps
  111. },
  112. marking: { ...c.props.marking }
  113. });
  114. }
  115. private _previewPass: ImagePass;
  116. private get previewPass() {
  117. return this._previewPass || (this._previewPass = this.createPass(false));
  118. }
  119. private _imagePass: ImagePass;
  120. get imagePass() {
  121. if (this._imagePass) {
  122. const c = this.plugin.canvas3d!;
  123. const aoProps = c.props.postprocessing.occlusion;
  124. this._imagePass.setProps({
  125. cameraHelper: { axes: this.values.axes },
  126. transparentBackground: this.values.transparent,
  127. // TODO: optimize because this creates a copy of a large object!
  128. postprocessing: {
  129. ...c.props.postprocessing,
  130. occlusion: aoProps.name === 'on'
  131. ? { name: 'on', params: { ...aoProps.params, samples: 128 } }
  132. : aoProps
  133. },
  134. marking: { ...c.props.marking }
  135. });
  136. return this._imagePass;
  137. }
  138. return this._imagePass = this.createPass(true);
  139. }
  140. getFilename(extension = '.png') {
  141. const models = this.plugin.state.data.select(StateSelection.Generators.rootsOfType(PluginStateObject.Molecule.Model)).map(s => s.obj!.data);
  142. const uniqueIds = new Set<string>();
  143. models.forEach(m => uniqueIds.add(m.entryId.toUpperCase()));
  144. const idString = SetUtils.toArray(uniqueIds).join('-');
  145. return `${idString || 'molstar-image'}${extension}`;
  146. }
  147. private canvas = function () {
  148. const canvas = document.createElement('canvas');
  149. return canvas;
  150. }();
  151. private previewCanvas = function () {
  152. const canvas = document.createElement('canvas');
  153. return canvas;
  154. }();
  155. private previewData = {
  156. image: { data: new Uint8ClampedArray(1), width: 1, height: 0 } as ImageData,
  157. background: Color(0),
  158. transparent: false
  159. };
  160. resetCrop() {
  161. this.behaviors.relativeCrop.next({ x: 0, y: 0, width: 1, height: 1 });
  162. }
  163. toggleAutocrop() {
  164. if (this.cropParams.auto) {
  165. this.behaviors.cropParams.next({ ...this.cropParams, auto: false });
  166. this.resetCrop();
  167. } else {
  168. this.behaviors.cropParams.next({ ...this.cropParams, auto: true });
  169. }
  170. }
  171. get isFullFrame() {
  172. const crop = this.relativeCrop;
  173. return equalEps(crop.x, 0, 1e-5) && equalEps(crop.y, 0, 1e-5) && equalEps(crop.width, 1, 1e-5) && equalEps(crop.height, 1, 1e-5);
  174. }
  175. autocrop(relativePadding = this.cropParams.relativePadding) {
  176. const { data, width, height } = this.previewData.image;
  177. const isTransparent = this.previewData.transparent;
  178. const bgColor = isTransparent ? this.previewData.background : 0xff000000 | this.previewData.background;
  179. let l = width, r = 0, t = height, b = 0;
  180. for (let j = 0; j < height; j++) {
  181. const jj = j * width;
  182. for (let i = 0; i < width; i++) {
  183. const o = 4 * (jj + i);
  184. if (isTransparent) {
  185. if (data[o + 3] === 0) continue;
  186. } else {
  187. const c = (data[o] << 16) | (data[o + 1] << 8) | (data[o + 2]) | (data[o + 3] << 24);
  188. if (c === bgColor) continue;
  189. }
  190. if (i < l) l = i;
  191. if (i > r) r = i;
  192. if (j < t) t = j;
  193. if (j > b) b = j;
  194. }
  195. }
  196. if (l > r) {
  197. const x = l;
  198. l = r;
  199. r = x;
  200. }
  201. if (t > b) {
  202. const x = t;
  203. t = b;
  204. b = x;
  205. }
  206. const tw = r - l + 1, th = b - t + 1;
  207. l -= relativePadding * tw;
  208. r += relativePadding * tw;
  209. t -= relativePadding * th;
  210. b += relativePadding * th;
  211. const crop: Viewport = {
  212. x: Math.max(0, l / width),
  213. y: Math.max(0, t / height),
  214. width: Math.min(1, (r - l + 1) / width),
  215. height: Math.min(1, (b - t + 1) / height)
  216. };
  217. this.behaviors.relativeCrop.next(crop);
  218. }
  219. getPreview(maxDim = 320) {
  220. const { width, height } = this.getSize();
  221. if (width <= 0 || height <= 0) return;
  222. const f = width / height;
  223. let w = 0, h = 0;
  224. if (f > 1) {
  225. w = maxDim;
  226. h = Math.round(maxDim / f);
  227. } else {
  228. h = maxDim;
  229. w = Math.round(maxDim * f);
  230. }
  231. const canvasProps = this.plugin.canvas3d!.props;
  232. this.previewPass.setProps({
  233. cameraHelper: { axes: this.values.axes },
  234. transparentBackground: this.values.transparent,
  235. // TODO: optimize because this creates a copy of a large object!
  236. postprocessing: canvasProps.postprocessing,
  237. marking: canvasProps.marking
  238. });
  239. const imageData = this.previewPass.getImageData(w, h);
  240. const canvas = this.previewCanvas;
  241. canvas.width = imageData.width;
  242. canvas.height = imageData.height;
  243. this.previewData.image = imageData;
  244. this.previewData.background = canvasProps.renderer.backgroundColor;
  245. this.previewData.transparent = this.values.transparent;
  246. const canvasCtx = canvas.getContext('2d');
  247. if (!canvasCtx) throw new Error('Could not create canvas 2d context');
  248. canvasCtx.putImageData(imageData, 0, 0);
  249. if (this.cropParams.auto) this.autocrop();
  250. this.events.previewed.next(void 0);
  251. return { canvas, width: w, height: h };
  252. }
  253. getSizeAndViewport() {
  254. const { width, height } = this.getSize();
  255. const crop = this.relativeCrop;
  256. const viewport: Viewport = {
  257. x: Math.floor(crop.x * width),
  258. y: Math.floor(crop.y * height),
  259. width: Math.ceil(crop.width * width),
  260. height: Math.ceil(crop.height * height)
  261. };
  262. if (viewport.width + viewport.x > width) viewport.width = width - viewport.x;
  263. if (viewport.height + viewport.y > height) viewport.height = height - viewport.y;
  264. return { width, height, viewport };
  265. }
  266. private async draw(ctx: RuntimeContext) {
  267. const { width, height, viewport } = this.getSizeAndViewport();
  268. if (width <= 0 || height <= 0) return;
  269. await ctx.update('Rendering image...');
  270. const imageData = this.imagePass.getImageData(width, height, viewport);
  271. await ctx.update('Encoding image...');
  272. const canvas = this.canvas;
  273. canvas.width = imageData.width;
  274. canvas.height = imageData.height;
  275. const canvasCtx = canvas.getContext('2d');
  276. if (!canvasCtx) throw new Error('Could not create canvas 2d context');
  277. canvasCtx.putImageData(imageData, 0, 0);
  278. return;
  279. }
  280. private copyToClipboardTask() {
  281. const cb = navigator.clipboard as any;
  282. if (!cb?.write) {
  283. this.plugin.log.error('clipboard.write not supported!');
  284. return;
  285. }
  286. return Task.create('Copy Image', async ctx => {
  287. await this.draw(ctx);
  288. await ctx.update('Converting image...');
  289. const blob = await canvasToBlob(this.canvas, 'png');
  290. const item = new ClipboardItem({ 'image/png': blob });
  291. await cb.write([item]);
  292. this.plugin.log.message('Image copied to clipboard.');
  293. });
  294. }
  295. getImageDataUri() {
  296. return this.plugin.runTask(Task.create('Generate Image', async ctx => {
  297. await this.draw(ctx);
  298. await ctx.update('Converting image...');
  299. return this.canvas.toDataURL('png');
  300. }));
  301. }
  302. copyToClipboard() {
  303. const task = this.copyToClipboardTask();
  304. if (!task) return;
  305. return this.plugin.runTask(task);
  306. }
  307. private downloadTask(filename?: string) {
  308. return Task.create('Download Image', async ctx => {
  309. await this.draw(ctx);
  310. await ctx.update('Downloading image...');
  311. const blob = await canvasToBlob(this.canvas, 'png');
  312. download(blob, filename ?? this.getFilename());
  313. });
  314. }
  315. download(filename?: string) {
  316. this.plugin.runTask(this.downloadTask(filename));
  317. }
  318. constructor(private plugin: PluginContext) {
  319. super();
  320. }
  321. }
  322. declare const ClipboardItem: any;