diff --git a/src/drawerbase.js b/src/drawerbase.js index 3355b45c..01646c90 100644 --- a/src/drawerbase.js +++ b/src/drawerbase.js @@ -38,7 +38,14 @@ * @typedef BaseDrawerOptions * @memberOf OpenSeadragon * @property {boolean} [usePrivateCache=false] specify whether the drawer should use - * detached (=internal) cache object in case it has to perform type conversion + * detached (=internal) cache object in case it has to perform custom type conversion atop + * what cache performs. In that case, drawer must implement internalCacheCreate() which gets data in one + * of formats the drawer declares as supported. This method must return object to be used during drawing. + * You should probably implement also internalCacheFree() to provide cleanup logics. + * + * @property {boolean} [preloadCache=true] + * When internalCacheCreate is used, it can be applied offline (asynchronously) during data processing = preloading, + * or just in time before rendering (if necessary). Preloading supports */ const OpenSeadragon = $; // (re)alias back to OpenSeadragon for JSDoc @@ -86,6 +93,7 @@ OpenSeadragon.DrawerBase = class DrawerBase{ this.container.appendChild( this.canvas ); this._checkInterfaceImplementation(); + this.setInternalCacheNeedsRefresh(); // initializes stamp } /** @@ -97,7 +105,8 @@ OpenSeadragon.DrawerBase = class DrawerBase{ */ get defaultOptions() { return { - usePrivateCache: false + usePrivateCache: false, + preloadCache: true, }; } @@ -207,6 +216,15 @@ OpenSeadragon.DrawerBase = class DrawerBase{ $.console.error('Drawer.destroy must be implemented by child class'); } + /** + * Destroy internal cache. Should be called within destroy() when + * usePrivateCache is set to true. Ensures cleanup of anything created + * by internalCacheCreate(...). + */ + destroyInternalCache() { + this.viewer.tileCache.clearDrawerInternalCache(this); + } + /** * @param {TiledImage} tiledImage the tiled image that is calling the function * @returns {Boolean} Whether this drawer requires enforcing minimum tile overlap to avoid showing seams. @@ -240,6 +258,31 @@ OpenSeadragon.DrawerBase = class DrawerBase{ $.console.warn('[drawer].clear() is deprecated. The drawer is responsible for clearing itself as needed before drawing tiles.'); } + /** + * If options.usePrivateCache is true, this method MUST RETURN the private cache content + * @param {OpenSeadragon.CacheRecord} cache + * @param {OpenSeadragon.Tile} tile + * @return any + */ + internalCacheCreate(cache, tile) {} + + /** + * It is possible to perform any necessary cleanup on internal cache, necessary if you + * need to clean up some memory (e.g. destroy canvas by setting with & height to 0). + * @param {*} data object returned by internalCacheCreate(...) + */ + internalCacheFree(data) {} + + /** + * Call to invalidate internal cache. It will be rebuilt. With synchronous converions, + * it will be rebuilt immediatelly. With asynchronous, it will be rebuilt once invalidation + * routine happens, e.g. you should call also requestInvalidate() if you need to happen + * it as soon as possible. + */ + setInternalCacheNeedsRefresh() { + this._dataNeedsRefresh = $.now(); + } + // Private functions /** diff --git a/src/tile.js b/src/tile.js index b04dbc61..473207af 100644 --- a/src/tile.js +++ b/src/tile.js @@ -795,8 +795,6 @@ $.Tile.prototype = { this.tiledImage = null; this._caches = {}; this._cacheSize = 0; - this.element = null; - this.imgElement = null; this.loaded = false; this.loading = false; this._cKey = this._ocKey; diff --git a/src/tilecache.js b/src/tilecache.js index f23d5e26..2f8b9335 100644 --- a/src/tilecache.js +++ b/src/tilecache.js @@ -79,7 +79,7 @@ */ await() { if (!this._promise) { //if not cache loaded, do not fail - return $.Promise.resolve(); + return $.Promise.resolve(this._data); } return this._promise; } @@ -162,92 +162,171 @@ } /** - * Access of the data by drawers, synchronous function. Should always access a valid main cache, e.g. - * cache swap performed on working cache (replaceCache()) must be synchronous such that cache is always - * ready to render, and swaps atomically between render calls. + * Access of the data by drawers, synchronous function. Should always access a valid main cache. + * This is ensured by invalidation routine that executes data modification on a copy record, and + * then synchronously swaps records (main caches) to the new data between render calls. + * + * If a drawer decides to have internal cache with synchronous behavior, it is (if necessary) + * performed during this phase. * * @param {OpenSeadragon.DrawerBase} drawer drawer reference which requests the data: the drawer * defines the supported formats this cache should return **synchronously** * @param {OpenSeadragon.Tile} tileToDraw reference to the tile that is in the process of drawing and * for which we request the data; if we attempt to draw such tile while main cache target is destroyed, * attempt to reset the tile state to force system to re-download it again - * @returns {OpenSeadragon.CacheRecord|OpenSeadragon.SimpleCacheRecord|undefined} desired data if available, + * @returns {OpenSeadragon.CacheRecord|OpenSeadragon.InternalCacheRecord|undefined} desired data if available, * wrapped in the cache container. This data is guaranteed to be loaded & in the type supported by the drawer. * Returns undefined if the data is not ready for rendering. * @private */ getDataForRendering(drawer, tileToDraw) { - const supportedTypes = drawer.getSupportedDataFormats(), - keepInternalCopy = drawer.options.usePrivateCache; - if (this.loaded && supportedTypes.includes(this.type)) { - return this; - } + const keepInternalCopy = drawer.options.usePrivateCache; + // Test cache state + if (!this.loaded) { + $.console.error("Attempt to draw tile when not loaded main cache!"); + return undefined; + } if (this._destroyed) { $.console.error("Attempt to draw tile with destroyed main cache!"); tileToDraw._unload(); // try to restore the state so that the tile is later on fetched again return undefined; } - let internalCache = this[DRAWER_INTERNAL_CACHE]; - internalCache = internalCache && internalCache[drawer.getId()]; - if (keepInternalCopy && !internalCache) { - $.console.warn("Attempt to render cache that is not prepared for current drawer " + - "supported format: the preparation should've happened after tile processing has finished.", - this, tileToDraw); - - this.prepareForRendering(drawer.getId(), supportedTypes, keepInternalCopy) - .then(() => this._triggerNeedsDraw()); + // Ensure cache in a format suitable for the current drawer. If not it is an error, prepareForRendering + // should be called at the end of invalidation routine instead. Since the processing is async, we are + // unable to provide the rendering data immediatelly - return. + const supportedTypes = drawer.getSupportedDataFormats(); + if (!supportedTypes.includes(this.type)) { + $.console.error("Attempt to draw tile with unsupported target drawer type!"); + this.prepareForRendering(drawer); return undefined; } - if (internalCache) { - internalCache.withTileReference(this._tRef); + // If we support internal cache + if (keepInternalCopy) { + // let sync preparation handle data if no preloading desired + if (!drawer.options.preloadCache) { + return this.prepareInternalCacheSync(drawer); + } + // or check internal cache state before returning + const internalCache = this._getInternalCacheRef(drawer); + if (!internalCache || !internalCache.loaded) { + $.console.error("Attempt to draw tile with internal cache non-ready state!"); + return undefined; + } + return internalCache; + } + + // else just return self reference + return this; + } + + /** + * Preparation for rendering ensures the CacheRecord is in a format supported by the current + * drawer. Furthermore, if internal cache is to be used by a drawer with preloading enabled, + * it happens in this step. + * + * Note: Should not be called if cache type is already among supported types. + * @private + * @param {OpenSeadragon.DrawerBase} drawer + * @return {OpenSeadragon.Promise<*>} reference to the data, + * or null if not data yet loaded/ready (usually due to error) + */ + prepareForRendering(drawer) { + const supportedTypes = drawer.getRequiredDataFormats(); + + // If not loaded, await until ready and try again + if (!this.loaded) { + return this.await().then(_ => this.prepareForRendering(drawer)); + } + + let selfPromise; + // If not in one of required types, transform + if (!supportedTypes.includes(this.type)) { + selfPromise = this.transformTo(supportedTypes); } else { - internalCache = this; + selfPromise = this.await(); } - // Cache in the process of loading, no-op - if (!internalCache.loaded) { - $.console.warn("Attempt to render cache that is not prepared for current drawer: " + - "internal cache still loading: this should be awaited.", - this, tileToDraw); - this._triggerNeedsDraw(); - return undefined; + // If internal cache wanted and preloading enabled, convert now + if (drawer.options.usePrivateCache && drawer.options.preloadCache) { + return selfPromise.then(_ => this.prepareInternalCacheAsync(drawer)); + } + return selfPromise; + } + + /** + * Internal cache is defined by a Drawer. Async preparation happens as the last step in the + * invalidation routine. + * Must not be called if drawer.options.usePrivateCache == false. Called inside prepareForRenderine + * by cache itself if preloadCache == true (supports async behavior). + * + * @private + * @param {OpenSeadragon.DrawerBase} drawer + * @return {OpenSeadragon.Promise<*>} reference to the data wrapped in a promise, + * or null if not data yet loaded/ready (usually due to error) + */ + prepareInternalCacheAsync(drawer) { + let internalCache = this._getInternalCacheRef(drawer); + if (this._checkInternalCacheUpToDate(internalCache, drawer)) { + return internalCache.await(); } - if (!supportedTypes.includes(internalCache.type)) { - let logReference = this[DRAWER_INTERNAL_CACHE]; - logReference = logReference ? Object.entries(logReference) : this; - $.console.warn("Attempt to render cache that is not prepared for current drawer " + - "supported format: the preparation should've happened after tile processing has finished.", - logReference, tileToDraw); - - internalCache.transformTo(supportedTypes.length > 1 ? supportedTypes : supportedTypes[0]) - .then(() => this._triggerNeedsDraw()); - return undefined; // type is NOT compatible + // Force reset + if (internalCache && !internalCache.loaded) { + internalCache.await().then(() => internalCache.destroy()); } + + $.console.assert(this._tRef, "Data Create called from invalidation routine needs tile reference!"); + const transformedData = drawer.internalCacheCreate(this, this._tRef); + $.console.assert(transformedData !== undefined, "[DrawerBase.internalCacheCreate] must return a value if usePrivateCache is enabled!"); + const drawerID = drawer.getId(); + internalCache = this[DRAWER_INTERNAL_CACHE][drawerID] = new $.InternalCacheRecord(transformedData, + drawerID, (data) => drawer.internalCacheFree(data)); + return internalCache.await(); + } + + /** + * Internal cache is defined by a Drawer. Sync preparation happens directly before rendering. + * Must not be called if drawer.options.usePrivateCache == false. Called inside getDataForRendering + * by cache itself if preloadCache == false (without support for async behavior). + * @private + * @param {OpenSeadragon.DrawerBase} drawer + * @return {OpenSeadragon.InternalCacheRecord} reference to the cache + */ + prepareInternalCacheSync(drawer) { + let internalCache = this._getInternalCacheRef(drawer); + if (this._checkInternalCacheUpToDate(internalCache, drawer)) { + return internalCache; + } + + // Force reset + if (internalCache) { + internalCache.destroy(); + } + + $.console.assert(this._tRef, "Data Create called from drawing loop needs tile reference!"); + const transformedData = drawer.internalCacheCreate(this, this._tRef); + $.console.assert(transformedData !== undefined, "[DrawerBase.internalCacheCreate] must return a value if usePrivateCache is enabled!"); + + const drawerID = drawer.getId(); + internalCache = this[DRAWER_INTERNAL_CACHE][drawerID] = new $.InternalCacheRecord(transformedData, + drawerID, (data) => drawer.internalCacheFree(data)); return internalCache; } /** - * Should not be called if cache type is already among supported types + * Get an internal cache reference for given drawer + * @param {OpenSeadragon.DrawerBase} drawer + * @return {OpenSeadragon.InternalCacheRecord|undefined} * @private - * @param drawerId - * @param supportedTypes - * @param keepInternalCopy if a drawer requests internal copy, it means it can only use - * given cache for itself, cannot be shared -> initialize privately - * @return {OpenSeadragon.Promise | null} - * reference to the cache processed for drawer rendering requirements, or null on error */ - prepareForRendering(drawerId, supportedTypes, keepInternalCopy = true) { - // if not internal copy and we have no data, or we are ready to render, exit - if (!this.loaded || supportedTypes.includes(this.type)) { - return $.Promise.resolve(this); - } - - if (!keepInternalCopy) { - return this.transformTo(supportedTypes); + _getInternalCacheRef(drawer) { + const options = drawer.options; + if (!options.usePrivateCache) { + $.console.error("[CacheRecord.prepareInternalCacheSync] must not be called when usePrivateCache is false."); + return undefined; } // we can get here only if we want to render incompatible type @@ -255,32 +334,19 @@ if (!internalCache) { internalCache = this[DRAWER_INTERNAL_CACHE] = {}; } + return internalCache[drawer.getId()]; + } - internalCache = internalCache[drawerId]; - if (internalCache && supportedTypes.includes(internalCache.type)) { - // already done - return $.Promise.resolve(this); - } - - const conversionPath = $.convertor.getConversionPath(this.type, supportedTypes); - if (!conversionPath) { - $.console.error(`[getDataForRendering] Conversion ${this.type} ---> ${supportedTypes} cannot be done!`); - return $.Promise.resolve(this); - } - const newInternalCache = new $.SimpleCacheRecord(); - - newInternalCache.withTileReference(this._tRef); - const selectedFormat = conversionPath[conversionPath.length - 1].target.value; - return $.convertor.convert(this._tRef, this.data, this.type, selectedFormat).then(data => { - newInternalCache.setDataAs(data, selectedFormat); // synchronous, SimpleCacheRecord call - - // if existed, delete - if (internalCache) { - internalCache.destroy(); - } - this[DRAWER_INTERNAL_CACHE][drawerId] = newInternalCache; - return newInternalCache; - }); + /** + * @param {OpenSeadragon.InternalCacheRecord} internalCache + * @param {OpenSeadragon.DrawerBase} drawer + * @return {boolean} false if the internal cache is outdated + * @private + */ + _checkInternalCacheUpToDate(internalCache, drawer) { + // We respect existing records, unless they are outdated. Invalidation routine by its nature + // destroys internal cache, therefore we do not need to check if internal cache is consistent with its parent. + return internalCache && internalCache.loaded && internalCache.tstamp >= drawer._dataNeedsRefresh; } /** @@ -330,15 +396,24 @@ /** * If cache ceases to be the primary one, free data + * @param {string} drawerId if undefined, all caches are freed, else only target one * @private */ - destroyInternalCache() { + destroyInternalCache(drawerId = undefined) { const internal = this[DRAWER_INTERNAL_CACHE]; if (internal) { - for (let iCache in internal) { - internal[iCache].destroy(); + if (drawerId) { + const cache = internal[drawerId]; + if (cache) { + cache.destroy(); + delete internal[drawerId]; + } + } else { + for (let iCache in internal) { + internal[iCache].destroy(); + } + delete this[DRAWER_INTERNAL_CACHE]; } - delete this[DRAWER_INTERNAL_CACHE]; } } @@ -623,7 +698,7 @@ }; /** - * @class SimpleCacheRecord + * @class InternalCacheRecord * @memberof OpenSeadragon * @classdesc Simple cache record without robust support for async access. Meant for internal use only. * @@ -635,12 +710,23 @@ * It also does not record tiles nor allows cache/tile sharing. * @private */ - $.SimpleCacheRecord = class { - constructor(preferredTypes) { - this._data = null; - this._type = null; - this.loaded = false; - this.format = Array.isArray(preferredTypes) ? preferredTypes : null; + $.InternalCacheRecord = class { + constructor(data, type, onDestroy) { + this.tstamp = $.now(); + this._ondestroy = onDestroy; + this._type = type; + + if (data instanceof $.Promise) { + this._promise = data; + data.then(data => { + this.loaded = true; + this._data = data; + }); + } else { + this._promise = null; + this.loaded = true; + this._data = data; + } } /** @@ -659,83 +745,39 @@ return this._type; } + /** + * Await ongoing process so that we get cache ready on callback. + * @returns {OpenSeadragon.Promise} + */ + await() { + if (!this._promise) { //if not cache loaded, do not fail + return $.Promise.resolve(this._data); + } + return this._promise; + } + /** * Must be called before transformTo or setDataAs. To keep * compatible api with CacheRecord where tile refs are known. * @param {OpenSeadragon.Tile} referenceTile reference tile for conversion - * @return {OpenSeadragon.SimpleCacheRecord} self reference for builder pattern + * @return {OpenSeadragon.InternalCacheRecord} self reference for builder pattern */ withTileReference(referenceTile) { this._temporaryTileRef = referenceTile; return this; } - /** - * Transform cache to desired type and get the data after conversion. - * Does nothing if the type equals to the current type. Asynchronous. - * @param {string|string[]} type if array provided, the system will - * try to optimize for the best type to convert to. - * @returns {OpenSeadragon.Promise} - */ - transformTo(type) { - $.console.assert(this._temporaryTileRef, "SimpleCacheRecord needs tile reference set before update operation!"); - const convertor = $.convertor, - conversionPath = convertor.getConversionPath(this._type, type); - if (!conversionPath) { - $.console.error(`[SimpleCacheRecord.transformTo] Conversion ${this._type} ---> ${type} cannot be done!`); - return $.Promise.resolve(); //no-op - } - - const stepCount = conversionPath.length, - _this = this, - convert = (x, i) => { - if (i >= stepCount) { - _this._data = x; - _this.loaded = true; - _this._temporaryTileRef = null; - return $.Promise.resolve(x); - } - let edge = conversionPath[i]; - try { - // no test for y - less robust approach - let y = edge.transform(this._temporaryTileRef, x); - convertor.destroy(x, edge.origin.value); - const result = $.type(y) === "promise" ? y : $.Promise.resolve(y); - return result.then(res => convert(res, i + 1)); - } catch (e) { - _this.loaded = false; - _this._temporaryTileRef = null; - throw e; - } - }; - - this.loaded = false; - // Read target type from the conversion path: [edge.target] = Vertex, its value=type - this._type = conversionPath[stepCount - 1].target.value; - const promise = convert(this._data, 0); - this._data = undefined; - return promise; - } - /** * Free all the data and call data destructors if defined. */ destroy() { - $.convertor.destroy(this._data, this._type); - this._data = null; - this._type = null; - } - - /** - * Safely overwrite the cache data and return the old data - * @private - */ - setDataAs(data, type) { - // no check for state, users must ensure compatibility manually - $.convertor.destroy(this._data, this._type); - this._type = type; - this._data = data; - this.loaded = true; + if (this.loaded) { + if (this._ondestroy) { + this._ondestroy(this._data); + } + this._data = null; + this.loaded = false; + } } }; @@ -1155,6 +1197,24 @@ this._cachesLoadedCount = 0; } + /** + * Clean up internal drawer data for a given drawer + * @param {OpenSeadragon.DrawerBase} drawer + */ + clearDrawerInternalCache(drawer) { + const drawerId = drawer.getId(); + for (let zombie of this._zombiesLoaded) { + if (zombie) { + zombie.destroyInternalCache(drawerId); + } + } + for (let cache of this._cachesLoaded) { + if (cache) { + cache.destroyInternalCache(drawerId); + } + } + } + /** * Returns reference to all tiles loaded by a particular * tiled image item diff --git a/src/webgldrawer.js b/src/webgldrawer.js index 4e4ca052..dd2b6452 100644 --- a/src/webgldrawer.js +++ b/src/webgldrawer.js @@ -100,17 +100,15 @@ this._setupCanvases(); this._setupRenderer(); - this._supportedFormats = this._setupTextureHandlers(); - this._requiredFormats = this._supportedFormats; - this._setupCallCount = 1; - + this._supportedFormats = ["context2d", "image"]; this.context = this._outputContext; // API required by tests } get defaultOptions() { return { // use detached cache: our type conversion will not collide (and does not have to preserve CPU data ref) - usePrivateCache: true + usePrivateCache: true, + preloadCache: false, }; } @@ -171,6 +169,8 @@ this.viewer.drawer = null; } + this.destroyInternalCache(); + // set our destroyed flag to true this._destroyed = true; } @@ -241,16 +241,7 @@ this._backupCanvasDrawer = this.viewer.requestDrawer('canvas', {mainDrawer: false}); this._backupCanvasDrawer.canvas.style.setProperty('visibility', 'hidden'); this._backupCanvasDrawer.getSupportedDataFormats = () => this._supportedFormats; - this._backupCanvasDrawer.getDataToDraw = (tile) => { - const cache = tile.getCache(tile.cacheKey); - if (!cache) { - $.console.warn("Attempt to draw tile %s when not cached!", tile); - return undefined; - } - const dataCache = cache.getDataForRendering(this, tile); - // Use CPU Data for the drawer instead - return dataCache && dataCache.cpuData; - }; + this._backupCanvasDrawer.getDataToDraw = this.getDataToDraw.bind(this); } return this._backupCanvasDrawer; @@ -389,7 +380,7 @@ let numTilesToDraw = indexInDrawArray + 1; const textureInfo = this.getDataToDraw(tile); - if (textureInfo) { + if (textureInfo && textureInfo.texture) { this._getTileData(tile, tiledImage, textureInfo, overallMatrix, indexInDrawArray, texturePositionArray, textureDataArray, matrixArray, opacityArray); } else { // console.log('No tile info', tile); @@ -480,10 +471,6 @@ } - getRequiredDataFormats() { - return this._requiredFormats; - } - // Public API required by all Drawer implementations /** * Sets whether image smoothing is enabled or disabled @@ -492,15 +479,9 @@ setImageSmoothingEnabled(enabled){ if( this._imageSmoothingEnabled !== enabled ){ this._imageSmoothingEnabled = enabled; - - // Todo consider removing old type handlers if _supportedFormats had already types defined, - // and remove support for rendering old types... - const newFormats = this._setupTextureHandlers(); // re-sets the type to enforce re-initialization - this._supportedFormats.push(...newFormats); - this._requiredFormats = newFormats; - return this.viewer.requestInvalidate(); + this.setInternalCacheNeedsRefresh(); + this.viewer.forceRedraw(); } - return $.Promise.resolve(); } /** @@ -837,7 +818,6 @@ //bind the frame buffer to the new texture gl.bindFramebuffer(gl.FRAMEBUFFER, this._glFrameBuffer); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this._renderToTexture, 0); - } // private @@ -883,95 +863,96 @@ this.viewer.addHandler("resize", this._resizeHandler); } - _setupTextureHandlers() { - const tex2DCompatibleLoader = (tile, data) => { - let tiledImage = tile.tiledImage; - let gl = this._gl; - let texture; - let position; + internalCacheCreate(cache, tile) { + let tiledImage = tile.tiledImage; + let gl = this._gl; + let texture; + let position; - if (!tiledImage.isTainted()) { - if((data instanceof CanvasRenderingContext2D) && $.isCanvasTainted(data.canvas)){ - tiledImage.setTainted(true); - $.console.warn('WebGL cannot be used to draw this TiledImage because it has tainted data. Does crossOriginPolicy need to be set?'); - this._raiseDrawerErrorEvent(tiledImage, 'Tainted data cannot be used by the WebGLDrawer. Falling back to CanvasDrawer for this TiledImage.'); + let data = cache.data; + + if (!tiledImage.isTainted()) { + if((data instanceof CanvasRenderingContext2D) && $.isCanvasTainted(data.canvas)){ + tiledImage.setTainted(true); + $.console.warn('WebGL cannot be used to draw this TiledImage because it has tainted data. Does crossOriginPolicy need to be set?'); + this._raiseDrawerErrorEvent(tiledImage, 'Tainted data cannot be used by the WebGLDrawer. Falling back to CanvasDrawer for this TiledImage.'); + this.setInternalCacheNeedsRefresh(); + } else { + let sourceWidthFraction, sourceHeightFraction; + if (tile.sourceBounds) { + sourceWidthFraction = Math.min(tile.sourceBounds.width, data.width) / data.width; + sourceHeightFraction = Math.min(tile.sourceBounds.height, data.height) / data.height; } else { - let sourceWidthFraction, sourceHeightFraction; - if (tile.sourceBounds) { - sourceWidthFraction = Math.min(tile.sourceBounds.width, data.width) / data.width; - sourceHeightFraction = Math.min(tile.sourceBounds.height, data.height) / data.height; - } else { - sourceWidthFraction = 1; - sourceHeightFraction = 1; - } + sourceWidthFraction = 1; + sourceHeightFraction = 1; + } - // create a gl Texture for this tile and bind the canvas with the image data - texture = gl.createTexture(); - let overlap = tiledImage.source.tileOverlap; - if( overlap > 0){ - // calculate the normalized position of the rect to actually draw - // discarding overlap. - let overlapFraction = this._calculateOverlapFraction(tile, tiledImage); + // create a gl Texture for this tile and bind the canvas with the image data + texture = gl.createTexture(); + let overlap = tiledImage.source.tileOverlap; + if( overlap > 0){ + // calculate the normalized position of the rect to actually draw + // discarding overlap. + let overlapFraction = this._calculateOverlapFraction(tile, tiledImage); - let left = (tile.x === 0 ? 0 : overlapFraction.x) * sourceWidthFraction; - let top = (tile.y === 0 ? 0 : overlapFraction.y) * sourceHeightFraction; - let right = (tile.isRightMost ? 1 : 1 - overlapFraction.x) * sourceWidthFraction; - let bottom = (tile.isBottomMost ? 1 : 1 - overlapFraction.y) * sourceHeightFraction; - position = this._makeQuadVertexBuffer(left, right, top, bottom); - } else if (sourceWidthFraction === 1 && sourceHeightFraction === 1) { - // no overlap and no padding: this texture can use the unit quad as its position data - position = this._unitQuad; - } else { - position = this._makeQuadVertexBuffer(0, sourceWidthFraction, 0, sourceHeightFraction); - } + let left = (tile.x === 0 ? 0 : overlapFraction.x) * sourceWidthFraction; + let top = (tile.y === 0 ? 0 : overlapFraction.y) * sourceHeightFraction; + let right = (tile.isRightMost ? 1 : 1 - overlapFraction.x) * sourceWidthFraction; + let bottom = (tile.isBottomMost ? 1 : 1 - overlapFraction.y) * sourceHeightFraction; + position = this._makeQuadVertexBuffer(left, right, top, bottom); + } else if (sourceWidthFraction === 1 && sourceHeightFraction === 1) { + // no overlap and no padding: this texture can use the unit quad as its position data + position = this._unitQuad; + } else { + position = this._makeQuadVertexBuffer(0, sourceWidthFraction, 0, sourceHeightFraction); + } - gl.activeTexture(gl.TEXTURE0); - gl.bindTexture(gl.TEXTURE_2D, texture); - // Set the parameters so we can render any size image. - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, this._textureFilter()); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, this._textureFilter()); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + // Set the parameters so we can render any size image. + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, this._textureFilter()); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, this._textureFilter()); - try { - // This depends on gl.TEXTURE_2D being bound to the texture - // associated with this canvas before calling this function - gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, data); - } catch (e){ - // Todo a bit dirty re-use of the tainted flag, but makes the code more stable - tiledImage.setTainted(true); - $.console.error('Error uploading image data to WebGL. Falling back to canvas renderer.', e); - this._raiseDrawerErrorEvent(tiledImage, 'Unknown error when uploading texture. Falling back to CanvasDrawer for this TiledImage.'); - } + try { + // This depends on gl.TEXTURE_2D being bound to the texture + // associated with this canvas before calling this function + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, data); + // TextureInfo stored in the cache + return { + texture: texture, + position: position, + }; + } catch (e){ + // Todo a bit dirty re-use of the tainted flag, but makes the code more stable + tiledImage.setTainted(true); + $.console.error('Error uploading image data to WebGL. Falling back to canvas renderer.', e); + this._raiseDrawerErrorEvent(tiledImage, 'Unknown error when uploading texture. Falling back to CanvasDrawer for this TiledImage.'); + this.setInternalCacheNeedsRefresh(); } } + } + if (data instanceof Image) { + const canvas = document.createElement( 'canvas' ); + canvas.width = data.width; + canvas.height = data.height; + const context = canvas.getContext('2d', { willReadFrequently: true }); + context.drawImage( data, 0, 0 ); + data = context; + } + if (data instanceof CanvasRenderingContext2D) { + return data; + } + $.console.error("Unsupported data used for WebGL Drawer - probably a bug!"); + return {}; + } - // TextureInfo stored in the cache - return { - texture: texture, - position: position, - cpuData: data // Reference to the outer cache data, used to draw if webgl canont be used - }; - }; - const tex2DCompatibleDestructor = textureInfo => { - if (textureInfo) { - this._gl.deleteTexture(textureInfo.texture); - } - }; - - const thisType = `${this.getId()}_${this._setupCallCount++}_TEX_2D`; - // Differentiate type also based on type used to upload data: we can support bidirectional conversion. - const c2dTexType = thisType + ":context2d", - imageTexType = thisType + ":image"; - - // We should be OK uploading any of these types. The complexity is selected to be O(3n), should be - // more than linear pass over pixels - $.convertor.learn("context2d", c2dTexType, (t, d) => tex2DCompatibleLoader(t, d.canvas), 1, 3); - $.convertor.learn("image", imageTexType, tex2DCompatibleLoader, 1, 3); - - $.convertor.learnDestroy(c2dTexType, tex2DCompatibleDestructor); - $.convertor.learnDestroy(imageTexType, tex2DCompatibleDestructor); - return [c2dTexType, imageTexType]; + internalCacheFree(data) { + if (data && data.texture) { + this._gl.deleteTexture(data.texture); + data.texture = null; + } } // private diff --git a/src/world.js b/src/world.js index 3530cf05..6ab55742 100644 --- a/src/world.js +++ b/src/world.js @@ -310,9 +310,7 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W // We call the event on the parent viewer window no matter what const eventTarget = this.viewer.viewer || this.viewer; // However, we must pick the correct drawer reference (navigator VS viewer) - const supportedFormats = this.viewer.drawer.getRequiredDataFormats(); - const keepInternalCacheCopy = this.viewer.drawer.options.usePrivateCache; - const drawerId = this.viewer.drawer.getId(); + const drawer = this.viewer.drawer; const jobList = tileList.map(tile => { const tiledImage = tile.tiledImage; @@ -360,7 +358,6 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W tiledImage._tileCache.restoreTilesThatShareOriginalCache(tile, tile.getCache(tile.originalCacheKey), true); } }; - /** * @event tile-invalidated * @memberof OpenSeadragon.Viewer @@ -391,7 +388,7 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W }).then(_ => { if (originalCache.__invStamp === tStamp && (tile.loaded || tile.loading)) { if (workingCache) { - return workingCache.prepareForRendering(drawerId, supportedFormats, keepInternalCacheCopy).then(c => { + return workingCache.prepareForRendering(drawer).then(c => { if (c && originalCache.__invStamp === tStamp) { atomicCacheSwap(); originalCache.__invStamp = null; @@ -402,7 +399,7 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W // If we requested restore, perform now if (restoreTiles) { const freshOriginalCacheRef = tile.getCache(tile.originalCacheKey); - return freshOriginalCacheRef.prepareForRendering(drawerId, supportedFormats, keepInternalCacheCopy).then((c) => { + return freshOriginalCacheRef.prepareForRendering(drawer).then((c) => { if (c && originalCache.__invStamp === tStamp) { atomicCacheSwap(); originalCache.__invStamp = null; @@ -412,7 +409,7 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W // Preventive call to ensure we stay compatible const freshMainCacheRef = tile.getCache(); - return freshMainCacheRef.prepareForRendering(drawerId, supportedFormats, keepInternalCacheCopy).then(() => { + return freshMainCacheRef.prepareForRendering(drawer).then(() => { atomicCacheSwap(); originalCache.__invStamp = null; }); diff --git a/test/modules/tilecache.js b/test/modules/tilecache.js index accbdec5..483d3cac 100644 --- a/test/modules/tilecache.js +++ b/test/modules/tilecache.js @@ -86,124 +86,165 @@ destroyE++; }); - OpenSeadragon.TestCacheDrawer = class extends OpenSeadragon.DrawerBase { - constructor(opts) { - super(opts); - this.testEvents = new OpenSeadragon.EventSource(); - } - - getType() { - return "test-cache-drawer"; - } - - // Make test use private cache - get defaultOptions() { - return { - usePrivateCache: true - }; - } - - getSupportedDataFormats() { - return [T_C, T_E]; - } - - static isSupported() { - return true; - } - - _createDrawingElement() { - return document.createElement("div"); - } - - draw(tiledImages) { - for (let image of tiledImages) { - const tilesDoDraw = image.getTilesToDraw().map(info => info.tile); - for (let tile of tilesDoDraw) { - const data = this.getDataToDraw(tile); - this.testEvents.raiseEvent('test-tile', { - tile: tile, - dataToDraw: data, - }); - } - } - } - - canRotate() { - return true; - } - - destroy() { - //noop - } - - setImageSmoothingEnabled(imageSmoothingEnabled){ - //noop - } - - drawDebuggingRect(rect) { - //noop - } - - clear(){ - //noop - } - } - - OpenSeadragon.EmptyTestT_ATileSource = class extends OpenSeadragon.TileSource { - - supports( data, url ){ - return data && data.isTestSource; - } - - configure( data, url, postData ){ - return { - width: 512, /* width *required */ - height: 512, /* height *required */ - tileSize: 128, /* tileSize *required */ - tileOverlap: 0, /* tileOverlap *required */ - minLevel: 0, /* minLevel */ - maxLevel: 3, /* maxLevel */ - tilesUrl: "", /* tilesUrl */ - fileFormat: "", /* fileFormat */ - displayRects: null /* displayRects */ - } - } - - getTileUrl(level, x, y) { - return String(level); //treat each tile on level same to introduce cache overlaps - } - - downloadTileStart(context) { - context.finish(0, null, T_A); - } - } - // ---------- QUnit.module('TileCache', { beforeEach: function () { $('
').appendTo("#qunit-fixture"); testLog.reset(); - - viewer = OpenSeadragon({ - id: 'example', - prefixUrl: '/build/openseadragon/images/', - maxImageCacheCount: 200, //should be enough to fit test inside the cache - springStiffness: 100, // Faster animation = faster tests - drawer: 'test-cache-drawer', - }); OpenSeadragon.ImageLoader.prototype.addJob = originalJob; // Reset counters typeAtoB = 0, typeBtoC = 0, typeCtoA = 0, typeDtoA = 0, typeCtoE = 0; copyA = 0, copyB = 0, copyC = 0, copyD = 0, copyE = 0; destroyA = 0, destroyB = 0, destroyC = 0, destroyD = 0, destroyE = 0; + + OpenSeadragon.TestCacheDrawer = class extends OpenSeadragon.DrawerBase { + constructor(opts) { + super(opts); + this.testEvents = new OpenSeadragon.EventSource(); + } + + static isSupported() { + return true; + } + + _createDrawingElement() { + return document.createElement("div"); + } + + draw(tiledImages) { + for (let image of tiledImages) { + const tilesDoDraw = image.getTilesToDraw().map(info => info.tile); + for (let tile of tilesDoDraw) { + const data = this.getDataToDraw(tile); + this.testEvents.raiseEvent('test-tile', { + tile: tile, + dataToDraw: data, + }); + } + } + } + + internalCacheFree(data) { + this.testEvents.raiseEvent('free-data'); + } + + canRotate() { + return true; + } + + destroy() { + this.destroyInternalCache(); + } + + setImageSmoothingEnabled(imageSmoothingEnabled){ + //noop + } + + drawDebuggingRect(rect) { + //noop + } + + clear(){ + //noop + } + } + + OpenSeadragon.SyncInternalCacheDrawer = class extends OpenSeadragon.TestCacheDrawer { + + getType() { + return "test-cache-drawer-sync"; + } + + getSupportedDataFormats() { + return [T_C, T_E]; + } + + // Make test use private cache + get defaultOptions() { + return { + usePrivateCache: true, + preloadCache: false, + }; + } + + internalCacheCreate(cache, tile) { + this.testEvents.raiseEvent('create-data'); + return cache.data; + } + } + + OpenSeadragon.AsnycInternalCacheDrawer = class extends OpenSeadragon.TestCacheDrawer { + + getType() { + return "test-cache-drawer-async"; + } + + getSupportedDataFormats() { + return [T_A]; + } + + // Make test use private cache + get defaultOptions() { + return { + usePrivateCache: true, + preloadCache: true, + }; + } + + internalCacheCreate(cache, tile) { + this.testEvents.raiseEvent('create-data'); + return cache.getDataAs(T_C, true); + } + + internalCacheFree(data) { + super.internalCacheFree(data); + // Be nice and truly destroy the data copy + OpenSeadragon.convertor.destroy(data, T_C); + } + } + + OpenSeadragon.EmptyTestT_ATileSource = class extends OpenSeadragon.TileSource { + + supports( data, url ){ + return data && data.isTestSource; + } + + configure( data, url, postData ){ + return { + width: 512, /* width *required */ + height: 512, /* height *required */ + tileSize: 128, /* tileSize *required */ + tileOverlap: 0, /* tileOverlap *required */ + minLevel: 0, /* minLevel */ + maxLevel: 3, /* maxLevel */ + tilesUrl: "", /* tilesUrl */ + fileFormat: "", /* fileFormat */ + displayRects: null /* displayRects */ + } + } + + getTileUrl(level, x, y) { + return String(level); //treat each tile on level same to introduce cache overlaps + } + + downloadTileStart(context) { + context.finish(0, null, T_A); + } + } }, afterEach: function () { if (viewer && viewer.close) { viewer.close(); } + // Some tests test all drawers - remove test drawers to avoid collision with other tests + OpenSeadragon.EmptyTestT_ATileSource = null; + OpenSeadragon.AsnycInternalCacheDrawer = null; + OpenSeadragon.SyncInternalCacheDrawer = null; + OpenSeadragon.TestCacheDrawer = null; + viewer = null; } }); @@ -313,18 +354,34 @@ done(); }); - //Tile API and cache interaction - QUnit.test('Tile: basic rendering & test setup', function(test) { + // Tile API and cache interaction + QUnit.test('Tile: basic rendering & test setup (sync drawer)', function(test) { const done = test.async(); + viewer = OpenSeadragon({ + id: 'example', + prefixUrl: '/build/openseadragon/images/', + maxImageCacheCount: 200, //should be enough to fit test inside the cache + springStiffness: 100, // Faster animation = faster tests + drawer: 'test-cache-drawer-sync', + }); + const tileCache = viewer.tileCache; const drawer = viewer.drawer; let testTileCalled = false; + let countFreeCalled = 0; + let countCreateCalled = 0; drawer.testEvents.addHandler('test-tile', e => { testTileCalled = true; test.ok(e.dataToDraw, "Tile data is ready to be drawn"); }); + drawer.testEvents.addHandler('create-data', e => { + countCreateCalled++; + }); + drawer.testEvents.addHandler('free-data', e => { + countFreeCalled++; + }); viewer.addHandler('open', async () => { await viewer.waitForFinishedJobsForTest(); @@ -339,13 +396,17 @@ for (let tile of tileCache._tilesLoaded) { const cache = tile.getCache(); - test.equal(cache.type, T_A, "Cache data was not affected, the drawer uses internal cache."); + test.equal(cache.type, T_C, "Cache data was affected, the drawer supports only T_C since there is no way to get to T_E."); const internalCache = cache.getDataForRendering(drawer, tile); - test.equal(internalCache.type, T_C, "Conversion A->C ready, since there is no way to get to T_E."); + test.equal(internalCache.type, viewer.drawer.getId(), "Sync conversion routine means T_C is also internal since dataCreate only creates data. However, internal cache keeps type of the drawer ID."); test.ok(internalCache.loaded, "Internal cache ready."); } + test.ok(countCreateCalled > 0, "Internal cache creation called."); + viewer.drawer.destroyInternalCache(); + test.equal(countCreateCalled, countFreeCalled, "Free called as many times as create."); + done(); }); viewer.open([ @@ -357,6 +418,13 @@ QUnit.test('Tile & Invalidation API: basic conversion & preprocessing', function(test) { const done = test.async(); + viewer = OpenSeadragon({ + id: 'example', + prefixUrl: '/build/openseadragon/images/', + maxImageCacheCount: 200, //should be enough to fit test inside the cache + springStiffness: 100, // Faster animation = faster tests + drawer: 'test-cache-drawer-async', + }); const tileCache = viewer.tileCache; const drawer = viewer.drawer; @@ -376,7 +444,6 @@ _currentTestVal = value; viewer.world.needsDraw(); viewer.world.draw(); - previousTestValue = value; _currentTestVal = undefined; } @@ -402,8 +469,6 @@ viewer.addHandler('tile-invalidated', testHandler); await viewer.world.requestInvalidate(true); - await sleep(1); // necessary to make space for internal updates - testDrawingRoutine(2); //test for each level only single cache was processed const processedLevels = {}; @@ -421,37 +486,37 @@ test.equal(origCache.data, 0, "Original cache data was not affected, the drawer uses internal cache."); const cache = tile.getCache(); - test.equal(cache.type, T_C, "Main Cache Updated (suite 1)"); - test.equal(cache.data, previousTestValue, "Main Cache Updated (suite 1)"); + test.equal(cache.type, T_A, "Main Cache Converted T_C -> T_A (drawer supports type A) (suite 1)"); + test.equal(cache.data, 3, "Conversion step increases plugin-stored value 2 to 3"); const internalCache = cache.getDataForRendering(drawer, tile); - test.equal(T_C, internalCache.type, "Conversion A->C ready, since there is no way to get to T_E."); + test.equal(internalCache.type, viewer.drawer.getId(), "Internal cache has type of the drawer ID."); test.ok(internalCache.loaded, "Internal cache ready."); } + // Internal cache will have value 5: main cache is 3, type is T_A, + testDrawingRoutine(5); // internal cache transforms to T_C: two steps, TA->TB->TC 3+2 // Test that basic scenario with reset data false starts from the main cache data of previous round const modificationConstant = 50; viewer.removeHandler('tile-invalidated', testHandler); testHandler = async e => { const data = await e.getData(T_B); - test.equal(data, previousTestValue + 2, "C -> A -> B conversion happened."); + test.equal(data, 4, "A -> B conversion happened, we started from value 3 in the main cache."); await e.setData(data + modificationConstant, T_B); - console.log(data + modificationConstant); test.notOk(e.outdated(), "Event is still valid."); }; - console.log(previousTestValue, modificationConstant) viewer.addHandler('tile-invalidated', testHandler); await viewer.world.requestInvalidate(false); - await sleep(1); // necessary to make space for a draw call - // We set data as TB - there is T_C -> T_A -> T_B -> T_C conversion round - let newValue = previousTestValue + modificationConstant + 3; - testDrawingRoutine(newValue); - newValue--; // intenrla cache performed +1 conversion, but here we have main cache with one step less + // We set data as TB - there is required T_A: T_B -> T_C -> T_A conversion round on the main cache + let newValue = modificationConstant + 4 + 2; + // and there is still requirement of T_C on internal data, +2 steps + testDrawingRoutine(newValue + 2); + for (let tile of tileCache._tilesLoaded) { const cache = tile.getCache(); - test.equal(cache.type, T_B, "Main Cache Updated (suite 2)."); + test.equal(cache.type, T_A, "Main Cache Updated (suite 2)."); test.equal(cache.data, newValue, "Main Cache Updated (suite 2)."); } @@ -485,14 +550,22 @@ viewer.removeHandler('tile-invalidated', testHandler); testHandler = async e => { const data = await e.getData(T_B); - test.equal(data, 42, "Copy: 41 + 1."); - await e.setData(data, T_E); - e.resetData(); + test.equal(data, 44, "Copy: 41 +2 (previous request invalidate ends at T_A) + 1 (we request type B)."); + await e.setData(data, T_E); // there is no way to convert T_E -> T_A, this would throw an error + e.resetData(); // reset data will revert to original cache }; viewer.addHandler('tile-invalidated', testHandler); + + // The data will be 45 since no change has been made: + // last main cache set was 41 T_B, supported T_A = +2 + // and internal requirement T_C = +2 + const checkNotCalled = e => { + test.ok(false, "Create data must not be called when there is no change!"); + }; + drawer.testEvents.addHandler('create-data', checkNotCalled); + await viewer.world.requestInvalidate(false); - await sleep(1); // necessary to make space for a draw call - testDrawingRoutine(42); + testDrawingRoutine(45); for (let tile of tileCache._tilesLoaded) { const origCache = tile.getCache(tile.originalCacheKey); @@ -501,6 +574,7 @@ } test.ok(testTileCalled, "Drawer tested at least one tile."); + viewer.destroy(); done(); }); viewer.open([ @@ -632,6 +706,14 @@ QUnit.test('Zombie Cache', function(test) { const done = test.async(); + viewer = OpenSeadragon({ + id: 'example', + prefixUrl: '/build/openseadragon/images/', + maxImageCacheCount: 200, //should be enough to fit test inside the cache + springStiffness: 100, // Faster animation = faster tests + drawer: 'test-cache-drawer-sync', + }); + //test jobs by coverage: fail if cached coverage not fully re-stored without jobs let jobCounter = 0, coverage = undefined; OpenSeadragon.ImageLoader.prototype.addJob = function (options) { @@ -711,6 +793,14 @@ QUnit.test('Zombie Cache Replace Item', function(test) { const done = test.async(); + viewer = OpenSeadragon({ + id: 'example', + prefixUrl: '/build/openseadragon/images/', + maxImageCacheCount: 200, //should be enough to fit test inside the cache + springStiffness: 100, // Faster animation = faster tests + drawer: 'test-cache-drawer-sync', + }); + let jobCounter = 0, coverage = undefined; OpenSeadragon.ImageLoader.prototype.addJob = function (options) { jobCounter++;