UNPKG

@ngageoint/mage.image.service

Version:

Orient images attached to MAGE observations according to EXIF meta-data and generate configurable size thumbnails.

369 lines (367 loc) 20.2 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __asyncValues = (this && this.__asyncValues) || function (o) { if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined."); var m = o[Symbol.asyncIterator], i; return m ? m.call(o) : (o = typeof __values === "function" ? __values(o) : o[Symbol.iterator](), i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i); function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; } function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); } }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.thumbnailDimensionsForAttachment = exports.thumbnailAttachmentImage = exports.orientAttachmentImage = exports.AttachmentProcessingResult = exports.processImageAttachments = exports.createImagePluginControl = exports.defaultImagePluginConfig = void 0; const entities_observations_1 = require("@ngageoint/mage.service/lib/entities/observations/entities.observations"); const path_1 = __importDefault(require("path")); exports.defaultImagePluginConfig = Object.freeze({ enabled: true, intervalSeconds: 60, intervalBatchSize: 10, thumbnailSizes: [150, 320, 800, 1024, 2048], }); /** * Create the image plugin app controller. * * {@link defaultImagePluginConfig | default configuration}. * @param stateRepo * @param eventRepo * @param obsRepoForEvent * @param attachmentStore * @param attachmentQuery * @param imageService * @param console * @returns */ const createImagePluginControl = (stateRepo, eventRepo, obsRepoForEvent, attachmentStore, attachmentQuery, imageService, console) => __awaiter(void 0, void 0, void 0, function* () { const processing = { nextStates: new Map(), nextTimeout: undefined, runningInterval: Promise.resolve(), stopped: true }; function safeGetConfig() { return __awaiter(this, void 0, void 0, function* () { return yield stateRepo.get().then(x => !!x ? x : stateRepo.put(exports.defaultImagePluginConfig)); }); } function processAndScheduleNext() { return __awaiter(this, void 0, void 0, function* () { if (processing.stopped) { return; } const config = yield safeGetConfig(); if (!config.enabled) { return; } return yield processImageAttachments(config, processing.nextStates || null, attachmentQuery, imageService, eventRepo, obsRepoForEvent, attachmentStore, console) .then(eventStates => { processing.nextStates = eventStates; processing.nextTimeout = setTimeout(() => { processing.runningInterval = processAndScheduleNext(); }, config.intervalSeconds * 1000); }, err => { console.error('error during processing interval', err); }); }); } function start() { if (!processing.stopped) { return; } processing.stopped = false; processing.runningInterval = processAndScheduleNext(); } function stop() { return __awaiter(this, void 0, void 0, function* () { processing.stopped = true; clearTimeout(processing.nextTimeout); yield processing.runningInterval; }); } const control = { getConfig() { return __awaiter(this, void 0, void 0, function* () { const config = yield stateRepo.get(); return config; }); }, applyConfig(configPatch) { return __awaiter(this, void 0, void 0, function* () { const current = yield safeGetConfig(); const next = Object.keys(exports.defaultImagePluginConfig).reduce((config, key) => { const configKey = key; const value = configPatch[configKey] === void (0) ? current[configKey] : configPatch[configKey]; return Object.assign(Object.assign({}, config), { [configKey]: value }); }, {}); const saved = yield stateRepo.put(next); stop().then(start); return saved; }); }, start, stop }; return control; }); exports.createImagePluginControl = createImagePluginControl; /** TODO: reads zero-byte file (?) and fails - mark as processed 2023-09-27T09:31:50.738Z - [mage.image] error processing attachment { eventId: 17, observationId: '619ec65d7dc44d090239b266', attachmentId: '619ec6677dc44d090239b268' } -- process result: [Error: pngload_buffer: libspng read error vips2png: unable to write to target target] 2023-09-27T20:26:57.400Z - [mage.image] error processing attachment { eventId: 17, observationId: '619fbe0b7dc44d090239b4d4', attachmentId: '619fbe107dc44d090239b4d6' } -- process result: [Error: Input buffer contains unsupported image format] */ function processImageAttachments(pluginState, eventProcessingStates, findUnprocessedAttachments, imageService, eventRepo, observationRepoForEvent, attachmentStore, console) { var _a, e_1, _b, _c; var _d; return __awaiter(this, void 0, void 0, function* () { console.info('processing image attachments ...'); const startTime = Date.now(); const allEvents = yield eventRepo.findActiveEvents(); eventProcessingStates = syncProcessingStatesFromAllEvents(allEvents, eventProcessingStates); const eventLatestModifiedTimes = new Map(); const unprocessedAttachments = yield findUnprocessedAttachments(Array.from(eventProcessingStates.values()), null, startTime, pluginState.intervalBatchSize); unprocessedAttachments[Symbol.asyncIterator]; let processedCount = 0; try { for (var _e = true, unprocessedAttachments_1 = __asyncValues(unprocessedAttachments), unprocessedAttachments_1_1; unprocessedAttachments_1_1 = yield unprocessedAttachments_1.next(), _a = unprocessedAttachments_1_1.done, !_a;) { _c = unprocessedAttachments_1_1.value; _e = false; try { const unprocessed = _c; // TODO: check results for errors console.info(`processing attachment`, unprocessed); const { observationId, attachmentId } = unprocessed; const observationRepo = yield observationRepoForEvent(unprocessed.eventId); const orient = (observation) => __awaiter(this, void 0, void 0, function* () { return orientAttachmentImage(observation, attachmentId, imageService, attachmentStore, console); }); const thumbnail = (observation) => __awaiter(this, void 0, void 0, function* () { return thumbnailAttachmentImage(observation, attachmentId, pluginState.thumbnailSizes, imageService, attachmentStore, console); }); const [original, processed] = yield observationRepo.findById(observationId) .then(saveResultOf(orient, observationRepo)) .then(saveResultOf(thumbnail, observationRepo)); if (original instanceof entities_observations_1.Observation) { if (processed instanceof entities_observations_1.Observation) { const eventLatestModified = eventLatestModifiedTimes.get(unprocessed.eventId) || 0; const attachment = original.attachmentFor(attachmentId); const attachmentLastModified = ((_d = attachment === null || attachment === void 0 ? void 0 : attachment.lastModified) === null || _d === void 0 ? void 0 : _d.getTime()) || original.lastModified.getTime(); if (attachmentLastModified > eventLatestModified) { eventLatestModifiedTimes.set(unprocessed.eventId, attachmentLastModified); } console.info(`processed attachment ${(attachment === null || attachment === void 0 ? void 0 : attachment.name) || '<unnamed>'}`, unprocessed); } else { console.error(`error processing attachment`, unprocessed, '\n-- process result:', processed); const attachment = original.attachmentFor(attachmentId); if (attachment && !attachment.oriented) { const oriented = yield observationRepo.patchAttachment(original, attachmentId, { oriented: true }); if (oriented instanceof Error) { console.error(`error marking attachment oriented after failed processing:`, unprocessed, oriented); } } } processedCount++; } } finally { _e = true; } } } catch (e_1_1) { e_1 = { error: e_1_1 }; } finally { try { if (!_e && !_a && (_b = unprocessedAttachments_1.return)) yield _b.call(unprocessedAttachments_1); } finally { if (e_1) throw e_1.error; } } console.info(`finished image attachment processing interval - ${processedCount} attachments`); return new Map(Array.from(eventProcessingStates.entries(), ([eventId, state]) => { return [eventId, { event: state.event, latestAttachmentProcessedTimestamp: eventLatestModifiedTimes.get(eventId) || 0 }]; })); }); } exports.processImageAttachments = processImageAttachments; class AttachmentProcessingResult { constructor(observation, attachmentId, patch, error) { this.observation = observation; this.attachmentId = attachmentId; this.patch = patch; this.error = error; } get success() { return !this.error; } } exports.AttachmentProcessingResult = AttachmentProcessingResult; function orientAttachmentImage(observation, attachmentId, imageService, attachmentStore, console) { return __awaiter(this, void 0, void 0, function* () { const attachment = observation.attachmentFor(attachmentId); if (!attachment) { return new AttachmentProcessingResult(observation, attachmentId, undefined, Error(`attachment ${attachmentId} does not exist on observation ${observation.id}`)); } const content = yield attachmentStore.readContent(attachmentId, observation); if (!content || content instanceof Error) { console.error(`error reading content of image attachment ${attachmentId} observation ${observation.id}:`, content || 'content not found'); return new AttachmentProcessingResult(observation, attachmentId, undefined, content || new Error('content not found')); } const pending = yield attachmentStore.stagePendingContent(); const oriented = yield imageService.autoOrient(imageContentForAttachment(attachment, content), pending.tempLocation); if (oriented instanceof Error) { console.error(`error orienting attachment ${attachmentId} on observation ${observation.id} at ${attachment.contentLocator}`, oriented); return new AttachmentProcessingResult(observation, attachmentId, undefined, oriented); } const storeResult = yield attachmentStore.saveContent(pending, attachment.id, observation); if (storeResult instanceof entities_observations_1.AttachmentStoreError) { console.error(`error saving pending oriented content ${pending.id} for attachment ${attachmentId} on observation ${observation.id}:`, storeResult); return new AttachmentProcessingResult(observation, attachmentId, undefined, storeResult); } const patch = Object.assign(Object.assign({ oriented: true, contentType: oriented.mediaType, size: oriented.sizeInBytes }, oriented.dimensions), storeResult); return new AttachmentProcessingResult(observation, attachmentId, patch); }); } exports.orientAttachmentImage = orientAttachmentImage; function thumbnailAttachmentImage(observation, attachmentId, thumbnailSizes, imageService, attachmentStore, console) { return __awaiter(this, void 0, void 0, function* () { if (thumbnailSizes.length === 0) { return new AttachmentProcessingResult(observation, attachmentId); } const attachment = observation.attachmentFor(attachmentId); if (!attachment) { const err = new Error(`attachment ${attachmentId} does not exist on observation ${observation.id}`); return new AttachmentProcessingResult(observation, attachmentId, undefined, err); } /* TODO: this thumbnail meta-data and content saving sequence is pretty awkward, having to add thumbnails to an observation to pass to the content store, which returns updates for the thumbnails that the client then must apply to the observation to patch the attachment in the database. these APIs could use some modification to be more convenient and intuitive. */ const thumbResults = (yield Promise.all(thumbnailSizes.map(thumbnailSize => { return generateAndStageThumbnail(thumbnailSize, attachment, observation, imageService, attachmentStore, console); }))).filter(x => !(x instanceof Error)); let obsWithThumbs = thumbResults.reduce((obsWithThumbs, thumbResult) => { if (thumbResult instanceof Error) { return obsWithThumbs; } return (0, entities_observations_1.putAttachmentThumbnailForMinDimension)(obsWithThumbs, attachmentId, thumbResult.thumbnail); }, observation); const storedThumbs = yield Promise.all(thumbResults.map(stagedThumb => { return attachmentStore.saveThumbnailContent(stagedThumb.pendingContent, stagedThumb.thumbnail.minDimension, attachmentId, obsWithThumbs); })); obsWithThumbs = storedThumbs.reduce((obsWithThumbs, storedThumb) => { if (storedThumb instanceof Error || !storedThumb) { return obsWithThumbs; } return (0, entities_observations_1.putAttachmentThumbnailForMinDimension)(obsWithThumbs, attachmentId, storedThumb); }, obsWithThumbs); const storedThumbPatch = { thumbnails: obsWithThumbs.attachmentFor(attachmentId).thumbnails }; return new AttachmentProcessingResult(observation, attachmentId, storedThumbPatch); }); } exports.thumbnailAttachmentImage = thumbnailAttachmentImage; function syncProcessingStatesFromAllEvents(allEvents, states) { states = states || new Map(); const newStates = new Map(); for (const event of allEvents) { const state = states.get(event.id) || { event, latestAttachmentProcessedTimestamp: 0 }; newStates.set(event.id, state); } return newStates; } /** * Perform the given attachment processing operation. If the operation * produces an attachment patch, apply the patch and save the observation. */ function saveResultOf(processAttachment, repo) { return (target) => __awaiter(this, void 0, void 0, function* () { const [original, next] = Array.isArray(target) ? target : [target, target]; if (original instanceof entities_observations_1.Observation && next instanceof entities_observations_1.Observation) { const result = yield processAttachment(next); if (result.patch) { const patched = yield repo.patchAttachment(next, result.attachmentId, result.patch); return [original, result.error || patched]; } return [original, result.error || original]; } return [original, next]; }); } function generateAndStageThumbnail(thumbnailSize, attachment, observation, imageService, attachmentStore, console) { return __awaiter(this, void 0, void 0, function* () { const attachmentId = attachment.id; const attachmentName = attachment.name || ''; const attachmentExt = path_1.default.extname(attachmentName); const attachmentBareName = attachmentName.slice(0, attachmentName.length - attachmentExt.length) || attachmentId; const content = yield attachmentStore.readContent(attachmentId, observation); if (content instanceof Error) { const message = `error reading content for attachment ${attachmentId}, observation ${observation.id}: ${content}`; console.error(message, content); return new Error(message); } if (content === null) { const message = `content not found for attachment ${attachmentId}, observation ${observation.id}`; console.error(message); return new Error(message); } const source = imageContentForAttachment(attachment, content); const pendingContent = yield attachmentStore.stagePendingContent(); const thumbInfo = yield imageService.scaleToDimension(thumbnailSize, source, pendingContent.tempLocation); if (thumbInfo instanceof Error) { const message = `error scaling image on attachment ${attachmentId}: ${thumbInfo}`; console.error(message, thumbInfo); return new Error(message); } const thumbnail = attachment.thumbnails.find(x => x.minDimension === thumbnailSize) || { minDimension: thumbnailSize }; return { thumbnail: Object.assign(Object.assign({}, thumbnail), { name: `${attachmentBareName}-${thumbnailSize}${attachmentExt}`, contentType: thumbInfo.mediaType, width: thumbInfo.dimensions.width, height: thumbInfo.dimensions.height, size: thumbInfo.sizeInBytes }), pendingContent }; }); } function imageContentForAttachment(x, bytes) { const dimensions = typeof x.width === 'number' && typeof x.height === 'number' ? { width: x.width, height: x.height } : undefined; return { bytes, dimensions, mediaType: x.contentType, sizeInBytes: x.size }; } /** * Scale the dimensions of the given attachment to the dimensions of a * thumbnail with the given minimum target dimension. Return null if either of * the attachment's dimensions are not numeric. * @param attachment * @param minThumbDimension * @returns */ const thumbnailDimensionsForAttachment = (attachment, minThumbDimension) => { if (!attachment.width || attachment.width <= 0 || !attachment.height || attachment.height <= 0) { return null; } const [width, height] = attachment.width <= attachment.height ? [minThumbDimension, Math.ceil((minThumbDimension / attachment.width) * attachment.height)] : [Math.ceil((minThumbDimension / attachment.height) * attachment.width), minThumbDimension]; return { width, height }; }; exports.thumbnailDimensionsForAttachment = thumbnailDimensionsForAttachment; //# sourceMappingURL=processor.js.map