molstar
Version:
A comprehensive macromolecular library.
155 lines (154 loc) • 7.55 kB
JavaScript
/**
* Copyright (c) 2023-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Adam Midlik <midlik@gmail.com>
*
* Command-line application for rendering images from MolViewSpec files
* From Molstar NPM package:
* npm install molstar canvas gl jpeg-js pngjs
* npx mvs-render -i examples/mvs/1cbs.mvsj -o ../outputs/1cbs.png --size 800x600 --molj
* From Molstar source code:
* npm install
* npm install --no-save canvas gl jpeg-js pngjs // these packages are not listed in Mol* dependencies for performance reasons
* npm run build
* node lib/commonjs/cli/mvs/mvs-render -i examples/mvs/1cbs.mvsj -o ../outputs/1cbs.png --size 800x600 --molj
*/
import { ArgumentParser } from 'argparse';
import fs from 'fs';
import gl from 'gl';
import jpegjs from 'jpeg-js';
import path from 'path';
import pngjs from 'pngjs';
import { Canvas3DParams } from '../../mol-canvas3d/canvas3d';
import { setCanvasModule } from '../../mol-geo/geometry/text/font-atlas';
import { HeadlessPluginContext } from '../../mol-plugin/headless-plugin-context';
import { DefaultPluginSpec, PluginSpec } from '../../mol-plugin/spec';
import { defaultCanvas3DParams } from '../../mol-plugin/util/headless-screenshot';
import { Task } from '../../mol-task';
import { setFSModule } from '../../mol-util/data-source';
import { onelinerJsonString } from '../../mol-util/json';
import { ParamDefinition as PD } from '../../mol-util/param-definition';
// MolViewSpec must be imported after HeadlessPluginContext
import { Mp4Export } from '../../extensions/mp4-export';
import { MolViewSpec } from '../../extensions/mvs/behavior';
import { loadMVSX } from '../../extensions/mvs/components/formats';
import { loadMVS } from '../../extensions/mvs/load';
import { MVSData } from '../../extensions/mvs/mvs-data';
setFSModule(fs);
setCanvasModule(require('canvas'));
const DEFAULT_SIZE = '800x800';
/** Return parsed command line arguments for `main` */
function parseArguments() {
const parser = new ArgumentParser({ description: 'Command-line application for rendering images from MolViewSpec files' });
parser.add_argument('-i', '--input', { required: true, nargs: '+', help: 'Input file(s) in .mvsj or .mvsx format. File format is inferred from the file extension.' });
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)' });
parser.add_argument('-s', '--size', { help: `Output image resolution, {width}x{height}. Default: ${DEFAULT_SIZE}.`, default: DEFAULT_SIZE });
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)` });
parser.add_argument('-n', '--no-extensions', { action: 'store_true', help: `Do not apply builtin MVS-loading extensions (not a part of standard MVS specification)` });
const args = parser.parse_args();
try {
const parts = args.size.split('x');
if (parts.length !== 2)
throw new Error('Must contain two x-separated parts');
args.size = { width: parseIntStrict(parts[0]), height: parseIntStrict(parts[1]) };
}
catch (_a) {
parser.error(`argument: --size: invalid image size string: '${args.size}' (must be two x-separated integers (width and height), e.g. '400x300')`);
}
if (args.input.length !== args.output.length) {
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' : ''})`);
}
return { ...args };
}
/** Main workflow for rendering images from MolViewSpec files */
async function main(args) {
const plugin = await createHeadlessPlugin(args);
for (let i = 0; i < args.input.length; i++) {
const input = args.input[i];
const output = args.output[i];
console.log(`Processing ${input} -> ${output}`);
let mvsData;
let sourceUrl;
if (input.toLowerCase().endsWith('.mvsj')) {
const data = fs.readFileSync(input, { encoding: 'utf8' });
mvsData = MVSData.fromMVSJ(data);
sourceUrl = `file://${path.resolve(input)}`;
}
else if (input.toLowerCase().endsWith('.mvsx')) {
const data = fs.readFileSync(input);
const mvsx = await plugin.runTask(Task.create('Load MVSX', async (ctx) => loadMVSX(plugin, ctx, data)));
mvsData = mvsx.mvsData;
sourceUrl = mvsx.sourceUrl;
}
else {
throw new Error(`Input file name must end with .mvsj or .mvsx: ${input}`);
}
await loadMVS(plugin, mvsData, { sanityChecks: true, sourceUrl: sourceUrl, extensions: args.no_extensions ? [] : undefined });
fs.mkdirSync(path.dirname(output), { recursive: true });
if (args.molj) {
await plugin.saveStateSnapshot(withExtension(output, '.molj'));
}
if (output.toLowerCase().endsWith('.mp4')) {
await plugin.saveAnimation(output);
}
else {
await plugin.saveImage(output);
}
checkState(plugin);
}
await plugin.clear();
plugin.dispose();
}
/** Return a new and initiatized HeadlessPlugin */
async function createHeadlessPlugin(args) {
const externalModules = { gl, pngjs, 'jpeg-js': jpegjs };
const spec = DefaultPluginSpec();
spec.behaviors.push(PluginSpec.Behavior(MolViewSpec));
spec.behaviors.push(PluginSpec.Behavior(Mp4Export));
const headlessCanvasOptions = defaultCanvas3DParams();
const canvasOptions = {
...PD.getDefaultValues(Canvas3DParams),
cameraResetDurationMs: headlessCanvasOptions.cameraResetDurationMs,
postprocessing: headlessCanvasOptions.postprocessing,
};
const plugin = new HeadlessPluginContext(externalModules, spec, args.size, { canvas: canvasOptions });
try {
await plugin.init();
}
catch (error) {
plugin.dispose();
throw error;
}
return plugin;
}
/** Parse integer, fail early. */
function parseIntStrict(str) {
if (str === '')
throw new Error('Is empty string');
const result = Number(str);
if (isNaN(result))
throw new Error('Is NaN');
if (Math.floor(result) !== result)
throw new Error('Is not integer');
return result;
}
/** Replace the file extension in `filename` by `extension`. If `filename` has no extension, add it. */
function withExtension(filename, extension) {
const oldExtension = path.extname(filename);
return filename.slice(0, -oldExtension.length) + extension;
}
/** Check Mol* state, print and throw error if any cell is not OK. */
function checkState(plugin) {
const cells = Array.from(plugin.state.data.cells.values());
const badCell = cells.find(cell => cell.status !== 'ok');
if (badCell) {
console.error(`Building Mol* state failed`);
console.error(` Transformer: ${badCell.transform.transformer.id}`);
console.error(` Params: ${onelinerJsonString(badCell.transform.params)}`);
console.error(` Error: ${badCell.errorText}`);
console.error(``);
throw new Error(`Building Mol* state failed: ${badCell.errorText}`);
}
}
main(parseArguments());