mvs-render.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. /**
  2. * Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  3. *
  4. * @author Adam Midlik <midlik@gmail.com>
  5. *
  6. * Command-line application for rendering images from MolViewSpec files
  7. * Build: npm install --no-save gl jpeg-js pngjs // these packages are not listed in Mol* dependencies for performance reasons
  8. * npm run build
  9. * Run: node lib/commonjs/examples/mvs/mvs-render -i examples/mvs/1cbs.mvsj -o ../outputs/1cbs.png --size 800x600 --molj
  10. */
  11. import { ArgumentParser } from 'argparse';
  12. import fs from 'fs';
  13. import gl from 'gl';
  14. import jpegjs from 'jpeg-js';
  15. import path from 'path';
  16. import pngjs from 'pngjs';
  17. import { Canvas3DParams } from '../../mol-canvas3d/canvas3d';
  18. import { PluginContext } from '../../mol-plugin/context';
  19. import { HeadlessPluginContext } from '../../mol-plugin/headless-plugin-context';
  20. import { DefaultPluginSpec, PluginSpec } from '../../mol-plugin/spec';
  21. import { ExternalModules, defaultCanvas3DParams } from '../../mol-plugin/util/headless-screenshot';
  22. import { setFSModule } from '../../mol-util/data-source';
  23. import { onelinerJsonString } from '../../mol-util/json';
  24. import { ParamDefinition as PD } from '../../mol-util/param-definition';
  25. // MolViewSpec must be imported after HeadlessPluginContext
  26. import { MolViewSpec } from '../../extensions/mvs/behavior';
  27. import { loadMVS } from '../../extensions/mvs/load';
  28. import { MVSData } from '../../extensions/mvs/mvs-data';
  29. import { dfs } from '../../extensions/mvs/tree/generic/tree-utils';
  30. setFSModule(fs);
  31. const DEFAULT_SIZE = '800x800';
  32. /** Command line argument values for `main` */
  33. interface Args {
  34. input: string[],
  35. output: string[],
  36. size: { width: number, height: number },
  37. molj: boolean,
  38. }
  39. /** Return parsed command line arguments for `main` */
  40. function parseArguments(): Args {
  41. const parser = new ArgumentParser({ description: 'Command-line application for rendering images from MolViewSpec files' });
  42. parser.add_argument('-i', '--input', { required: true, nargs: '+', help: 'Input file(s) in .mvsj format' });
  43. parser.add_argument('-o', '--output', { required: true, nargs: '+', help: 'File path(s) for output files (one output path for each input file). Output format is inferred from the file extension (.png or .jpg)' });
  44. parser.add_argument('-s', '--size', { help: `Output image resolution, {width}x{height}. Default: ${DEFAULT_SIZE}.`, default: DEFAULT_SIZE });
  45. parser.add_argument('-m', '--molj', { action: 'store_true', help: `Save Mol* state (.molj) in addition to rendered images (use the same output file paths but with .molj extension)` });
  46. const args = parser.parse_args();
  47. try {
  48. const parts = args.size.split('x');
  49. if (parts.length !== 2) throw new Error('Must contain two x-separated parts');
  50. args.size = { width: parseIntStrict(parts[0]), height: parseIntStrict(parts[1]) };
  51. } catch {
  52. parser.error(`argument: --size: invalid image size string: '${args.size}' (must be two x-separated integers (width and height), e.g. '400x300')`);
  53. }
  54. if (args.input.length !== args.output.length) {
  55. parser.error(`argument: --output: must specify the same number of input and output file paths (specified ${args.input.length} input path${args.input.length !== 1 ? 's' : ''} but ${args.output.length} output path${args.output.length !== 1 ? 's' : ''})`);
  56. }
  57. return { ...args };
  58. }
  59. /** Main workflow for rendering images from MolViewSpec files */
  60. async function main(args: Args): Promise<void> {
  61. const plugin = await createHeadlessPlugin(args);
  62. for (let i = 0; i < args.input.length; i++) {
  63. const input = args.input[i];
  64. const output = args.output[i];
  65. console.log(`Processing ${input} -> ${output}`);
  66. const data = fs.readFileSync(input, { encoding: 'utf8' });
  67. const mvsData = MVSData.fromMVSJ(data);
  68. removeLabelNodes(mvsData);
  69. await loadMVS(plugin, mvsData, { sanityChecks: true, replaceExisting: true });
  70. fs.mkdirSync(path.dirname(output), { recursive: true });
  71. if (args.molj) {
  72. await plugin.saveStateSnapshot(withExtension(output, '.molj'));
  73. }
  74. await plugin.saveImage(output);
  75. checkState(plugin);
  76. }
  77. await plugin.clear();
  78. plugin.dispose();
  79. }
  80. /** Return a new and initiatized HeadlessPlugin */
  81. async function createHeadlessPlugin(args: Pick<Args, 'size'>): Promise<HeadlessPluginContext> {
  82. const externalModules: ExternalModules = { gl, pngjs, 'jpeg-js': jpegjs };
  83. const spec = DefaultPluginSpec();
  84. spec.behaviors.push(PluginSpec.Behavior(MolViewSpec));
  85. const headlessCanvasOptions = defaultCanvas3DParams();
  86. const canvasOptions = {
  87. ...PD.getDefaultValues(Canvas3DParams),
  88. cameraResetDurationMs: headlessCanvasOptions.cameraResetDurationMs,
  89. postprocessing: headlessCanvasOptions.postprocessing,
  90. };
  91. const plugin = new HeadlessPluginContext(externalModules, spec, args.size, { canvas: canvasOptions });
  92. try {
  93. await plugin.init();
  94. } catch (error) {
  95. plugin.dispose();
  96. throw error;
  97. }
  98. return plugin;
  99. }
  100. /** Parse integer, fail early. */
  101. function parseIntStrict(str: string): number {
  102. if (str === '') throw new Error('Is empty string');
  103. const result = Number(str);
  104. if (isNaN(result)) throw new Error('Is NaN');
  105. if (Math.floor(result) !== result) throw new Error('Is not integer');
  106. return result;
  107. }
  108. /** Replace the file extension in `filename` by `extension`. If `filename` has no extension, add it. */
  109. function withExtension(filename: string, extension: string): string {
  110. const oldExtension = path.extname(filename);
  111. return filename.slice(0, -oldExtension.length) + extension;
  112. }
  113. /** Remove any label* nodes from the MVS tree (in-place). Print warning if removed at least one node. */
  114. function removeLabelNodes(mvsData: MVSData): void {
  115. let removedSomething = false;
  116. dfs(mvsData.root, node => {
  117. const nChildren = node.children?.length ?? 0;
  118. node.children = node.children?.filter(c => !c.kind.startsWith('label'));
  119. if ((node.children?.length ?? 0) !== nChildren) {
  120. removedSomething = true;
  121. }
  122. });
  123. if (removedSomething) {
  124. // trying to render labels would fail because `document` is not available in Nodejs
  125. console.error('Rendering labels is not yet supported in mvs-render. Skipping any label* nodes.');
  126. }
  127. }
  128. /** Check Mol* state, print and throw error if any cell is not OK. */
  129. function checkState(plugin: PluginContext): void {
  130. const cells = Array.from(plugin.state.data.cells.values());
  131. const badCell = cells.find(cell => cell.status !== 'ok');
  132. if (badCell) {
  133. console.error(`Building Mol* state failed`);
  134. console.error(` Transformer: ${badCell.transform.transformer.id}`);
  135. console.error(` Params: ${onelinerJsonString(badCell.transform.params)}`);
  136. console.error(` Error: ${badCell.errorText}`);
  137. console.error(``);
  138. throw new Error(`Building Mol* state failed: ${badCell.errorText}`);
  139. }
  140. }
  141. main(parseArguments());