From 8e965afac7c95c4aaf8432e65257df87a4e0633d Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Wed, 6 May 2026 10:04:40 -0400 Subject: [PATCH 1/9] feat: Generate all thumbnails --- .../create-dicomweb/bin/createdicomweb.mjs | 19 +- .../lib/commands/thumbnailMain.mjs | 584 ++++++++---------- packages/create-dicomweb/lib/index.mjs | 1 + .../lib/instance/DicomWebReader.mjs | 14 + .../lib/instance/DicomWebStream.mjs | 6 +- .../lib/instance/FileDicomWebReader.mjs | 22 + .../lib/instance/HttpDicomWebReader.mjs | 117 ++++ .../lib/instance/dicomDirLocation.mjs | 5 + .../static-wado-deploy/lib/DeployGroup.mjs | 6 +- .../static-wado-deploy/lib/deployConfig.mjs | 5 + .../static-wado-deploy/lib/studiesMain.mjs | 15 +- 11 files changed, 445 insertions(+), 349 deletions(-) create mode 100644 packages/create-dicomweb/lib/instance/HttpDicomWebReader.mjs diff --git a/packages/create-dicomweb/bin/createdicomweb.mjs b/packages/create-dicomweb/bin/createdicomweb.mjs index 30fd6df1..4b4b9fc6 100755 --- a/packages/create-dicomweb/bin/createdicomweb.mjs +++ b/packages/create-dicomweb/bin/createdicomweb.mjs @@ -235,18 +235,28 @@ function parseFrameNumbers(frameNumbersStr) { program .command('thumbnail') .description('Generate thumbnail(s) for DICOM instance(s)') - .argument('', 'Study Instance UID (required)') + .argument('[studySelector]', 'Study selector: StudyInstanceUID, query pattern (e.g. PatientID=25), or true for all studies') .option('--dicomdir ', 'Base directory path where DICOMweb structure is located', '~/dicomweb') + .option('--output-dicomdir ', 'Output directory for generated thumbnails (required when --dicomdir is http/https)') + .option('--study-selector ', 'Study selector override: StudyInstanceUID, query pattern, or true') .option('--series-uid ', 'Specific Series Instance UID to process (if not provided, uses first series from study query)') .option('--sop-uid ', 'Specific SOP Instance UID to process (if not provided, uses first instance from series)') .option('--frame-numbers ', 'Frame numbers to generate thumbnails for (comma-separated, supports ranges, e.g., "1-3,17")', '1') .option('--series-thumbnail', 'Generate thumbnails for series (middle SOP instance, middle frame for multiframe)') - .action(async (studyUID, options) => { + .option( + '--all-thumbnails', + 'Generate thumbnails for every SOP instance plus series and study level (uses middle frame for multiframe)' + ) + .action(async (studySelectorArg, options) => { updateVerboseLog(); const thumbnailOptions = {}; if (options.dicomdir) { thumbnailOptions.dicomdir = handleHomeRelative(options.dicomdir); } + if (options.outputDicomdir) { + thumbnailOptions.outputDicomdir = handleHomeRelative(options.outputDicomdir); + } + thumbnailOptions.studySelector = studySelectorArg || options.studySelector || 'true'; if (options.seriesUid) { thumbnailOptions.seriesUid = options.seriesUid; } @@ -256,6 +266,9 @@ program if (options.seriesThumbnail) { thumbnailOptions.seriesThumbnail = true; } + if (options.allThumbnails) { + thumbnailOptions.allThumbnails = true; + } // Parse frame numbers try { @@ -266,7 +279,7 @@ program process.exit(1); } - await thumbnailMain(studyUID, thumbnailOptions); + await thumbnailMain(thumbnailOptions.studySelector, thumbnailOptions); }); program diff --git a/packages/create-dicomweb/lib/commands/thumbnailMain.mjs b/packages/create-dicomweb/lib/commands/thumbnailMain.mjs index f3f13d48..42a9ef47 100644 --- a/packages/create-dicomweb/lib/commands/thumbnailMain.mjs +++ b/packages/create-dicomweb/lib/commands/thumbnailMain.mjs @@ -1,184 +1,34 @@ -import fs from 'fs'; -import path from 'path'; -import { FileDicomWebReader } from '../instance/FileDicomWebReader.mjs'; +import { DicomWebStream } from '../instance/DicomWebStream.mjs'; import { FileDicomWebWriter } from '../instance/FileDicomWebWriter.mjs'; -import { Tags } from '@radicalimaging/static-wado-util'; -import { readBulkData } from '@radicalimaging/static-wado-util'; +import { Tags, qidoFilter } from '@radicalimaging/static-wado-util'; import StaticWado from '@radicalimaging/static-wado-creator'; const { getValue } = Tags; -/** - * Generates thumbnails for series (middle SOP instance, middle frame for multiframe) - * @param {string} studyUID - Study Instance UID - * @param {Object} options - Options object - * @param {string} options.dicomdir - Base directory path where DICOMweb structure is located - * @param {string} [options.seriesUid] - Specific Series Instance UID to process (if not provided, processes all series) - * @param {Object} options.reader - FileDicomWebReader instance - */ -async function generateSeriesThumbnails(studyUID, options = {}) { - const { dicomdir, seriesUid, reader } = options; - - // Step 1: Get list of series to process - const seriesIndex = await reader.readJsonFile( - reader.getStudyPath(studyUID, { path: 'series' }), - 'index.json' - ); - - if (!seriesIndex || !Array.isArray(seriesIndex) || seriesIndex.length === 0) { - throw new Error(`No series found for study ${studyUID}`); - } - - // Filter to specific series if provided, otherwise process all - let seriesToProcess = seriesIndex; - if (seriesUid) { - seriesToProcess = seriesIndex.filter( - series => getValue(series, Tags.SeriesInstanceUID) === seriesUid - ); - if (seriesToProcess.length === 0) { - throw new Error(`Series ${seriesUid} not found in study ${studyUID}`); - } +function parseSelectorToQuery(selector) { + const params = new URLSearchParams(selector); + const query = {}; + for (const [key, value] of params.entries()) { + query[key] = value; } + return query; +} - console.log(`Generating series thumbnails for ${seriesToProcess.length} series...`); - - // Step 2: Process each series - const seriesPromises = seriesToProcess.map(async series => { - const targetSeriesUID = getValue(series, Tags.SeriesInstanceUID); - if (!targetSeriesUID) { - console.warn('Could not extract SeriesInstanceUID from series query, skipping'); - return; - } - - console.log(`Processing series ${targetSeriesUID}...`); - - // Step 3: Read series metadata to get all instances - const seriesMetadata = await reader.readJsonFile( - reader.getSeriesPath(studyUID, targetSeriesUID), - 'metadata' - ); - - if (!seriesMetadata || !Array.isArray(seriesMetadata) || seriesMetadata.length === 0) { - console.warn(`No series metadata found for series ${targetSeriesUID}, skipping`); - return; - } - - // Step 4: Choose middle SOP instance - const middleInstanceIndex = Math.floor(seriesMetadata.length / 2); - const targetInstanceMetadata = seriesMetadata[middleInstanceIndex]; - const targetInstanceUID = getValue(targetInstanceMetadata, Tags.SOPInstanceUID); - - if (!targetInstanceUID) { - console.warn( - `Could not extract SOPInstanceUID from instance metadata for series ${targetSeriesUID}, skipping` - ); - return; - } - - console.log( - `Using middle instance ${targetInstanceUID} (${middleInstanceIndex + 1} of ${seriesMetadata.length}) for series ${targetSeriesUID}` - ); - - // Step 5: Determine middle frame for multiframe - const numberOfFrames = getValue(targetInstanceMetadata, Tags.NumberOfFrames) || 1; - const middleFrame = Math.ceil(numberOfFrames / 2); - - console.log( - `Using middle frame ${middleFrame} of ${numberOfFrames} for instance ${targetInstanceUID}` - ); - - // Step 6: Read pixel data first to get definitive transfer syntax - try { - const pixelData = await readPixelData( - dicomdir, - studyUID, - targetSeriesUID, - targetInstanceMetadata, - middleFrame - ); - - const frameTransferSyntaxUid = pixelData.transferSyntaxUid; - if (!frameTransferSyntaxUid) { - console.warn( - `Could not determine transfer syntax UID for instance ${targetInstanceUID} from pixel data, skipping` - ); - return; - } - - // Step 7: Create writer only after we have definitive transfer syntax - const writer = new FileDicomWebWriter( - { - studyInstanceUid: studyUID, - seriesInstanceUid: targetSeriesUID, - sopInstanceUid: targetInstanceUID, - transferSyntaxUid: frameTransferSyntaxUid, - }, - { baseDir: dicomdir } - ); - - // Convert ArrayBuffer to Uint8Array if needed - let imageFrame = pixelData.binaryData; - if (imageFrame instanceof ArrayBuffer) { - imageFrame = new Uint8Array(imageFrame); - } - - // Step 9: Generate thumbnail and write at instance level - const writeThumbnailCallback = async (buffer, canvasDest) => { - if (!buffer) { - console.warn( - `No thumbnail buffer generated for series ${targetSeriesUID}, instance ${targetInstanceUID}` - ); - return; - } - - console.log(`Writing series thumbnail for instance ${targetInstanceUID}...`); - - // Write thumbnail at instance level: .../instances//thumbnail - const thumbnailStreamInfo = await writer.openSeriesStream('thumbnail', { gzip: false }); - thumbnailStreamInfo.stream.write(Buffer.from(buffer)); - await writer.closeStream(thumbnailStreamInfo.streamKey); - - console.log(`Series thumbnail written successfully for instance ${targetInstanceUID}`); - }; - - // Generate thumbnail using StaticWado's internal method - await StaticWado.internalGenerateImage( - imageFrame, - null, // dataset - not needed when using metadata - targetInstanceMetadata, - frameTransferSyntaxUid, - writeThumbnailCallback - ); - - console.log(`Series thumbnail generation completed for series ${targetSeriesUID}`); - } catch (error) { - console.error( - `Error generating series thumbnail for series ${targetSeriesUID}: ${error.message}` - ); - throw error; - } - }); - - // Wait for all series thumbnails to be generated - try { - await Promise.all(seriesPromises); - console.log(`Series thumbnail generation completed for study ${studyUID}`); - } catch (error) { - console.error(`Error generating series thumbnails: ${error.message}`); - throw error; - } +function asStudyList(studiesValue) { + if (Array.isArray(studiesValue)) return studiesValue; + if (studiesValue && typeof studiesValue === 'object') return [studiesValue]; + return []; } /** * Reads pixel data from instance metadata - * @param {string} baseDir - Base directory for DICOMweb structure * @param {string} studyUID - Study Instance UID * @param {string} seriesUID - Series Instance UID * @param {Object} instanceMetadata - Instance metadata object * @param {number} frameNumber - Frame number (1-based, default: 1) * @returns {Promise} - Object with binaryData, transferSyntaxUid, and contentType */ -async function readPixelData(baseDir, studyUID, seriesUID, instanceMetadata, frameNumber = 1) { +async function readPixelData(reader, studyUID, seriesUID, instanceMetadata, frameNumber = 1) { const pixelDataTag = Tags.PixelData; const pixelData = instanceMetadata[pixelDataTag]; @@ -191,29 +41,13 @@ async function readPixelData(baseDir, studyUID, seriesUID, instanceMetadata, fra throw new Error('No BulkDataURI found in PixelData'); } - const studyDir = path.join(baseDir, `studies/${studyUID}`); - const seriesDir = path.join(studyDir, `series/${seriesUID}`); - - // Resolve bulk data path. SeriesSummary writes series-relative paths: - // - Frames: "instances//frames" (resolve from seriesDir) - // - Bulkdata: "../../bulkdata/..." (resolve from seriesDir) - // Legacy instance-relative "./frames" is resolved from instance dir. - let bulkData; - if (bulkDataURI.indexOf('frames') !== -1) { - const isSeriesRelative = bulkDataURI.startsWith('./instances/'); - if (!isSeriesRelative && !getValue(instanceMetadata, Tags.SOPInstanceUID)) { - throw new Error( - 'No SOPInstanceUID in instance metadata; cannot resolve instance-relative frames path' - ); - } - const frameBaseDir = isSeriesRelative - ? seriesDir - : path.join(seriesDir, 'instances', getValue(instanceMetadata, Tags.SOPInstanceUID)); - const frameBaseName = isSeriesRelative ? bulkDataURI : './frames'; - bulkData = await readBulkData(frameBaseDir, frameBaseName, frameNumber); - } else { - bulkData = await readBulkData(seriesDir, bulkDataURI); - } + const bulkData = await reader.readBulkData( + studyUID, + seriesUID, + bulkDataURI, + frameNumber, + getValue(instanceMetadata, Tags.SOPInstanceUID) + ); if (!bulkData) { throw new Error(`Failed to read bulk data for frame ${frameNumber}`); @@ -229,188 +63,262 @@ async function readPixelData(baseDir, studyUID, seriesUID, instanceMetadata, fra }; } -/** - * Main function for creating thumbnails - * @param {string} studyUID - Study Instance UID - * @param {Object} options - Options object - * @param {string} [options.dicomdir] - Base directory path where DICOMweb structure is located - * @param {string} [options.seriesUid] - Specific Series Instance UID to process (if not provided, uses first series from study query) - * @param {string} [options.instanceUid] - Specific SOP Instance UID to process (if not provided, uses first instance from series) - * @param {number|number[]} [options.frameNumber] - Frame number to use for thumbnail (default: 1) - deprecated, use frameNumbers instead - * @param {number[]} [options.frameNumbers] - Array of frame numbers to generate thumbnails for (default: [1]) - * @param {boolean} [options.seriesThumbnail] - Generate thumbnails for series (middle SOP instance, middle frame for multiframe) - */ -export async function thumbnailMain(studyUID, options = {}) { - const { dicomdir, seriesUid, instanceUid, frameNumber, frameNumbers, seriesThumbnail } = options; - - // Support both frameNumber (single) and frameNumbers (array) for backward compatibility - const framesToProcess = frameNumbers || (frameNumber ? [frameNumber] : [1]); - - if (!dicomdir) { - throw new Error('dicomdir option is required'); +async function writeThumbnailForTarget({ + reader, + dicomdir, + outputDicomdir, + studyUID, + seriesUID, + instanceMetadata, + frameNumber, + level, +}) { + const instanceUID = getValue(instanceMetadata, Tags.SOPInstanceUID); + if (!instanceUID) { + throw new Error('Could not extract SOPInstanceUID from instance metadata'); } - if (!studyUID) { - throw new Error('studyUID is required'); + const pixelData = await readPixelData(reader, studyUID, seriesUID, instanceMetadata, frameNumber); + const transferSyntaxUid = pixelData.transferSyntaxUid; + if (!transferSyntaxUid) { + throw new Error(`Could not determine transfer syntax UID for instance ${instanceUID}`); } - const reader = new FileDicomWebReader(dicomdir); + const writer = new FileDicomWebWriter( + { + studyInstanceUid: studyUID, + seriesInstanceUid: seriesUID, + sopInstanceUid: instanceUID, + transferSyntaxUid, + }, + { baseDir: outputDicomdir || dicomdir } + ); - // If seriesThumbnail is enabled, process series thumbnails - if (seriesThumbnail) { - return await generateSeriesThumbnails(studyUID, { dicomdir, seriesUid, reader }); + let imageFrame = pixelData.binaryData; + if (imageFrame instanceof ArrayBuffer) { + imageFrame = new Uint8Array(imageFrame); } - let targetSeriesUID = seriesUid; - - // Step 1: If series UID not provided, read study query to find series - if (!targetSeriesUID) { - console.log(`Reading study query to find series for study ${studyUID}...`); - const studyQuery = await reader.readJsonFile(reader.getStudyPath(studyUID), 'index.json'); - - if (!studyQuery || !Array.isArray(studyQuery) || studyQuery.length === 0) { - throw new Error(`No study query found for study ${studyUID}`); + const writeThumbnailCallback = async buffer => { + if (!buffer) { + console.warn( + `No thumbnail buffer generated for ${level} thumbnail (${studyUID}/${seriesUID}/${instanceUID})` + ); + return; } - // Read series index to get available series - const seriesIndex = await reader.readJsonFile( - reader.getStudyPath(studyUID, { path: 'series' }), - 'index.json' - ); - - if (!seriesIndex || !Array.isArray(seriesIndex) || seriesIndex.length === 0) { - throw new Error(`No series found for study ${studyUID}`); + let thumbnailStreamInfo; + if (level === 'study') { + thumbnailStreamInfo = await writer.openStudyStream('thumbnail', { gzip: false }); + } else if (level === 'series') { + thumbnailStreamInfo = await writer.openSeriesStream('thumbnail', { gzip: false }); + } else { + thumbnailStreamInfo = await writer.openInstanceStream('thumbnail', { gzip: false }); } - // Use the first series - const firstSeries = seriesIndex[0]; - targetSeriesUID = getValue(firstSeries, Tags.SeriesInstanceUID); + thumbnailStreamInfo.stream.write(Buffer.from(buffer)); + await writer.closeStream(thumbnailStreamInfo.streamKey); + }; - if (!targetSeriesUID) { - throw new Error('Could not extract SeriesInstanceUID from series query'); + await StaticWado.internalGenerateImage( + imageFrame, + null, + instanceMetadata, + transferSyntaxUid, + writeThumbnailCallback + ); +} + +async function resolveStudyUIDs(reader, studySelector) { + if (!studySelector || studySelector === 'true') { + if (typeof reader.queryStudies === 'function') { + const studies = await reader.queryStudies('true'); + return asStudyList(studies).map(study => getValue(study, Tags.StudyInstanceUID)).filter(Boolean); } + const studies = await reader.readJsonFile('studies', 'index.json'); + return asStudyList(studies).map(study => getValue(study, Tags.StudyInstanceUID)).filter(Boolean); + } - console.log(`Using first series: ${targetSeriesUID}`); + if (studySelector.includes('=')) { + if (typeof reader.queryStudies === 'function') { + const studies = await reader.queryStudies(studySelector); + return asStudyList(studies).map(study => getValue(study, Tags.StudyInstanceUID)).filter(Boolean); + } + const studies = asStudyList(await reader.readJsonFile('studies', 'index.json')); + const filtered = qidoFilter(studies, parseSelectorToQuery(studySelector)); + return filtered.map(study => getValue(study, Tags.StudyInstanceUID)).filter(Boolean); } - // Step 2: Read series metadata - console.log(`Reading series metadata for series ${targetSeriesUID}...`); - const seriesMetadata = await reader.readJsonFile( - reader.getSeriesPath(studyUID, targetSeriesUID), - 'metadata' - ); + return [studySelector]; +} - if (!seriesMetadata || !Array.isArray(seriesMetadata) || seriesMetadata.length === 0) { - throw new Error(`No series metadata found for series ${targetSeriesUID}`); - } +async function generateForStudy(studyUID, options = {}) { + const { reader, dicomdir, outputDicomdir, seriesUid, instanceUid, frameNumbers, frameNumber, allThumbnails, seriesThumbnail } = + options; + const framesToProcess = frameNumbers || (frameNumber ? [frameNumber] : [1]); - // Step 3: Find instance to use - let targetInstanceMetadata = null; - let targetInstanceUID = instanceUid; + const seriesIndex = await reader.readJsonFile( + reader.getStudyPath(studyUID, { path: 'series' }), + 'index.json' + ); + if (!seriesIndex || !Array.isArray(seriesIndex) || seriesIndex.length === 0) { + throw new Error(`No series found for study ${studyUID}`); + } - if (targetInstanceUID) { - // Find specific instance - targetInstanceMetadata = seriesMetadata.find( - instance => getValue(instance, Tags.SOPInstanceUID) === targetInstanceUID - ); + let seriesToProcess = seriesIndex; + if (seriesUid) { + seriesToProcess = seriesIndex.filter(series => getValue(series, Tags.SeriesInstanceUID) === seriesUid); + if (!seriesToProcess.length) throw new Error(`Series ${seriesUid} not found in study ${studyUID}`); + } - if (!targetInstanceMetadata) { - throw new Error(`Instance ${targetInstanceUID} not found in series metadata`); + if (allThumbnails) { + const seriesMetadataCache = []; + for (const seriesItem of seriesToProcess) { + const targetSeriesUID = getValue(seriesItem, Tags.SeriesInstanceUID); + if (!targetSeriesUID) continue; + const seriesMetadata = await reader.readJsonFile(reader.getSeriesPath(studyUID, targetSeriesUID), 'metadata'); + if (!Array.isArray(seriesMetadata) || !seriesMetadata.length) continue; + seriesMetadataCache.push({ seriesUid: targetSeriesUID, metadata: seriesMetadata }); + for (const metadata of seriesMetadata) { + const numberOfFrames = getValue(metadata, Tags.NumberOfFrames) || 1; + await writeThumbnailForTarget({ + reader, + dicomdir, + outputDicomdir, + studyUID, + seriesUID: targetSeriesUID, + instanceMetadata: metadata, + frameNumber: Math.ceil(numberOfFrames / 2), + level: 'instance', + }); + } + const middle = seriesMetadata[Math.floor(seriesMetadata.length / 2)]; + const middleFrames = getValue(middle, Tags.NumberOfFrames) || 1; + await writeThumbnailForTarget({ + reader, + dicomdir, + outputDicomdir, + studyUID, + seriesUID: targetSeriesUID, + instanceMetadata: middle, + frameNumber: Math.ceil(middleFrames / 2), + level: 'series', + }); } - } else { - // Use first instance - targetInstanceMetadata = seriesMetadata[0]; - targetInstanceUID = getValue(targetInstanceMetadata, Tags.SOPInstanceUID); + if (!seriesMetadataCache.length) return; + const middleSeries = seriesMetadataCache[Math.floor(seriesMetadataCache.length / 2)]; + const middleInstance = middleSeries.metadata[Math.floor(middleSeries.metadata.length / 2)]; + const middleFrames = getValue(middleInstance, Tags.NumberOfFrames) || 1; + await writeThumbnailForTarget({ + reader, + dicomdir, + outputDicomdir, + studyUID, + seriesUID: middleSeries.seriesUid, + instanceMetadata: middleInstance, + frameNumber: Math.ceil(middleFrames / 2), + level: 'study', + }); + return; + } - if (!targetInstanceUID) { - throw new Error('Could not extract SOPInstanceUID from instance metadata'); + if (seriesThumbnail) { + for (const series of seriesToProcess) { + const targetSeriesUID = getValue(series, Tags.SeriesInstanceUID); + if (!targetSeriesUID) continue; + const metadata = await reader.readJsonFile(reader.getSeriesPath(studyUID, targetSeriesUID), 'metadata'); + if (!Array.isArray(metadata) || !metadata.length) continue; + const middle = metadata[Math.floor(metadata.length / 2)]; + const middleFrames = getValue(middle, Tags.NumberOfFrames) || 1; + await writeThumbnailForTarget({ + reader, + dicomdir, + outputDicomdir, + studyUID, + seriesUID: targetSeriesUID, + instanceMetadata: middle, + frameNumber: Math.ceil(middleFrames / 2), + level: 'series', + }); } - - console.log(`Using first instance: ${targetInstanceUID}`); + return; } - // Step 4: Generate thumbnails for each frame; create writer only after first definitive transfer syntax, then new writer when it changes - console.log( - `Generating thumbnails for ${framesToProcess.length} frame(s): ${framesToProcess.join(', ')}...` - ); + const targetSeriesUID = getValue(seriesToProcess[0], Tags.SeriesInstanceUID); + const seriesMetadata = await reader.readJsonFile(reader.getSeriesPath(studyUID, targetSeriesUID), 'metadata'); + if (!Array.isArray(seriesMetadata) || !seriesMetadata.length) { + throw new Error(`No series metadata found for series ${targetSeriesUID}`); + } + let instanceMetadata = seriesMetadata[0]; + if (instanceUid) { + instanceMetadata = seriesMetadata.find(instance => getValue(instance, Tags.SOPInstanceUID) === instanceUid); + if (!instanceMetadata) throw new Error(`Instance ${instanceUid} not found in series metadata`); + } + const targetInstanceUID = getValue(instanceMetadata, Tags.SOPInstanceUID); let writer = null; let lastTransferSyntaxUid = null; - for (const frameNum of framesToProcess) { - try { - console.log(`Processing frame ${frameNum}...`); - - // Read pixel data first to get definitive transfer syntax - const pixelData = await readPixelData( - dicomdir, - studyUID, - targetSeriesUID, - targetInstanceMetadata, - frameNum - ); - - const frameTransferSyntaxUid = pixelData.transferSyntaxUid; - if (!frameTransferSyntaxUid) { - throw new Error('Could not determine transfer syntax UID from pixel data'); - } - - // Create writer on first frame or when transfer syntax changes - if (!writer || lastTransferSyntaxUid !== frameTransferSyntaxUid) { - writer = new FileDicomWebWriter( - { - studyInstanceUid: studyUID, - seriesInstanceUid: targetSeriesUID, - sopInstanceUid: targetInstanceUID, - transferSyntaxUid: frameTransferSyntaxUid, - }, - { baseDir: dicomdir } - ); - lastTransferSyntaxUid = frameTransferSyntaxUid; - } - - // Convert ArrayBuffer to Uint8Array if needed - let imageFrame = pixelData.binaryData; - if (imageFrame instanceof ArrayBuffer) { - imageFrame = new Uint8Array(imageFrame); - } - - const thumbnailFilename = framesToProcess.length > 1 ? `thumbnail-${frameNum}` : 'thumbnail'; - - // Callback to write thumbnail (receives buffer and canvasDest) - const writeThumbnailCallback = async (buffer, canvasDest) => { - if (!buffer) { - console.warn(`No thumbnail buffer generated for frame ${frameNum}`); - return; - } - - console.log(`Writing thumbnail for instance ${targetInstanceUID}, frame ${frameNum}...`); - - const thumbnailStreamInfo = await writer.openInstanceStream(thumbnailFilename, { - gzip: false, - }); - thumbnailStreamInfo.stream.write(Buffer.from(buffer)); - await writer.closeStream(thumbnailStreamInfo.streamKey); - - console.log(`Thumbnail written successfully for frame ${frameNum} as ${thumbnailFilename}`); - }; - - await StaticWado.internalGenerateImage( - imageFrame, - null, - targetInstanceMetadata, - frameTransferSyntaxUid, - writeThumbnailCallback + const pixelData = await readPixelData(reader, studyUID, targetSeriesUID, instanceMetadata, frameNum); + const frameTransferSyntaxUid = pixelData.transferSyntaxUid; + if (!frameTransferSyntaxUid) throw new Error('Could not determine transfer syntax UID from pixel data'); + if (!writer || lastTransferSyntaxUid !== frameTransferSyntaxUid) { + writer = new FileDicomWebWriter( + { + studyInstanceUid: studyUID, + seriesInstanceUid: targetSeriesUID, + sopInstanceUid: targetInstanceUID, + transferSyntaxUid: frameTransferSyntaxUid, + }, + { baseDir: outputDicomdir || dicomdir } ); - - console.log(`Thumbnail generation completed for frame ${frameNum}`); - } catch (error) { - console.error(`Error generating thumbnail for frame ${frameNum}: ${error.message}`); - throw error; + lastTransferSyntaxUid = frameTransferSyntaxUid; } + let imageFrame = pixelData.binaryData; + if (imageFrame instanceof ArrayBuffer) imageFrame = new Uint8Array(imageFrame); + const thumbnailFilename = framesToProcess.length > 1 ? `thumbnail-${frameNum}` : 'thumbnail'; + await StaticWado.internalGenerateImage(imageFrame, null, instanceMetadata, frameTransferSyntaxUid, async buffer => { + if (!buffer) return; + const thumbnailStreamInfo = await writer.openInstanceStream(thumbnailFilename, { gzip: false }); + thumbnailStreamInfo.stream.write(Buffer.from(buffer)); + await writer.closeStream(thumbnailStreamInfo.streamKey); + }); } +} - console.log( - `Thumbnail generation completed for study ${studyUID}, series ${targetSeriesUID}, instance ${targetInstanceUID}` - ); +/** + * Main function for creating thumbnails + * @param {string} studyUID - Study Instance UID + * @param {Object} options - Options object + * @param {string} [options.dicomdir] - Base directory path where DICOMweb structure is located + * @param {string} [options.seriesUid] - Specific Series Instance UID to process (if not provided, uses first series from study query) + * @param {string} [options.instanceUid] - Specific SOP Instance UID to process (if not provided, uses first instance from series) + * @param {number|number[]} [options.frameNumber] - Frame number to use for thumbnail (default: 1) - deprecated, use frameNumbers instead + * @param {number[]} [options.frameNumbers] - Array of frame numbers to generate thumbnails for (default: [1]) + * @param {boolean} [options.seriesThumbnail] - Generate thumbnails for series (middle SOP instance, middle frame for multiframe) + * @param {boolean} [options.allThumbnails] - Generate thumbnails for all SOP instances, all series, and study level + */ +export async function thumbnailMain(studySelector, options = {}) { + const { dicomdir, outputDicomdir } = options; + if (!dicomdir) { + throw new Error('dicomdir option is required'); + } + const reader = DicomWebStream.createReader(dicomdir); + if (!reader) { + throw new Error(`dicomdir is not a valid file/http location: ${dicomdir}`); + } + if (/^https?:\/\//i.test(dicomdir) && !outputDicomdir) { + throw new Error('--output-dicomdir is required when dicomdir is http/https'); + } + if ((outputDicomdir || dicomdir).startsWith('http')) { + throw new Error('Thumbnail output must be a file path, not an http(s) endpoint'); + } + const studyUIDs = await resolveStudyUIDs(reader, studySelector || 'true'); + if (!studyUIDs.length) { + throw new Error(`No studies matched selector: ${studySelector || 'true'}`); + } + for (const studyUID of studyUIDs) { + await generateForStudy(studyUID, { ...options, reader, dicomdir, outputDicomdir }); + } + console.log(`Thumbnail generation completed for ${studyUIDs.length} study(ies)`); } diff --git a/packages/create-dicomweb/lib/index.mjs b/packages/create-dicomweb/lib/index.mjs index 98fce46d..469c2b76 100644 --- a/packages/create-dicomweb/lib/index.mjs +++ b/packages/create-dicomweb/lib/index.mjs @@ -1,6 +1,7 @@ export * from './commands/index.mjs'; export { DicomWebReader } from './instance/DicomWebReader.mjs'; export { FileDicomWebReader } from './instance/FileDicomWebReader.mjs'; +export { HttpDicomWebReader } from './instance/HttpDicomWebReader.mjs'; export { DicomWebWriter } from './instance/DicomWebWriter.mjs'; export { FileDicomWebWriter } from './instance/FileDicomWebWriter.mjs'; export { DicomWebStream } from './instance/DicomWebStream.mjs'; diff --git a/packages/create-dicomweb/lib/instance/DicomWebReader.mjs b/packages/create-dicomweb/lib/instance/DicomWebReader.mjs index 9e7459d4..3b35bdde 100644 --- a/packages/create-dicomweb/lib/instance/DicomWebReader.mjs +++ b/packages/create-dicomweb/lib/instance/DicomWebReader.mjs @@ -170,6 +170,20 @@ export class DicomWebReader { return this.openInputStream(relativePath, filename); } + /** + * Reads bulk data for an instance/frame. + * Must be implemented by subclasses. + * @param {string} studyUID - Study Instance UID + * @param {string} seriesUID - Series Instance UID + * @param {string} bulkDataURI - BulkData URI from metadata + * @param {number} [frameNumber] - Optional frame number (1-based) + * @param {string} [instanceUID] - Optional SOP Instance UID for resolving instance-relative frame paths + * @returns {Promise<{binaryData:ArrayBuffer,transferSyntaxUid:string|null,contentType:string}|null>} + */ + async readBulkData(studyUID, seriesUID, bulkDataURI, frameNumber = undefined, instanceUID = undefined) { + throw new Error('readBulkData must be implemented by subclass'); + } + /** * Reads a JSON file from a stream and parses it * @param {Readable} stream - Readable stream containing JSON data diff --git a/packages/create-dicomweb/lib/instance/DicomWebStream.mjs b/packages/create-dicomweb/lib/instance/DicomWebStream.mjs index 61365e49..ecdc6e50 100644 --- a/packages/create-dicomweb/lib/instance/DicomWebStream.mjs +++ b/packages/create-dicomweb/lib/instance/DicomWebStream.mjs @@ -1,7 +1,8 @@ import { FileDicomWebReader } from './FileDicomWebReader.mjs'; +import { HttpDicomWebReader } from './HttpDicomWebReader.mjs'; import { FileDicomWebWriter } from './FileDicomWebWriter.mjs'; import { MultipartResponseDicomWebWriter } from './MultipartResponseDicomWebWriter.mjs'; -import { isDicomDirLocation, dicomDirPathFromLocation } from './dicomDirLocation.mjs'; +import { isDicomDirLocation, dicomDirPathFromLocation, isHttpLocation } from './dicomDirLocation.mjs'; /** * Factory for file-based DICOMweb reader/writer instances from a dicomdir (file) location. @@ -39,6 +40,9 @@ export class DicomWebStream { * @returns {import('./FileDicomWebReader.mjs').FileDicomWebReader|null} */ static createReader(location) { + if (isHttpLocation(location)) { + return new HttpDicomWebReader(location); + } const baseDir = dicomDirPathFromLocation(location); if (baseDir == null) { return null; diff --git a/packages/create-dicomweb/lib/instance/FileDicomWebReader.mjs b/packages/create-dicomweb/lib/instance/FileDicomWebReader.mjs index d0e36474..651a4ee8 100644 --- a/packages/create-dicomweb/lib/instance/FileDicomWebReader.mjs +++ b/packages/create-dicomweb/lib/instance/FileDicomWebReader.mjs @@ -3,6 +3,7 @@ import path from 'path'; import { createGunzip } from 'zlib'; import { DicomWebReader } from './DicomWebReader.mjs'; import { removeStaleMetadataDir } from './removeStaleMetadataDir.mjs'; +import { readBulkData as readBulkDataFromFile } from '@radicalimaging/static-wado-util'; /** * File-based implementation of DicomWebReader @@ -110,6 +111,27 @@ export class FileDicomWebReader extends DicomWebReader { return this._openStream(relativePath, filename); } + async readBulkData(studyUID, seriesUID, bulkDataURI, frameNumber = undefined, instanceUID = undefined) { + const studyDir = path.join(this.baseDir, `studies/${studyUID}`); + const seriesDir = path.join(studyDir, `series/${seriesUID}`); + + if (bulkDataURI.indexOf('frames') !== -1) { + const isSeriesRelative = bulkDataURI.startsWith('./instances/'); + if (!isSeriesRelative && !instanceUID) { + throw new Error( + 'No SOPInstanceUID in instance metadata; cannot resolve instance-relative frames path' + ); + } + const frameBaseDir = isSeriesRelative + ? seriesDir + : path.join(seriesDir, 'instances', instanceUID); + const frameBaseName = isSeriesRelative ? bulkDataURI : './frames'; + return readBulkDataFromFile(frameBaseDir, frameBaseName, frameNumber); + } + + return readBulkDataFromFile(seriesDir, bulkDataURI); + } + /** * Reads a JSON file; on decompress or parse error, optionally deletes the file and returns undefined. * @param {string} relativePath - Relative path within baseDir diff --git a/packages/create-dicomweb/lib/instance/HttpDicomWebReader.mjs b/packages/create-dicomweb/lib/instance/HttpDicomWebReader.mjs new file mode 100644 index 00000000..6130b3c3 --- /dev/null +++ b/packages/create-dicomweb/lib/instance/HttpDicomWebReader.mjs @@ -0,0 +1,117 @@ +import { Readable } from 'stream'; +import { DicomWebReader } from './DicomWebReader.mjs'; + +function normalizeBaseUrl(baseUrl) { + return baseUrl.replace(/\/+$/, ''); +} + +function joinUrlPath(baseUrl, relativePath, filename = '') { + const parts = [baseUrl, relativePath, filename].filter(Boolean); + return parts + .join('/') + .replace(/([^:]\/)\/+/g, '$1'); +} + +export class HttpDicomWebReader extends DicomWebReader { + constructor(baseUrl) { + super(baseUrl); + this.baseUrl = normalizeBaseUrl(baseUrl); + } + + _mapRelativePathToQido(relativePath, filename) { + if (filename !== 'index.json') return null; + const parts = String(relativePath || '') + .split('/') + .filter(Boolean); + if (parts[0] !== 'studies') return null; + if (parts.length === 1) return 'studies'; + if (parts.length === 2) return `studies/${parts[1]}`; + if (parts.length === 3 && parts[2] === 'series') return `studies/${parts[1]}/series`; + return null; + } + + _resolveBulkDataPath(studyUID, seriesUID, bulkDataURI, frameNumber) { + const frameSuffix = frameNumber ? `/${frameNumber}` : ''; + if (/^https?:\/\//i.test(bulkDataURI)) { + return `${bulkDataURI}${frameSuffix}`; + } + + if (bulkDataURI.startsWith('./')) { + const rel = bulkDataURI.slice(2); + return joinUrlPath(this.baseUrl, `studies/${studyUID}/series/${seriesUID}`, `${rel}${frameSuffix}`); + } + + if (bulkDataURI.startsWith('instances/')) { + return joinUrlPath( + this.baseUrl, + `studies/${studyUID}/series/${seriesUID}`, + `${bulkDataURI}${frameSuffix}` + ); + } + + if (bulkDataURI.startsWith('studies/')) { + return joinUrlPath(this.baseUrl, `${bulkDataURI}${frameSuffix}`); + } + + return joinUrlPath( + this.baseUrl, + `studies/${studyUID}/series/${seriesUID}`, + `${bulkDataURI}${frameSuffix}` + ); + } + + async _fetch(url) { + const response = await fetch(url); + if (response.status === 404) return null; + if (!response.ok) { + throw new Error(`HTTP ${response.status} for ${url}`); + } + return response; + } + + async readJsonFile(relativePath, filename) { + const qidoPath = this._mapRelativePathToQido(relativePath, filename); + if (qidoPath) { + const response = await this._fetch(joinUrlPath(this.baseUrl, qidoPath)); + if (!response) return undefined; + return response.json(); + } + + const response = await this._fetch(joinUrlPath(this.baseUrl, relativePath, filename)); + if (!response) return undefined; + return response.json(); + } + + async openInputStream(relativePath, filename) { + const response = await this._fetch(joinUrlPath(this.baseUrl, relativePath, filename)); + if (!response) return undefined; + if (!response.body) { + const buffer = Buffer.from(await response.arrayBuffer()); + return Readable.from(buffer); + } + return Readable.fromWeb(response.body); + } + + async readBulkData(studyUID, seriesUID, bulkDataURI, frameNumber = undefined, instanceUID = undefined) { + const url = this._resolveBulkDataPath(studyUID, seriesUID, bulkDataURI, frameNumber); + const response = await this._fetch(url); + if (!response) return null; + const binaryData = await response.arrayBuffer(); + return { + binaryData, + transferSyntaxUid: null, + contentType: response.headers.get('content-type') || 'application/octet-stream', + }; + } + + async queryStudies(studySelector) { + let query = ''; + if (typeof studySelector === 'string' && studySelector !== 'true') { + query = `?${studySelector}`; + } + const response = await this._fetch(joinUrlPath(this.baseUrl, `studies${query}`)); + if (!response) return []; + const list = await response.json(); + return Array.isArray(list) ? list : []; + } +} diff --git a/packages/create-dicomweb/lib/instance/dicomDirLocation.mjs b/packages/create-dicomweb/lib/instance/dicomDirLocation.mjs index 2385fcdd..b8e3d8c2 100644 --- a/packages/create-dicomweb/lib/instance/dicomDirLocation.mjs +++ b/packages/create-dicomweb/lib/instance/dicomDirLocation.mjs @@ -56,3 +56,8 @@ export function dicomDirPathFromLocation(location) { } return s; } + +export function isHttpLocation(location) { + if (typeof location !== 'string') return false; + return /^https?:\/\//i.test(location.trim()); +} diff --git a/packages/static-wado-deploy/lib/DeployGroup.mjs b/packages/static-wado-deploy/lib/DeployGroup.mjs index f4ae67a4..171e2c4d 100644 --- a/packages/static-wado-deploy/lib/DeployGroup.mjs +++ b/packages/static-wado-deploy/lib/DeployGroup.mjs @@ -150,6 +150,7 @@ class DeployGroup { const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); const files = []; const directories = []; + const includePatterns = Array.isArray(this.options.include) ? this.options.include : []; // Separate files and directories for optimized processing for (const entry of entries) { @@ -160,8 +161,11 @@ class DeployGroup { const shouldExclude = Array.from(excludePatterns).some( pattern => entryRelativePath.indexOf(pattern) !== -1 ); + const shouldInclude = + includePatterns.length === 0 || + includePatterns.some(pattern => entryRelativePath.indexOf(pattern) !== -1); - if (shouldExclude) continue; + if (shouldExclude || !shouldInclude) continue; if (entry.isDirectory()) { directories.push({ path: fullPath, relativePath: entryRelativePath }); diff --git a/packages/static-wado-deploy/lib/deployConfig.mjs b/packages/static-wado-deploy/lib/deployConfig.mjs index 5ee122e4..6decdbdf 100644 --- a/packages/static-wado-deploy/lib/deployConfig.mjs +++ b/packages/static-wado-deploy/lib/deployConfig.mjs @@ -106,6 +106,11 @@ const { deployConfig } = ConfigPoint.register({ description: 'Skip storing, so that indexing only is done', defaultValue: false, }, + { + key: '--thumbnail-only', + description: 'Upload only thumbnail files under the selected studies', + defaultValue: false, + }, ], isDefault: true, main: studiesMain, diff --git a/packages/static-wado-deploy/lib/studiesMain.mjs b/packages/static-wado-deploy/lib/studiesMain.mjs index 7ab58880..993878d6 100644 --- a/packages/static-wado-deploy/lib/studiesMain.mjs +++ b/packages/static-wado-deploy/lib/studiesMain.mjs @@ -20,21 +20,24 @@ export default async function (options, program) { export async function studyMainSingle(studyUID, options) { console.warn('Uploading study', studyUID); const studyDirectory = studyUID ? `studies/${studyUID}` : 'studies'; + const uploadOptions = options.thumbnailOnly + ? { ...options, include: ['thumbnail'], exclude: ['temp'], index: false } + : options; if (options.retrieve) { console.log('Retrieve studyUID', studyUID); - await commonMain(this, 'root', options, retrieveDeploy.bind(null, studyDirectory)); + await commonMain(this, 'root', uploadOptions, retrieveDeploy.bind(null, studyDirectory)); return; } - if (!options.skipStore) { - await commonMain(this, 'root', options, uploadDeploy.bind(null, studyDirectory)); + if (!uploadOptions.skipStore) { + await commonMain(this, 'root', uploadOptions, uploadDeploy.bind(null, studyDirectory)); console.log('Storing studyUID', studyUID); - await commonMain(this, 'root', options, uploadDeploy.bind(null, studyDirectory)); + await commonMain(this, 'root', uploadOptions, uploadDeploy.bind(null, studyDirectory)); } - if (options.index) { + if (uploadOptions.index) { console.log('Calling commonMain to create index'); - await commonMain(this, 'root', options, uploadIndex.bind(null, studyDirectory)); + await commonMain(this, 'root', uploadOptions, uploadIndex.bind(null, studyDirectory)); } else { console.log('NOT Calling commonMain to create index'); } From a6fbb551c7a9846f3a0c1306170c12741f4f28bf Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Fri, 8 May 2026 11:29:53 -0400 Subject: [PATCH 2/9] Add s3 storage --- bun.lock | 18 +- .../create-dicomweb/bin/createdicomweb.mjs | 14 +- .../lib/commands/thumbnailMain.mjs | 469 +++++++++++++++--- .../lib/instance/HttpDicomWebReader.mjs | 45 +- .../lib/instance/s3ThumbnailOutput.mjs | 292 +++++++++++ packages/cs3d/src/api/getRenderedBuffer.ts | 13 +- packages/s3-deploy/lib/S3Ops.mjs | 4 +- 7 files changed, 763 insertions(+), 92 deletions(-) create mode 100644 packages/create-dicomweb/lib/instance/s3ThumbnailOutput.mjs diff --git a/bun.lock b/bun.lock index 045d8495..fd5c4cf2 100644 --- a/bun.lock +++ b/bun.lock @@ -41,7 +41,7 @@ }, "packages/create-dicomweb": { "name": "@radicalimaging/create-dicomweb", - "version": "1.7.4", + "version": "1.7.6", "bin": { "createdicomweb": "bin/createdicomweb.mjs", }, @@ -65,7 +65,7 @@ }, "packages/cs3d": { "name": "@radicalimaging/cs3d", - "version": "1.7.4", + "version": "1.7.6", "dependencies": { "@cornerstonejs/core": "^4.22.3", "canvas": "3.1.0", @@ -102,7 +102,7 @@ }, "packages/s3-deploy": { "name": "@radicalimaging/s3-deploy", - "version": "1.7.4", + "version": "1.7.6", "dependencies": { "@aws-sdk/client-s3": "^3.374.0", "@aws-sdk/lib-storage": "^3.862.0", @@ -119,7 +119,7 @@ }, "packages/static-wado-creator": { "name": "@radicalimaging/static-wado-creator", - "version": "1.7.4", + "version": "1.7.6", "bin": { "mkdicomweb": "bin/mkdicomweb.mjs", }, @@ -140,7 +140,7 @@ }, "packages/static-wado-deploy": { "name": "@radicalimaging/static-wado-deploy", - "version": "1.7.4", + "version": "1.7.6", "bin": { "deploydicomweb": "bin/deploydicomweb.mjs", }, @@ -158,7 +158,7 @@ }, "packages/static-wado-plugins": { "name": "@radicalimaging/static-wado-plugins", - "version": "1.7.4", + "version": "1.7.6", "dependencies": { "@radicalimaging/static-wado-util": ">=1.7.0", "config-point": ">=0.5.1", @@ -171,7 +171,7 @@ }, "packages/static-wado-scp": { "name": "@radicalimaging/static-wado-scp", - "version": "1.7.4", + "version": "1.7.6", "bin": { "dicomwebscp": "bin/dicomwebscp.mjs", }, @@ -189,7 +189,7 @@ }, "packages/static-wado-util": { "name": "@radicalimaging/static-wado-util", - "version": "1.7.4", + "version": "1.7.6", "dependencies": { "@cornerstonejs/codec-openjpeg": ">=1.2.3", "@cornerstonejs/codec-openjph": ">=2.4.5", @@ -210,7 +210,7 @@ }, "packages/static-wado-webserver": { "name": "@radicalimaging/static-wado-webserver", - "version": "1.7.4", + "version": "1.7.6", "bin": { "dicomwebserver": "bin/dicomwebserver.mjs", "monitordicomwebserver": "bin/monitordicomwebserver.mjs", diff --git a/packages/create-dicomweb/bin/createdicomweb.mjs b/packages/create-dicomweb/bin/createdicomweb.mjs index 4b4b9fc6..648148a1 100755 --- a/packages/create-dicomweb/bin/createdicomweb.mjs +++ b/packages/create-dicomweb/bin/createdicomweb.mjs @@ -247,8 +247,13 @@ program '--all-thumbnails', 'Generate thumbnails for every SOP instance plus series and study level (uses middle frame for multiframe)' ) - .action(async (studySelectorArg, options) => { - updateVerboseLog(); + .option('--force', 'Regenerate thumbnails even if they already exist at the output location') + .action(async (studySelectorArg, options, command) => { + const merged = + typeof command?.optsWithGlobals === 'function' + ? command.optsWithGlobals() + : { ...program.opts(), ...options }; + createVerboseLog(!!merged.verbose, { quiet: !!merged.quiet }); const thumbnailOptions = {}; if (options.dicomdir) { thumbnailOptions.dicomdir = handleHomeRelative(options.dicomdir); @@ -269,7 +274,10 @@ program if (options.allThumbnails) { thumbnailOptions.allThumbnails = true; } - + if (options.force) { + thumbnailOptions.force = true; + } + // Parse frame numbers try { const frameNumbers = parseFrameNumbers(options.frameNumbers); diff --git a/packages/create-dicomweb/lib/commands/thumbnailMain.mjs b/packages/create-dicomweb/lib/commands/thumbnailMain.mjs index 42a9ef47..bd9ee989 100644 --- a/packages/create-dicomweb/lib/commands/thumbnailMain.mjs +++ b/packages/create-dicomweb/lib/commands/thumbnailMain.mjs @@ -1,10 +1,34 @@ +import fs from 'fs'; +import path from 'path'; +import { pathToFileURL } from 'url'; import { DicomWebStream } from '../instance/DicomWebStream.mjs'; import { FileDicomWebWriter } from '../instance/FileDicomWebWriter.mjs'; -import { Tags, qidoFilter } from '@radicalimaging/static-wado-util'; +import { + isS3OutputUri, + parseS3OutputUri, + getBunS3ClientForBucket, + joinS3ObjectKey, + thumbnailRelativeKey, + putS3ThumbnailJpeg, + s3ObjectExists, +} from '../instance/s3ThumbnailOutput.mjs'; +import { Tags, qidoFilter, handleHomeRelative } from '@radicalimaging/static-wado-util'; import StaticWado from '@radicalimaging/static-wado-creator'; const { getValue } = Tags; +/** Explicit VR Little Endian — default when no transfer syntax is found in headers or metadata */ +const DEFAULT_TRANSFER_SYNTAX_UID = '1.2.840.10008.1.2.1'; + +function getTransferSyntaxFromInstanceMetadata(metadata) { + const hex = getValue(metadata, Tags.TransferSyntaxUID); + if (hex) return hex; + const nat = metadata?.TransferSyntaxUID; + if (nat?.Value?.[0]) return nat.Value[0]; + if (typeof nat === 'string') return nat; + return undefined; +} + function parseSelectorToQuery(selector) { const params = new URLSearchParams(selector); const query = {}; @@ -20,6 +44,91 @@ function asStudyList(studiesValue) { return []; } +/** + * PS3.x-style one-line warning when a single thumbnail is skipped (decode/render/write). + * @param {Object} p + * @param {string} p.studyUID + * @param {string} p.seriesUID + * @param {string} p.instanceUID + * @param {string} p.level + * @param {unknown} p.error + */ +function warnThumbnailSkippedDicom({ studyUID, seriesUID, instanceUID, level, error }) { + const reason = error instanceof Error ? error.message : String(error); + console.warn( + `*** DICOM warning [THUMBNAIL_SKIP] StudyInstanceUID=${studyUID} SeriesInstanceUID=${seriesUID} SOPInstanceUID=${instanceUID} Level=${level}: ${reason}` + ); + if (error instanceof Error && error.stack) { + console.verbose('[thumbnail] skip stack', error.stack); + } +} + +/** + * Resolved output root for thumbnails (local path or s3:// URI). + */ +function outputRoot(outputDicomdir, dicomdir) { + return outputDicomdir || dicomdir; +} + +/** + * Same display URL as {@link logThumbnailWritten}: https://, file://, or s3:// + * @param {Object} p + */ +function formatThumbnailOutputHref({ dicomdir, outputDicomdir, studyUID, seriesUID, instanceUID, level, filename }) { + const rel = thumbnailRelativeKey(level, studyUID, seriesUID, instanceUID, filename); + const outBase = outputRoot(outputDicomdir, dicomdir); + + if (isS3OutputUri(outBase)) { + const { bucket, keyPrefix } = parseS3OutputUri(outBase); + const key = joinS3ObjectKey(keyPrefix, rel); + return `s3://${bucket}/${key}`; + } + + const dicomdirStr = String(dicomdir ?? '').trim(); + if (/^https?:\/\//i.test(dicomdirStr)) { + const base = dicomdirStr.replace(/\/?$/, '/'); + return new URL(rel, base).href; + } + const root = handleHomeRelative(outBase); + const fullPath = path.normalize(path.join(root, ...rel.split('/').filter(Boolean))); + return pathToFileURL(fullPath).href; +} + +/** + * In non-quiet mode, print where the thumbnail was written: https://, file://, or s3:// + */ +function logThumbnailWritten(params) { + console.noQuiet('Thumbnail written:', formatThumbnailOutputHref(params)); +} + +/** + * In non-quiet mode, print that the thumbnail already exists at the same URL shape as {@link logThumbnailWritten}. + */ +function logThumbnailAlreadyExists(params) { + console.noQuiet('Already exists:', formatThumbnailOutputHref(params)); +} + +/** + * True if a thumbnail file/object is already present at the output root (filesystem or S3). + * @param {Object} p + * @param {'study'|'series'|'instance'} p.level + */ +async function thumbnailExistsAtOutput({ outputDicomdir, dicomdir, studyUID, seriesUID, instanceUID, level, filename }) { + const rel = thumbnailRelativeKey(level, studyUID, seriesUID, instanceUID, filename); + const outBase = outputRoot(outputDicomdir, dicomdir); + + if (isS3OutputUri(outBase)) { + const { bucket, keyPrefix } = parseS3OutputUri(outBase); + const key = joinS3ObjectKey(keyPrefix, rel); + const client = await getBunS3ClientForBucket(bucket); + return s3ObjectExists(client, key); + } + + const root = handleHomeRelative(outBase); + const fullPath = path.normalize(path.join(root, ...rel.split('/').filter(Boolean))); + return fs.existsSync(fullPath); +} + /** * Reads pixel data from instance metadata * @param {string} studyUID - Study Instance UID @@ -29,6 +138,7 @@ function asStudyList(studiesValue) { * @returns {Promise} - Object with binaryData, transferSyntaxUid, and contentType */ async function readPixelData(reader, studyUID, seriesUID, instanceMetadata, frameNumber = 1) { + const sopUID = getValue(instanceMetadata, Tags.SOPInstanceUID); const pixelDataTag = Tags.PixelData; const pixelData = instanceMetadata[pixelDataTag]; @@ -41,24 +151,57 @@ async function readPixelData(reader, studyUID, seriesUID, instanceMetadata, fram throw new Error('No BulkDataURI found in PixelData'); } + console.verbose('[thumbnail] readPixelData request', { + studyUID, + seriesUID, + sopInstanceUID: sopUID, + frameNumber, + bulkDataURI: typeof bulkDataURI === 'string' ? bulkDataURI.slice(0, 120) : bulkDataURI, + }); + const bulkData = await reader.readBulkData( studyUID, seriesUID, bulkDataURI, frameNumber, - getValue(instanceMetadata, Tags.SOPInstanceUID) + sopUID ); if (!bulkData) { throw new Error(`Failed to read bulk data for frame ${frameNumber}`); } + const fromMeta = getTransferSyntaxFromInstanceMetadata(instanceMetadata); + let transferSyntaxUid = + bulkData.transferSyntaxUid || + pixelData.transferSyntaxUid || + fromMeta; + + if (!transferSyntaxUid) { + console.warn( + `[thumbnail] No TransferSyntaxUID in metadata or HTTP headers for instance ${sopUID}; using default ${DEFAULT_TRANSFER_SYNTAX_UID}. If decoding fails, inspect responses with -v.` + ); + transferSyntaxUid = DEFAULT_TRANSFER_SYNTAX_UID; + } + + console.verbose('[thumbnail] readPixelData resolved', { + sopInstanceUID: sopUID, + transferSyntaxUid, + sources: { + bulkDataResponse: bulkData.transferSyntaxUid ?? '(none)', + pixelDataTag: pixelData.transferSyntaxUid ?? '(none)', + instanceMetadata: fromMeta ?? '(none)', + }, + contentType: bulkData.contentType, + byteLength: + bulkData.binaryData instanceof ArrayBuffer + ? bulkData.binaryData.byteLength + : bulkData.binaryData?.length, + }); + return { binaryData: bulkData.binaryData, - transferSyntaxUid: - bulkData.transferSyntaxUid || - pixelData.transferSyntaxUid || - getValue(instanceMetadata, Tags.TransferSyntaxUID), + transferSyntaxUid, contentType: bulkData.contentType, }; } @@ -72,61 +215,131 @@ async function writeThumbnailForTarget({ instanceMetadata, frameNumber, level, + force, }) { const instanceUID = getValue(instanceMetadata, Tags.SOPInstanceUID); if (!instanceUID) { throw new Error('Could not extract SOPInstanceUID from instance metadata'); } - const pixelData = await readPixelData(reader, studyUID, seriesUID, instanceMetadata, frameNumber); - const transferSyntaxUid = pixelData.transferSyntaxUid; - if (!transferSyntaxUid) { - throw new Error(`Could not determine transfer syntax UID for instance ${instanceUID}`); - } - - const writer = new FileDicomWebWriter( - { - studyInstanceUid: studyUID, - seriesInstanceUid: seriesUID, - sopInstanceUid: instanceUID, - transferSyntaxUid, - }, - { baseDir: outputDicomdir || dicomdir } - ); + console.verbose('[thumbnail] writeThumbnailForTarget', { + level, + studyUID, + seriesUID, + instanceUID, + frameNumber, + outputBase: outputRoot(outputDicomdir, dicomdir), + force: !!force, + }); - let imageFrame = pixelData.binaryData; - if (imageFrame instanceof ArrayBuffer) { - imageFrame = new Uint8Array(imageFrame); + if (!force) { + try { + const exists = await thumbnailExistsAtOutput({ + outputDicomdir, + dicomdir, + studyUID, + seriesUID, + instanceUID, + level, + filename: 'thumbnail', + }); + if (exists) { + logThumbnailAlreadyExists({ + dicomdir, + outputDicomdir, + studyUID, + seriesUID, + instanceUID, + level, + filename: 'thumbnail', + }); + return; + } + } catch (error) { + console.verbose('[thumbnail] could not check existing thumbnail; will attempt generation', error); + } } - const writeThumbnailCallback = async buffer => { - if (!buffer) { - console.warn( - `No thumbnail buffer generated for ${level} thumbnail (${studyUID}/${seriesUID}/${instanceUID})` - ); - return; + try { + const pixelData = await readPixelData(reader, studyUID, seriesUID, instanceMetadata, frameNumber); + const transferSyntaxUid = pixelData.transferSyntaxUid; + const outBase = outputRoot(outputDicomdir, dicomdir); + const useS3 = isS3OutputUri(outBase); + + let imageFrame = pixelData.binaryData; + if (imageFrame instanceof ArrayBuffer) { + imageFrame = new Uint8Array(imageFrame); } - let thumbnailStreamInfo; - if (level === 'study') { - thumbnailStreamInfo = await writer.openStudyStream('thumbnail', { gzip: false }); - } else if (level === 'series') { - thumbnailStreamInfo = await writer.openSeriesStream('thumbnail', { gzip: false }); - } else { - thumbnailStreamInfo = await writer.openInstanceStream('thumbnail', { gzip: false }); + let s3Client; + let s3Bucket; + let s3KeyPrefix; + if (useS3) { + const parsed = parseS3OutputUri(outBase); + s3Bucket = parsed.bucket; + s3KeyPrefix = parsed.keyPrefix; + s3Client = await getBunS3ClientForBucket(s3Bucket); } - thumbnailStreamInfo.stream.write(Buffer.from(buffer)); - await writer.closeStream(thumbnailStreamInfo.streamKey); - }; + const writer = useS3 + ? null + : new FileDicomWebWriter( + { + studyInstanceUid: studyUID, + seriesInstanceUid: seriesUID, + sopInstanceUid: instanceUID, + transferSyntaxUid, + }, + { baseDir: outBase } + ); - await StaticWado.internalGenerateImage( - imageFrame, - null, - instanceMetadata, - transferSyntaxUid, - writeThumbnailCallback - ); + const writeThumbnailCallback = async buffer => { + if (!buffer) { + console.warn( + `*** DICOM warning [THUMBNAIL_SKIP] StudyInstanceUID=${studyUID} SeriesInstanceUID=${seriesUID} SOPInstanceUID=${instanceUID} Level=${level}: No thumbnail buffer generated after render` + ); + return; + } + + if (useS3) { + const relKey = thumbnailRelativeKey(level, studyUID, seriesUID, instanceUID, 'thumbnail'); + const key = joinS3ObjectKey(s3KeyPrefix, relKey); + await putS3ThumbnailJpeg(s3Client, key, Buffer.from(buffer), s3Bucket); + } else { + let thumbnailStreamInfo; + if (level === 'study') { + thumbnailStreamInfo = await writer.openStudyStream('thumbnail', { gzip: false }); + } else if (level === 'series') { + thumbnailStreamInfo = await writer.openSeriesStream('thumbnail', { gzip: false }); + } else { + thumbnailStreamInfo = await writer.openInstanceStream('thumbnail', { gzip: false }); + } + + thumbnailStreamInfo.stream.write(Buffer.from(buffer)); + await writer.closeStream(thumbnailStreamInfo.streamKey); + } + + logThumbnailWritten({ + dicomdir, + outputDicomdir, + studyUID, + seriesUID, + instanceUID, + level, + filename: 'thumbnail', + }); + }; + + await StaticWado.internalGenerateImage( + imageFrame, + null, + instanceMetadata, + transferSyntaxUid, + writeThumbnailCallback + ); + } catch (error) { + warnThumbnailSkippedDicom({ studyUID, seriesUID, instanceUID, level, error }); + } } async function resolveStudyUIDs(reader, studySelector) { @@ -153,10 +366,31 @@ async function resolveStudyUIDs(reader, studySelector) { } async function generateForStudy(studyUID, options = {}) { - const { reader, dicomdir, outputDicomdir, seriesUid, instanceUid, frameNumbers, frameNumber, allThumbnails, seriesThumbnail } = - options; + const { + reader, + dicomdir, + outputDicomdir, + seriesUid, + instanceUid, + frameNumbers, + frameNumber, + allThumbnails, + seriesThumbnail, + force, + } = options; const framesToProcess = frameNumbers || (frameNumber ? [frameNumber] : [1]); + console.verbose('[thumbnail] generateForStudy start', { + studyUID, + allThumbnails: !!allThumbnails, + seriesThumbnail: !!seriesThumbnail, + seriesUid: seriesUid ?? '(any)', + instanceUid: instanceUid ?? '(default)', + framesToProcess, + dicomdir, + outputDicomdir: outputDicomdir ?? '(same as dicomdir)', + }); + const seriesIndex = await reader.readJsonFile( reader.getStudyPath(studyUID, { path: 'series' }), 'index.json' @@ -165,6 +399,8 @@ async function generateForStudy(studyUID, options = {}) { throw new Error(`No series found for study ${studyUID}`); } + console.verbose('[thumbnail] series index length', seriesIndex.length); + let seriesToProcess = seriesIndex; if (seriesUid) { seriesToProcess = seriesIndex.filter(series => getValue(series, Tags.SeriesInstanceUID) === seriesUid); @@ -190,6 +426,7 @@ async function generateForStudy(studyUID, options = {}) { instanceMetadata: metadata, frameNumber: Math.ceil(numberOfFrames / 2), level: 'instance', + force, }); } const middle = seriesMetadata[Math.floor(seriesMetadata.length / 2)]; @@ -203,6 +440,7 @@ async function generateForStudy(studyUID, options = {}) { instanceMetadata: middle, frameNumber: Math.ceil(middleFrames / 2), level: 'series', + force, }); } if (!seriesMetadataCache.length) return; @@ -218,6 +456,7 @@ async function generateForStudy(studyUID, options = {}) { instanceMetadata: middleInstance, frameNumber: Math.ceil(middleFrames / 2), level: 'study', + force, }); return; } @@ -239,6 +478,7 @@ async function generateForStudy(studyUID, options = {}) { instanceMetadata: middle, frameNumber: Math.ceil(middleFrames / 2), level: 'series', + force, }); } return; @@ -256,33 +496,106 @@ async function generateForStudy(studyUID, options = {}) { if (!instanceMetadata) throw new Error(`Instance ${instanceUid} not found in series metadata`); } const targetInstanceUID = getValue(instanceMetadata, Tags.SOPInstanceUID); + const outBase = outputRoot(outputDicomdir, dicomdir); + const useS3 = isS3OutputUri(outBase); let writer = null; let lastTransferSyntaxUid = null; + + let s3Client; + let s3Bucket; + let s3KeyPrefix; + if (useS3) { + const parsed = parseS3OutputUri(outBase); + s3Bucket = parsed.bucket; + s3KeyPrefix = parsed.keyPrefix; + s3Client = await getBunS3ClientForBucket(s3Bucket); + console.verbose('[thumbnail] S3 thumbnail output', { bucket: s3Bucket, keyPrefix: s3KeyPrefix || '(root)' }); + } + for (const frameNum of framesToProcess) { - const pixelData = await readPixelData(reader, studyUID, targetSeriesUID, instanceMetadata, frameNum); - const frameTransferSyntaxUid = pixelData.transferSyntaxUid; - if (!frameTransferSyntaxUid) throw new Error('Could not determine transfer syntax UID from pixel data'); - if (!writer || lastTransferSyntaxUid !== frameTransferSyntaxUid) { - writer = new FileDicomWebWriter( - { - studyInstanceUid: studyUID, - seriesInstanceUid: targetSeriesUID, - sopInstanceUid: targetInstanceUID, - transferSyntaxUid: frameTransferSyntaxUid, - }, - { baseDir: outputDicomdir || dicomdir } - ); - lastTransferSyntaxUid = frameTransferSyntaxUid; - } - let imageFrame = pixelData.binaryData; - if (imageFrame instanceof ArrayBuffer) imageFrame = new Uint8Array(imageFrame); const thumbnailFilename = framesToProcess.length > 1 ? `thumbnail-${frameNum}` : 'thumbnail'; - await StaticWado.internalGenerateImage(imageFrame, null, instanceMetadata, frameTransferSyntaxUid, async buffer => { - if (!buffer) return; - const thumbnailStreamInfo = await writer.openInstanceStream(thumbnailFilename, { gzip: false }); - thumbnailStreamInfo.stream.write(Buffer.from(buffer)); - await writer.closeStream(thumbnailStreamInfo.streamKey); - }); + + if (!force) { + try { + const exists = await thumbnailExistsAtOutput({ + outputDicomdir, + dicomdir, + studyUID, + seriesUID: targetSeriesUID, + instanceUID: targetInstanceUID, + level: 'instance', + filename: thumbnailFilename, + }); + if (exists) { + logThumbnailAlreadyExists({ + dicomdir, + outputDicomdir, + studyUID, + seriesUID: targetSeriesUID, + instanceUID: targetInstanceUID, + level: 'instance', + filename: thumbnailFilename, + }); + continue; + } + } catch (error) { + console.verbose(`[thumbnail] could not check existing thumbnail for frame ${frameNum}; will attempt generation`, error); + } + } + + try { + const pixelData = await readPixelData(reader, studyUID, targetSeriesUID, instanceMetadata, frameNum); + const frameTransferSyntaxUid = pixelData.transferSyntaxUid; + if (!useS3 && (!writer || lastTransferSyntaxUid !== frameTransferSyntaxUid)) { + writer = new FileDicomWebWriter( + { + studyInstanceUid: studyUID, + seriesInstanceUid: targetSeriesUID, + sopInstanceUid: targetInstanceUID, + transferSyntaxUid: frameTransferSyntaxUid, + }, + { baseDir: outBase } + ); + lastTransferSyntaxUid = frameTransferSyntaxUid; + } + let imageFrame = pixelData.binaryData; + if (imageFrame instanceof ArrayBuffer) imageFrame = new Uint8Array(imageFrame); + await StaticWado.internalGenerateImage(imageFrame, null, instanceMetadata, frameTransferSyntaxUid, async buffer => { + if (!buffer) return; + if (useS3) { + const relKey = thumbnailRelativeKey( + 'instance', + studyUID, + targetSeriesUID, + targetInstanceUID, + thumbnailFilename + ); + const key = joinS3ObjectKey(s3KeyPrefix, relKey); + await putS3ThumbnailJpeg(s3Client, key, Buffer.from(buffer), s3Bucket); + } else { + const thumbnailStreamInfo = await writer.openInstanceStream(thumbnailFilename, { gzip: false }); + thumbnailStreamInfo.stream.write(Buffer.from(buffer)); + await writer.closeStream(thumbnailStreamInfo.streamKey); + } + logThumbnailWritten({ + dicomdir, + outputDicomdir, + studyUID, + seriesUID: targetSeriesUID, + instanceUID: targetInstanceUID, + level: 'instance', + filename: thumbnailFilename, + }); + }); + } catch (error) { + warnThumbnailSkippedDicom({ + studyUID, + seriesUID: targetSeriesUID, + instanceUID: targetInstanceUID, + level: `instance/frame-${frameNum}`, + error, + }); + } } } @@ -297,6 +610,7 @@ async function generateForStudy(studyUID, options = {}) { * @param {number[]} [options.frameNumbers] - Array of frame numbers to generate thumbnails for (default: [1]) * @param {boolean} [options.seriesThumbnail] - Generate thumbnails for series (middle SOP instance, middle frame for multiframe) * @param {boolean} [options.allThumbnails] - Generate thumbnails for all SOP instances, all series, and study level + * @param {boolean} [options.force] - Regenerate even when output thumbnail already exists */ export async function thumbnailMain(studySelector, options = {}) { const { dicomdir, outputDicomdir } = options; @@ -313,12 +627,21 @@ export async function thumbnailMain(studySelector, options = {}) { if ((outputDicomdir || dicomdir).startsWith('http')) { throw new Error('Thumbnail output must be a file path, not an http(s) endpoint'); } + + console.verbose('[thumbnail] thumbnailMain', { + studySelector: studySelector || 'true', + dicomdir, + outputDicomdir: outputDicomdir ?? '(same as dicomdir)', + reader: reader.constructor?.name ?? typeof reader, + }); + const studyUIDs = await resolveStudyUIDs(reader, studySelector || 'true'); + console.verbose('[thumbnail] resolved studies', studyUIDs.length, studyUIDs); if (!studyUIDs.length) { throw new Error(`No studies matched selector: ${studySelector || 'true'}`); } for (const studyUID of studyUIDs) { await generateForStudy(studyUID, { ...options, reader, dicomdir, outputDicomdir }); } - console.log(`Thumbnail generation completed for ${studyUIDs.length} study(ies)`); + console.noQuiet(`Thumbnail generation completed for ${studyUIDs.length} study(ies)`); } diff --git a/packages/create-dicomweb/lib/instance/HttpDicomWebReader.mjs b/packages/create-dicomweb/lib/instance/HttpDicomWebReader.mjs index 6130b3c3..608c3fd8 100644 --- a/packages/create-dicomweb/lib/instance/HttpDicomWebReader.mjs +++ b/packages/create-dicomweb/lib/instance/HttpDicomWebReader.mjs @@ -1,6 +1,22 @@ import { Readable } from 'stream'; import { DicomWebReader } from './DicomWebReader.mjs'; +/** + * Extract transfer syntax UID from WADO-RS / fetch response headers. + * @param {Headers} headers + * @returns {string|null} + */ +function transferSyntaxUidFromResponseHeaders(headers) { + const ct = headers.get('content-type') || ''; + const m = ct.match(/transfer-syntax\s*=\s*["']?([0-9.]+)["']?/i); + if (m) return m[1]; + for (const name of ['x-transfer-syntax-uid', 'x-dicom-transfer-syntax']) { + const v = headers.get(name); + if (v?.trim()) return v.trim(); + } + return null; +} + function normalizeBaseUrl(baseUrl) { return baseUrl.replace(/\/+$/, ''); } @@ -61,7 +77,15 @@ export class HttpDicomWebReader extends DicomWebReader { } async _fetch(url) { + console.verbose('[HttpDicomWebReader] GET', url); const response = await fetch(url); + const ct = response.headers.get('content-type'); + const cl = response.headers.get('content-length'); + console.verbose( + `[HttpDicomWebReader] <- ${response.status} ${response.statusText}`, + ct ? `content-type=${ct}` : '', + cl != null ? `content-length=${cl}` : '' + ); if (response.status === 404) return null; if (!response.ok) { throw new Error(`HTTP ${response.status} for ${url}`); @@ -72,12 +96,16 @@ export class HttpDicomWebReader extends DicomWebReader { async readJsonFile(relativePath, filename) { const qidoPath = this._mapRelativePathToQido(relativePath, filename); if (qidoPath) { - const response = await this._fetch(joinUrlPath(this.baseUrl, qidoPath)); + const reqUrl = joinUrlPath(this.baseUrl, qidoPath); + console.verbose('[HttpDicomWebReader] readJsonFile (QIDO)', reqUrl); + const response = await this._fetch(reqUrl); if (!response) return undefined; return response.json(); } - const response = await this._fetch(joinUrlPath(this.baseUrl, relativePath, filename)); + const reqUrl = joinUrlPath(this.baseUrl, relativePath, filename); + console.verbose('[HttpDicomWebReader] readJsonFile', reqUrl); + const response = await this._fetch(reqUrl); if (!response) return undefined; return response.json(); } @@ -97,10 +125,15 @@ export class HttpDicomWebReader extends DicomWebReader { const response = await this._fetch(url); if (!response) return null; const binaryData = await response.arrayBuffer(); + const contentType = response.headers.get('content-type') || 'application/octet-stream'; + const transferSyntaxUid = transferSyntaxUidFromResponseHeaders(response.headers); + console.verbose( + `[HttpDicomWebReader] readBulkData frame=${frameNumber ?? 'n/a'} bytes=${binaryData.byteLength} transferSyntaxUid=${transferSyntaxUid ?? '(not in headers)'}` + ); return { binaryData, - transferSyntaxUid: null, - contentType: response.headers.get('content-type') || 'application/octet-stream', + transferSyntaxUid, + contentType, }; } @@ -109,7 +142,9 @@ export class HttpDicomWebReader extends DicomWebReader { if (typeof studySelector === 'string' && studySelector !== 'true') { query = `?${studySelector}`; } - const response = await this._fetch(joinUrlPath(this.baseUrl, `studies${query}`)); + const reqUrl = joinUrlPath(this.baseUrl, `studies${query}`); + console.verbose('[HttpDicomWebReader] queryStudies', reqUrl); + const response = await this._fetch(reqUrl); if (!response) return []; const list = await response.json(); return Array.isArray(list) ? list : []; diff --git a/packages/create-dicomweb/lib/instance/s3ThumbnailOutput.mjs b/packages/create-dicomweb/lib/instance/s3ThumbnailOutput.mjs new file mode 100644 index 00000000..3dd48e16 --- /dev/null +++ b/packages/create-dicomweb/lib/instance/s3ThumbnailOutput.mjs @@ -0,0 +1,292 @@ +/** + * S3 thumbnail upload helpers using Bun's native {@link https://bun.com/reference/bun/S3Client S3Client}. + * Thumbnail output to `s3://` requires running under the Bun runtime (`bun run` / `bunx`), not Node. + */ + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +/** + * Minimal INI parse for `~/.aws/credentials` (section headers + key = value lines). + * @param {string} content + * @returns {Record>} + */ +function parseIniSections(content) { + /** @type {Record>} */ + const profiles = {}; + let section = ''; + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith(';')) continue; + const sectionMatch = /^\[([^\]]+)\]\s*$/.exec(trimmed); + if (sectionMatch) { + section = sectionMatch[1].trim(); + if (section.toLowerCase().startsWith('profile ')) { + section = section.slice('profile '.length).trim(); + } + if (!profiles[section]) profiles[section] = {}; + continue; + } + const eq = trimmed.indexOf('='); + if (eq === -1 || !section) continue; + const k = trimmed.slice(0, eq).trim(); + let v = trimmed.slice(eq + 1).trim(); + if ( + (v.startsWith('"') && v.endsWith('"')) || + (v.startsWith("'") && v.endsWith("'")) + ) { + v = v.slice(1, -1); + } + profiles[section][k] = v; + } + return profiles; +} + +/** + * Trims keys/secrets; drops empty session tokens. + * Long-term IAM user access keys start with **AKIA** and must **not** send `x-amz-security-token`. + * A leftover `aws_session_token` in ~/.aws/credentials (e.g. from a copied block) causes + * "The provided token is malformed or otherwise invalid." + * + * Temporary STS credentials use **ASIA** and require `sessionToken`. + * + * @param {{ accessKeyId: string, secretAccessKey: string, sessionToken?: string }} creds + * @returns {{ accessKeyId: string, secretAccessKey: string, sessionToken?: string }} + */ +function normalizeCredentialsForS3(creds) { + const accessKeyId = typeof creds.accessKeyId === 'string' ? creds.accessKeyId.trim() : ''; + const secretAccessKey = typeof creds.secretAccessKey === 'string' ? creds.secretAccessKey.trim() : ''; + let sessionToken = + typeof creds.sessionToken === 'string' ? creds.sessionToken.trim() : undefined; + if (!sessionToken) sessionToken = undefined; + + const hadSession = Boolean(sessionToken); + if (accessKeyId.startsWith('AKIA')) { + if (hadSession) { + console.verbose( + '[S3] omitting session token: access key is AKIA (long-term IAM); aws_session_token must not be sent' + ); + } + sessionToken = undefined; + } + + return { + accessKeyId, + secretAccessKey, + ...(sessionToken ? { sessionToken } : {}), + }; +} + +/** + * Reads access keys from `~/.aws/credentials` for profile `default` (or `AWS_PROFILE`). + * Does not use the AWS SDK — plain file read + INI-style parse. + * + * @returns {{ accessKeyId: string, secretAccessKey: string, sessionToken?: string } | null} + */ +export function readAwsCredentialsFromSharedFile() { + const credentialsPath = path.join(os.homedir(), '.aws', 'credentials'); + try { + if (!fs.existsSync(credentialsPath)) { + return null; + } + const content = fs.readFileSync(credentialsPath, 'utf8'); + const profiles = parseIniSections(content); + const profile = process.env.AWS_PROFILE || 'default'; + const keys = profiles[profile]; + if (!keys) { + return null; + } + const accessKeyId = keys.aws_access_key_id || keys.AWS_ACCESS_KEY_ID; + const secretAccessKey = keys.aws_secret_access_key || keys.AWS_SECRET_ACCESS_KEY; + const sessionToken = keys.aws_session_token || keys.AWS_SESSION_TOKEN; + if (!accessKeyId || !secretAccessKey) { + return null; + } + return normalizeCredentialsForS3({ + accessKeyId, + secretAccessKey, + ...(sessionToken ? { sessionToken } : {}), + }); + } catch { + return null; + } +} + +/** + * Env vars take precedence; otherwise `~/.aws/credentials` (default profile). + * @returns {{ source: string, creds: { accessKeyId?: string, secretAccessKey?: string, sessionToken?: string } }} + */ +function resolveAwsCredentialsForBun() { + if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) { + return { + source: 'environment', + creds: normalizeCredentialsForS3({ + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + ...(process.env.AWS_SESSION_TOKEN && { sessionToken: process.env.AWS_SESSION_TOKEN }), + }), + }; + } + const fromFile = readAwsCredentialsFromSharedFile(); + if (fromFile) { + return { + source: 'shared-credentials-file (~/.aws/credentials)', + creds: normalizeCredentialsForS3(fromFile), + }; + } + return { source: 'none', creds: {} }; +} + +/** + * True if the string is an S3 bucket URI for output (s3://bucket or s3://bucket/prefix). + * @param {string} location + * @returns {boolean} + */ +export function isS3OutputUri(location) { + return typeof location === 'string' && /^s3:\/\//i.test(location.trim()); +} + +/** + * @param {string} uri - s3://bucket or s3://bucket/optional/prefix + * @returns {{ bucket: string, keyPrefix: string }} + */ +export function parseS3OutputUri(uri) { + const s = uri.trim(); + const m = s.match(/^s3:\/\/([^/]+)(?:\/(.*))?$/i); + if (!m) { + throw new Error(`Invalid S3 URI (expected s3://bucket or s3://bucket/prefix): ${uri}`); + } + const bucket = m[1]; + if (!bucket) { + throw new Error(`S3 URI missing bucket name: ${uri}`); + } + let keyPrefix = (m[2] || '').replace(/\\/g, '/').replace(/\/+$/, ''); + return { bucket, keyPrefix }; +} + +/** + * DICOMweb-relative path for a thumbnail, matching FileDicomWebWriter layout. + * @param {'study'|'series'|'instance'} level + * @param {string} studyUID + * @param {string} seriesUID + * @param {string} [sopInstanceUid] + * @param {string} filename - e.g. thumbnail or thumbnail-2 + * @returns {string} key path without bucket (no leading slash) + */ +export function thumbnailRelativeKey(level, studyUID, seriesUID, sopInstanceUid, filename) { + if (level === 'study') { + return `studies/${studyUID}/${filename}`; + } + if (level === 'series') { + return `studies/${studyUID}/series/${seriesUID}/${filename}`; + } + return `studies/${studyUID}/series/${seriesUID}/instances/${sopInstanceUid}/${filename}`; +} + +/** + * @param {string} keyPrefix - optional prefix inside the bucket + * @param {string} relativeKey - path from DICOMweb root + */ +export function joinS3ObjectKey(keyPrefix, relativeKey) { + const rel = relativeKey.replace(/^\/+/, '').replace(/\\/g, '/'); + if (!keyPrefix) return rel; + const pre = keyPrefix.replace(/\/+$/, ''); + return `${pre}/${rel}`; +} + +let s3ClientCtorPromise; + +/** + * @returns {Promise} + */ +async function getS3ClientConstructor() { + if (typeof Bun === 'undefined') { + throw new Error( + 'Thumbnail output to s3:// requires the Bun runtime. Run with: bun bin/createdicomweb.mjs thumbnail ... (or bunx createdicomweb thumbnail ...)' + ); + } + if (!s3ClientCtorPromise) { + s3ClientCtorPromise = import('bun').then(m => { + const Ctor = m.S3Client; + if (!Ctor) { + throw new Error('Bun S3Client is not available; upgrade Bun to a version with S3 support.'); + } + return Ctor; + }); + } + return s3ClientCtorPromise; +} + +const bucketClientCache = new Map(); + +/** + * Returns a Bun {@link import('bun').S3Client} scoped to one bucket (cached). + * + * Credentials (no AWS SDK): `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` if set, else + * the **`[default]`** profile in **`~/.aws/credentials`** (or **`AWS_PROFILE`**). Region from + * `AWS_REGION` / `AWS_DEFAULT_REGION` / `S3_REGION`. Endpoint defaults to + * `https://s3..amazonaws.com` unless `AWS_ENDPOINT` / `S3_ENDPOINT` is set. + * + * @param {string} bucket + * @param {string} [region] + * @returns {Promise} + */ +export async function getBunS3ClientForBucket(bucket, region) { + const r = + region || + process.env.AWS_REGION || + process.env.AWS_DEFAULT_REGION || + process.env.S3_REGION || + 'us-east-1'; + const key = `${bucket}\0${r}`; + let client = bucketClientCache.get(key); + if (!client) { + const S3Client = await getS3ClientConstructor(); + const { source, creds } = resolveAwsCredentialsForBun(); + const endpoint = + process.env.AWS_ENDPOINT || process.env.S3_ENDPOINT || `https://s3.${r}.amazonaws.com`; + console.verbose('[S3] auth:', source); + client = new S3Client({ + bucket, + region: r, + endpoint, + ...creds, + }); + bucketClientCache.set(key, client); + } + return client; +} + +/** + * @param {import('bun').S3Client} client - Bun S3Client for the bucket + * @param {string} key - full object key within the bucket + * @returns {Promise} + */ +export async function s3ObjectExists(client, key) { + return client.exists(key); +} + +function thumbnailBodyByteLength(body) { + if (body == null) return 0; + if (typeof body.byteLength === 'number') return body.byteLength; + if (typeof body.length === 'number') return body.length; + return 0; +} + +/** + * @param {import('bun').S3Client} client - Bun S3Client for the bucket + * @param {string} key - full object key within the bucket + * @param {Buffer|Uint8Array} body + * @param {string} [bucketName] - bucket name for logs (if omitted, tries client.bucket / client.config.bucket) + */ +export async function putS3ThumbnailJpeg(client, key, body, bucketName) { + await client.write(key, body, { type: 'image/jpeg' }); + const bytes = thumbnailBodyByteLength(body); + let bucket = bucketName; + if (!bucket && client && typeof client === 'object') { + bucket = client.bucket || client.config?.bucket; + } + if (!bucket) bucket = '(bucket)'; + console.verbose(`[S3] PUT OK s3://${bucket}/${key} (${bytes} bytes) Content-Type=image/jpeg`); +} diff --git a/packages/cs3d/src/api/getRenderedBuffer.ts b/packages/cs3d/src/api/getRenderedBuffer.ts index 0df06b1f..0c0c03a6 100644 --- a/packages/cs3d/src/api/getRenderedBuffer.ts +++ b/packages/cs3d/src/api/getRenderedBuffer.ts @@ -12,6 +12,17 @@ function getValue(metadata, tag) { } return value.Value[0]; } + +/** PS3.x-style one-line warning for thumbnail render skips (machine-readable UIDs) */ +function warnThumbnailRenderDicom(metadata: Record | undefined, err: unknown) { + const study = metadata ? getValue(metadata, '0020000D') : undefined; + const series = metadata ? getValue(metadata, '0020000E') : undefined; + const sop = metadata ? getValue(metadata, '00080018') : undefined; + const reason = err instanceof Error ? err.message : String(err); + console.warn( + `*** DICOM warning [THUMBNAIL_RENDER] StudyInstanceUID=${study ?? 'unknown'} SeriesInstanceUID=${series ?? 'unknown'} SOPInstanceUID=${sop ?? 'unknown'}: ${reason}` + ); +} /** * It gets through callback call the rendered image into canvas. * It simulates rendering of decodedPixel data into server side (fake) canvas. @@ -50,6 +61,6 @@ export async function getRenderedBuffer( const buffer = canvasImageToBuffer(canvasDest, 'image/jpeg', quality); await doneCallback?.(buffer, canvasDest); } catch (e) { - console.warn('Unable to create rendered (thumbnail) because:', e); + warnThumbnailRenderDicom(metadata as Record, e); } } diff --git a/packages/s3-deploy/lib/S3Ops.mjs b/packages/s3-deploy/lib/S3Ops.mjs index 9292bf9e..e9086e6a 100644 --- a/packages/s3-deploy/lib/S3Ops.mjs +++ b/packages/s3-deploy/lib/S3Ops.mjs @@ -338,7 +338,9 @@ class S3Ops { }); await upload.done(); - console.verbose('Successfully uploaded', Key); + console.verbose( + `Successfully uploaded s3://${this.group.Bucket}/${Key} (${ContentSize} bytes) <- ${fileName}` + ); return true; } catch (error) { lastError = error; From 51fc5db35ed5c2e0f9d5d1493842929b3c96644e Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Fri, 8 May 2026 17:00:22 -0400 Subject: [PATCH 3/9] Fix decoding using LEI for encapsulated (multipart) data --- .../lib/commands/thumbnailMain.mjs | 76 +++++++++++++++++-- .../lib/instance/HttpDicomWebReader.mjs | 55 ++++++++++++-- .../lib/reader/readBulkData.js | 10 ++- 3 files changed, 126 insertions(+), 15 deletions(-) diff --git a/packages/create-dicomweb/lib/commands/thumbnailMain.mjs b/packages/create-dicomweb/lib/commands/thumbnailMain.mjs index bd9ee989..37fa8ee4 100644 --- a/packages/create-dicomweb/lib/commands/thumbnailMain.mjs +++ b/packages/create-dicomweb/lib/commands/thumbnailMain.mjs @@ -20,7 +20,19 @@ const { getValue } = Tags; /** Explicit VR Little Endian — default when no transfer syntax is found in headers or metadata */ const DEFAULT_TRANSFER_SYNTAX_UID = '1.2.840.10008.1.2.1'; -function getTransferSyntaxFromInstanceMetadata(metadata) { +/** + * Transfer syntax from instance metadata. + * Tag {@link Tags.AvailableTransferSyntaxUID} (00083002) is only meaningful for **single-part** bulk + * storage (e.g. raw compressed files where path/extension implies a TS family); see {@link resolveThumbnailTransferSyntaxUid}. + * + * @param {Object} metadata + * @param {{ includeAvailableTransferSyntax?: boolean }} [opts] + */ +function getTransferSyntaxFromInstanceMetadata(metadata, { includeAvailableTransferSyntax = true } = {}) { + if (includeAvailableTransferSyntax) { + const available = getValue(metadata, Tags.AvailableTransferSyntaxUID); + if (available) return available; + } const hex = getValue(metadata, Tags.TransferSyntaxUID); if (hex) return hex; const nat = metadata?.TransferSyntaxUID; @@ -29,6 +41,42 @@ function getTransferSyntaxFromInstanceMetadata(metadata) { return undefined; } +/** + * Inner multipart Content-Type `transfer-syntax=` overrides metadata (including 00083002). + * {@link Tags.AvailableTransferSyntaxUID} applies only when bulk data was **not** multipart/related-wrapped. + * + * @param {Object} bulkData - Result of {@link DicomWebReader.readBulkData} + * @param {Object} pixelData - PixelData element from instance metadata + * @param {Object} instanceMetadata + * @returns {{ transferSyntaxUid: string | undefined, resolutionNote: string }} + */ +function resolveThumbnailTransferSyntaxUid(bulkData, pixelData, instanceMetadata) { + const innerUid = bulkData.transferSyntaxUid; + if (bulkData.transferSyntaxFromInnerPart && innerUid) { + return { transferSyntaxUid: innerUid, resolutionNote: 'inner multipart Content-Type transfer-syntax' }; + } + + if (bulkData.wasMultipart) { + const ts = + innerUid || + pixelData.transferSyntaxUid || + getTransferSyntaxFromInstanceMetadata(instanceMetadata, { includeAvailableTransferSyntax: false }); + return { + transferSyntaxUid: ts, + resolutionNote: 'multipart bulk without inner transfer-syntax (tag 00083002 not applied)', + }; + } + + const ts = + innerUid || + pixelData.transferSyntaxUid || + getTransferSyntaxFromInstanceMetadata(instanceMetadata, { includeAvailableTransferSyntax: true }); + return { + transferSyntaxUid: ts, + resolutionNote: 'single-part bulk (may use tag 00083002 AvailableTransferSyntaxUID)', + }; +} + function parseSelectorToQuery(selector) { const params = new URLSearchParams(selector); const query = {}; @@ -171,15 +219,23 @@ async function readPixelData(reader, studyUID, seriesUID, instanceMetadata, fram throw new Error(`Failed to read bulk data for frame ${frameNumber}`); } - const fromMeta = getTransferSyntaxFromInstanceMetadata(instanceMetadata); - let transferSyntaxUid = - bulkData.transferSyntaxUid || - pixelData.transferSyntaxUid || - fromMeta; + const { transferSyntaxUid: resolvedTs, resolutionNote } = resolveThumbnailTransferSyntaxUid( + bulkData, + pixelData, + instanceMetadata + ); + let transferSyntaxUid = resolvedTs; + + const fromMetaWithAvailable = getTransferSyntaxFromInstanceMetadata(instanceMetadata, { + includeAvailableTransferSyntax: true, + }); + const fromMetaNoAvailable = getTransferSyntaxFromInstanceMetadata(instanceMetadata, { + includeAvailableTransferSyntax: false, + }); if (!transferSyntaxUid) { console.warn( - `[thumbnail] No TransferSyntaxUID in metadata or HTTP headers for instance ${sopUID}; using default ${DEFAULT_TRANSFER_SYNTAX_UID}. If decoding fails, inspect responses with -v.` + `[thumbnail] No transfer syntax for instance ${sopUID} (${resolutionNote}); using default ${DEFAULT_TRANSFER_SYNTAX_UID}. If decoding fails, inspect responses with -v.` ); transferSyntaxUid = DEFAULT_TRANSFER_SYNTAX_UID; } @@ -187,10 +243,14 @@ async function readPixelData(reader, studyUID, seriesUID, instanceMetadata, fram console.verbose('[thumbnail] readPixelData resolved', { sopInstanceUID: sopUID, transferSyntaxUid, + resolutionNote, + wasMultipart: !!bulkData.wasMultipart, + transferSyntaxFromInnerPart: !!bulkData.transferSyntaxFromInnerPart, sources: { bulkDataResponse: bulkData.transferSyntaxUid ?? '(none)', pixelDataTag: pixelData.transferSyntaxUid ?? '(none)', - instanceMetadata: fromMeta ?? '(none)', + metadata00083002: fromMetaWithAvailable ?? '(none)', + metadata00020010only: fromMetaNoAvailable ?? '(none)', }, contentType: bulkData.contentType, byteLength: diff --git a/packages/create-dicomweb/lib/instance/HttpDicomWebReader.mjs b/packages/create-dicomweb/lib/instance/HttpDicomWebReader.mjs index 608c3fd8..ea2dce5c 100644 --- a/packages/create-dicomweb/lib/instance/HttpDicomWebReader.mjs +++ b/packages/create-dicomweb/lib/instance/HttpDicomWebReader.mjs @@ -1,6 +1,18 @@ import { Readable } from 'stream'; +import { extractMultipart } from '@radicalimaging/static-wado-util'; import { DicomWebReader } from './DicomWebReader.mjs'; +/** + * Extract transfer-syntax UID from a Content-Type header value (HTTP or MIME part). + * @param {string} ct + * @returns {string|null} + */ +function transferSyntaxUidFromContentTypeHeader(ct) { + if (!ct) return null; + const m = ct.match(/transfer-syntax\s*=\s*["']?([0-9.]+)["']?/i); + return m ? m[1] : null; +} + /** * Extract transfer syntax UID from WADO-RS / fetch response headers. * @param {Headers} headers @@ -8,8 +20,8 @@ import { DicomWebReader } from './DicomWebReader.mjs'; */ function transferSyntaxUidFromResponseHeaders(headers) { const ct = headers.get('content-type') || ''; - const m = ct.match(/transfer-syntax\s*=\s*["']?([0-9.]+)["']?/i); - if (m) return m[1]; + const fromCt = transferSyntaxUidFromContentTypeHeader(ct); + if (fromCt) return fromCt; for (const name of ['x-transfer-syntax-uid', 'x-dicom-transfer-syntax']) { const v = headers.get(name); if (v?.trim()) return v.trim(); @@ -124,16 +136,47 @@ export class HttpDicomWebReader extends DicomWebReader { const url = this._resolveBulkDataPath(studyUID, seriesUID, bulkDataURI, frameNumber); const response = await this._fetch(url); if (!response) return null; - const binaryData = await response.arrayBuffer(); - const contentType = response.headers.get('content-type') || 'application/octet-stream'; - const transferSyntaxUid = transferSyntaxUidFromResponseHeaders(response.headers); + let binaryData = await response.arrayBuffer(); + const outerContentType = response.headers.get('content-type') || 'application/octet-stream'; + let contentType = outerContentType; + let transferSyntaxUid = transferSyntaxUidFromResponseHeaders(response.headers); + let wasMultipart = false; + let transferSyntaxFromInnerPart = false; + + /** WADO-RS frame bodies are often multipart/related: decode inner part and read transfer-syntax from its Content-Type */ + if (/multipart/i.test(outerContentType)) { + wasMultipart = true; + try { + const extracted = extractMultipart(outerContentType, binaryData); + if (extracted?.pixelData?.byteLength) { + binaryData = extracted.pixelData; + const partCt = extracted.multipartContentType || extracted.contentType || ''; + const fromPart = transferSyntaxUidFromContentTypeHeader(partCt); + if (fromPart) { + transferSyntaxUid = fromPart; + transferSyntaxFromInnerPart = true; + } + if (extracted.contentType && extracted.contentType !== outerContentType) { + contentType = extracted.contentType; + } + console.verbose( + `[HttpDicomWebReader] readBulkData extracted multipart part bytes=${binaryData.byteLength} partContentType=${partCt.slice(0, 120)} transferSyntaxUid=${transferSyntaxUid ?? '(not in part)'} transferSyntaxFromInnerPart=${transferSyntaxFromInnerPart}` + ); + } + } catch (err) { + console.verbose('[HttpDicomWebReader] multipart extract failed; using full response buffer', err); + } + } + console.verbose( - `[HttpDicomWebReader] readBulkData frame=${frameNumber ?? 'n/a'} bytes=${binaryData.byteLength} transferSyntaxUid=${transferSyntaxUid ?? '(not in headers)'}` + `[HttpDicomWebReader] readBulkData frame=${frameNumber ?? 'n/a'} bytes=${binaryData.byteLength} transferSyntaxUid=${transferSyntaxUid ?? '(not in headers)'} wasMultipart=${wasMultipart}` ); return { binaryData, transferSyntaxUid, contentType, + wasMultipart, + transferSyntaxFromInnerPart, }; } diff --git a/packages/static-wado-util/lib/reader/readBulkData.js b/packages/static-wado-util/lib/reader/readBulkData.js index 7be01b46..9151c5d7 100644 --- a/packages/static-wado-util/lib/reader/readBulkData.js +++ b/packages/static-wado-util/lib/reader/readBulkData.js @@ -89,6 +89,8 @@ const readBulkData = async (dirSrc, baseName, frame) => { binaryData: data.buffer, contentType, transferSyntaxUid, + wasMultipart: false, + transferSyntaxFromInnerPart: false, }; } @@ -121,7 +123,13 @@ const readBulkData = async (dirSrc, baseName, frame) => { const binaryData = data.buffer.slice(startData, endData); - return { binaryData, contentType, transferSyntaxUid }; + return { + binaryData, + contentType, + transferSyntaxUid, + wasMultipart: true, + transferSyntaxFromInnerPart: Boolean(transferSyntaxUid), + }; }; module.exports = readBulkData; From ca717cde035b9d022ec5b97971c52bd58ed3ad9d Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Fri, 8 May 2026 17:05:37 -0400 Subject: [PATCH 4/9] Print correct URL for testing --- .../create-dicomweb/lib/commands/thumbnailMain.mjs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/create-dicomweb/lib/commands/thumbnailMain.mjs b/packages/create-dicomweb/lib/commands/thumbnailMain.mjs index 37fa8ee4..7ac7473f 100644 --- a/packages/create-dicomweb/lib/commands/thumbnailMain.mjs +++ b/packages/create-dicomweb/lib/commands/thumbnailMain.mjs @@ -119,7 +119,9 @@ function outputRoot(outputDicomdir, dicomdir) { } /** - * Same display URL as {@link logThumbnailWritten}: https://, file://, or s3:// + * Browser-oriented URL for logs: when output is S3, prefer {@link dicomdir} as HTTPS prefix so the path + * matches where reads are served; fall back to `s3://` only if there is no HTTP retrieve base. + * Otherwise same as before: https from dicomdir, or file:// for local output. * @param {Object} p */ function formatThumbnailOutputHref({ dicomdir, outputDicomdir, studyUID, seriesUID, instanceUID, level, filename }) { @@ -127,6 +129,11 @@ function formatThumbnailOutputHref({ dicomdir, outputDicomdir, studyUID, seriesU const outBase = outputRoot(outputDicomdir, dicomdir); if (isS3OutputUri(outBase)) { + const retrieveBase = String(dicomdir ?? '').trim(); + if (/^https?:\/\//i.test(retrieveBase)) { + const base = retrieveBase.replace(/\/?$/, '/'); + return new URL(rel, base).href; + } const { bucket, keyPrefix } = parseS3OutputUri(outBase); const key = joinS3ObjectKey(keyPrefix, rel); return `s3://${bucket}/${key}`; @@ -143,7 +150,7 @@ function formatThumbnailOutputHref({ dicomdir, outputDicomdir, studyUID, seriesU } /** - * In non-quiet mode, print where the thumbnail was written: https://, file://, or s3:// + * In non-quiet mode, print where the thumbnail was written (retrieve HTTPS when S3 output + HTTP dicomdir). */ function logThumbnailWritten(params) { console.noQuiet('Thumbnail written:', formatThumbnailOutputHref(params)); From 3c61544ace26ee38ef809829fa835fea11119a97 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Fri, 8 May 2026 17:30:33 -0400 Subject: [PATCH 5/9] Support video thumbnails --- .../lib/commands/thumbnailMain.mjs | 449 ++++++++++++------ packages/static-wado-creator/lib/index.mjs | 11 + .../lib/writer/VideoWriter.js | 13 + 3 files changed, 317 insertions(+), 156 deletions(-) diff --git a/packages/create-dicomweb/lib/commands/thumbnailMain.mjs b/packages/create-dicomweb/lib/commands/thumbnailMain.mjs index 7ac7473f..fd3e5b27 100644 --- a/packages/create-dicomweb/lib/commands/thumbnailMain.mjs +++ b/packages/create-dicomweb/lib/commands/thumbnailMain.mjs @@ -1,5 +1,7 @@ import fs from 'fs'; +import os from 'os'; import path from 'path'; +import { pipeline } from 'stream/promises'; import { pathToFileURL } from 'url'; import { DicomWebStream } from '../instance/DicomWebStream.mjs'; import { FileDicomWebWriter } from '../instance/FileDicomWebWriter.mjs'; @@ -12,8 +14,16 @@ import { putS3ThumbnailJpeg, s3ObjectExists, } from '../instance/s3ThumbnailOutput.mjs'; -import { Tags, qidoFilter, handleHomeRelative } from '@radicalimaging/static-wado-util'; -import StaticWado from '@radicalimaging/static-wado-creator'; +import { + Tags, + qidoFilter, + handleHomeRelative, + execSpawn, +} from '@radicalimaging/static-wado-util'; +import StaticWado, { + getVideoFileExtensionForTransferSyntaxUid, + isVideoTransferSyntaxUid, +} from '@radicalimaging/static-wado-creator'; const { getValue } = Tags; @@ -77,6 +87,201 @@ function resolveThumbnailTransferSyntaxUid(bulkData, pixelData, instanceMetadata }; } +function getMetadataTransferSyntaxForRouting(metadata) { + return getTransferSyntaxFromInstanceMetadata(metadata, { includeAvailableTransferSyntax: true }); +} + +function hasPhotometricInterpretation(metadata) { + return Boolean(getValue(metadata, Tags.PhotometricInterpretation)); +} + +function isSegInstance(metadata) { + return getValue(metadata, Tags.Modality) === 'SEG'; +} + +/** + * @returns {{ ok: boolean, reason?: string, metaTs?: string }} + */ +function thumbnailInstancePrecheck(metadata, force) { + const metaTs = getMetadataTransferSyntaxForRouting(metadata); + const isVid = metaTs && isVideoTransferSyntaxUid(metaTs); + if (!isVid && !hasPhotometricInterpretation(metadata)) { + return { ok: false, reason: 'missing PhotometricInterpretation (00280004)', metaTs }; + } + if (isSegInstance(metadata) && !force) { + return { ok: false, reason: 'Modality is SEG (use --force to thumbnail)', metaTs }; + } + return { ok: true, metaTs }; +} + +async function loadVideoBytesFromPixelBulk(reader, studyUID, seriesUID, instanceMetadata, frameNumber = 1) { + const sopUID = getValue(instanceMetadata, Tags.SOPInstanceUID); + const pixelData = instanceMetadata[Tags.PixelData]; + if (!pixelData?.BulkDataURI) { + console.verbose('[thumbnail] video bulk fallback: no PixelData BulkDataURI'); + return null; + } + try { + const bulkData = await reader.readBulkData( + studyUID, + seriesUID, + pixelData.BulkDataURI, + frameNumber, + sopUID + ); + if (!bulkData?.binaryData) return null; + const bd = bulkData.binaryData; + const buf = bd instanceof ArrayBuffer ? Buffer.from(bd) : Buffer.from(bd); + return buf.byteLength ? buf : null; + } catch (e) { + console.verbose('[thumbnail] video bulk fallback read failed', e); + return null; + } +} + +/** + * Stream `instances/.../rendered/index.` to disk (avoids holding full clip in JS heap). + * @returns {Promise} + */ +async function streamRenderedVideoToFile(reader, studyUID, seriesUID, instanceUID, ext, destPath) { + const filename = path.posix.join('rendered', `index.${ext}`); + let stream; + try { + stream = await reader.openInstanceInputStream(studyUID, seriesUID, instanceUID, filename); + } catch { + return false; + } + if (!stream) return false; + const out = fs.createWriteStream(destPath); + try { + await pipeline(stream, out); + return true; + } catch (e) { + console.verbose('[thumbnail] stream rendered video to disk failed', e); + try { + await fs.promises.unlink(destPath); + } catch { + /* ignore */ + } + return false; + } +} + +async function ffmpegExtractFirstFrameToJpeg(videoPath, jpegOutPath) { + const code = await execSpawn( + `ffmpeg -hide_banner -loglevel error -i "${videoPath}" -y -f image2 -frames:v 1 -update 1 "${jpegOutPath}"` + ); + if (code !== 0) { + throw new Error(`ffmpeg exited with code ${code}`); + } +} + +/** + * Video transfer syntax: prefer `rendered/index.`; if missing, read encapsulated video from PixelData bulk (same bits as VideoWriter). + * @returns {Promise} JPEG bytes, or null if not a video TS / nothing readable + */ +async function tryThumbnailFromRenderedVideo( + reader, + studyUID, + seriesUID, + instanceUID, + instanceMetadata, + frameNumber = 1 +) { + const metaTs = getMetadataTransferSyntaxForRouting(instanceMetadata); + if (!metaTs || !isVideoTransferSyntaxUid(metaTs)) return null; + + const ext = getVideoFileExtensionForTransferSyntaxUid(metaTs); + if (!ext) return null; + + console.verbose('[thumbnail] video TS — trying rendered/index.' + ext, { metaTs }); + + const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'cdw-thumb-')); + const vidPath = path.join(tmpDir, `video.${ext}`); + const jpgPath = path.join(tmpDir, 'thumb.jpg'); + try { + let haveVideo = await streamRenderedVideoToFile(reader, studyUID, seriesUID, instanceUID, ext, vidPath); + if (!haveVideo) { + console.verbose('[thumbnail] video TS — no rendered stream; PixelData bulk frame', { frameNumber }); + const vidBuf = await loadVideoBytesFromPixelBulk(reader, studyUID, seriesUID, instanceMetadata, frameNumber); + if (!vidBuf?.length) return null; + await fs.promises.writeFile(vidPath, vidBuf); + } + + await ffmpegExtractFirstFrameToJpeg(vidPath, jpgPath); + return await fs.promises.readFile(jpgPath); + } finally { + await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } +} + +async function persistThumbnailJpeg({ + jpegBuffer, + dicomdir, + outputDicomdir, + studyUID, + seriesUID, + instanceUID, + level, + filename, + transferSyntaxUidForWriter, +}) { + const outBase = outputRoot(outputDicomdir, dicomdir); + const useS3 = isS3OutputUri(outBase); + + let s3Client; + let s3Bucket; + let s3KeyPrefix; + if (useS3) { + const parsed = parseS3OutputUri(outBase); + s3Bucket = parsed.bucket; + s3KeyPrefix = parsed.keyPrefix; + s3Client = await getBunS3ClientForBucket(s3Bucket); + } + + const writer = useS3 + ? null + : new FileDicomWebWriter( + { + studyInstanceUid: studyUID, + seriesInstanceUid: seriesUID, + sopInstanceUid: instanceUID, + transferSyntaxUid: transferSyntaxUidForWriter, + }, + { baseDir: outBase } + ); + + const buf = Buffer.from(jpegBuffer); + + if (useS3) { + const relKey = thumbnailRelativeKey(level, studyUID, seriesUID, instanceUID, filename); + const key = joinS3ObjectKey(s3KeyPrefix, relKey); + await putS3ThumbnailJpeg(s3Client, key, buf, s3Bucket); + } else { + let thumbnailStreamInfo; + if (level === 'study') { + thumbnailStreamInfo = await writer.openStudyStream(filename, { gzip: false }); + } else if (level === 'series') { + thumbnailStreamInfo = await writer.openSeriesStream(filename, { gzip: false }); + } else { + thumbnailStreamInfo = await writer.openInstanceStream(filename, { gzip: false }); + } + + thumbnailStreamInfo.stream.write(buf); + await writer.closeStream(thumbnailStreamInfo.streamKey); + } + + logThumbnailWritten({ + dicomdir, + outputDicomdir, + studyUID, + seriesUID, + instanceUID, + level, + filename, + }); +} + function parseSelectorToQuery(selector) { const params = new URLSearchParams(selector); const query = {}; @@ -283,6 +488,7 @@ async function writeThumbnailForTarget({ frameNumber, level, force, + thumbnailFilename = 'thumbnail', }) { const instanceUID = getValue(instanceMetadata, Tags.SOPInstanceUID); if (!instanceUID) { @@ -295,10 +501,23 @@ async function writeThumbnailForTarget({ seriesUID, instanceUID, frameNumber, + thumbnailFilename, outputBase: outputRoot(outputDicomdir, dicomdir), force: !!force, }); + const pre = thumbnailInstancePrecheck(instanceMetadata, force); + if (!pre.ok) { + warnThumbnailSkippedDicom({ + studyUID, + seriesUID, + instanceUID, + level, + error: new Error(pre.reason), + }); + return; + } + if (!force) { try { const exists = await thumbnailExistsAtOutput({ @@ -308,7 +527,7 @@ async function writeThumbnailForTarget({ seriesUID, instanceUID, level, - filename: 'thumbnail', + filename: thumbnailFilename, }); if (exists) { logThumbnailAlreadyExists({ @@ -318,7 +537,7 @@ async function writeThumbnailForTarget({ seriesUID, instanceUID, level, - filename: 'thumbnail', + filename: thumbnailFilename, }); return; } @@ -327,83 +546,74 @@ async function writeThumbnailForTarget({ } } + const metaTs = pre.metaTs; + if (metaTs && isVideoTransferSyntaxUid(metaTs)) { + try { + const jpegBuf = await tryThumbnailFromRenderedVideo( + reader, + studyUID, + seriesUID, + instanceUID, + instanceMetadata, + frameNumber + ); + if (jpegBuf?.length) { + await persistThumbnailJpeg({ + jpegBuffer: jpegBuf, + dicomdir, + outputDicomdir, + studyUID, + seriesUID, + instanceUID, + level, + filename: thumbnailFilename, + transferSyntaxUidForWriter: DEFAULT_TRANSFER_SYNTAX_UID, + }); + return; + } + warnThumbnailSkippedDicom({ + studyUID, + seriesUID, + instanceUID, + level, + error: new Error( + 'Video transfer syntax but neither rendered/index. nor PixelData bulk could be read for ffmpeg' + ), + }); + } catch (error) { + warnThumbnailSkippedDicom({ studyUID, seriesUID, instanceUID, level, error }); + } + return; + } + try { const pixelData = await readPixelData(reader, studyUID, seriesUID, instanceMetadata, frameNumber); const transferSyntaxUid = pixelData.transferSyntaxUid; - const outBase = outputRoot(outputDicomdir, dicomdir); - const useS3 = isS3OutputUri(outBase); let imageFrame = pixelData.binaryData; if (imageFrame instanceof ArrayBuffer) { imageFrame = new Uint8Array(imageFrame); } - let s3Client; - let s3Bucket; - let s3KeyPrefix; - if (useS3) { - const parsed = parseS3OutputUri(outBase); - s3Bucket = parsed.bucket; - s3KeyPrefix = parsed.keyPrefix; - s3Client = await getBunS3ClientForBucket(s3Bucket); - } - - const writer = useS3 - ? null - : new FileDicomWebWriter( - { - studyInstanceUid: studyUID, - seriesInstanceUid: seriesUID, - sopInstanceUid: instanceUID, - transferSyntaxUid, - }, - { baseDir: outBase } - ); - - const writeThumbnailCallback = async buffer => { + await StaticWado.internalGenerateImage(imageFrame, null, instanceMetadata, transferSyntaxUid, async buffer => { if (!buffer) { console.warn( `*** DICOM warning [THUMBNAIL_SKIP] StudyInstanceUID=${studyUID} SeriesInstanceUID=${seriesUID} SOPInstanceUID=${instanceUID} Level=${level}: No thumbnail buffer generated after render` ); return; } - - if (useS3) { - const relKey = thumbnailRelativeKey(level, studyUID, seriesUID, instanceUID, 'thumbnail'); - const key = joinS3ObjectKey(s3KeyPrefix, relKey); - await putS3ThumbnailJpeg(s3Client, key, Buffer.from(buffer), s3Bucket); - } else { - let thumbnailStreamInfo; - if (level === 'study') { - thumbnailStreamInfo = await writer.openStudyStream('thumbnail', { gzip: false }); - } else if (level === 'series') { - thumbnailStreamInfo = await writer.openSeriesStream('thumbnail', { gzip: false }); - } else { - thumbnailStreamInfo = await writer.openInstanceStream('thumbnail', { gzip: false }); - } - - thumbnailStreamInfo.stream.write(Buffer.from(buffer)); - await writer.closeStream(thumbnailStreamInfo.streamKey); - } - - logThumbnailWritten({ + await persistThumbnailJpeg({ + jpegBuffer: buffer, dicomdir, outputDicomdir, studyUID, seriesUID, instanceUID, level, - filename: 'thumbnail', + filename: thumbnailFilename, + transferSyntaxUidForWriter: transferSyntaxUid, }); - }; - - await StaticWado.internalGenerateImage( - imageFrame, - null, - instanceMetadata, - transferSyntaxUid, - writeThumbnailCallback - ); + }); } catch (error) { warnThumbnailSkippedDicom({ studyUID, seriesUID, instanceUID, level, error }); } @@ -563,106 +773,33 @@ async function generateForStudy(studyUID, options = {}) { if (!instanceMetadata) throw new Error(`Instance ${instanceUid} not found in series metadata`); } const targetInstanceUID = getValue(instanceMetadata, Tags.SOPInstanceUID); - const outBase = outputRoot(outputDicomdir, dicomdir); - const useS3 = isS3OutputUri(outBase); - let writer = null; - let lastTransferSyntaxUid = null; + console.verbose('[thumbnail] instance thumbnail target', { + studyUID, + seriesUID: targetSeriesUID, + sopInstanceUID: targetInstanceUID, + hint: instanceUid ? 'from --sop-uid' : 'first entry in series metadata (use --sop-uid for another instance)', + }); - let s3Client; - let s3Bucket; - let s3KeyPrefix; - if (useS3) { - const parsed = parseS3OutputUri(outBase); - s3Bucket = parsed.bucket; - s3KeyPrefix = parsed.keyPrefix; - s3Client = await getBunS3ClientForBucket(s3Bucket); - console.verbose('[thumbnail] S3 thumbnail output', { bucket: s3Bucket, keyPrefix: s3KeyPrefix || '(root)' }); - } + const metaTsRoute = getMetadataTransferSyntaxForRouting(instanceMetadata); + const isVidInst = Boolean(metaTsRoute && isVideoTransferSyntaxUid(metaTsRoute)); + /** One ffmpeg thumbnail per video instance; multi-frame frame lists apply to regular pixel data only */ + const frameNumsToRun = + isVidInst && framesToProcess.length > 1 ? [framesToProcess[0]] : framesToProcess; - for (const frameNum of framesToProcess) { + for (const frameNum of frameNumsToRun) { const thumbnailFilename = framesToProcess.length > 1 ? `thumbnail-${frameNum}` : 'thumbnail'; - - if (!force) { - try { - const exists = await thumbnailExistsAtOutput({ - outputDicomdir, - dicomdir, - studyUID, - seriesUID: targetSeriesUID, - instanceUID: targetInstanceUID, - level: 'instance', - filename: thumbnailFilename, - }); - if (exists) { - logThumbnailAlreadyExists({ - dicomdir, - outputDicomdir, - studyUID, - seriesUID: targetSeriesUID, - instanceUID: targetInstanceUID, - level: 'instance', - filename: thumbnailFilename, - }); - continue; - } - } catch (error) { - console.verbose(`[thumbnail] could not check existing thumbnail for frame ${frameNum}; will attempt generation`, error); - } - } - - try { - const pixelData = await readPixelData(reader, studyUID, targetSeriesUID, instanceMetadata, frameNum); - const frameTransferSyntaxUid = pixelData.transferSyntaxUid; - if (!useS3 && (!writer || lastTransferSyntaxUid !== frameTransferSyntaxUid)) { - writer = new FileDicomWebWriter( - { - studyInstanceUid: studyUID, - seriesInstanceUid: targetSeriesUID, - sopInstanceUid: targetInstanceUID, - transferSyntaxUid: frameTransferSyntaxUid, - }, - { baseDir: outBase } - ); - lastTransferSyntaxUid = frameTransferSyntaxUid; - } - let imageFrame = pixelData.binaryData; - if (imageFrame instanceof ArrayBuffer) imageFrame = new Uint8Array(imageFrame); - await StaticWado.internalGenerateImage(imageFrame, null, instanceMetadata, frameTransferSyntaxUid, async buffer => { - if (!buffer) return; - if (useS3) { - const relKey = thumbnailRelativeKey( - 'instance', - studyUID, - targetSeriesUID, - targetInstanceUID, - thumbnailFilename - ); - const key = joinS3ObjectKey(s3KeyPrefix, relKey); - await putS3ThumbnailJpeg(s3Client, key, Buffer.from(buffer), s3Bucket); - } else { - const thumbnailStreamInfo = await writer.openInstanceStream(thumbnailFilename, { gzip: false }); - thumbnailStreamInfo.stream.write(Buffer.from(buffer)); - await writer.closeStream(thumbnailStreamInfo.streamKey); - } - logThumbnailWritten({ - dicomdir, - outputDicomdir, - studyUID, - seriesUID: targetSeriesUID, - instanceUID: targetInstanceUID, - level: 'instance', - filename: thumbnailFilename, - }); - }); - } catch (error) { - warnThumbnailSkippedDicom({ - studyUID, - seriesUID: targetSeriesUID, - instanceUID: targetInstanceUID, - level: `instance/frame-${frameNum}`, - error, - }); - } + await writeThumbnailForTarget({ + reader, + dicomdir, + outputDicomdir, + studyUID, + seriesUID: targetSeriesUID, + instanceMetadata, + frameNumber: frameNum, + level: 'instance', + force, + thumbnailFilename, + }); } } diff --git a/packages/static-wado-creator/lib/index.mjs b/packages/static-wado-creator/lib/index.mjs index 175577b6..bbfb900a 100644 --- a/packages/static-wado-creator/lib/index.mjs +++ b/packages/static-wado-creator/lib/index.mjs @@ -5,9 +5,20 @@ import createMain from './createMain.js'; import deleteMain from './deleteMain.js'; import adaptProgramOpts from './util/adaptProgramOpts.js'; import { uids } from '@radicalimaging/static-wado-util'; +import videoWriterFactory from './writer/VideoWriter.js'; const { configureProgram } = programIndex; +/** @param {string|object} uid - Transfer syntax UID string or dcmjs-like dataset */ +export function isVideoTransferSyntaxUid(uid) { + return videoWriterFactory.isVideo(uid); +} + +/** @param {string|object} uid */ +export function getVideoFileExtensionForTransferSyntaxUid(uid) { + return videoWriterFactory.getVideoFileExtensionForTransferSyntaxUid(uid); +} + StaticWado.mkdicomwebConfig = mkdicomwebConfig; StaticWado.createMain = createMain; StaticWado.deleteMain = deleteMain; diff --git a/packages/static-wado-creator/lib/writer/VideoWriter.js b/packages/static-wado-creator/lib/writer/VideoWriter.js index 2ee0970b..4d41e50e 100644 --- a/packages/static-wado-creator/lib/writer/VideoWriter.js +++ b/packages/static-wado-creator/lib/writer/VideoWriter.js @@ -22,6 +22,17 @@ const VIDEO_TYPES = { const isVideo = value => VIDEO_TYPES[value && value.string ? value.string(Tags.RawTransferSyntaxUID) : value]; +/** + * File extension (mp4, mpg, h265) for `rendered/index.` written by VideoWriter, or null. + * @param {string|import('dcmjs').data.DicomDict} value - Transfer syntax UID string or dataset-like object + * @returns {string|null} + */ +function getVideoFileExtensionForTransferSyntaxUid(value) { + const key = value && value.string ? value.string(Tags.RawTransferSyntaxUID) : value; + if (typeof key !== 'string') return null; + return VIDEO_TYPES[key] || null; +} + const VideoWriter = () => async function run(id, dataSet) { console.log(`Writing video ${id.sopInstanceUid}`); @@ -51,3 +62,5 @@ const VideoWriter = () => module.exports = VideoWriter; VideoWriter.isVideo = isVideo; +VideoWriter.VIDEO_TYPES = VIDEO_TYPES; +VideoWriter.getVideoFileExtensionForTransferSyntaxUid = getVideoFileExtensionForTransferSyntaxUid; From 8706be7a943ff4acd2520c729672e4536dce0032 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Wed, 13 May 2026 12:21:14 -0400 Subject: [PATCH 6/9] Use shared resolver and isHttp request --- .../create-dicomweb/bin/createdicomweb.mjs | 15 +- .../lib/commands/thumbnailMain.mjs | 1879 +++++++++-------- .../lib/instance/FileDicomWebReader.mjs | 29 +- .../lib/instance/HttpDicomWebReader.mjs | 45 +- .../lib/instance/SeriesSummary.mjs | 8 +- .../lib/instance/writeBulkdataFilter.mjs | 3 +- packages/static-wado-util/lib/index.ts | 10 + .../lib/reader/bulkDataUriResolve.js | 179 ++ .../lib/reader/readBulkData.js | 2 +- .../test/unit/bulkDataUriResolve.jest.js | 94 + .../server/createMissingThumbnail.mjs | 11 +- 11 files changed, 1353 insertions(+), 922 deletions(-) create mode 100644 packages/static-wado-util/lib/reader/bulkDataUriResolve.js create mode 100644 packages/static-wado-util/test/unit/bulkDataUriResolve.jest.js diff --git a/packages/create-dicomweb/bin/createdicomweb.mjs b/packages/create-dicomweb/bin/createdicomweb.mjs index 648148a1..c7be77ea 100755 --- a/packages/create-dicomweb/bin/createdicomweb.mjs +++ b/packages/create-dicomweb/bin/createdicomweb.mjs @@ -242,10 +242,13 @@ program .option('--series-uid ', 'Specific Series Instance UID to process (if not provided, uses first series from study query)') .option('--sop-uid ', 'Specific SOP Instance UID to process (if not provided, uses first instance from series)') .option('--frame-numbers ', 'Frame numbers to generate thumbnails for (comma-separated, supports ranges, e.g., "1-3,17")', '1') - .option('--series-thumbnail', 'Generate thumbnails for series (middle SOP instance, middle frame for multiframe)') .option( - '--all-thumbnails', - 'Generate thumbnails for every SOP instance plus series and study level (uses middle frame for multiframe)' + '--study-thumbnail', + 'Representative mode: include study-level thumbnail and only instance JPEGs for the study-representative SOP (middle series, middle instance). Combine with --series-thumbnail so every series also gets a series-level thumbnail.' + ) + .option( + '--series-thumbnail', + 'Representative mode: include series-level thumbnail(s) from each series’ middle SOP and only instance JPEGs for those representative SOPs (plus study-representative SOP when --study-thumbnail is set). Omit both flags for full study thumbnail generation.' ) .option('--force', 'Regenerate thumbnails even if they already exist at the output location') .action(async (studySelectorArg, options, command) => { @@ -268,12 +271,12 @@ program if (options.sopUid) { thumbnailOptions.instanceUid = options.sopUid; } + if (options.studyThumbnail) { + thumbnailOptions.studyThumbnail = true; + } if (options.seriesThumbnail) { thumbnailOptions.seriesThumbnail = true; } - if (options.allThumbnails) { - thumbnailOptions.allThumbnails = true; - } if (options.force) { thumbnailOptions.force = true; } diff --git a/packages/create-dicomweb/lib/commands/thumbnailMain.mjs b/packages/create-dicomweb/lib/commands/thumbnailMain.mjs index fd3e5b27..758bae7e 100644 --- a/packages/create-dicomweb/lib/commands/thumbnailMain.mjs +++ b/packages/create-dicomweb/lib/commands/thumbnailMain.mjs @@ -1,851 +1,1028 @@ -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import { pipeline } from 'stream/promises'; -import { pathToFileURL } from 'url'; -import { DicomWebStream } from '../instance/DicomWebStream.mjs'; -import { FileDicomWebWriter } from '../instance/FileDicomWebWriter.mjs'; -import { - isS3OutputUri, - parseS3OutputUri, - getBunS3ClientForBucket, - joinS3ObjectKey, - thumbnailRelativeKey, - putS3ThumbnailJpeg, - s3ObjectExists, -} from '../instance/s3ThumbnailOutput.mjs'; -import { - Tags, - qidoFilter, - handleHomeRelative, - execSpawn, -} from '@radicalimaging/static-wado-util'; -import StaticWado, { - getVideoFileExtensionForTransferSyntaxUid, - isVideoTransferSyntaxUid, -} from '@radicalimaging/static-wado-creator'; - -const { getValue } = Tags; - -/** Explicit VR Little Endian — default when no transfer syntax is found in headers or metadata */ -const DEFAULT_TRANSFER_SYNTAX_UID = '1.2.840.10008.1.2.1'; - -/** - * Transfer syntax from instance metadata. - * Tag {@link Tags.AvailableTransferSyntaxUID} (00083002) is only meaningful for **single-part** bulk - * storage (e.g. raw compressed files where path/extension implies a TS family); see {@link resolveThumbnailTransferSyntaxUid}. - * - * @param {Object} metadata - * @param {{ includeAvailableTransferSyntax?: boolean }} [opts] - */ -function getTransferSyntaxFromInstanceMetadata(metadata, { includeAvailableTransferSyntax = true } = {}) { - if (includeAvailableTransferSyntax) { - const available = getValue(metadata, Tags.AvailableTransferSyntaxUID); - if (available) return available; - } - const hex = getValue(metadata, Tags.TransferSyntaxUID); - if (hex) return hex; - const nat = metadata?.TransferSyntaxUID; - if (nat?.Value?.[0]) return nat.Value[0]; - if (typeof nat === 'string') return nat; - return undefined; -} - -/** - * Inner multipart Content-Type `transfer-syntax=` overrides metadata (including 00083002). - * {@link Tags.AvailableTransferSyntaxUID} applies only when bulk data was **not** multipart/related-wrapped. - * - * @param {Object} bulkData - Result of {@link DicomWebReader.readBulkData} - * @param {Object} pixelData - PixelData element from instance metadata - * @param {Object} instanceMetadata - * @returns {{ transferSyntaxUid: string | undefined, resolutionNote: string }} - */ -function resolveThumbnailTransferSyntaxUid(bulkData, pixelData, instanceMetadata) { - const innerUid = bulkData.transferSyntaxUid; - if (bulkData.transferSyntaxFromInnerPart && innerUid) { - return { transferSyntaxUid: innerUid, resolutionNote: 'inner multipart Content-Type transfer-syntax' }; - } - - if (bulkData.wasMultipart) { - const ts = - innerUid || - pixelData.transferSyntaxUid || - getTransferSyntaxFromInstanceMetadata(instanceMetadata, { includeAvailableTransferSyntax: false }); - return { - transferSyntaxUid: ts, - resolutionNote: 'multipart bulk without inner transfer-syntax (tag 00083002 not applied)', - }; - } - - const ts = - innerUid || - pixelData.transferSyntaxUid || - getTransferSyntaxFromInstanceMetadata(instanceMetadata, { includeAvailableTransferSyntax: true }); - return { - transferSyntaxUid: ts, - resolutionNote: 'single-part bulk (may use tag 00083002 AvailableTransferSyntaxUID)', - }; -} - -function getMetadataTransferSyntaxForRouting(metadata) { - return getTransferSyntaxFromInstanceMetadata(metadata, { includeAvailableTransferSyntax: true }); -} - -function hasPhotometricInterpretation(metadata) { - return Boolean(getValue(metadata, Tags.PhotometricInterpretation)); -} - -function isSegInstance(metadata) { - return getValue(metadata, Tags.Modality) === 'SEG'; -} - -/** - * @returns {{ ok: boolean, reason?: string, metaTs?: string }} - */ -function thumbnailInstancePrecheck(metadata, force) { - const metaTs = getMetadataTransferSyntaxForRouting(metadata); - const isVid = metaTs && isVideoTransferSyntaxUid(metaTs); - if (!isVid && !hasPhotometricInterpretation(metadata)) { - return { ok: false, reason: 'missing PhotometricInterpretation (00280004)', metaTs }; - } - if (isSegInstance(metadata) && !force) { - return { ok: false, reason: 'Modality is SEG (use --force to thumbnail)', metaTs }; - } - return { ok: true, metaTs }; -} - -async function loadVideoBytesFromPixelBulk(reader, studyUID, seriesUID, instanceMetadata, frameNumber = 1) { - const sopUID = getValue(instanceMetadata, Tags.SOPInstanceUID); - const pixelData = instanceMetadata[Tags.PixelData]; - if (!pixelData?.BulkDataURI) { - console.verbose('[thumbnail] video bulk fallback: no PixelData BulkDataURI'); - return null; - } - try { - const bulkData = await reader.readBulkData( - studyUID, - seriesUID, - pixelData.BulkDataURI, - frameNumber, - sopUID - ); - if (!bulkData?.binaryData) return null; - const bd = bulkData.binaryData; - const buf = bd instanceof ArrayBuffer ? Buffer.from(bd) : Buffer.from(bd); - return buf.byteLength ? buf : null; - } catch (e) { - console.verbose('[thumbnail] video bulk fallback read failed', e); - return null; - } -} - -/** - * Stream `instances/.../rendered/index.` to disk (avoids holding full clip in JS heap). - * @returns {Promise} - */ -async function streamRenderedVideoToFile(reader, studyUID, seriesUID, instanceUID, ext, destPath) { - const filename = path.posix.join('rendered', `index.${ext}`); - let stream; - try { - stream = await reader.openInstanceInputStream(studyUID, seriesUID, instanceUID, filename); - } catch { - return false; - } - if (!stream) return false; - const out = fs.createWriteStream(destPath); - try { - await pipeline(stream, out); - return true; - } catch (e) { - console.verbose('[thumbnail] stream rendered video to disk failed', e); - try { - await fs.promises.unlink(destPath); - } catch { - /* ignore */ - } - return false; - } -} - -async function ffmpegExtractFirstFrameToJpeg(videoPath, jpegOutPath) { - const code = await execSpawn( - `ffmpeg -hide_banner -loglevel error -i "${videoPath}" -y -f image2 -frames:v 1 -update 1 "${jpegOutPath}"` - ); - if (code !== 0) { - throw new Error(`ffmpeg exited with code ${code}`); - } -} - -/** - * Video transfer syntax: prefer `rendered/index.`; if missing, read encapsulated video from PixelData bulk (same bits as VideoWriter). - * @returns {Promise} JPEG bytes, or null if not a video TS / nothing readable - */ -async function tryThumbnailFromRenderedVideo( - reader, - studyUID, - seriesUID, - instanceUID, - instanceMetadata, - frameNumber = 1 -) { - const metaTs = getMetadataTransferSyntaxForRouting(instanceMetadata); - if (!metaTs || !isVideoTransferSyntaxUid(metaTs)) return null; - - const ext = getVideoFileExtensionForTransferSyntaxUid(metaTs); - if (!ext) return null; - - console.verbose('[thumbnail] video TS — trying rendered/index.' + ext, { metaTs }); - - const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'cdw-thumb-')); - const vidPath = path.join(tmpDir, `video.${ext}`); - const jpgPath = path.join(tmpDir, 'thumb.jpg'); - try { - let haveVideo = await streamRenderedVideoToFile(reader, studyUID, seriesUID, instanceUID, ext, vidPath); - if (!haveVideo) { - console.verbose('[thumbnail] video TS — no rendered stream; PixelData bulk frame', { frameNumber }); - const vidBuf = await loadVideoBytesFromPixelBulk(reader, studyUID, seriesUID, instanceMetadata, frameNumber); - if (!vidBuf?.length) return null; - await fs.promises.writeFile(vidPath, vidBuf); - } - - await ffmpegExtractFirstFrameToJpeg(vidPath, jpgPath); - return await fs.promises.readFile(jpgPath); - } finally { - await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); - } -} - -async function persistThumbnailJpeg({ - jpegBuffer, - dicomdir, - outputDicomdir, - studyUID, - seriesUID, - instanceUID, - level, - filename, - transferSyntaxUidForWriter, -}) { - const outBase = outputRoot(outputDicomdir, dicomdir); - const useS3 = isS3OutputUri(outBase); - - let s3Client; - let s3Bucket; - let s3KeyPrefix; - if (useS3) { - const parsed = parseS3OutputUri(outBase); - s3Bucket = parsed.bucket; - s3KeyPrefix = parsed.keyPrefix; - s3Client = await getBunS3ClientForBucket(s3Bucket); - } - - const writer = useS3 - ? null - : new FileDicomWebWriter( - { - studyInstanceUid: studyUID, - seriesInstanceUid: seriesUID, - sopInstanceUid: instanceUID, - transferSyntaxUid: transferSyntaxUidForWriter, - }, - { baseDir: outBase } - ); - - const buf = Buffer.from(jpegBuffer); - - if (useS3) { - const relKey = thumbnailRelativeKey(level, studyUID, seriesUID, instanceUID, filename); - const key = joinS3ObjectKey(s3KeyPrefix, relKey); - await putS3ThumbnailJpeg(s3Client, key, buf, s3Bucket); - } else { - let thumbnailStreamInfo; - if (level === 'study') { - thumbnailStreamInfo = await writer.openStudyStream(filename, { gzip: false }); - } else if (level === 'series') { - thumbnailStreamInfo = await writer.openSeriesStream(filename, { gzip: false }); - } else { - thumbnailStreamInfo = await writer.openInstanceStream(filename, { gzip: false }); - } - - thumbnailStreamInfo.stream.write(buf); - await writer.closeStream(thumbnailStreamInfo.streamKey); - } - - logThumbnailWritten({ - dicomdir, - outputDicomdir, - studyUID, - seriesUID, - instanceUID, - level, - filename, - }); -} - -function parseSelectorToQuery(selector) { - const params = new URLSearchParams(selector); - const query = {}; - for (const [key, value] of params.entries()) { - query[key] = value; - } - return query; -} - -function asStudyList(studiesValue) { - if (Array.isArray(studiesValue)) return studiesValue; - if (studiesValue && typeof studiesValue === 'object') return [studiesValue]; - return []; -} - -/** - * PS3.x-style one-line warning when a single thumbnail is skipped (decode/render/write). - * @param {Object} p - * @param {string} p.studyUID - * @param {string} p.seriesUID - * @param {string} p.instanceUID - * @param {string} p.level - * @param {unknown} p.error - */ -function warnThumbnailSkippedDicom({ studyUID, seriesUID, instanceUID, level, error }) { - const reason = error instanceof Error ? error.message : String(error); - console.warn( - `*** DICOM warning [THUMBNAIL_SKIP] StudyInstanceUID=${studyUID} SeriesInstanceUID=${seriesUID} SOPInstanceUID=${instanceUID} Level=${level}: ${reason}` - ); - if (error instanceof Error && error.stack) { - console.verbose('[thumbnail] skip stack', error.stack); - } -} - -/** - * Resolved output root for thumbnails (local path or s3:// URI). - */ -function outputRoot(outputDicomdir, dicomdir) { - return outputDicomdir || dicomdir; -} - -/** - * Browser-oriented URL for logs: when output is S3, prefer {@link dicomdir} as HTTPS prefix so the path - * matches where reads are served; fall back to `s3://` only if there is no HTTP retrieve base. - * Otherwise same as before: https from dicomdir, or file:// for local output. - * @param {Object} p - */ -function formatThumbnailOutputHref({ dicomdir, outputDicomdir, studyUID, seriesUID, instanceUID, level, filename }) { - const rel = thumbnailRelativeKey(level, studyUID, seriesUID, instanceUID, filename); - const outBase = outputRoot(outputDicomdir, dicomdir); - - if (isS3OutputUri(outBase)) { - const retrieveBase = String(dicomdir ?? '').trim(); - if (/^https?:\/\//i.test(retrieveBase)) { - const base = retrieveBase.replace(/\/?$/, '/'); - return new URL(rel, base).href; - } - const { bucket, keyPrefix } = parseS3OutputUri(outBase); - const key = joinS3ObjectKey(keyPrefix, rel); - return `s3://${bucket}/${key}`; - } - - const dicomdirStr = String(dicomdir ?? '').trim(); - if (/^https?:\/\//i.test(dicomdirStr)) { - const base = dicomdirStr.replace(/\/?$/, '/'); - return new URL(rel, base).href; - } - const root = handleHomeRelative(outBase); - const fullPath = path.normalize(path.join(root, ...rel.split('/').filter(Boolean))); - return pathToFileURL(fullPath).href; -} - -/** - * In non-quiet mode, print where the thumbnail was written (retrieve HTTPS when S3 output + HTTP dicomdir). - */ -function logThumbnailWritten(params) { - console.noQuiet('Thumbnail written:', formatThumbnailOutputHref(params)); -} - -/** - * In non-quiet mode, print that the thumbnail already exists at the same URL shape as {@link logThumbnailWritten}. - */ -function logThumbnailAlreadyExists(params) { - console.noQuiet('Already exists:', formatThumbnailOutputHref(params)); -} - -/** - * True if a thumbnail file/object is already present at the output root (filesystem or S3). - * @param {Object} p - * @param {'study'|'series'|'instance'} p.level - */ -async function thumbnailExistsAtOutput({ outputDicomdir, dicomdir, studyUID, seriesUID, instanceUID, level, filename }) { - const rel = thumbnailRelativeKey(level, studyUID, seriesUID, instanceUID, filename); - const outBase = outputRoot(outputDicomdir, dicomdir); - - if (isS3OutputUri(outBase)) { - const { bucket, keyPrefix } = parseS3OutputUri(outBase); - const key = joinS3ObjectKey(keyPrefix, rel); - const client = await getBunS3ClientForBucket(bucket); - return s3ObjectExists(client, key); - } - - const root = handleHomeRelative(outBase); - const fullPath = path.normalize(path.join(root, ...rel.split('/').filter(Boolean))); - return fs.existsSync(fullPath); -} - -/** - * Reads pixel data from instance metadata - * @param {string} studyUID - Study Instance UID - * @param {string} seriesUID - Series Instance UID - * @param {Object} instanceMetadata - Instance metadata object - * @param {number} frameNumber - Frame number (1-based, default: 1) - * @returns {Promise} - Object with binaryData, transferSyntaxUid, and contentType - */ -async function readPixelData(reader, studyUID, seriesUID, instanceMetadata, frameNumber = 1) { - const sopUID = getValue(instanceMetadata, Tags.SOPInstanceUID); - const pixelDataTag = Tags.PixelData; - const pixelData = instanceMetadata[pixelDataTag]; - - if (!pixelData) { - throw new Error('No PixelData found in instance metadata'); - } - - const bulkDataURI = pixelData.BulkDataURI; - if (!bulkDataURI) { - throw new Error('No BulkDataURI found in PixelData'); - } - - console.verbose('[thumbnail] readPixelData request', { - studyUID, - seriesUID, - sopInstanceUID: sopUID, - frameNumber, - bulkDataURI: typeof bulkDataURI === 'string' ? bulkDataURI.slice(0, 120) : bulkDataURI, - }); - - const bulkData = await reader.readBulkData( - studyUID, - seriesUID, - bulkDataURI, - frameNumber, - sopUID - ); - - if (!bulkData) { - throw new Error(`Failed to read bulk data for frame ${frameNumber}`); - } - - const { transferSyntaxUid: resolvedTs, resolutionNote } = resolveThumbnailTransferSyntaxUid( - bulkData, - pixelData, - instanceMetadata - ); - let transferSyntaxUid = resolvedTs; - - const fromMetaWithAvailable = getTransferSyntaxFromInstanceMetadata(instanceMetadata, { - includeAvailableTransferSyntax: true, - }); - const fromMetaNoAvailable = getTransferSyntaxFromInstanceMetadata(instanceMetadata, { - includeAvailableTransferSyntax: false, - }); - - if (!transferSyntaxUid) { - console.warn( - `[thumbnail] No transfer syntax for instance ${sopUID} (${resolutionNote}); using default ${DEFAULT_TRANSFER_SYNTAX_UID}. If decoding fails, inspect responses with -v.` - ); - transferSyntaxUid = DEFAULT_TRANSFER_SYNTAX_UID; - } - - console.verbose('[thumbnail] readPixelData resolved', { - sopInstanceUID: sopUID, - transferSyntaxUid, - resolutionNote, - wasMultipart: !!bulkData.wasMultipart, - transferSyntaxFromInnerPart: !!bulkData.transferSyntaxFromInnerPart, - sources: { - bulkDataResponse: bulkData.transferSyntaxUid ?? '(none)', - pixelDataTag: pixelData.transferSyntaxUid ?? '(none)', - metadata00083002: fromMetaWithAvailable ?? '(none)', - metadata00020010only: fromMetaNoAvailable ?? '(none)', - }, - contentType: bulkData.contentType, - byteLength: - bulkData.binaryData instanceof ArrayBuffer - ? bulkData.binaryData.byteLength - : bulkData.binaryData?.length, - }); - - return { - binaryData: bulkData.binaryData, - transferSyntaxUid, - contentType: bulkData.contentType, - }; -} - -async function writeThumbnailForTarget({ - reader, - dicomdir, - outputDicomdir, - studyUID, - seriesUID, - instanceMetadata, - frameNumber, - level, - force, - thumbnailFilename = 'thumbnail', -}) { - const instanceUID = getValue(instanceMetadata, Tags.SOPInstanceUID); - if (!instanceUID) { - throw new Error('Could not extract SOPInstanceUID from instance metadata'); - } - - console.verbose('[thumbnail] writeThumbnailForTarget', { - level, - studyUID, - seriesUID, - instanceUID, - frameNumber, - thumbnailFilename, - outputBase: outputRoot(outputDicomdir, dicomdir), - force: !!force, - }); - - const pre = thumbnailInstancePrecheck(instanceMetadata, force); - if (!pre.ok) { - warnThumbnailSkippedDicom({ - studyUID, - seriesUID, - instanceUID, - level, - error: new Error(pre.reason), - }); - return; - } - - if (!force) { - try { - const exists = await thumbnailExistsAtOutput({ - outputDicomdir, - dicomdir, - studyUID, - seriesUID, - instanceUID, - level, - filename: thumbnailFilename, - }); - if (exists) { - logThumbnailAlreadyExists({ - dicomdir, - outputDicomdir, - studyUID, - seriesUID, - instanceUID, - level, - filename: thumbnailFilename, - }); - return; - } - } catch (error) { - console.verbose('[thumbnail] could not check existing thumbnail; will attempt generation', error); - } - } - - const metaTs = pre.metaTs; - if (metaTs && isVideoTransferSyntaxUid(metaTs)) { - try { - const jpegBuf = await tryThumbnailFromRenderedVideo( - reader, - studyUID, - seriesUID, - instanceUID, - instanceMetadata, - frameNumber - ); - if (jpegBuf?.length) { - await persistThumbnailJpeg({ - jpegBuffer: jpegBuf, - dicomdir, - outputDicomdir, - studyUID, - seriesUID, - instanceUID, - level, - filename: thumbnailFilename, - transferSyntaxUidForWriter: DEFAULT_TRANSFER_SYNTAX_UID, - }); - return; - } - warnThumbnailSkippedDicom({ - studyUID, - seriesUID, - instanceUID, - level, - error: new Error( - 'Video transfer syntax but neither rendered/index. nor PixelData bulk could be read for ffmpeg' - ), - }); - } catch (error) { - warnThumbnailSkippedDicom({ studyUID, seriesUID, instanceUID, level, error }); - } - return; - } - - try { - const pixelData = await readPixelData(reader, studyUID, seriesUID, instanceMetadata, frameNumber); - const transferSyntaxUid = pixelData.transferSyntaxUid; - - let imageFrame = pixelData.binaryData; - if (imageFrame instanceof ArrayBuffer) { - imageFrame = new Uint8Array(imageFrame); - } - - await StaticWado.internalGenerateImage(imageFrame, null, instanceMetadata, transferSyntaxUid, async buffer => { - if (!buffer) { - console.warn( - `*** DICOM warning [THUMBNAIL_SKIP] StudyInstanceUID=${studyUID} SeriesInstanceUID=${seriesUID} SOPInstanceUID=${instanceUID} Level=${level}: No thumbnail buffer generated after render` - ); - return; - } - await persistThumbnailJpeg({ - jpegBuffer: buffer, - dicomdir, - outputDicomdir, - studyUID, - seriesUID, - instanceUID, - level, - filename: thumbnailFilename, - transferSyntaxUidForWriter: transferSyntaxUid, - }); - }); - } catch (error) { - warnThumbnailSkippedDicom({ studyUID, seriesUID, instanceUID, level, error }); - } -} - -async function resolveStudyUIDs(reader, studySelector) { - if (!studySelector || studySelector === 'true') { - if (typeof reader.queryStudies === 'function') { - const studies = await reader.queryStudies('true'); - return asStudyList(studies).map(study => getValue(study, Tags.StudyInstanceUID)).filter(Boolean); - } - const studies = await reader.readJsonFile('studies', 'index.json'); - return asStudyList(studies).map(study => getValue(study, Tags.StudyInstanceUID)).filter(Boolean); - } - - if (studySelector.includes('=')) { - if (typeof reader.queryStudies === 'function') { - const studies = await reader.queryStudies(studySelector); - return asStudyList(studies).map(study => getValue(study, Tags.StudyInstanceUID)).filter(Boolean); - } - const studies = asStudyList(await reader.readJsonFile('studies', 'index.json')); - const filtered = qidoFilter(studies, parseSelectorToQuery(studySelector)); - return filtered.map(study => getValue(study, Tags.StudyInstanceUID)).filter(Boolean); - } - - return [studySelector]; -} - -async function generateForStudy(studyUID, options = {}) { - const { - reader, - dicomdir, - outputDicomdir, - seriesUid, - instanceUid, - frameNumbers, - frameNumber, - allThumbnails, - seriesThumbnail, - force, - } = options; - const framesToProcess = frameNumbers || (frameNumber ? [frameNumber] : [1]); - - console.verbose('[thumbnail] generateForStudy start', { - studyUID, - allThumbnails: !!allThumbnails, - seriesThumbnail: !!seriesThumbnail, - seriesUid: seriesUid ?? '(any)', - instanceUid: instanceUid ?? '(default)', - framesToProcess, - dicomdir, - outputDicomdir: outputDicomdir ?? '(same as dicomdir)', - }); - - const seriesIndex = await reader.readJsonFile( - reader.getStudyPath(studyUID, { path: 'series' }), - 'index.json' - ); - if (!seriesIndex || !Array.isArray(seriesIndex) || seriesIndex.length === 0) { - throw new Error(`No series found for study ${studyUID}`); - } - - console.verbose('[thumbnail] series index length', seriesIndex.length); - - let seriesToProcess = seriesIndex; - if (seriesUid) { - seriesToProcess = seriesIndex.filter(series => getValue(series, Tags.SeriesInstanceUID) === seriesUid); - if (!seriesToProcess.length) throw new Error(`Series ${seriesUid} not found in study ${studyUID}`); - } - - if (allThumbnails) { - const seriesMetadataCache = []; - for (const seriesItem of seriesToProcess) { - const targetSeriesUID = getValue(seriesItem, Tags.SeriesInstanceUID); - if (!targetSeriesUID) continue; - const seriesMetadata = await reader.readJsonFile(reader.getSeriesPath(studyUID, targetSeriesUID), 'metadata'); - if (!Array.isArray(seriesMetadata) || !seriesMetadata.length) continue; - seriesMetadataCache.push({ seriesUid: targetSeriesUID, metadata: seriesMetadata }); - for (const metadata of seriesMetadata) { - const numberOfFrames = getValue(metadata, Tags.NumberOfFrames) || 1; - await writeThumbnailForTarget({ - reader, - dicomdir, - outputDicomdir, - studyUID, - seriesUID: targetSeriesUID, - instanceMetadata: metadata, - frameNumber: Math.ceil(numberOfFrames / 2), - level: 'instance', - force, - }); - } - const middle = seriesMetadata[Math.floor(seriesMetadata.length / 2)]; - const middleFrames = getValue(middle, Tags.NumberOfFrames) || 1; - await writeThumbnailForTarget({ - reader, - dicomdir, - outputDicomdir, - studyUID, - seriesUID: targetSeriesUID, - instanceMetadata: middle, - frameNumber: Math.ceil(middleFrames / 2), - level: 'series', - force, - }); - } - if (!seriesMetadataCache.length) return; - const middleSeries = seriesMetadataCache[Math.floor(seriesMetadataCache.length / 2)]; - const middleInstance = middleSeries.metadata[Math.floor(middleSeries.metadata.length / 2)]; - const middleFrames = getValue(middleInstance, Tags.NumberOfFrames) || 1; - await writeThumbnailForTarget({ - reader, - dicomdir, - outputDicomdir, - studyUID, - seriesUID: middleSeries.seriesUid, - instanceMetadata: middleInstance, - frameNumber: Math.ceil(middleFrames / 2), - level: 'study', - force, - }); - return; - } - - if (seriesThumbnail) { - for (const series of seriesToProcess) { - const targetSeriesUID = getValue(series, Tags.SeriesInstanceUID); - if (!targetSeriesUID) continue; - const metadata = await reader.readJsonFile(reader.getSeriesPath(studyUID, targetSeriesUID), 'metadata'); - if (!Array.isArray(metadata) || !metadata.length) continue; - const middle = metadata[Math.floor(metadata.length / 2)]; - const middleFrames = getValue(middle, Tags.NumberOfFrames) || 1; - await writeThumbnailForTarget({ - reader, - dicomdir, - outputDicomdir, - studyUID, - seriesUID: targetSeriesUID, - instanceMetadata: middle, - frameNumber: Math.ceil(middleFrames / 2), - level: 'series', - force, - }); - } - return; - } - - const targetSeriesUID = getValue(seriesToProcess[0], Tags.SeriesInstanceUID); - const seriesMetadata = await reader.readJsonFile(reader.getSeriesPath(studyUID, targetSeriesUID), 'metadata'); - if (!Array.isArray(seriesMetadata) || !seriesMetadata.length) { - throw new Error(`No series metadata found for series ${targetSeriesUID}`); - } - - let instanceMetadata = seriesMetadata[0]; - if (instanceUid) { - instanceMetadata = seriesMetadata.find(instance => getValue(instance, Tags.SOPInstanceUID) === instanceUid); - if (!instanceMetadata) throw new Error(`Instance ${instanceUid} not found in series metadata`); - } - const targetInstanceUID = getValue(instanceMetadata, Tags.SOPInstanceUID); - console.verbose('[thumbnail] instance thumbnail target', { - studyUID, - seriesUID: targetSeriesUID, - sopInstanceUID: targetInstanceUID, - hint: instanceUid ? 'from --sop-uid' : 'first entry in series metadata (use --sop-uid for another instance)', - }); - - const metaTsRoute = getMetadataTransferSyntaxForRouting(instanceMetadata); - const isVidInst = Boolean(metaTsRoute && isVideoTransferSyntaxUid(metaTsRoute)); - /** One ffmpeg thumbnail per video instance; multi-frame frame lists apply to regular pixel data only */ - const frameNumsToRun = - isVidInst && framesToProcess.length > 1 ? [framesToProcess[0]] : framesToProcess; - - for (const frameNum of frameNumsToRun) { - const thumbnailFilename = framesToProcess.length > 1 ? `thumbnail-${frameNum}` : 'thumbnail'; - await writeThumbnailForTarget({ - reader, - dicomdir, - outputDicomdir, - studyUID, - seriesUID: targetSeriesUID, - instanceMetadata, - frameNumber: frameNum, - level: 'instance', - force, - thumbnailFilename, - }); - } -} - -/** - * Main function for creating thumbnails - * @param {string} studyUID - Study Instance UID - * @param {Object} options - Options object - * @param {string} [options.dicomdir] - Base directory path where DICOMweb structure is located - * @param {string} [options.seriesUid] - Specific Series Instance UID to process (if not provided, uses first series from study query) - * @param {string} [options.instanceUid] - Specific SOP Instance UID to process (if not provided, uses first instance from series) - * @param {number|number[]} [options.frameNumber] - Frame number to use for thumbnail (default: 1) - deprecated, use frameNumbers instead - * @param {number[]} [options.frameNumbers] - Array of frame numbers to generate thumbnails for (default: [1]) - * @param {boolean} [options.seriesThumbnail] - Generate thumbnails for series (middle SOP instance, middle frame for multiframe) - * @param {boolean} [options.allThumbnails] - Generate thumbnails for all SOP instances, all series, and study level - * @param {boolean} [options.force] - Regenerate even when output thumbnail already exists - */ -export async function thumbnailMain(studySelector, options = {}) { - const { dicomdir, outputDicomdir } = options; - if (!dicomdir) { - throw new Error('dicomdir option is required'); - } - const reader = DicomWebStream.createReader(dicomdir); - if (!reader) { - throw new Error(`dicomdir is not a valid file/http location: ${dicomdir}`); - } - if (/^https?:\/\//i.test(dicomdir) && !outputDicomdir) { - throw new Error('--output-dicomdir is required when dicomdir is http/https'); - } - if ((outputDicomdir || dicomdir).startsWith('http')) { - throw new Error('Thumbnail output must be a file path, not an http(s) endpoint'); - } - - console.verbose('[thumbnail] thumbnailMain', { - studySelector: studySelector || 'true', - dicomdir, - outputDicomdir: outputDicomdir ?? '(same as dicomdir)', - reader: reader.constructor?.name ?? typeof reader, - }); - - const studyUIDs = await resolveStudyUIDs(reader, studySelector || 'true'); - console.verbose('[thumbnail] resolved studies', studyUIDs.length, studyUIDs); - if (!studyUIDs.length) { - throw new Error(`No studies matched selector: ${studySelector || 'true'}`); - } - for (const studyUID of studyUIDs) { - await generateForStudy(studyUID, { ...options, reader, dicomdir, outputDicomdir }); - } - console.noQuiet(`Thumbnail generation completed for ${studyUIDs.length} study(ies)`); -} +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { pipeline } from 'stream/promises'; +import { pathToFileURL } from 'url'; +import { DicomWebStream } from '../instance/DicomWebStream.mjs'; +import { FileDicomWebWriter } from '../instance/FileDicomWebWriter.mjs'; +import { + isS3OutputUri, + parseS3OutputUri, + getBunS3ClientForBucket, + joinS3ObjectKey, + thumbnailRelativeKey, + putS3ThumbnailJpeg, + s3ObjectExists, +} from '../instance/s3ThumbnailOutput.mjs'; +import { isHttpLocation } from '../instance/dicomDirLocation.mjs'; +import { + Tags, + qidoFilter, + handleHomeRelative, + execSpawn, +} from '@radicalimaging/static-wado-util'; +import StaticWado, { + getVideoFileExtensionForTransferSyntaxUid, + isVideoTransferSyntaxUid, +} from '@radicalimaging/static-wado-creator'; + +const { getValue } = Tags; + +/** Explicit VR Little Endian — default when no transfer syntax is found in headers or metadata */ +const DEFAULT_TRANSFER_SYNTAX_UID = '1.2.840.10008.1.2.1'; + +/** + * Modalities that do not carry raster pixel data suitable for JPEG thumbnails. + * SR/PR/KO/RWV/SEG are required; others are common DICOM non-image service-object modalities. + * Use `--force` to attempt a thumbnail anyway. + */ +const THUMBNAIL_OMIT_MODALITIES = new Set([ + 'SR', + 'SEG', + 'PR', + 'KO', + 'RWV', + 'AU', + 'DOC', + 'REG', + 'RTSTRUCT', + 'RTPLAN', + 'RTDOSE', + 'RTRECORD', + 'ECG', + 'EPS', + 'OT', + 'TEXT', +]); + +function modalityOmittedFromThumbnails(metadata) { + const raw = getValue(metadata, Tags.Modality); + if (!raw || typeof raw !== 'string') return null; + const mod = raw.trim().toUpperCase(); + return THUMBNAIL_OMIT_MODALITIES.has(mod) ? mod : null; +} + +/** + * Transfer syntax from instance metadata. + * Tag {@link Tags.AvailableTransferSyntaxUID} (00083002) is only meaningful for **single-part** bulk + * storage (e.g. raw compressed files where path/extension implies a TS family); see {@link resolveThumbnailTransferSyntaxUid}. + * + * @param {Object} metadata + * @param {{ includeAvailableTransferSyntax?: boolean }} [opts] + */ +function getTransferSyntaxFromInstanceMetadata(metadata, { includeAvailableTransferSyntax = true } = {}) { + if (includeAvailableTransferSyntax) { + const available = getValue(metadata, Tags.AvailableTransferSyntaxUID); + if (available) return available; + } + const hex = getValue(metadata, Tags.TransferSyntaxUID); + if (hex) return hex; + const nat = metadata?.TransferSyntaxUID; + if (nat?.Value?.[0]) return nat.Value[0]; + if (typeof nat === 'string') return nat; + return undefined; +} + +/** + * Inner multipart Content-Type `transfer-syntax=` overrides metadata (including 00083002). + * {@link Tags.AvailableTransferSyntaxUID} applies only when bulk data was **not** multipart/related-wrapped. + * + * @param {Object} bulkData - Result of {@link DicomWebReader.readBulkData} + * @param {Object} pixelData - PixelData element from instance metadata + * @param {Object} instanceMetadata + * @returns {{ transferSyntaxUid: string | undefined, resolutionNote: string }} + */ +function resolveThumbnailTransferSyntaxUid(bulkData, pixelData, instanceMetadata) { + const innerUid = bulkData.transferSyntaxUid; + if (bulkData.transferSyntaxFromInnerPart && innerUid) { + return { transferSyntaxUid: innerUid, resolutionNote: 'inner multipart Content-Type transfer-syntax' }; + } + + if (bulkData.wasMultipart) { + const ts = + innerUid || + pixelData.transferSyntaxUid || + getTransferSyntaxFromInstanceMetadata(instanceMetadata, { includeAvailableTransferSyntax: false }); + return { + transferSyntaxUid: ts, + resolutionNote: 'multipart bulk without inner transfer-syntax (tag 00083002 not applied)', + }; + } + + const ts = + innerUid || + pixelData.transferSyntaxUid || + getTransferSyntaxFromInstanceMetadata(instanceMetadata, { includeAvailableTransferSyntax: true }); + return { + transferSyntaxUid: ts, + resolutionNote: 'single-part bulk (may use tag 00083002 AvailableTransferSyntaxUID)', + }; +} + +function getMetadataTransferSyntaxForRouting(metadata) { + return getTransferSyntaxFromInstanceMetadata(metadata, { includeAvailableTransferSyntax: true }); +} + +function hasPhotometricInterpretation(metadata) { + return Boolean(getValue(metadata, Tags.PhotometricInterpretation)); +} + +/** + * @returns {{ ok: boolean, reason?: string, metaTs?: string }} + */ +function thumbnailInstancePrecheck(metadata, force) { + const omittedMod = modalityOmittedFromThumbnails(metadata); + if (omittedMod && !force) { + return { + ok: false, + reason: `Modality ${omittedMod} is omitted from thumbnail generation (use --force to override)`, + metaTs: undefined, + }; + } + const metaTs = getMetadataTransferSyntaxForRouting(metadata); + const isVid = metaTs && isVideoTransferSyntaxUid(metaTs); + if (!isVid && !hasPhotometricInterpretation(metadata)) { + return { ok: false, reason: 'missing PhotometricInterpretation (00280004)', metaTs }; + } + return { ok: true, metaTs }; +} + +async function loadVideoBytesFromPixelBulk(reader, studyUID, seriesUID, instanceMetadata, frameNumber = 1) { + const sopUID = getValue(instanceMetadata, Tags.SOPInstanceUID); + const pixelData = instanceMetadata[Tags.PixelData]; + if (!pixelData?.BulkDataURI) { + console.verbose('[thumbnail] video bulk fallback: no PixelData BulkDataURI'); + return null; + } + try { + const bulkData = await reader.readBulkData( + studyUID, + seriesUID, + pixelData.BulkDataURI, + frameNumber, + sopUID + ); + if (!bulkData?.binaryData) return null; + const bd = bulkData.binaryData; + const buf = bd instanceof ArrayBuffer ? Buffer.from(bd) : Buffer.from(bd); + return buf.byteLength ? buf : null; + } catch (e) { + console.verbose('[thumbnail] video bulk fallback read failed', e); + return null; + } +} + +/** + * Stream `instances/.../rendered/index.` to disk (avoids holding full clip in JS heap). + * @returns {Promise} + */ +async function streamRenderedVideoToFile(reader, studyUID, seriesUID, instanceUID, ext, destPath) { + const filename = path.posix.join('rendered', `index.${ext}`); + let stream; + try { + stream = await reader.openInstanceInputStream(studyUID, seriesUID, instanceUID, filename); + } catch { + return false; + } + if (!stream) return false; + const out = fs.createWriteStream(destPath); + try { + await pipeline(stream, out); + return true; + } catch (e) { + console.verbose('[thumbnail] stream rendered video to disk failed', e); + try { + await fs.promises.unlink(destPath); + } catch { + /* ignore */ + } + return false; + } +} + +async function ffmpegExtractFirstFrameToJpeg(videoPath, jpegOutPath) { + const code = await execSpawn( + `ffmpeg -hide_banner -loglevel error -i "${videoPath}" -y -f image2 -frames:v 1 -update 1 "${jpegOutPath}"` + ); + if (code !== 0) { + throw new Error(`ffmpeg exited with code ${code}`); + } +} + +/** + * Video transfer syntax: prefer `rendered/index.`; if missing, read encapsulated video from PixelData bulk (same bits as VideoWriter). + * @returns {Promise} JPEG bytes, or null if not a video TS / nothing readable + */ +async function tryThumbnailFromRenderedVideo( + reader, + studyUID, + seriesUID, + instanceUID, + instanceMetadata, + frameNumber = 1 +) { + const metaTs = getMetadataTransferSyntaxForRouting(instanceMetadata); + if (!metaTs || !isVideoTransferSyntaxUid(metaTs)) return null; + + const ext = getVideoFileExtensionForTransferSyntaxUid(metaTs); + if (!ext) return null; + + console.verbose('[thumbnail] video TS — trying rendered/index.' + ext, { metaTs }); + + const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'cdw-thumb-')); + const vidPath = path.join(tmpDir, `video.${ext}`); + const jpgPath = path.join(tmpDir, 'thumb.jpg'); + try { + let haveVideo = await streamRenderedVideoToFile(reader, studyUID, seriesUID, instanceUID, ext, vidPath); + if (!haveVideo) { + console.verbose('[thumbnail] video TS — no rendered stream; PixelData bulk frame', { frameNumber }); + const vidBuf = await loadVideoBytesFromPixelBulk(reader, studyUID, seriesUID, instanceMetadata, frameNumber); + if (!vidBuf?.length) return null; + await fs.promises.writeFile(vidPath, vidBuf); + } + + await ffmpegExtractFirstFrameToJpeg(vidPath, jpgPath); + return await fs.promises.readFile(jpgPath); + } finally { + await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } +} + +async function persistThumbnailJpeg({ + jpegBuffer, + dicomdir, + outputDicomdir, + studyUID, + seriesUID, + instanceUID, + level, + filename, + transferSyntaxUidForWriter, +}) { + const outBase = outputRoot(outputDicomdir, dicomdir); + const useS3 = isS3OutputUri(outBase); + + let s3Client; + let s3Bucket; + let s3KeyPrefix; + if (useS3) { + const parsed = parseS3OutputUri(outBase); + s3Bucket = parsed.bucket; + s3KeyPrefix = parsed.keyPrefix; + s3Client = await getBunS3ClientForBucket(s3Bucket); + } + + const writer = useS3 + ? null + : new FileDicomWebWriter( + { + studyInstanceUid: studyUID, + seriesInstanceUid: seriesUID, + sopInstanceUid: instanceUID, + transferSyntaxUid: transferSyntaxUidForWriter, + }, + { baseDir: outBase } + ); + + const buf = Buffer.from(jpegBuffer); + + if (useS3) { + const relKey = thumbnailRelativeKey(level, studyUID, seriesUID, instanceUID, filename); + const key = joinS3ObjectKey(s3KeyPrefix, relKey); + await putS3ThumbnailJpeg(s3Client, key, buf, s3Bucket); + } else { + let thumbnailStreamInfo; + if (level === 'study') { + thumbnailStreamInfo = await writer.openStudyStream(filename, { gzip: false }); + } else if (level === 'series') { + thumbnailStreamInfo = await writer.openSeriesStream(filename, { gzip: false }); + } else { + thumbnailStreamInfo = await writer.openInstanceStream(filename, { gzip: false }); + } + + thumbnailStreamInfo.stream.write(buf); + await writer.closeStream(thumbnailStreamInfo.streamKey); + } + + logThumbnailWritten({ + dicomdir, + outputDicomdir, + studyUID, + seriesUID, + instanceUID, + level, + filename, + }); +} + +function parseSelectorToQuery(selector) { + const params = new URLSearchParams(selector); + const query = {}; + for (const [key, value] of params.entries()) { + query[key] = value; + } + return query; +} + +function asStudyList(studiesValue) { + if (Array.isArray(studiesValue)) return studiesValue; + if (studiesValue && typeof studiesValue === 'object') return [studiesValue]; + return []; +} + +/** + * PS3.x-style one-line warning when a single thumbnail is skipped (decode/render/write). + * @param {Object} p + * @param {string} p.studyUID + * @param {string} p.seriesUID + * @param {string} p.instanceUID + * @param {string} p.level + * @param {unknown} p.error + */ +function warnThumbnailSkippedDicom({ studyUID, seriesUID, instanceUID, level, error }) { + const reason = error instanceof Error ? error.message : String(error); + console.warn( + `*** DICOM warning [THUMBNAIL_SKIP] StudyInstanceUID=${studyUID} SeriesInstanceUID=${seriesUID} SOPInstanceUID=${instanceUID} Level=${level}: ${reason}` + ); + if (error instanceof Error && error.stack) { + console.verbose('[thumbnail] skip stack', error.stack); + } +} + +/** + * Resolved output root for thumbnails (local path or s3:// URI). + */ +function outputRoot(outputDicomdir, dicomdir) { + return outputDicomdir || dicomdir; +} + +/** + * Browser-oriented URL for logs: when output is S3, prefer {@link dicomdir} as HTTPS prefix so the path + * matches where reads are served; fall back to `s3://` only if there is no HTTP retrieve base. + * Otherwise same as before: https from dicomdir, or file:// for local output. + * @param {Object} p + */ +function formatThumbnailOutputHref({ dicomdir, outputDicomdir, studyUID, seriesUID, instanceUID, level, filename }) { + const rel = thumbnailRelativeKey(level, studyUID, seriesUID, instanceUID, filename); + const outBase = outputRoot(outputDicomdir, dicomdir); + + if (isS3OutputUri(outBase)) { + const retrieveBase = String(dicomdir ?? '').trim(); + if (isHttpLocation(retrieveBase)) { + const base = retrieveBase.replace(/\/?$/, '/'); + return new URL(rel, base).href; + } + const { bucket, keyPrefix } = parseS3OutputUri(outBase); + const key = joinS3ObjectKey(keyPrefix, rel); + return `s3://${bucket}/${key}`; + } + + const dicomdirStr = String(dicomdir ?? '').trim(); + if (isHttpLocation(dicomdirStr)) { + const base = dicomdirStr.replace(/\/?$/, '/'); + return new URL(rel, base).href; + } + const root = handleHomeRelative(outBase); + const fullPath = path.normalize(path.join(root, ...rel.split('/').filter(Boolean))); + return pathToFileURL(fullPath).href; +} + +/** + * In non-quiet mode, print where the thumbnail was written (retrieve HTTPS when S3 output + HTTP dicomdir). + */ +function logThumbnailWritten(params) { + console.noQuiet('Thumbnail written:', formatThumbnailOutputHref(params)); +} + +/** + * In non-quiet mode, print that the thumbnail already exists at the same URL shape as {@link logThumbnailWritten}. + */ +function logThumbnailAlreadyExists(params) { + console.noQuiet('Already exists:', formatThumbnailOutputHref(params)); +} + +/** + * True if a thumbnail file/object is already present at the output root (filesystem or S3). + * @param {Object} p + * @param {'study'|'series'|'instance'} p.level + */ +async function thumbnailExistsAtOutput({ outputDicomdir, dicomdir, studyUID, seriesUID, instanceUID, level, filename }) { + const rel = thumbnailRelativeKey(level, studyUID, seriesUID, instanceUID, filename); + const outBase = outputRoot(outputDicomdir, dicomdir); + + if (isS3OutputUri(outBase)) { + const { bucket, keyPrefix } = parseS3OutputUri(outBase); + const key = joinS3ObjectKey(keyPrefix, rel); + const client = await getBunS3ClientForBucket(bucket); + return s3ObjectExists(client, key); + } + + const root = handleHomeRelative(outBase); + const fullPath = path.normalize(path.join(root, ...rel.split('/').filter(Boolean))); + return fs.existsSync(fullPath); +} + +/** + * Reads pixel data from instance metadata + * @param {string} studyUID - Study Instance UID + * @param {string} seriesUID - Series Instance UID + * @param {Object} instanceMetadata - Instance metadata object + * @param {number} frameNumber - Frame number (1-based, default: 1) + * @returns {Promise} - Object with binaryData, transferSyntaxUid, and contentType + */ +async function readPixelData(reader, studyUID, seriesUID, instanceMetadata, frameNumber = 1) { + const sopUID = getValue(instanceMetadata, Tags.SOPInstanceUID); + const pixelDataTag = Tags.PixelData; + const pixelData = instanceMetadata[pixelDataTag]; + + if (!pixelData) { + throw new Error('No PixelData found in instance metadata'); + } + + const bulkDataURI = pixelData.BulkDataURI; + if (!bulkDataURI) { + throw new Error('No BulkDataURI found in PixelData'); + } + + console.verbose('[thumbnail] readPixelData request', { + studyUID, + seriesUID, + sopInstanceUID: sopUID, + frameNumber, + bulkDataURI: typeof bulkDataURI === 'string' ? bulkDataURI.slice(0, 120) : bulkDataURI, + }); + + const bulkData = await reader.readBulkData( + studyUID, + seriesUID, + bulkDataURI, + frameNumber, + sopUID + ); + + if (!bulkData) { + throw new Error(`Failed to read bulk data for frame ${frameNumber}`); + } + + const { transferSyntaxUid: resolvedTs, resolutionNote } = resolveThumbnailTransferSyntaxUid( + bulkData, + pixelData, + instanceMetadata + ); + let transferSyntaxUid = resolvedTs; + + const fromMetaWithAvailable = getTransferSyntaxFromInstanceMetadata(instanceMetadata, { + includeAvailableTransferSyntax: true, + }); + const fromMetaNoAvailable = getTransferSyntaxFromInstanceMetadata(instanceMetadata, { + includeAvailableTransferSyntax: false, + }); + + if (!transferSyntaxUid) { + console.warn( + `[thumbnail] No transfer syntax for instance ${sopUID} (${resolutionNote}); using default ${DEFAULT_TRANSFER_SYNTAX_UID}. If decoding fails, inspect responses with -v.` + ); + transferSyntaxUid = DEFAULT_TRANSFER_SYNTAX_UID; + } + + console.verbose('[thumbnail] readPixelData resolved', { + sopInstanceUID: sopUID, + transferSyntaxUid, + resolutionNote, + wasMultipart: !!bulkData.wasMultipart, + transferSyntaxFromInnerPart: !!bulkData.transferSyntaxFromInnerPart, + sources: { + bulkDataResponse: bulkData.transferSyntaxUid ?? '(none)', + pixelDataTag: pixelData.transferSyntaxUid ?? '(none)', + metadata00083002: fromMetaWithAvailable ?? '(none)', + metadata00020010only: fromMetaNoAvailable ?? '(none)', + }, + contentType: bulkData.contentType, + byteLength: + bulkData.binaryData instanceof ArrayBuffer + ? bulkData.binaryData.byteLength + : bulkData.binaryData?.length, + }); + + return { + binaryData: bulkData.binaryData, + transferSyntaxUid, + contentType: bulkData.contentType, + }; +} + +async function writeThumbnailForTarget({ + reader, + dicomdir, + outputDicomdir, + studyUID, + seriesUID, + instanceMetadata, + frameNumber, + level, + force, + thumbnailFilename = 'thumbnail', +}) { + const instanceUID = getValue(instanceMetadata, Tags.SOPInstanceUID); + if (!instanceUID) { + throw new Error('Could not extract SOPInstanceUID from instance metadata'); + } + + console.verbose('[thumbnail] writeThumbnailForTarget', { + level, + studyUID, + seriesUID, + instanceUID, + frameNumber, + thumbnailFilename, + outputBase: outputRoot(outputDicomdir, dicomdir), + force: !!force, + }); + + const pre = thumbnailInstancePrecheck(instanceMetadata, force); + if (!pre.ok) { + warnThumbnailSkippedDicom({ + studyUID, + seriesUID, + instanceUID, + level, + error: new Error(pre.reason), + }); + return; + } + + if (!force) { + try { + const exists = await thumbnailExistsAtOutput({ + outputDicomdir, + dicomdir, + studyUID, + seriesUID, + instanceUID, + level, + filename: thumbnailFilename, + }); + if (exists) { + logThumbnailAlreadyExists({ + dicomdir, + outputDicomdir, + studyUID, + seriesUID, + instanceUID, + level, + filename: thumbnailFilename, + }); + return; + } + } catch (error) { + console.verbose('[thumbnail] could not check existing thumbnail; will attempt generation', error); + } + } + + const metaTs = pre.metaTs; + if (metaTs && isVideoTransferSyntaxUid(metaTs)) { + try { + const jpegBuf = await tryThumbnailFromRenderedVideo( + reader, + studyUID, + seriesUID, + instanceUID, + instanceMetadata, + frameNumber + ); + if (jpegBuf?.length) { + await persistThumbnailJpeg({ + jpegBuffer: jpegBuf, + dicomdir, + outputDicomdir, + studyUID, + seriesUID, + instanceUID, + level, + filename: thumbnailFilename, + transferSyntaxUidForWriter: DEFAULT_TRANSFER_SYNTAX_UID, + }); + return; + } + warnThumbnailSkippedDicom({ + studyUID, + seriesUID, + instanceUID, + level, + error: new Error( + 'Video transfer syntax but neither rendered/index. nor PixelData bulk could be read for ffmpeg' + ), + }); + } catch (error) { + warnThumbnailSkippedDicom({ studyUID, seriesUID, instanceUID, level, error }); + } + return; + } + + try { + const pixelData = await readPixelData(reader, studyUID, seriesUID, instanceMetadata, frameNumber); + const transferSyntaxUid = pixelData.transferSyntaxUid; + + let imageFrame = pixelData.binaryData; + if (imageFrame instanceof ArrayBuffer) { + imageFrame = new Uint8Array(imageFrame); + } + + await StaticWado.internalGenerateImage(imageFrame, null, instanceMetadata, transferSyntaxUid, async buffer => { + if (!buffer) { + console.warn( + `*** DICOM warning [THUMBNAIL_SKIP] StudyInstanceUID=${studyUID} SeriesInstanceUID=${seriesUID} SOPInstanceUID=${instanceUID} Level=${level}: No thumbnail buffer generated after render` + ); + return; + } + await persistThumbnailJpeg({ + jpegBuffer: buffer, + dicomdir, + outputDicomdir, + studyUID, + seriesUID, + instanceUID, + level, + filename: thumbnailFilename, + transferSyntaxUidForWriter: transferSyntaxUid, + }); + }); + } catch (error) { + warnThumbnailSkippedDicom({ studyUID, seriesUID, instanceUID, level, error }); + } +} + +async function resolveStudyUIDs(reader, studySelector) { + if (!studySelector || studySelector === 'true') { + if (typeof reader.queryStudies === 'function') { + const studies = await reader.queryStudies('true'); + return asStudyList(studies).map(study => getValue(study, Tags.StudyInstanceUID)).filter(Boolean); + } + const studies = await reader.readJsonFile('studies', 'index.json'); + return asStudyList(studies).map(study => getValue(study, Tags.StudyInstanceUID)).filter(Boolean); + } + + if (studySelector.includes('=')) { + if (typeof reader.queryStudies === 'function') { + const studies = await reader.queryStudies(studySelector); + return asStudyList(studies).map(study => getValue(study, Tags.StudyInstanceUID)).filter(Boolean); + } + const studies = asStudyList(await reader.readJsonFile('studies', 'index.json')); + const filtered = qidoFilter(studies, parseSelectorToQuery(studySelector)); + return filtered.map(study => getValue(study, Tags.StudyInstanceUID)).filter(Boolean); + } + + return [studySelector]; +} + +/** + * Instance-level JPEG(s) for one representative SOP (middle frame / video rules). + */ +async function writeRepresentativeInstanceThumbnails({ + reader, + dicomdir, + outputDicomdir, + studyUID, + seriesUID, + instanceMetadata, + framesToProcess, + force, +}) { + const metaTsRoute = getMetadataTransferSyntaxForRouting(instanceMetadata); + const isVidInst = Boolean(metaTsRoute && isVideoTransferSyntaxUid(metaTsRoute)); + const frameNumsToRun = + isVidInst && framesToProcess.length > 1 ? [framesToProcess[0]] : framesToProcess; + + for (const frameNum of frameNumsToRun) { + const thumbnailFilename = framesToProcess.length > 1 ? `thumbnail-${frameNum}` : 'thumbnail'; + await writeThumbnailForTarget({ + reader, + dicomdir, + outputDicomdir, + studyUID, + seriesUID, + instanceMetadata, + frameNumber: frameNum, + level: 'instance', + force, + thumbnailFilename, + }); + } +} + +async function generateForStudy(studyUID, options = {}) { + const { + reader, + dicomdir, + outputDicomdir, + seriesUid, + instanceUid, + frameNumbers, + frameNumber, + onDemandThumbnail, + studyThumbnail, + seriesThumbnail, + force, + } = options; + const framesToProcess = frameNumbers || (frameNumber ? [frameNumber] : [1]); + const studyT = !!studyThumbnail; + const seriesT = !!seriesThumbnail; + + console.verbose('[thumbnail] generateForStudy start', { + studyUID, + onDemandThumbnail: !!onDemandThumbnail, + studyThumbnail: studyT, + seriesThumbnail: seriesT, + seriesUid: seriesUid ?? '(any)', + instanceUid: instanceUid ?? '(default)', + framesToProcess, + dicomdir, + outputDicomdir: outputDicomdir ?? '(same as dicomdir)', + }); + + const seriesIndex = await reader.readJsonFile( + reader.getStudyPath(studyUID, { path: 'series' }), + 'index.json' + ); + if (!seriesIndex || !Array.isArray(seriesIndex) || seriesIndex.length === 0) { + throw new Error(`No series found for study ${studyUID}`); + } + + console.verbose('[thumbnail] series index length', seriesIndex.length); + + let seriesToProcess = seriesIndex; + if (seriesUid) { + seriesToProcess = seriesIndex.filter(series => getValue(series, Tags.SeriesInstanceUID) === seriesUid); + if (!seriesToProcess.length) throw new Error(`Series ${seriesUid} not found in study ${studyUID}`); + } + + /** On-demand: one thumbnail for the URL the server requested (legacy --summary-thumbnail semantics). */ + if (onDemandThumbnail) { + if (instanceUid) { + const targetSeriesUID = getValue(seriesToProcess[0], Tags.SeriesInstanceUID); + const seriesMetadata = await reader.readJsonFile( + reader.getSeriesPath(studyUID, targetSeriesUID), + 'metadata' + ); + if (!Array.isArray(seriesMetadata) || !seriesMetadata.length) { + throw new Error(`No series metadata found for series ${targetSeriesUID}`); + } + const instanceMetadata = seriesMetadata.find( + instance => getValue(instance, Tags.SOPInstanceUID) === instanceUid + ); + if (!instanceMetadata) { + throw new Error(`Instance ${instanceUid} not found in series metadata`); + } + console.verbose('[thumbnail] on-demand instance target', { + studyUID, + seriesUID: targetSeriesUID, + sopInstanceUID: instanceUid, + }); + await writeRepresentativeInstanceThumbnails({ + reader, + dicomdir, + outputDicomdir, + studyUID, + seriesUID: targetSeriesUID, + instanceMetadata, + framesToProcess, + force, + }); + return; + } + + if (seriesUid) { + const targetSeriesUID = getValue(seriesToProcess[0], Tags.SeriesInstanceUID); + const seriesMetadata = await reader.readJsonFile( + reader.getSeriesPath(studyUID, targetSeriesUID), + 'metadata' + ); + if (!Array.isArray(seriesMetadata) || !seriesMetadata.length) { + throw new Error(`No series metadata found for series ${targetSeriesUID}`); + } + const middle = seriesMetadata[Math.floor(seriesMetadata.length / 2)]; + const middleFrames = getValue(middle, Tags.NumberOfFrames) || 1; + await writeThumbnailForTarget({ + reader, + dicomdir, + outputDicomdir, + studyUID, + seriesUID: targetSeriesUID, + instanceMetadata: middle, + frameNumber: Math.ceil(middleFrames / 2), + level: 'series', + force, + }); + return; + } + + const middleSeriesEntry = seriesToProcess[Math.floor(seriesToProcess.length / 2)]; + const middleSeriesUID = getValue(middleSeriesEntry, Tags.SeriesInstanceUID); + if (!middleSeriesUID) { + throw new Error(`Could not resolve middle series UID for study ${studyUID}`); + } + const middleSeriesMetadata = await reader.readJsonFile( + reader.getSeriesPath(studyUID, middleSeriesUID), + 'metadata' + ); + if (!Array.isArray(middleSeriesMetadata) || !middleSeriesMetadata.length) { + throw new Error(`No metadata for middle series ${middleSeriesUID}`); + } + const middleInstance = middleSeriesMetadata[Math.floor(middleSeriesMetadata.length / 2)]; + const middleFrames = getValue(middleInstance, Tags.NumberOfFrames) || 1; + await writeThumbnailForTarget({ + reader, + dicomdir, + outputDicomdir, + studyUID, + seriesUID: middleSeriesUID, + instanceMetadata: middleInstance, + frameNumber: Math.ceil(middleFrames / 2), + level: 'study', + force, + }); + return; + } + + /** Study and/or series “representative” mode: only instance JPEGs for SOPs used as study/series thumbnails. */ + if (studyT || seriesT) { + const instanceJobs = []; + const seenSop = new Set(); + + const pushInstanceJob = (seriesUID, metadata) => { + const sop = getValue(metadata, Tags.SOPInstanceUID); + if (!sop || seenSop.has(sop)) return; + seenSop.add(sop); + instanceJobs.push({ seriesUID, metadata }); + }; + + if (studyT) { + const middleSeriesEntry = seriesToProcess[Math.floor(seriesToProcess.length / 2)]; + const middleSeriesUID = getValue(middleSeriesEntry, Tags.SeriesInstanceUID); + if (!middleSeriesUID) { + throw new Error(`Could not resolve middle series UID for study ${studyUID}`); + } + const middleSeriesMetadata = await reader.readJsonFile( + reader.getSeriesPath(studyUID, middleSeriesUID), + 'metadata' + ); + if (!Array.isArray(middleSeriesMetadata) || !middleSeriesMetadata.length) { + throw new Error(`No metadata for middle series ${middleSeriesUID}`); + } + const studyRepInstance = middleSeriesMetadata[Math.floor(middleSeriesMetadata.length / 2)]; + const studyRepFrames = getValue(studyRepInstance, Tags.NumberOfFrames) || 1; + await writeThumbnailForTarget({ + reader, + dicomdir, + outputDicomdir, + studyUID, + seriesUID: middleSeriesUID, + instanceMetadata: studyRepInstance, + frameNumber: Math.ceil(studyRepFrames / 2), + level: 'study', + force, + }); + pushInstanceJob(middleSeriesUID, studyRepInstance); + } + + if (seriesT) { + for (const series of seriesToProcess) { + const targetSeriesUID = getValue(series, Tags.SeriesInstanceUID); + if (!targetSeriesUID) continue; + const metadata = await reader.readJsonFile(reader.getSeriesPath(studyUID, targetSeriesUID), 'metadata'); + if (!Array.isArray(metadata) || !metadata.length) continue; + const middle = metadata[Math.floor(metadata.length / 2)]; + const middleFrames = getValue(middle, Tags.NumberOfFrames) || 1; + const writeSeriesLevel = seriesT; + if (writeSeriesLevel) { + await writeThumbnailForTarget({ + reader, + dicomdir, + outputDicomdir, + studyUID, + seriesUID: targetSeriesUID, + instanceMetadata: middle, + frameNumber: Math.ceil(middleFrames / 2), + level: 'series', + force, + }); + } + pushInstanceJob(targetSeriesUID, middle); + } + } + + for (const { seriesUID: suid, metadata } of instanceJobs) { + await writeRepresentativeInstanceThumbnails({ + reader, + dicomdir, + outputDicomdir, + studyUID, + seriesUID: suid, + instanceMetadata: metadata, + framesToProcess, + force, + }); + } + return; + } + + // Default: every SOP instance + every series + study-level thumbnail. + const seriesMetadataCache = []; + for (const seriesItem of seriesToProcess) { + const targetSeriesUID = getValue(seriesItem, Tags.SeriesInstanceUID); + if (!targetSeriesUID) continue; + const seriesMetadata = await reader.readJsonFile(reader.getSeriesPath(studyUID, targetSeriesUID), 'metadata'); + if (!Array.isArray(seriesMetadata) || !seriesMetadata.length) continue; + seriesMetadataCache.push({ seriesUid: targetSeriesUID, metadata: seriesMetadata }); + for (const metadata of seriesMetadata) { + const numberOfFrames = getValue(metadata, Tags.NumberOfFrames) || 1; + await writeThumbnailForTarget({ + reader, + dicomdir, + outputDicomdir, + studyUID, + seriesUID: targetSeriesUID, + instanceMetadata: metadata, + frameNumber: Math.ceil(numberOfFrames / 2), + level: 'instance', + force, + }); + } + const middle = seriesMetadata[Math.floor(seriesMetadata.length / 2)]; + const middleFrames = getValue(middle, Tags.NumberOfFrames) || 1; + await writeThumbnailForTarget({ + reader, + dicomdir, + outputDicomdir, + studyUID, + seriesUID: targetSeriesUID, + instanceMetadata: middle, + frameNumber: Math.ceil(middleFrames / 2), + level: 'series', + force, + }); + } + if (!seriesMetadataCache.length) return; + const middleSeries = seriesMetadataCache[Math.floor(seriesMetadataCache.length / 2)]; + const middleInstance = middleSeries.metadata[Math.floor(middleSeries.metadata.length / 2)]; + const middleFrames = getValue(middleInstance, Tags.NumberOfFrames) || 1; + await writeThumbnailForTarget({ + reader, + dicomdir, + outputDicomdir, + studyUID, + seriesUID: middleSeries.seriesUid, + instanceMetadata: middleInstance, + frameNumber: Math.ceil(middleFrames / 2), + level: 'study', + force, + }); +} + +/** + * Main function for creating thumbnails + * @param {string} studyUID - Study Instance UID + * @param {Object} options - Options object + * @param {string} [options.dicomdir] - Base directory path where DICOMweb structure is located + * @param {string} [options.seriesUid] - Specific Series Instance UID to process (if not provided, uses first series from study query) + * @param {string} [options.instanceUid] - Specific SOP Instance UID to process (if not provided, uses first instance from series) + * @param {number|number[]} [options.frameNumber] - Frame number to use for thumbnail (default: 1) - deprecated, use frameNumbers instead + * @param {number[]} [options.frameNumbers] - Array of frame numbers to generate thumbnails for (default: [1]) + * @param {boolean} [options.studyThumbnail] - With {@link options.seriesThumbnail}, restrict instance JPEGs to representative SOPs only; write study-level thumbnail from middle series/instance. When both flags are set, also write a series-level thumbnail for every series. + * @param {boolean} [options.seriesThumbnail] - With study/series representative mode: write series-level thumbnails (all series when combined with {@link options.studyThumbnail}, otherwise each series in scope) and restrict instance JPEGs to those representative SOPs. + * @param {boolean} [options.onDemandThumbnail] - Internal: generate a single thumbnail implied by instance/series/study selection (used by the static webserver). + * @param {boolean} [options.force] - Regenerate even when output thumbnail already exists + */ +export async function thumbnailMain(studySelector, options = {}) { + const { dicomdir, outputDicomdir } = options; + if (!dicomdir) { + throw new Error('dicomdir option is required'); + } + const reader = DicomWebStream.createReader(dicomdir); + if (!reader) { + throw new Error(`dicomdir is not a valid file/http location: ${dicomdir}`); + } + if (isHttpLocation(dicomdir) && !outputDicomdir) { + throw new Error('--output-dicomdir is required when dicomdir is http/https'); + } + if ((outputDicomdir || dicomdir).startsWith('http')) { + throw new Error('Thumbnail output must be a file path, not an http(s) endpoint'); + } + + console.verbose('[thumbnail] thumbnailMain', { + studySelector: studySelector || 'true', + dicomdir, + outputDicomdir: outputDicomdir ?? '(same as dicomdir)', + reader: reader.constructor?.name ?? typeof reader, + }); + + const studyUIDs = await resolveStudyUIDs(reader, studySelector || 'true'); + console.verbose('[thumbnail] resolved studies', studyUIDs.length, studyUIDs); + if (!studyUIDs.length) { + throw new Error(`No studies matched selector: ${studySelector || 'true'}`); + } + for (const studyUID of studyUIDs) { + await generateForStudy(studyUID, { ...options, reader, dicomdir, outputDicomdir }); + } + console.noQuiet(`Thumbnail generation completed for ${studyUIDs.length} study(ies)`); +} diff --git a/packages/create-dicomweb/lib/instance/FileDicomWebReader.mjs b/packages/create-dicomweb/lib/instance/FileDicomWebReader.mjs index 651a4ee8..b6aef036 100644 --- a/packages/create-dicomweb/lib/instance/FileDicomWebReader.mjs +++ b/packages/create-dicomweb/lib/instance/FileDicomWebReader.mjs @@ -3,7 +3,7 @@ import path from 'path'; import { createGunzip } from 'zlib'; import { DicomWebReader } from './DicomWebReader.mjs'; import { removeStaleMetadataDir } from './removeStaleMetadataDir.mjs'; -import { readBulkData as readBulkDataFromFile } from '@radicalimaging/static-wado-util'; +import { readBulkData as readBulkDataFromFile, resolveBulkDataLocation } from '@radicalimaging/static-wado-util'; /** * File-based implementation of DicomWebReader @@ -112,24 +112,17 @@ export class FileDicomWebReader extends DicomWebReader { } async readBulkData(studyUID, seriesUID, bulkDataURI, frameNumber = undefined, instanceUID = undefined) { - const studyDir = path.join(this.baseDir, `studies/${studyUID}`); - const seriesDir = path.join(studyDir, `series/${seriesUID}`); - - if (bulkDataURI.indexOf('frames') !== -1) { - const isSeriesRelative = bulkDataURI.startsWith('./instances/'); - if (!isSeriesRelative && !instanceUID) { - throw new Error( - 'No SOPInstanceUID in instance metadata; cannot resolve instance-relative frames path' - ); - } - const frameBaseDir = isSeriesRelative - ? seriesDir - : path.join(seriesDir, 'instances', instanceUID); - const frameBaseName = isSeriesRelative ? bulkDataURI : './frames'; - return readBulkDataFromFile(frameBaseDir, frameBaseName, frameNumber); + const spec = resolveBulkDataLocation(bulkDataURI, { + studyUID, + seriesUID, + instanceUID, + frameNumber, + }); + if (spec.kind !== 'readBulkData') { + throw new Error(`FileDicomWebReader: unsupported bulk resolve kind ${spec.kind}`); } - - return readBulkDataFromFile(seriesDir, bulkDataURI); + const dir = path.join(this.baseDir, spec.dirSuffix); + return readBulkDataFromFile(dir, spec.baseName, spec.frame); } /** diff --git a/packages/create-dicomweb/lib/instance/HttpDicomWebReader.mjs b/packages/create-dicomweb/lib/instance/HttpDicomWebReader.mjs index ea2dce5c..2ab17365 100644 --- a/packages/create-dicomweb/lib/instance/HttpDicomWebReader.mjs +++ b/packages/create-dicomweb/lib/instance/HttpDicomWebReader.mjs @@ -1,5 +1,5 @@ import { Readable } from 'stream'; -import { extractMultipart } from '@radicalimaging/static-wado-util'; +import { extractMultipart, resolveBulkDataLocation, bulkDataHttpPathUnderRoot } from '@radicalimaging/static-wado-util'; import { DicomWebReader } from './DicomWebReader.mjs'; /** @@ -58,36 +58,6 @@ export class HttpDicomWebReader extends DicomWebReader { return null; } - _resolveBulkDataPath(studyUID, seriesUID, bulkDataURI, frameNumber) { - const frameSuffix = frameNumber ? `/${frameNumber}` : ''; - if (/^https?:\/\//i.test(bulkDataURI)) { - return `${bulkDataURI}${frameSuffix}`; - } - - if (bulkDataURI.startsWith('./')) { - const rel = bulkDataURI.slice(2); - return joinUrlPath(this.baseUrl, `studies/${studyUID}/series/${seriesUID}`, `${rel}${frameSuffix}`); - } - - if (bulkDataURI.startsWith('instances/')) { - return joinUrlPath( - this.baseUrl, - `studies/${studyUID}/series/${seriesUID}`, - `${bulkDataURI}${frameSuffix}` - ); - } - - if (bulkDataURI.startsWith('studies/')) { - return joinUrlPath(this.baseUrl, `${bulkDataURI}${frameSuffix}`); - } - - return joinUrlPath( - this.baseUrl, - `studies/${studyUID}/series/${seriesUID}`, - `${bulkDataURI}${frameSuffix}` - ); - } - async _fetch(url) { console.verbose('[HttpDicomWebReader] GET', url); const response = await fetch(url); @@ -133,7 +103,18 @@ export class HttpDicomWebReader extends DicomWebReader { } async readBulkData(studyUID, seriesUID, bulkDataURI, frameNumber = undefined, instanceUID = undefined) { - const url = this._resolveBulkDataPath(studyUID, seriesUID, bulkDataURI, frameNumber); + const spec = resolveBulkDataLocation(bulkDataURI, { + studyUID, + seriesUID, + instanceUID, + frameNumber, + }); + let url; + if (spec.kind === 'httpAbsolute') { + url = spec.url; + } else { + url = joinUrlPath(this.baseUrl, bulkDataHttpPathUnderRoot(spec)); + } const response = await this._fetch(url); if (!response) return null; let binaryData = await response.arrayBuffer(); diff --git a/packages/create-dicomweb/lib/instance/SeriesSummary.mjs b/packages/create-dicomweb/lib/instance/SeriesSummary.mjs index 6c64c8b4..0601c421 100644 --- a/packages/create-dicomweb/lib/instance/SeriesSummary.mjs +++ b/packages/create-dicomweb/lib/instance/SeriesSummary.mjs @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import { FileDicomWebReader } from './FileDicomWebReader.mjs'; import { writeMultipleWithRetry } from './writeWithRetry.mjs'; -import { Tags, TagLists } from '@radicalimaging/static-wado-util'; +import { Tags, TagLists, rewriteBulkDataUriForSeriesMetadata } from '@radicalimaging/static-wado-util'; const { getValue, setValue } = Tags; @@ -34,11 +34,7 @@ function updateLocation(instanceMetadata, instanceUID) { for (const [key, value] of Object.entries(obj)) { if (key === 'BulkDataURI' && typeof value === 'string') { - if (value === './frames' || value.startsWith('./frames')) { - result[key] = `./instances/${instanceUid}/frames`; - } else { - result[key] = value.replace(/^(\.\.\/){4}bulkdata\//, '../../bulkdata/'); - } + result[key] = rewriteBulkDataUriForSeriesMetadata(value, instanceUid); } else { result[key] = processObject(value, instanceUid); } diff --git a/packages/create-dicomweb/lib/instance/writeBulkdataFilter.mjs b/packages/create-dicomweb/lib/instance/writeBulkdataFilter.mjs index b617d25b..84a8c7c0 100644 --- a/packages/create-dicomweb/lib/instance/writeBulkdataFilter.mjs +++ b/packages/create-dicomweb/lib/instance/writeBulkdataFilter.mjs @@ -1,5 +1,6 @@ import crypto from 'crypto'; import { constants } from 'dcmjs'; +import { bulkDataUriRelativeFromInstance } from '@radicalimaging/static-wado-util'; import { FileDicomWebWriter } from './FileDicomWebWriter.mjs'; const { TagHex, BULKDATA_VRS } = constants; @@ -237,7 +238,7 @@ export function writeBulkdataFilter(options = {}) { // Replace Value array with BulkDataURI immediately (synchronously) delete dest.Value; - dest.BulkDataURI = `../../../../bulkdata/${hashPath}.gz`; + dest.BulkDataURI = bulkDataUriRelativeFromInstance(`${hashPath}.gz`); // Only write bulkdata file if the value is an array of ArrayBuffers or Buffers if (!isWritableData) { diff --git a/packages/static-wado-util/lib/index.ts b/packages/static-wado-util/lib/index.ts index 16885740..be6342f8 100644 --- a/packages/static-wado-util/lib/index.ts +++ b/packages/static-wado-util/lib/index.ts @@ -3,6 +3,12 @@ export { bilinear, replicate } from './image/bilinear'; import handleHomeRelative from './handleHomeRelative'; import JSONReader from './reader/JSONReader'; import readBulkData from './reader/readBulkData'; +import { + resolveBulkDataLocation, + bulkDataHttpPathUnderRoot, + rewriteBulkDataUriForSeriesMetadata, + bulkDataUriRelativeFromInstance, +} from './reader/bulkDataUriResolve.js'; import JSONWriter from './writer/JSONWriter'; import dirScanner from './reader/dirScanner'; import qidoFilter from './qidoFilter'; @@ -73,4 +79,8 @@ export { createProgressReporter, parseTimeoutToMs, parseSizeToBytes, + resolveBulkDataLocation, + bulkDataHttpPathUnderRoot, + rewriteBulkDataUriForSeriesMetadata, + bulkDataUriRelativeFromInstance, }; diff --git a/packages/static-wado-util/lib/reader/bulkDataUriResolve.js b/packages/static-wado-util/lib/reader/bulkDataUriResolve.js new file mode 100644 index 00000000..f8e666c5 --- /dev/null +++ b/packages/static-wado-util/lib/reader/bulkDataUriResolve.js @@ -0,0 +1,179 @@ +const path = require('path'); + +function isHttpAbsolute(uri) { + return /^https?:\/\//i.test(String(uri || '').trim()); +} + +function joinPosix(a, b) { + if (!b) return a; + if (!a) return b; + return path.posix.normalize(`${a.replace(/\/+$/, '')}/${b.replace(/^\/+/, '')}`); +} + +/** + * Longest-prefix anchor among study / series / instance roots so readBulkData(dir, relative) works. + * + * @param {string} resolved + * @param {string} studyUID + * @param {string} seriesUID + * @param {string} [instanceUID] + * @returns {string} dirSuffix under dicomweb root + */ +function pickReadBulkAnchorDir(resolved, studyUID, seriesUID, instanceUID) { + const study = `studies/${studyUID}`; + const series = `${study}/series/${seriesUID}`; + const inst = instanceUID ? `${series}/instances/${instanceUID}` : null; + const candidates = [inst, series, study].filter(Boolean).sort((a, b) => b.length - a.length); + for (const c of candidates) { + if (resolved === c || resolved.startsWith(`${c}/`)) { + return c; + } + } + return study; +} + +/** + * Resolve where bulk bytes live for static DICOMweb layout (no metadata-document / referent levels). + * + * @param {string} bulkDataURI + * @param {{ + * studyUID: string, + * seriesUID: string, + * instanceUID?: string, + * frameNumber?: number, + * }} options + * @returns {{ + * kind: 'httpAbsolute', + * url: string, + * } | { + * kind: 'readBulkData', + * dirSuffix: string, + * baseName: string, + * frame?: number, + * }} + */ +function resolveBulkDataLocation(bulkDataURI, options) { + const { studyUID, seriesUID, instanceUID, frameNumber } = options || {}; + if (!studyUID || !seriesUID) { + throw new Error('resolveBulkDataLocation requires studyUID and seriesUID'); + } + const uri = String(bulkDataURI ?? ''); + const seriesSuffix = `studies/${studyUID}/series/${seriesUID}`; + + if (isHttpAbsolute(uri)) { + const frameSuffix = frameNumber ? `/${frameNumber}` : ''; + return { kind: 'httpAbsolute', url: `${uri.trim()}${frameSuffix}` }; + } + + if (uri.trimStart().startsWith('studies/')) { + let p = path.posix.normalize(uri.trim()); + const fm = frameNumber; + if (fm && /\/frames$/.test(p)) { + const dirSuffix = p.replace(/\/frames$/, ''); + return { + kind: 'readBulkData', + dirSuffix, + baseName: './frames', + frame: fm, + }; + } + if (fm && /\/frames\/\d+$/.test(p)) { + const dirSuffix = p.replace(/\/frames\/\d+$/, ''); + return { + kind: 'readBulkData', + dirSuffix, + baseName: './frames', + frame: fm, + }; + } + const anchor = pickReadBulkAnchorDir(p, studyUID, seriesUID, instanceUID); + let baseName = path.posix.relative(anchor, p); + if (!baseName || baseName === '') { + baseName = '.'; + } + return { + kind: 'readBulkData', + dirSuffix: anchor, + baseName, + frame: fm, + }; + } + + if (uri.indexOf('frames') !== -1) { + const isSeriesRelative = uri.startsWith('./instances/'); + if (!isSeriesRelative && !instanceUID) { + throw new Error( + 'No SOPInstanceUID in instance metadata; cannot resolve instance-relative frames path' + ); + } + const dirSuffix = isSeriesRelative ? seriesSuffix : `${seriesSuffix}/instances/${instanceUID}`; + const baseName = isSeriesRelative ? uri : './frames'; + return { + kind: 'readBulkData', + dirSuffix, + baseName, + frame: frameNumber, + }; + } + + return { + kind: 'readBulkData', + dirSuffix: seriesSuffix, + baseName: uri.trim(), + frame: frameNumber, + }; +} + +/** + * HTTP path under server root (no leading slash), for byte-range / static file fetch. + * + * @param {{ kind: string, url?: string, dirSuffix?: string, baseName?: string, frame?: number }} spec + * @returns {string} + */ +function bulkDataHttpPathUnderRoot(spec) { + if (spec.kind === 'httpAbsolute') { + return spec.url; + } + if (spec.kind === 'readBulkData') { + let p = path.posix.normalize(`${spec.dirSuffix}/${spec.baseName}`.replace(/\/+/g, '/')); + if (spec.frame) { + p = `${p}/${spec.frame}`; + } + return p; + } + throw new Error(`Unknown resolve spec kind: ${spec.kind}`); +} + +/** + * Series-summary rewrite: instance-relative bulk URIs for series-level JSON. + * + * @param {string} bulkDataURI + * @param {string} instanceUid + * @returns {string} + */ +function rewriteBulkDataUriForSeriesMetadata(bulkDataURI, instanceUid) { + if (typeof bulkDataURI !== 'string') { + return bulkDataURI; + } + if (bulkDataURI === './frames' || bulkDataURI.startsWith('./frames')) { + return `./instances/${instanceUid}/frames`; + } + return bulkDataURI.replace(/^(\.\.\/){4}bulkdata\//, '../../bulkdata/'); +} + +/** + * Relative BulkDataURI written at instance metadata depth (matches writeBulkdataFilter). + * + * @param {string} hashPath e.g. ec/61/....mht.gz (no leading slash) + * @returns {string} + */ +function bulkDataUriRelativeFromInstance(hashPath) { + return `../../../../bulkdata/${hashPath}`; +} + +module.exports = { + resolveBulkDataLocation, + bulkDataHttpPathUnderRoot, + rewriteBulkDataUriForSeriesMetadata, + bulkDataUriRelativeFromInstance, +}; diff --git a/packages/static-wado-util/lib/reader/readBulkData.js b/packages/static-wado-util/lib/reader/readBulkData.js index 9151c5d7..e709827d 100644 --- a/packages/static-wado-util/lib/reader/readBulkData.js +++ b/packages/static-wado-util/lib/reader/readBulkData.js @@ -117,7 +117,7 @@ const readBulkData = async (dirSrc, baseName, frame) => { } transferSyntaxUid = tsValue.trim(); } - console.noQuiet('Bulkdata content type', `"${contentType}"`, `"${transferSyntaxUid}"`); + console.verbose('Bulkdata content type', `"${contentType}"`, `"${transferSyntaxUid}"`); } } diff --git a/packages/static-wado-util/test/unit/bulkDataUriResolve.jest.js b/packages/static-wado-util/test/unit/bulkDataUriResolve.jest.js new file mode 100644 index 00000000..788d648c --- /dev/null +++ b/packages/static-wado-util/test/unit/bulkDataUriResolve.jest.js @@ -0,0 +1,94 @@ +const { + resolveBulkDataLocation, + bulkDataHttpPathUnderRoot, + rewriteBulkDataUriForSeriesMetadata, + bulkDataUriRelativeFromInstance, +} = require('../../lib/reader/bulkDataUriResolve.js'); + +describe('resolveBulkDataLocation', () => { + const studyUID = '1.2.3.4.5'; + const seriesUID = '1.2.3.4.6'; + const instanceUID = '1.2.3.4.7'; + + it('resolves instance-relative ./frames', () => { + const spec = resolveBulkDataLocation('./frames', { + studyUID, + seriesUID, + instanceUID, + frameNumber: 2, + }); + expect(spec.kind).toBe('readBulkData'); + expect(spec.dirSuffix).toBe(`studies/${studyUID}/series/${seriesUID}/instances/${instanceUID}`); + expect(spec.baseName).toBe('./frames'); + expect(spec.frame).toBe(2); + }); + + it('resolves series-relative ./instances/.../frames', () => { + const uri = `./instances/${instanceUID}/frames`; + const spec = resolveBulkDataLocation(uri, { + studyUID, + seriesUID, + instanceUID, + frameNumber: 1, + }); + expect(spec.kind).toBe('readBulkData'); + expect(spec.dirSuffix).toBe(`studies/${studyUID}/series/${seriesUID}`); + expect(spec.baseName).toBe(uri); + expect(spec.frame).toBe(1); + }); + + it('anchors non-frame bulkdata relative to series directory', () => { + const spec = resolveBulkDataLocation('./bulkdata/ab/cd/file.mht.gz', { + studyUID, + seriesUID, + instanceUID, + }); + expect(spec.kind).toBe('readBulkData'); + expect(spec.dirSuffix).toBe(`studies/${studyUID}/series/${seriesUID}`); + expect(spec.baseName).toBe('./bulkdata/ab/cd/file.mht.gz'); + }); + + it('returns httpAbsolute for https URLs', () => { + const spec = resolveBulkDataLocation('https://example.com/pixel', { + studyUID, + seriesUID, + frameNumber: 3, + }); + expect(spec.kind).toBe('httpAbsolute'); + expect(spec.url).toBe('https://example.com/pixel/3'); + }); +}); + +describe('bulkDataHttpPathUnderRoot', () => { + const studyUID = '1.2.3'; + const seriesUID = '1.2.4'; + const instanceUID = '1.2.5'; + + it('builds path for instance frames', () => { + const spec = resolveBulkDataLocation('./frames', { + studyUID, + seriesUID, + instanceUID, + frameNumber: 1, + }); + const p = bulkDataHttpPathUnderRoot(spec); + expect(p).toBe(`studies/${studyUID}/series/${seriesUID}/instances/${instanceUID}/frames/1`); + }); +}); + +describe('rewriteBulkDataUriForSeriesMetadata', () => { + it('rewrites ./frames to series-relative instances path', () => { + expect(rewriteBulkDataUriForSeriesMetadata('./frames', '1.2.3')).toBe('./instances/1.2.3/frames'); + }); + it('shortens instance bulkdata relative path', () => { + expect( + rewriteBulkDataUriForSeriesMetadata('../../../../bulkdata/ab/cd/x.mht.gz', '1.2.3') + ).toBe('../../bulkdata/ab/cd/x.mht.gz'); + }); +}); + +describe('bulkDataUriRelativeFromInstance', () => { + it('matches legacy relative depth', () => { + expect(bulkDataUriRelativeFromInstance('ab/cd/ef.mht.gz')).toBe('../../../../bulkdata/ab/cd/ef.mht.gz'); + }); +}); diff --git a/packages/static-wado-webserver/lib/controllers/server/createMissingThumbnail.mjs b/packages/static-wado-webserver/lib/controllers/server/createMissingThumbnail.mjs index 09db0b63..0d9cdd20 100644 --- a/packages/static-wado-webserver/lib/controllers/server/createMissingThumbnail.mjs +++ b/packages/static-wado-webserver/lib/controllers/server/createMissingThumbnail.mjs @@ -26,25 +26,22 @@ export default function createMissingThumbnail(options) { ); try { + // Each request is for a single thumbnail (instance/series/study). Use on-demand semantics + // so thumbnailMain creates only the required thumbnail, not every thumbnail in the study. const thumbnailOptions = { dicomdir: baseDir, + onDemandThumbnail: true, }; if (instanceUID) { - // Generate thumbnail for specific instance thumbnailOptions.instanceUid = instanceUID; if (seriesUID) { thumbnailOptions.seriesUid = seriesUID; } } else if (seriesUID) { - // Generate series thumbnail (middle SOP instance, middle frame) thumbnailOptions.seriesUid = seriesUID; - thumbnailOptions.seriesThumbnail = true; - } else { - // Only studyUID provided - use default behavior (first series, first instance) - // Note: study-level thumbnails are not currently supported - console.verbose('Only studyUID provided, using default behavior'); } + // else: only studyUID provided -> study-level thumbnail (middle series, middle instance, middle frame). await thumbnailMain(studyUID, thumbnailOptions); console.verbose('Created missing thumbnail'); From 884cc405a3a4b19642855769b39680e27027b4a8 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Wed, 13 May 2026 12:32:38 -0400 Subject: [PATCH 7/9] Update package json --- bun.lock | 8 ++++++++ packages/static-wado-plugins/package.json | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/bun.lock b/bun.lock index fd5c4cf2..cd3d4752 100644 --- a/bun.lock +++ b/bun.lock @@ -166,6 +166,14 @@ "http-proxy-middleware": "^2.0.6", }, "devDependencies": { + "@babel/core": "^7.26.0", + "@babel/plugin-proposal-class-properties": "^7.18.6", + "@babel/plugin-transform-class-static-block": "^7.28.3", + "@babel/plugin-transform-modules-commonjs": "^7.16.8", + "@babel/preset-env": "^7.16.11", + "@babel/preset-typescript": "^7.28.5", + "babel-plugin-transform-import-meta": "^2.3.3", + "jest": "29.7.0", "must": "^0.13.4", }, }, diff --git a/packages/static-wado-plugins/package.json b/packages/static-wado-plugins/package.json index 74ca1129..fe5fa612 100644 --- a/packages/static-wado-plugins/package.json +++ b/packages/static-wado-plugins/package.json @@ -75,6 +75,14 @@ "http-proxy-middleware": "^2.0.6" }, "devDependencies": { + "@babel/core": "^7.26.0", + "@babel/plugin-proposal-class-properties": "^7.18.6", + "@babel/plugin-transform-class-static-block": "^7.28.3", + "@babel/plugin-transform-modules-commonjs": "^7.16.8", + "@babel/preset-env": "^7.16.11", + "@babel/preset-typescript": "^7.28.5", + "babel-plugin-transform-import-meta": "^2.3.3", + "jest": "29.7.0", "must": "^0.13.4" } } From 38ff63fd152e023e020a6b61c6ac93472d1e9969 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Tue, 19 May 2026 15:36:09 -0400 Subject: [PATCH 8/9] PR comments --- .../create-dicomweb/bin/createdicomweb.mjs | 125 ++++++++++++++---- .../static-wado-deploy/lib/DeployGroup.mjs | 15 ++- 2 files changed, 109 insertions(+), 31 deletions(-) diff --git a/packages/create-dicomweb/bin/createdicomweb.mjs b/packages/create-dicomweb/bin/createdicomweb.mjs index c7be77ea..1e973515 100755 --- a/packages/create-dicomweb/bin/createdicomweb.mjs +++ b/packages/create-dicomweb/bin/createdicomweb.mjs @@ -1,7 +1,15 @@ #!/usr/bin/env bun import { createRequire } from 'module'; import { Command } from 'commander'; -import { instanceMain, seriesMain, studyMain, createMain, stowMain, thumbnailMain, indexMain } from '../lib/index.mjs'; +import { + instanceMain, + seriesMain, + studyMain, + createMain, + stowMain, + thumbnailMain, + indexMain, +} from '../lib/index.mjs'; import { handleHomeRelative, createVerboseLog, @@ -11,9 +19,10 @@ import { const require = createRequire(import.meta.url); const pkg = require('../package.json'); -const runtime = typeof process !== 'undefined' && process.versions?.bun - ? `Bun ${process.versions.bun}` - : `Node ${process.versions?.node ?? 'unknown'}`; +const runtime = + typeof process !== 'undefined' && process.versions?.bun + ? `Bun ${process.versions.bun}` + : `Node ${process.versions?.node ?? 'unknown'}`; const program = new Command(); @@ -29,7 +38,11 @@ const updateVerboseLog = () => { program .name('createdicomweb') .description('dcmjs based tools for creation of metadata files') - .version(`${pkg.version}\n${runtime}`, '-V, --version', 'output create-dicomweb and runtime (Bun/Node) version') + .version( + `${pkg.version}\n${runtime}`, + '-V, --version', + 'output create-dicomweb and runtime (Bun/Node) version' + ) .option('-v, --verbose', 'Enable verbose logging') .option('-q, --quiet', 'Disable noQuiet logging'); @@ -37,7 +50,11 @@ program .command('instance') .description('Store instance data') .argument('', 'part 10 file(s)') - .option('--dicomdir ', 'Base directory path where binary .mht files will be written in DICOMweb structure','~/dicomweb') + .option( + '--dicomdir ', + 'Base directory path where binary .mht files will be written in DICOMweb structure', + '~/dicomweb' + ) .action(async (fileName, options) => { updateVerboseLog(); const instanceOptions = {}; @@ -51,8 +68,15 @@ program .command('series') .description('Generate series metadata files') .argument('', 'Study Instance UID') - .option('--dicomdir ', 'Base directory path where DICOMweb structure is located', '~/dicomweb') - .option('--series-uid ', 'Specific Series Instance UID to process (if not provided, processes all series in the study)') + .option( + '--dicomdir ', + 'Base directory path where DICOMweb structure is located', + '~/dicomweb' + ) + .option( + '--series-uid ', + 'Specific Series Instance UID to process (if not provided, processes all series in the study)' + ) .action(async (studyUID, options) => { updateVerboseLog(); const seriesOptions = {}; @@ -69,7 +93,11 @@ program .command('study') .description('Generate study metadata files') .argument('', 'Study Instance UID') - .option('--dicomdir ', 'Base directory path where DICOMweb structure is located', '~/dicomweb') + .option( + '--dicomdir ', + 'Base directory path where DICOMweb structure is located', + '~/dicomweb' + ) .action(async (studyUID, options) => { updateVerboseLog(); const studyOptions = {}; @@ -81,12 +109,24 @@ program program .command('create') - .description('Process instances and generate series and study metadata for all discovered studies') + .description( + 'Process instances and generate series and study metadata for all discovered studies' + ) .argument('', 'part 10 file(s) or directory(ies)') - .option('--dicomdir ', 'Base directory path where DICOMweb structure is located', '~/dicomweb') + .option( + '--dicomdir ', + 'Base directory path where DICOMweb structure is located', + '~/dicomweb' + ) .option('--no-study-index', 'Skip creating/updating studies/index.json.gz file') - .option('--bulkdata-size ', 'Size threshold in bytes for public bulkdata tags (default: 131074, i.e. 128k + 2)') - .option('--private-bulkdata-size ', 'Size threshold in bytes for private bulkdata tags (default: 128)') + .option( + '--bulkdata-size ', + 'Size threshold in bytes for public bulkdata tags (default: 131074, i.e. 128k + 2)' + ) + .option( + '--private-bulkdata-size ', + 'Size threshold in bytes for private bulkdata tags (default: 128)' + ) .action(async (fileNames, options) => { updateVerboseLog(); const createOptions = {}; @@ -235,13 +275,32 @@ function parseFrameNumbers(frameNumbersStr) { program .command('thumbnail') .description('Generate thumbnail(s) for DICOM instance(s)') - .argument('[studySelector]', 'Study selector: StudyInstanceUID, query pattern (e.g. PatientID=25), or true for all studies') - .option('--dicomdir ', 'Base directory path where DICOMweb structure is located', '~/dicomweb') - .option('--output-dicomdir ', 'Output directory for generated thumbnails (required when --dicomdir is http/https)') - .option('--study-selector ', 'Study selector override: StudyInstanceUID, query pattern, or true') - .option('--series-uid ', 'Specific Series Instance UID to process (if not provided, uses first series from study query)') - .option('--sop-uid ', 'Specific SOP Instance UID to process (if not provided, uses first instance from series)') - .option('--frame-numbers ', 'Frame numbers to generate thumbnails for (comma-separated, supports ranges, e.g., "1-3,17")', '1') + .argument( + '[studySelector]', + 'Study selector: StudyInstanceUID, query pattern (e.g. PatientID=25), or true for all studies' + ) + .option( + '--dicomdir ', + 'Base directory path where DICOMweb structure is located', + '~/dicomweb' + ) + .option( + '--output-dicomdir ', + 'Output directory for generated thumbnails (required when --dicomdir is http/https)' + ) + .option( + '--series-uid ', + 'Specific Series Instance UID to process (if not provided, uses first series from study query)' + ) + .option( + '--sop-uid ', + 'Specific SOP Instance UID to process (if not provided, uses first instance from series)' + ) + .option( + '--frame-numbers ', + 'Frame numbers to generate thumbnails for (comma-separated, supports ranges, e.g., "1-3,17")', + '1' + ) .option( '--study-thumbnail', 'Representative mode: include study-level thumbnail and only instance JPEGs for the study-representative SOP (middle series, middle instance). Combine with --series-thumbnail so every series also gets a series-level thumbnail.' @@ -289,15 +348,22 @@ program console.error(`Error parsing frame numbers: ${error.message}`); process.exit(1); } - + await thumbnailMain(thumbnailOptions.studySelector, thumbnailOptions); }); program .command('index') .description('Create or update studies/index.json.gz file by adding/updating study information') - .argument('[studyUIDs...]', 'Optional Study Instance UID(s) to process (if not provided, scans all studies)') - .option('--dicomdir ', 'Base directory path where DICOMweb structure is located', '~/dicomweb') + .argument( + '[studyUIDs...]', + 'Optional Study Instance UID(s) to process (if not provided, scans all studies)' + ) + .option( + '--dicomdir ', + 'Base directory path where DICOMweb structure is located', + '~/dicomweb' + ) .action(async (studyUIDs, options) => { updateVerboseLog(); const indexOptions = {}; @@ -311,8 +377,15 @@ program .command('part10') .description('Export DICOMweb metadata to Part 10 DICOM files') .argument('', 'Study Instance UID') - .option('--dicomdir ', 'Base directory path where DICOMweb structure is located', '~/dicomweb') - .option('--series-uid ', 'Specific Series Instance UID to export (if not provided, exports all series)') + .option( + '--dicomdir ', + 'Base directory path where DICOMweb structure is located', + '~/dicomweb' + ) + .option( + '--series-uid ', + 'Specific Series Instance UID to export (if not provided, exports all series)' + ) .option('--sop-uid ', 'Comma-separated list of SOP Instance UIDs to export') .option('-o, --output ', 'Output directory for Part 10 files', '.') .option('--format ', 'Output format: dicom, multipart, zip', 'dicom') @@ -336,4 +409,4 @@ program await part10Main(studyUID, part10Options); }); -program.parse(); \ No newline at end of file +program.parse(); diff --git a/packages/static-wado-deploy/lib/DeployGroup.mjs b/packages/static-wado-deploy/lib/DeployGroup.mjs index 171e2c4d..d8fc8c30 100644 --- a/packages/static-wado-deploy/lib/DeployGroup.mjs +++ b/packages/static-wado-deploy/lib/DeployGroup.mjs @@ -157,19 +157,24 @@ class DeployGroup { const fullPath = path.join(dirPath, entry.name); const entryRelativePath = path.join(relativePath, entry.name).replace(/\\/g, '/'); - // Early exclusion check + // Early exclusion check (applies to files and directories) const shouldExclude = Array.from(excludePatterns).some( pattern => entryRelativePath.indexOf(pattern) !== -1 ); - const shouldInclude = - includePatterns.length === 0 || - includePatterns.some(pattern => entryRelativePath.indexOf(pattern) !== -1); - if (shouldExclude || !shouldInclude) continue; + if (shouldExclude) continue; if (entry.isDirectory()) { + // Always recurse into non-excluded directories so include patterns can + // match nested paths (e.g. studies//series/.../thumbnail). directories.push({ path: fullPath, relativePath: entryRelativePath }); } else { + const shouldInclude = + includePatterns.length === 0 || + includePatterns.some(pattern => entryRelativePath.indexOf(pattern) !== -1); + + if (!shouldInclude) continue; + const stat = await fs.promises.stat(fullPath); files.push({ baseDir: this.baseDir, From 1ad4b313ee71d6085805134ef1c648459d972c9e Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Tue, 19 May 2026 15:51:03 -0400 Subject: [PATCH 9/9] Remove invalid duplicate upload --- packages/static-wado-deploy/lib/studiesMain.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/static-wado-deploy/lib/studiesMain.mjs b/packages/static-wado-deploy/lib/studiesMain.mjs index 993878d6..8aff2f5b 100644 --- a/packages/static-wado-deploy/lib/studiesMain.mjs +++ b/packages/static-wado-deploy/lib/studiesMain.mjs @@ -31,7 +31,6 @@ export async function studyMainSingle(studyUID, options) { } if (!uploadOptions.skipStore) { - await commonMain(this, 'root', uploadOptions, uploadDeploy.bind(null, studyDirectory)); console.log('Storing studyUID', studyUID); await commonMain(this, 'root', uploadOptions, uploadDeploy.bind(null, studyDirectory)); }