Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions app/scripts/nmr-cli/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ RUN apt-get update && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*

# Downgrade to Node 22 for nmr-processing compatibility
RUN npm install -g n && n 22 && hash -r

COPY package.json ./

RUN npm install
Expand All @@ -40,9 +43,11 @@ COPY . ./

RUN npm run build

# Install nmr-cli as a global package
RUN npm install -g .

# Install the nmr-cli as a global package
# Usage examples (run after `docker run -it nmr-cli bash`):
# nmr-cli parse-spectra -u https://s3.uni-jena.de/nmrxiv/production/archive/1368b8fd-3af7-48f7-8b6d-13de9c1ecf8b/492-62-6.zip -s
# For example, nmr-cli parse-spectra -u https://s3.uni-jena.de/nmrxiv/production/archive/d5ec45db-d984-46e9-bb72-c43fd4a463fa/Sali_Eth.zip -s
# For example, nmr-cli parse-spectra -u https://cheminfo.github.io/bruker-data-test/data/zipped/aspirin-1h.zip
# nmr-cli predict -n "1H" --id 1 --type "nmr;1H;1d" --shifts "1" --solvent "Dimethylsulphoxide-D6 (DMSO-D6, C2D6SO)" -m $"\n Ketcher 6122516162D 1 1.00000 0.00000 0\n\n 16 17 0 0 0 0 0 0 0 0999 V2000\n 1.1954 -4.6484 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 2.9258 -4.6479 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 2.0622 -4.1483 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 2.9258 -5.6488 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 1.1954 -5.6533 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 2.0644 -6.1483 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 3.7902 -4.1495 0.0000 N 0 0 0 0 0 0 0 0 0 0 0 0\n 4.6574 -4.6498 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 3.7964 -6.1512 0.0000 N 0 0 0 0 0 0 0 0 0 0 0 0\n 4.6596 -5.6458 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 5.5228 -4.1488 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 5.5277 -6.1421 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 6.3895 -4.6477 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 5.5216 -3.1488 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 7.2548 -4.1466 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 8.1215 -4.6455 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 3 1 2 0 0 0 0\n 1 5 1 0 0 0 0\n 5 6 2 0 0 0 0\n 6 4 1 0 0 0 0\n 4 2 1 0 0 0 0\n 2 3 1 0 0 0 0\n 4 9 1 0 0 0 0\n 9 10 2 0 0 0 0\n 10 8 1 0 0 0 0\n 8 7 2 0 0 0 0\n 7 2 1 0 0 0 0\n 8 11 1 0 0 0 0\n 10 12 1 0 0 0 0\n 11 13 1 0 0 0 0\n 11 14 2 0 0 0 0\n 13 15 1 0 0 0 0\n 15 16 1 0 0 0 0\nM END"
RUN npm install . -g
8 changes: 4 additions & 4 deletions app/scripts/nmr-cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion app/scripts/nmr-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
},
"devDependencies": {
"@types/lodash.merge": "^4.6.9",
"@types/node": "^26.2.0",
"@types/node": "^26.4.0",
"@types/yargs": "^17.0.35",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
Expand Down
163 changes: 163 additions & 0 deletions app/scripts/nmr-cli/src/parse/browser-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import playwright from 'playwright';
import type { FifoLogger } from 'fifo-logger';
import type { SpectrumWorker } from './spectrum-worker';
import { Spectrum } from '@zakodium/nmr-types';
import { Snapshot } from './spectrum-snapshot';
import { toMessage } from './utility/toMessage';

// How long to wait for the NMRium "Loading" indicator to appear/disappear
// before giving up on a single spectrum's snapshot. Prevents one stuck page
// from hanging the entire pipeline.
export const SNAPSHOT_LOADING_TIMEOUT_MS = 30_000;

// A single flaky failure (a slow spectrum tipping over the loading timeout,
// a one-off page hiccup) shouldn't permanently cost a spectrum its snapshot,
// so each capture gets one retry on a fresh page before giving up.
export const SNAPSHOT_MAX_ATTEMPTS = 2;

// Owns the single shared Firefox process. Multiple lanes each get their own
// context/page from it. `reset` is guarded so that if several lanes hit a
// dead browser at once, only the first one actually relaunches — the rest
// just see `current` has already changed and pick up the fresh instance.
export class BrowserManager {
private current: Promise<playwright.Browser> | null = null;

async get(): Promise<playwright.Browser> {
if (!this.current) {
this.current = playwright.firefox.launch();
}
return this.current;
}

async reset(stale: playwright.Browser): Promise<void> {
if (this.current && (await this.current) === stale) {
const toClose = this.current;
this.current = null;
await (await toClose).close().catch(() => { });
}
}

async closeAll(): Promise<void> {
if (this.current) {
const toClose = this.current;
this.current = null;
await (await toClose).close().catch(() => { });
}
}
}

// One snapshot lane = one browser tab, reused across every spectrum it's
// assigned. Launching the browser/context happens once per lane; every
// spectrum after the first gets a `page.reload()` (not a new context or
// browser) before its `nmr-wrapper:load` message, so each snapshot starts
// from a genuinely empty NMRium instance instead of relying on `load`
// merging vs. replacing the previous spectrum's state. A reload of an
// already-booted SPA is far cheaper than relaunching the browser/context,
// so this keeps the speed win while removing the state-leak risk.
export class SnapshotLane {
private context: playwright.BrowserContext | null = null;
private page: playwright.Page | null = null;
private hasLoadedSpectrum = false;

constructor(private manager: BrowserManager, private url: string) { }

private async ensurePage(): Promise<playwright.Page> {
if (this.page && !this.page.isClosed()) {
if (this.hasLoadedSpectrum) {
await this.page.reload();
await this.page.locator('text=Loading').waitFor({ state: 'hidden', timeout: SNAPSHOT_LOADING_TIMEOUT_MS });
}
return this.page;
}
const browser = await this.manager.get();
this.context = await browser.newContext(playwright.devices['Desktop Chrome HiDPI']);
this.page = await this.context.newPage();
await this.page.goto(this.url);
await this.page.locator('text=Loading').waitFor({ state: 'hidden', timeout: SNAPSHOT_LOADING_TIMEOUT_MS });
this.hasLoadedSpectrum = false;
return this.page;
}

// Discards this lane's page/context so the next attempt (or the next
// spectrum, if we're giving up) starts from a clean page instead of
// whatever broken state caused the failure. Only escalates to a full
// browser relaunch if the browser process itself is gone.
private async recover(): Promise<void> {
await this.context?.close().catch(() => { });
this.context = null;
this.page = null;
this.hasLoadedSpectrum = false;

const browser = await this.manager.get();
if (!browser.isConnected()) {
await this.manager.reset(browser);
}
}

private async attemptCapture(
spectrum: Spectrum,
version: unknown,
spectrumWorker: SpectrumWorker
): Promise<string> {
const page = await this.ensurePage();

const stringObject = await spectrumWorker.run('serialize', spectrum, version);

// Passed as a Playwright function argument rather than spliced into an
// evaluated script string, so a backtick or `${...}` sequence anywhere
// in the spectrum data can't break (or hijack) the script.
await page.evaluate(
({ data }) => {
window.postMessage({ type: 'nmr-wrapper:load', data: { data, type: 'nmrium' } }, '*');
},
{ data: JSON.parse(stringObject) }
);

await page.locator('text=Loading').waitFor({ state: 'hidden', timeout: SNAPSHOT_LOADING_TIMEOUT_MS });

const snapshot = await page.locator('#nmrSVG .container').screenshot();
this.hasLoadedSpectrum = true;
return snapshot.toString('base64');
}

async capture(
spectrum: Spectrum,
id: string,
version: unknown,
spectrumWorker: SpectrumWorker,
logger: FifoLogger
): Promise<Snapshot> {
let lastError: unknown;

for (let attempt = 1; attempt <= SNAPSHOT_MAX_ATTEMPTS; attempt++) {
const start = Date.now();
try {
const image = await this.attemptCapture(spectrum, version, spectrumWorker);
logger.info(
{ id, stage: 'snapshot', attempt, durationMs: Date.now() - start },
`Captured snapshot for spectrum: ${id}`
);
return { id, image };
} catch (e) {
lastError = e;
logger.error(
{ id, stage: 'snapshot', attempt, durationMs: Date.now() - start, details: toMessage(e) },
`Snapshot attempt ${attempt}/${SNAPSHOT_MAX_ATTEMPTS} failed for spectrum: ${id}`
);
await this.recover();
}
}

logger.error(
{ id, stage: 'snapshot', details: toMessage(lastError) },
`Giving up on snapshot for spectrum: ${id} after ${SNAPSHOT_MAX_ATTEMPTS} attempts`
);
return { id, image: null };
}

async dispose(): Promise<void> {
await this.context?.close().catch(() => { });
this.context = null;
this.page = null;
}
}
Loading
Loading