/********************************************************************** * * * **********************************************************************/ ((typeof define)[0]=='u'?function(f){module.exports=f(require)}:define) (function(require){ var module={} // make module AMD/node compatible... /*********************************************************************/ var actions = require('lib/actions') var features = require('lib/features') var core = require('features/core') try{ var sharp = requirejs('sharp') } catch(err){ var sharp = null } if(typeof(process) != 'undefined'){ var cp = requirejs('child_process') var fse = requirejs('fs-extra') var pathlib = requirejs('path') var glob = requirejs('glob') var exifReader = requirejs('exif-reader') var file = require('imagegrid/file') } /*********************************************************************/ // helpers... if(typeof(process) != 'undefined'){ var copy = file.denodeify(fse.copy) var ensureDir = file.denodeify(fse.ensureDir) } //--------------------------------------------------------------------- var normalizeOrientation = module.normalizeOrientation = function(orientation){ return { orientation: ({ 0: 0, 1: 0, 2: 0, 3: 180, 4: 0, 5: 90, 6: 90, 7: 90, 8: 270, })[orientation], flipped: ({ 0: null, 1: null, 2: ['horizontal'], 3: null, 4: ['vertical'], 5: ['vertical'], 6: null, 7: ['horizontal'], 8: null, })[orientation], } } //--------------------------------------------------------------------- // Convert image metadata from exif-reader output to format compatible // with exiftool (features/metadata.js) // Format: // { // // simple key-key pair... // 'path.to.value': 'output-key', // // // key with value handler... // 'path.to.other.value': ['output-key', handler], // // // alias to handler... // 'path.to.yet.another.value': ['output-key', 'path.to.other.value'], // } // var EXIF_FORMAT = module.EXIF_FORMAT = { // camera / lens... 'image.Make': 'make', 'image.Model': 'cameraModelName', 'image.Software': 'software', 'exif.LensModel': 'lensModel', // exposure... 'exif.ISO': 'iso', 'exif.FNumber': ['fNumber', function(v){ return 'f/'+v }], 'exif.ExposureTime': ['exposureTime', // NOTE: this is a bit of a brute-fore approach but for shutter // speeds this should not matter... function(v){ if(v > 0.5){ return ''+ v } for(var d = 1; (v * d) % 1 != 0; d++){} return (v * d) +'/'+ d }], // dates... 'exif.DateTimeOriginal': ['date/timeOriginal', function(v){ return v.toShortDate() }], 'image.ModifyDate': ['modifyDate', 'exif.DateTimeOriginal'], // IPCT... 'image.Artist': 'artist', 'image.Copyright': 'copyright', // XXX anything else??? } // NOTE: this only reads the .rating from xmp... var exifReader2exiftool = module.exifReader2exiftool = function(exif, xmp){ return Object.entries(EXIF_FORMAT) // handle exif... .reduce(function(res, [path, to]){ var handler ;[to, handler] = to instanceof Array ? to : [to] // resolve handler reference/alias... while(typeof(handler) == typeof('str')){ handler = EXIF_FORMAT[handler][1] } // resolve source path... var value = path.split(/\./g) .reduce(function(res, e){ return res && res[e] }, exif) // set the value... if(value !== undefined){ res[to] = handler ? handler(value) : value } return res }, {}) // handle xmp... .run(function(){ var rating = xmp // NOTE: we do not need the full XML // fluff here, just get some values... && parseInt( (xmp.toString() .match(/(?<(xmp:Rating)[^>]*>(?.*)<\/\2>)/i) || {groups: {}}) .groups.value) rating && (this.rating = rating) }) } /*********************************************************************/ var SharpActions = actions.Actions({ config: { 'preview-normalized': true, // can be: // 'gids' // 'files' 'preview-progress-mode': 'gids', 'preview-generate-threshold': 2000, // NOTE: this uses 'preview-sizes' and 'preview-path-template' // from filesystem.IndexFormat... }, // XXX need to distinguish if something was written in the promise chain... // ...return false??? // ......should the return value be a bit more informative??? // something like: // { // gid: .. // path: .. // status: .. // ... // } // XXX make backup name pattern configurable... // XXX add crop support... makeResizedImage: ['- Image/', core.doc`Make resized image(s)... .makeResizedImage(gid, size, path[, options]) .makeResizedImage(gids, size, path[, options]) -> promise Image size formats: 500px - resize to make image's *largest* dimension 500 pixels (default). 500p - resize to make image's *smallest* dimension 500 pixels. 500 - same as 500px options format: { // output image name... // // Used if processing a single image, ignored otherwise. name: null | , // image name pattern and data... // // NOTE: for more info on pattern see: .formatImageName(..) pattern: null | , data: null | { .. }, // if true and image is smaller than size enlarge it... // // default: null / false enlarge: null | true, // overwrite, backup or skip (default) existing images... // // default: null / false overwrite: null | true | 'backup', // if true do not write an image if it's smaller than size... // // default: null / false skipSmaller: null | true, // XXX not implemented... transform: ..., crop: ..., timestamp: ..., logger: ..., , } NOTE: all options are optional. NOTE: this will not overwrite existing images. `, core.taskAction('makeResizedImage', function(ticket, images, size, path, options={}){ var that = this // sanity check... if(arguments.length < 4){ ticket.reject() throw new Error('.makeResizedImage(..): ' +'need at least images, size and path.') } // setup runtime interactions... // // NOTE: we will resolve the ticket when we are fully done // and not on stop... var STOP = false ticket .onmessage('stop', function(){ STOP = true }) .then(function(){ // close progress bar... // NOTE: if we have multiple tasks let the last one // close the progress bar... if(that.tasks.titled(ticket.title).length == 0){ logger && logger.emit('close') } // cleanup... delete that.__cache_metadata_reading }) var abort = function(){ that.tasks.stop(ticket.title) } var CHUNK_SIZE = 4 // get/normalize images... //images = images || this.current images = images || 'all' // keywords... images = images == 'all' ? this.data.getImages('all') : images == 'current' ? this.current : images images = images instanceof Array ? images : [images] // sizing... var fit = typeof(size) == typeof('str') ? (size.endsWith('px') ? 'inside' : size.endsWith('p') ? 'outside' : 'inside') : 'inside' size = parseInt(size) // options... var { // naming... name, pattern, data, // file handling... enlarge, skipSmaller, overwrite, // transformations... transform, // XXX not implemented... crop, timestamp, logger, } = options // defaults... pattern = pattern || '%n' transform = transform === undefined ? true : transform timestamp = timestamp || Date.timeStamp() logger = logger !== false ? (logger || this.logger) : false logger = logger && logger.push('Resize', {onclose: abort}) // backup... // XXX make backup name pattern configurable... var backupName = function(to){ var i = 0 while(fse.existsSync(`${to}.${timestamp}.bak`+ (i || ''))){ i++ } return `${to}.${timestamp}.bak`+ (i || '') } return images .mapChunks(CHUNK_SIZE, function(gid){ if(STOP){ throw Array.STOP('aborted') } // skip non-images... if(!['image', null, undefined] .includes(that.images[gid].type)){ return false } // paths... var source = that.getImagePath(gid) var to = pathlib.join( path, (images.length == 1 && name) ? name : that.formatImageName(pattern, gid, data || {})) logger && logger.emit('queued', to) var img = sharp(source) return (skipSmaller ? // skip if smaller than size... img .metadata() .then(function(m){ // skip... if((fit == 'inside' && Math.max(m.width, m.height) < size) || (fit == 'outside' && Math.min(m.width, m.height) < size)){ logger && logger.emit('skipping', gid) return } // continue... return img }) : Promise.resolve(img)) // prepare to write... .then(function(img){ return img && ensureDir(pathlib.dirname(to)) .then(function(){ // handle existing image... if(fse.existsSync(to)){ // rename... if(overwrite == 'backup'){ fse.renameSync(to, backupName(to)) // remove... } else if(overwrite){ fse.removeSync(to) // skip... } else { logger && logger.emit('skipping', gid) return } } // write... return img .clone() // handle transform (.orientation / .flip) and .crop... .run(function(){ var img_data = that.images[gid] if(transform && (img_data.orientation || img_data.flipped)){ img_data.orientation && this.rotate(img_data.orientation) img_data.flipped && img_data.flipped.includes('horizontal') && this.flip() } img_data.flipped && img_data.flipped.includes('vertical') && this.flop() // XXX if(crop){ // XXX } }) .resize({ width: size, height: size, fit: fit, withoutEnlargement: !enlarge, }) .withMetadata() .toFile(to) .then(function(){ logger && logger.emit('done', to) return img }) }) }) }) .then(function(res){ ticket.resolve(res) return res == 'aborted' ? Promise.reject('aborted') : res }) })], // XXX this does not update image.base_path -- is this correct??? // XXX add support for offloading the processing to a thread/worker... makePreviews: ['Sharp|File/Make image $previews', core.doc`Make image previews Make previews for all images... .makePreviews() .makePreviews('all') -> promise Make previews for current image... .makePreviews('current') -> promise Make previews for specific image(s)... .makePreviews(gid) .makePreviews([gid, gid, ..]) -> promise Make previews of images, size and at base_path... .makePreviews(images, sizes) .makePreviews(images, sizes, base_path) -> promise NOTE: if base_path is given .images will not be updated with new preview paths... `, core.taskAction('makePreviews', function(ticket, images, sizes, base_path, logger){ var that = this // setup runtime interactions... // // NOTE: we will resolve the ticket when we are fully done // and not on stop... var STOP = false ticket .onmessage('stop', function(){ STOP = true }) .then(function(){ // close progress bar... // NOTE: if we have multiple tasks let the last one // close the progress bar... if(that.tasks.titled(ticket.title).length == 0){ gid_logger && gid_logger.emit('close') logger && logger.emit('close') } // cleanup... delete that.__cache_metadata_reading }) var abort = function(){ that.tasks.stop(ticket.title) } var CHUNK_SIZE = 4 var logger_mode = this.config['preview-progress-mode'] || 'gids' logger = logger !== false ? (logger || this.logger) : false var gid_logger = logger && logger.push('Images', {onclose: abort}) logger = logger && logger.push('Previews', {onclose: abort}) // get/normalize images... images = images || 'all' // keywords... images = images == 'all' ? this.data.getImages('all') : images == 'current' ? this.current : images images = images instanceof Array ? images : [images] // get/normalize sizes.... var cfg_sizes = this.config['preview-sizes'].slice() || [] cfg_sizes .sort() .reverse() // XXX revise... if(sizes){ sizes = sizes instanceof Array ? sizes : [sizes] // normalize to preview size... sizes = (this.config['preview-normalized'] ? sizes .map(function(s){ return cfg_sizes.filter(function(c){ return c >= s }).pop() || s }) : sizes) .unique() } else { sizes = cfg_sizes } var path_tpl = that.config['preview-path-template'] .replace(/\$INDEX|\$\{INDEX\}/g, that.config['index-dir'] || '.ImageGrid') gid_logger && gid_logger.emit('queued', images) return images .mapChunks(CHUNK_SIZE, function(gid){ if(STOP){ throw Array.STOP('aborted') } var img = that.images[gid] var base = base_path || img.base_path || that.location.path return sizes .map(function(size, i){ if(STOP){ throw Array.STOP('aborted') } var name = path = path_tpl .replace(/\$RESOLUTION|\$\{RESOLUTION\}/g, parseInt(size)) .replace(/\$GID|\$\{GID\}/g, gid) .replace(/\$NAME|\$\{NAME\}/g, img.name) return that.makeResizedImage(gid, size, base, { name, skipSmaller: true, transform: false, logger: logger_mode == 'gids' ? false : logger, }) .then(function([res]){ i == sizes.length-1 && gid_logger && gid_logger.emit('done', gid) // did not create a preview... if(!res){ return false } // update metadata... if(!base_path){ var preview = img.preview = img.preview || {} preview[parseInt(size) + 'px'] = name that.markChanged && that.markChanged('images', [gid]) } return [gid, size, name] }) }) }) .then(function(res){ ticket.resolve(res) return res == 'aborted' ? Promise.reject('aborted') : res.flat() }) })], // XXX add support for offloading the processing to a thread/worker... __cache_metadata_reading: null, cacheMetadata: ['- Sharp|Image/', core.doc`Cache metadata Cache metadata for current image... .cacheMetadata() .cacheMetadata('current') -> promise([ gid | null ]) Force cache metadata for current image... .cacheMetadata(true) .cacheMetadata('current', true) -> promise([ gid | null ]) Cache metadata for all images... .cacheMetadata('all') -> promise([ gid | null, .. ]) Force cache metadata for all images... .cacheMetadata('all', true) -> promise([ gid | null, .. ]) Cache metadata for specific images... .cacheMetadata([ gid, .. ]) -> promise([ gid | null, .. ]) Force cache metadata for specific images... .cacheMetadata([ gid, .. ], true) -> promise([ gid | null, .. ]) This will: - quickly reads/caches essential (.orientation and .flipped) metadata - quickly read some non-essential but already there values - generate priority previews for very large images (only when in index) This will overwrite/update if: - .orientation and .flipped iff image .orientation AND .flipped are unset or force is true - metadata if image .metadata is not set or .metadata.ImageGridMetadata is not set - all metadata if force is set to true NOTE: this will effectively update metadata format to the new spec... NOTE: for info on full metadata format see: .readMetadata(..) `, core.sessionTaskAction('cacheMetadata', function(ticket, images, logger){ var that = this // setup runtime interactions... // // NOTE: we will resolve the ticket when we are fully done // and not on stop... var STOP = false ticket .onmessage('stop', function(){ STOP = true }) .then(function(){ // close progress bar... // NOTE: if we have multiple tasks let the last one // close the progress bar... if(that.tasks.titled(ticket.title).length == 0){ logger && logger.emit('close') } that.off('clear', on_close) // cleanup... delete that.__cache_metadata_reading }) // clear the progress bar for the next session... var on_close this.one('clear', on_close = function(){ logger && logger.emit('close') }) // universal task abort... // NOTE: this will abort all the tasks of this type... var abort = function(){ that.tasks.stop(ticket.title) } var CHUNK_SIZE = 4 // handle logging and processing list... // NOTE: these will maintain .__cache_metadata_reading helping // avoid processing an image more than once at the same // time... var done = function(gid, msg){ logger && logger.emit(msg || 'done', gid) if(that.__cache_metadata_reading){ that.__cache_metadata_reading.delete(gid) } return gid } var skipping = function(gid){ return done(gid, 'skipping') } var force = false if(images === true){ force = true images = null } else if(logger === true){ force = true logger = arguments[2] } // NOTE: we are caching this to avoid messing things up when // loading before this was finished... var cached_images = this.images // get/normalize images... //images = images || this.current images = images || 'current' // keywords... images = images == 'all' ? this.data.getImages('all') : images == 'loaded' ? (this.ribbons ? this.ribbons.getImageGIDs() : this.data.getImages('all')) : images == 'current' ? this.current : images images = (images instanceof Array ? images : [images]) .filter(function(gid){ return !that.__cache_metadata_reading || !that.__cache_metadata_reading.has(gid) }) logger = logger !== false ? (logger || this.logger) : false logger = logger && logger.push('Caching image metadata', {onclose: abort, quiet: true}) logger && logger.emit('queued', images) /*/ XXX set this to tmp for .location.load =='loadImages' // XXX add preview cache directory... // - user defined path // - cleanable // partially (remove orphans) / full... // - not sure how to index... var base_path = that.location.load == 'loadIndex' ? null : tmp /*/ var base_path //*/ return images .mapChunks(CHUNK_SIZE, function(gid){ // abort... if(STOP){ throw Array.STOP('aborted') } var img = cached_images[gid] var path = img && that.getImagePath(gid) ;(that.__cache_metadata_reading = that.__cache_metadata_reading || new Set()) .add(gid) // skip... if(!(img && path && (force // high priority must be preset... || (img.orientation == null && img.flipped == null) // update metadata... || (img.metadata || {}).ImageGridMetadata == null))){ skipping(gid) return } return sharp(that.getImagePath(gid)) .metadata() .catch(function(){ skipping(gid) }) .then(function(metadata){ // no metadata... if(metadata == null){ skipping(gid) return } var o = normalizeOrientation(metadata.orientation) ;(force || img.orientation == null) // NOTE: we need to set orientation to something // or we'll check it again and again... && (img.orientation = o.orientation || 0) ;(force || img.flipped == null) && (img.flipped = o.flipped) // mark metadata as partially read... // NOTE: this will intentionally overwrite the // previous reader mark/mode... img.metadata = Object.assign( img.metadata || {}, { ImageGridMetadataReader: 'sharp/exif-reader/ImageGrid', // mark metadata as partial read... // NOTE: partial metadata will get reread by // the metadata feature upon request... ImageGridMetadata: 'partial', }) // read the metadata... var exif = metadata.exif && exifReader(metadata.exif) exif && Object.assign( img.metadata, exifReader2exiftool(exif, metadata.xmp)) // if image too large, generate preview(s)... // XXX EXPERIMENTAL... var size_threshold = that.config['preview-generate-threshold'] if(size_threshold && img.preview == null && Math.max(metadata.width, metadata.height) > size_threshold){ logger && logger.emit('Image too large', gid) // XXX make this more generic... // ...if 'loadImages' should create previews in tmp... that.location.load == 'loadIndex' && that.makePreviews(gid, that.config['preview-sizes-priority'] || 1080, base_path, logger) } that.markChanged && that.markChanged('images', [gid]) that.ribbons && that.ribbons.updateImage(gid) return done(gid) }) }) .then(function(res){ ticket.resolve(res) // XXX do we need this??? return res == 'aborted' ? Promise.reject('aborted') : res }) })], cacheAllMetadata: ['- Sharp|Image/', core.doc`Cache all metadata NOTE: this is a shorthand to .cacheMetadata('all', ..)`, 'cacheMetadata: "all" ...'], // shorthands... // XXX do we need these??? // ...better have a task manager UI... abortMakeResizedImage: ['- Sharp/', 'tasks.stop: "makeResizedImage"'], abortMakePreviews: ['- Sharp/', 'tasks.stop: "makePreviews"'], abortCacheMetadata: ['- Sharp/', 'tasks.stop: "cacheMetadata"'], }) var Sharp = module.Sharp = core.ImageGridFeatures.Feature({ title: '', doc: '', tag: 'sharp', depends: [ 'location', 'index-format', ], actions: SharpActions, isApplicable: function(){ return !!sharp }, handlers: [ //* XXX this needs to be run in the background... // XXX this is best done in a thread + needs to be abortable (on .load(..))... [['loadImages', 'loadNewImages'], function(){ this.cacheMetadata('all') }], //*/ // set orientation if not defined... // NOTE: progress on this is not shown so as to avoid spamming // the UI... // XXX should this be pre or post??? // ...creating a preview would be more logical than trying // to load a gigantic image, maybe even loading a placeholder // while doing so... //['updateImage.pre', // function(gid){ ['updateImage', function(_, gid){ var that = this // NOTE: as this directly affects the visible lag, this // must be as fast as possible... ;((this.images[gid] || {}).metadata || {}).ImageGridMetadata || this.cacheMetadata(gid, false) .then(function([res]){ res && that.logger && that.logger.emit('Cached metadata for', gid) }) }], // XXX need to: // - if image too large to set the preview to "loading..." // - create previews... // - update image... /* ['updateImage.pre', function(gid){ var that = this if(this.images[gid].preview == null){ sharp(this.getImagePath(gid)) .metadata() .then(function(metadata){ // current image is larger than any of the previews... if(Math.max(metadata.width, metadata.height) > Math.max.apply(Math, that.config['preview-sizes'])){ // create the currently needed preview first... that.makePreviews(gid, that.ribbons.getVisibleImageSize()) .then(function(){ // load the created preview... that.ribbons.updateImage(gid) // create the rest... that.makePreviews(gid) }) } }) } }] //*/ ], }) /********************************************************************** * vim:set ts=4 sw=4 : */ return module })