wikimedia/mediawiki-extensions-UploadWizard

View on GitHub
resources/mw.UploadWizardUpload.js

Summary

Maintainability
F
3 days
Test Coverage
/**
 * Represents the upload -- in its local and remote state. (Possibly those could be separate objects too...)
 * This is our 'model' object if we are thinking MVC. Needs to be better factored, lots of feature envy with the UploadWizard
 * states:
 *   'new' 'transporting' 'transported' 'metadata' 'stashed' 'details' 'submitting-details' 'complete' 'error'
 * should fork this into two -- local and remote, e.g. filename
 *
 * @param uw
 */
// eslint-disable-next-line no-unused-vars
( function ( uw ) {
    /**
     * Constructor for objects representing uploads. The workhorse of this entire extension.
     *
     * The upload knows nothing of other uploads. It manages its own interface, and transporting its own data, to
     * the server.
     *
     * Upload objects are usually created without a file, they are just associated with a form.
     * There is an "empty" fileInput which is invisibly floating above certain buttons in the interface, like "Add a file". When
     * this fileInput gets a file, this upload becomes 'filled'.
     *
     * @class
     * @mixes OO.EventEmitter
     * @param {uw.controller.Step} controller
     * @param {File} file
     */
    mw.UploadWizardUpload = function MWUploadWizardUpload( controller, file ) {
        OO.EventEmitter.call( this );

        this.index = mw.UploadWizardUpload.prototype.count;
        mw.UploadWizardUpload.prototype.count++;

        this.controller = controller;
        this.api = controller.api;
        this.file = file;
        this.state = 'new';
        this.imageinfo = {};
        this.title = undefined;
        this.thumbnailPromise = {};

        this.fileKey = undefined;

        // this should be moved to the interface, if we even keep this
        this.transportWeight = 1; // default all same

        // details
        this.ui = new mw.UploadWizardUploadInterface( this )
            .connect( this, {
                /*
                 * This may be confusing!
                 * This object also has a `remove` method, which will also be
                 * called when an upload is removed. But an upload can be
                 * removed for multiple reasons (one being clicking the "remove"
                 * button, which triggers this event - but another could be
                 * removing faulty uploads).
                 * To simplify things, we'll always initiate the remove from the
                 * controllers, so we'll relay this event to the controllers,
                 * which will then eventually come back to call `remove` on this
                 * object.
                 */
                'upload-removed': [ 'emit', 'remove-upload' ]
            } );

        if ( file.licenseName ) {
            this.ui.setLicenseText( file.licenseName );
        }
    };

    OO.mixinClass( mw.UploadWizardUpload, OO.EventEmitter );

    // Upload handler
    mw.UploadWizardUpload.prototype.uploadHandler = null;

    // increments with each upload
    mw.UploadWizardUpload.prototype.count = 0;

    /**
     * start
     *
     * @return {jQuery.Promise}
     */
    mw.UploadWizardUpload.prototype.start = function () {
        this.setTransportProgress( 0.0 );

        // handler -- usually ApiUploadFormDataHandler
        this.handler = this.getUploadHandler();
        return this.handler.start();
    };

    /**
     * Remove this upload. n.b. we trigger a removeUpload this is usually triggered from
     */
    mw.UploadWizardUpload.prototype.remove = function () {
        // remove the div that passed along the trigger
        this.ui.$div.remove();

        this.state = 'aborted';
    };

    /**
     * Wear our current progress, for observing processes to see
     *
     * @param {number} fraction
     */
    mw.UploadWizardUpload.prototype.setTransportProgress = function ( fraction ) {
        if ( this.state === 'aborted' ) {
            // We shouldn't be transporting anything anymore.
            return;
        }
        this.state = 'transporting';
        this.transportProgress = fraction;
        this.ui.$div.trigger( 'transportProgressEvent' );
    };

    /**
     * Stop the upload -- we have failed for some reason
     *
     * @param {string} code Error code from API
     * @param {string} html Error message
     * @param {jQuery} [$additionalStatus]
     */
    mw.UploadWizardUpload.prototype.setError = function ( code, html, $additionalStatus ) {
        if ( this.state === 'aborted' ) {
            // There's no point in reporting an error anymore.
            return;
        }
        this.state = 'error';
        this.transportProgress = 0;
        this.ui.showError( code, html, $additionalStatus );
    };

    /**
     * Called from any upload success condition
     *
     * @param {Object} result -- result of AJAX call
     */
    mw.UploadWizardUpload.prototype.setSuccess = function ( result ) {
        this.state = 'transported';
        this.transportProgress = 1;

        this.ui.setStatus( 'mwe-upwiz-getting-metadata' );

        this.extractUploadInfo( result.upload );
        this.state = 'stashed';
        this.ui.showStashed();

        this.emit( 'success' );
        // check all uploads, if they're complete, show the next button
        // TODO Make wizard connect to 'success' event
        this.controller.showNext();
    };

    /**
     * Get just the filename.
     *
     * @return {string}
     */
    mw.UploadWizardUpload.prototype.getFilename = function () {
        if ( this.file.fileName ) {
            return this.file.fileName;
        } else {
            // this property has a different name in FF vs Chrome.
            return this.file.name;
        }
    };

    /**
     * Get the basename of a path.
     * For error conditions, returns the empty string.
     *
     * @return {string} basename
     */
    mw.UploadWizardUpload.prototype.getBasename = function () {
        var path = this.getFilename();

        if ( path === undefined || path === null ) {
            return '';
        }

        // find index of last path separator in the path, add 1. (If no separator found, yields 0)
        // then take the entire string after that.
        return path.slice( Math.max( path.lastIndexOf( '/' ), path.lastIndexOf( '\\' ) ) + 1 );
    };

    /**
     * Sanitize and set the title of the upload.
     *
     * @param {string} title Unsanitized title.
     */
    mw.UploadWizardUpload.prototype.setTitle = function ( title ) {
        this.title = mw.Title.newFromFileName( title );
    };

    /**
     * Extract some JPEG metadata that we need to render thumbnails (EXIF rotation mostly).
     *
     * For JPEGs, we use the JsJpegMeta library in core to extract metadata,
     * including EXIF tags. This is done asynchronously once each file has been
     * read.
     *
     * For all other file types, we don't need or want to run this, and this function does nothing.
     *
     * @private
     * @return {jQuery.Promise} A promise, resolved when we're done
     */
    mw.UploadWizardUpload.prototype.extractMetadataFromJpegMeta = function () {
        var binReader, jpegmeta,
            deferred = $.Deferred(),
            upload = this;
        if ( this.file && this.file.type === 'image/jpeg' ) {
            binReader = new FileReader();
            binReader.onerror = function () {
                deferred.resolve();
            };
            binReader.onload = function () {
                var binStr, arr, i, meta;
                if ( binReader.result === null ) {
                    // Contrary to documentation, this sometimes fires for unsuccessful loads (T136235)
                    deferred.resolve();
                    return;
                }
                if ( typeof binReader.result === 'string' ) {
                    binStr = binReader.result;
                } else {
                    // Array buffer; convert to binary string for the library.
                    arr = new Uint8Array( binReader.result );
                    binStr = '';
                    for ( i = 0; i < arr.byteLength; i++ ) {
                        binStr += String.fromCharCode( arr[ i ] );
                    }
                }
                try {
                    jpegmeta = require( 'mediawiki.libs.jpegmeta' );
                    meta = jpegmeta( binStr, upload.file.fileName );
                    // eslint-disable-next-line camelcase, no-underscore-dangle
                    meta._binary_data = null;
                } catch ( e ) {
                    meta = null;
                }
                upload.extractMetadataFromJpegMetaCallback( meta );
                deferred.resolve();
            };
            if ( 'readAsBinaryString' in binReader ) {
                binReader.readAsBinaryString( upload.file );
            } else if ( 'readAsArrayBuffer' in binReader ) {
                binReader.readAsArrayBuffer( upload.file );
            }
        } else {
            deferred.resolve();
        }
        return deferred.promise();
    };

    /**
     * Map fields from jpegmeta's metadata return into our format (which is more like the imageinfo returned from the API
     *
     * @param {Object} meta As returned by jpegmeta
     */
    mw.UploadWizardUpload.prototype.extractMetadataFromJpegMetaCallback = function ( meta ) {
        var pixelHeightDim, pixelWidthDim, degrees;

        if ( meta !== undefined && meta !== null && typeof meta === 'object' ) {
            if ( this.imageinfo.metadata === undefined ) {
                this.imageinfo.metadata = {};
            }
            if ( meta.tiff && meta.tiff.Orientation ) {
                this.imageinfo.metadata.orientation = meta.tiff.Orientation.value;
            }
            if ( meta.general ) {
                pixelHeightDim = 'height';
                pixelWidthDim = 'width';
                // this must be called after orientation is set above. If no orientation set, defaults to 0
                degrees = this.getOrientationDegrees();

                // jpegmeta reports pixelHeight & width
                if ( degrees === 90 || degrees === 270 ) {
                    pixelHeightDim = 'width';
                    pixelWidthDim = 'height';
                }
                if ( meta.general.pixelHeight ) {
                    this.imageinfo[ pixelHeightDim ] = meta.general.pixelHeight.value;
                }
                if ( meta.general.pixelWidth ) {
                    this.imageinfo[ pixelWidthDim ] = meta.general.pixelWidth.value;
                }
            }
        }
    };

    /**
     * Accept the result from a successful API upload transport, and fill our own info
     *
     * @param {Object} resultUpload The JSON object from a successful API upload result.
     */
    mw.UploadWizardUpload.prototype.extractUploadInfo = function ( resultUpload ) {
        if ( resultUpload.filekey ) {
            this.fileKey = resultUpload.filekey;
        }

        if ( resultUpload.imageinfo ) {
            this.extractImageInfo( resultUpload.imageinfo );
        } else if ( resultUpload.stashimageinfo ) {
            this.extractImageInfo( resultUpload.stashimageinfo );
        }

    };

    /**
     * Extract image info into our upload object
     * Image info is obtained from various different API methods
     * This may overwrite metadata obtained from FileReader.
     *
     * @param {Object} imageinfo JSON object obtained from API result.
     */
    mw.UploadWizardUpload.prototype.extractImageInfo = function ( imageinfo ) {
        var key,
            upload = this;

        for ( key in imageinfo ) {
            // we get metadata as list of key-val pairs; convert to object for easier lookup. Assuming that EXIF fields are unique.
            if ( key === 'metadata' ) {
                if ( this.imageinfo.metadata === undefined ) {
                    this.imageinfo.metadata = {};
                }
                if ( imageinfo.metadata && imageinfo.metadata.length ) {
                    imageinfo.metadata.forEach( ( pair ) => {
                        if ( pair !== undefined ) {
                            upload.imageinfo.metadata[ pair.name.toLowerCase() ] = pair.value;
                        }
                    } );
                }
            } else {
                this.imageinfo[ key ] = imageinfo[ key ];
            }
        }
    };

    /**
     * Get information about stashed images
     *
     * See API documentation for prop=stashimageinfo for what 'props' can contain
     *
     * @param {Function} callback Called with null if failure, with imageinfo data structure if success
     * @param {Array} props Properties to extract
     * @param {number} [width] Width of thumbnail. Will force 'url' to be added to props
     * @param {number} [height] Height of thumbnail. Will force 'url' to be added to props
     */
    mw.UploadWizardUpload.prototype.getStashImageInfo = function ( callback, props, width, height ) {
        var params = {
            prop: 'stashimageinfo',
            siifilekey: this.fileKey,
            siiprop: props.join( '|' )
        };

        function ok( data ) {
            if ( !data || !data.query || !data.query.stashimageinfo ) {
                mw.log.warn( 'mw.UploadWizardUpload::getStashImageInfo> No data?' );
                callback( null );
                return;
            }
            callback( data.query.stashimageinfo );
        }

        function err( code ) {
            mw.log.warn( 'mw.UploadWizardUpload::getStashImageInfo> ' + code );
            callback( null );
        }

        if ( props === undefined ) {
            props = [];
        }

        if ( width !== undefined || height !== undefined ) {
            if ( props.indexOf( 'url' ) === -1 ) {
                props.push( 'url' );
            }
            if ( width !== undefined ) {
                params.siiurlwidth = width;
            }
            if ( height !== undefined ) {
                params.siiurlheight = height;
            }
        }

        this.api.get( params ).done( ok ).fail( err );
    };

    /**
     * Get information about published images
     * (There is some overlap with getStashedImageInfo, but it's different at every stage so it's clearer to have separate functions)
     * See API documentation for prop=imageinfo for what 'props' can contain
     *
     * @param {Function} callback Called with null if failure, with imageinfo data structure if success
     * @param {Array} props Properties to extract
     * @param {number} [width] Width of thumbnail. Will force 'url' to be added to props
     * @param {number} [height] Height of thumbnail. Will force 'url' to be added to props
     */
    mw.UploadWizardUpload.prototype.getImageInfo = function ( callback, props, width, height ) {
        var requestedTitle, params;

        function ok( data ) {
            var found;

            if ( data && data.query && data.query.pages ) {
                found = false;
                Object.keys( data.query.pages ).forEach( ( pageId ) => {
                    var page = data.query.pages[ pageId ];
                    if ( page.title && page.title === requestedTitle && page.imageinfo ) {
                        found = true;
                        callback( page.imageinfo );
                        return false;
                    }
                } );
                if ( found ) {
                    return;
                }
            }

            mw.log.warn( 'mw.UploadWizardUpload::getImageInfo> No data matching ' + requestedTitle + ' ?' );
            callback( null );
        }

        function err( code ) {
            mw.log.warn( 'mw.UploadWizardUpload::getImageInfo> ' + code );
            callback( null );
        }

        if ( props === undefined ) {
            props = [];
        }

        requestedTitle = this.title.getPrefixedText();
        params = {
            prop: 'imageinfo',
            titles: requestedTitle,
            iiprop: props.join( '|' )
        };

        if ( width !== undefined || height !== undefined ) {
            if ( props.indexOf( 'url' ) === -1 ) {
                props.push( 'url' );
            }
            if ( width !== undefined ) {
                params.iiurlwidth = width;
            }
            if ( height !== undefined ) {
                params.iiurlheight = height;
            }
        }

        this.api.get( params ).done( ok ).fail( err );
    };

    /**
     * Get the upload handler per browser capabilities
     *
     * @return {mw.ApiUploadFormDataHandler|mw.ApiUploadPostHandler} upload handler object
     */
    mw.UploadWizardUpload.prototype.getUploadHandler = function () {
        var constructor; // must be the name of a function in 'mw' namespace

        if ( !this.uploadHandler ) {
            constructor = 'ApiUploadFormDataHandler';
            if ( mw.UploadWizard.config.debug ) {
                mw.log( 'mw.UploadWizard::getUploadHandler> ' + constructor );
            }
            if ( this.file.fromURL ) {
                constructor = 'ApiUploadPostHandler';
            }
            this.uploadHandler = new mw[ constructor ]( this, this.api );
        }
        return this.uploadHandler;
    };

    /**
     * Explicitly fetch a thumbnail for a stashed upload of the desired width.
     *
     * @private
     * @param {number} width Desired width of thumbnail
     * @param {number} height Maximum height of thumbnail
     * @return {jQuery.Promise} Promise resolved with a HTMLImageElement, or null if thumbnail
     *     couldn't be generated
     */
    mw.UploadWizardUpload.prototype.getApiThumbnail = function ( width, height ) {
        var deferred = $.Deferred();

        function thumbnailPublisher( thumbnails ) {
            if ( thumbnails === null ) {
                // the api call failed somehow, no thumbnail data.
                deferred.resolve( null );
            } else {
                // ok, the api callback has returned us information on where the thumbnail(s) ARE, but that doesn't mean
                // they are actually there yet. Keep trying to set the source ( which should trigger "error" or "load" event )
                // on the image. If it loads publish the event with the image. If it errors out too many times, give up and publish
                // the event with a null.
                thumbnails.forEach( ( thumb ) => {
                    var timeoutMs, image;

                    if ( thumb.thumberror || ( !( thumb.thumburl && thumb.thumbwidth && thumb.thumbheight ) ) ) {
                        mw.log.warn( 'mw.UploadWizardUpload::getThumbnail> Thumbnail error or missing information' );
                        deferred.resolve( null );
                        return;
                    }

                    // executing this should cause a .load() or .error() event on the image
                    function setSrc() {
                        // IE 11 and Opera 12 will not, ever, re-request an image that they have already loaded
                        // once, regardless of caching headers. Append bogus stuff to the URL to make it work.
                        image.src = thumb.thumburl + '?' + Math.random();
                    }

                    // try to load this image with exponential backoff
                    // if the delay goes past 8 seconds, it gives up and publishes the event with null
                    timeoutMs = 100;
                    image = document.createElement( 'img' );
                    image.width = thumb.thumbwidth;
                    image.height = thumb.thumbheight;
                    $( image )
                        .on( 'load', () => {
                            // publish the image to anyone who wanted it
                            deferred.resolve( image );
                        } )
                        .on( 'error', () => {
                            // retry with exponential backoff
                            if ( timeoutMs < 8000 ) {
                                setTimeout( () => {
                                    timeoutMs = timeoutMs * 2 + Math.round( Math.random() * ( timeoutMs / 10 ) );
                                    setSrc();
                                }, timeoutMs );
                            } else {
                                deferred.resolve( null );
                            }
                        } );

                    // and, go!
                    setSrc();
                } );
            }
        }

        if ( this.state !== 'complete' ) {
            this.getStashImageInfo( thumbnailPublisher, [ 'url' ], width, height );
        } else {
            this.getImageInfo( thumbnailPublisher, [ 'url' ], width, height );
        }

        return deferred.promise();
    };

    /**
     * Return the orientation of the image in degrees. Relies on metadata that
     * may have been extracted at filereader stage, or after the upload when we fetch metadata. Default returns 0.
     *
     * @return {number} orientation in degrees: 0, 90, 180 or 270
     */
    mw.UploadWizardUpload.prototype.getOrientationDegrees = function () {
        var orientation = 0;
        if ( this.imageinfo && this.imageinfo.metadata && this.imageinfo.metadata.orientation ) {
            switch ( this.imageinfo.metadata.orientation ) {
                case 8:
                    // 'top left' -> 'left bottom'
                    orientation = 90;
                    break;
                case 3:
                    // 'top left' -> 'bottom right'
                    orientation = 180;
                    break;
                case 6:
                    // 'top left' -> 'right top'
                    orientation = 270;
                    break;
                default:
                    // 'top left' -> 'top left'
                    orientation = 0;
                    break;

            }
        }
        return orientation;
    };

    /**
     * Fit an image into width & height constraints with scaling factor
     *
     * @private
     * @param {HTMLImageElement} image
     * @param {Object} constraints Width & height properties
     * @return {number}
     */
    mw.UploadWizardUpload.prototype.getScalingFromConstraints = function ( image, constraints ) {
        var scaling = 1;
        Object.keys( constraints ).forEach( ( dim ) => {
            var s,
                constraint = constraints[ dim ];
            if ( constraint && image[ dim ] > constraint ) {
                s = constraint / image[ dim ];
                if ( s < scaling ) {
                    scaling = s;
                }
            }
        } );
        return scaling;
    };

    /**
     * Given an image (already loaded), dimension constraints
     * return canvas object scaled & transformed ( & rotated if metadata indicates it's needed )
     *
     * @deprecated 1.41 browsers apply orientation themselves since 2020. Remove this in 2026'ish
     * @private
     * @param {HTMLImageElement} image
     * @param {Object} constraints Width & height constraints
     * @return {HTMLCanvasElement|null}
     */
    mw.UploadWizardUpload.prototype.getTransformedCanvasElement = function ( image, constraints ) {
        var angle, scaling, width, height,
            dimensions, dx, dy, x, y, $canvas, ctx,
            scaleConstraints = constraints,
            rotation = 0;

        // if this wiki can rotate images to match their EXIF metadata,
        // we should do the same in our preview if the browser does not apply it already
        if ( mw.config.get( 'wgFileCanRotate' ) ) {
            angle = this.getOrientationDegrees();
            rotation = angle ? 360 - angle : 0;
        }

        // swap scaling constraints if needed by rotation...
        if ( rotation === 90 || rotation === 270 ) {
            scaleConstraints = {};
            if ( 'height' in constraints ) {
                scaleConstraints.width = constraints.height;
            }
            if ( 'width' in constraints ) {
                scaleConstraints.height = constraints.width;
            }
        }

        scaling = this.getScalingFromConstraints( image, scaleConstraints );

        width = image.width * scaling;
        height = image.height * scaling;

        dimensions = { width: width, height: height };
        if ( rotation === 90 || rotation === 270 ) {
            dimensions = { width: height, height: width };
        }

        // Start drawing at offset 0,0
        dx = 0;
        dy = 0;

        switch ( rotation ) {
            // If a rotation is applied, the direction of the axis
            // changes as well. You can derive the values below by
            // drawing on paper an axis system, rotate it and see
            // where the positive axis direction is
            case 90:
                x = dx;
                y = dy - height;
                break;
            case 180:
                x = dx - width;
                y = dy - height;
                break;
            case 270:
                x = dx - width;
                y = dy;
                break;
            default:
                x = dx;
                y = dy;
                break;
        }

        $canvas = $( '<canvas>' ).attr( dimensions );
        ctx = $canvas[ 0 ].getContext( '2d' );
        ctx.clearRect( dx, dy, width, height );
        ctx.rotate( rotation / 180 * Math.PI );
        try {
            // Calling #drawImage likes to throw all kinds of ridiculous exceptions in various browsers,
            // including but not limited to:
            // * (Firefox) NS_ERROR_NOT_AVAILABLE:
            // * (Internet Explorer / Edge) Not enough storage is available to complete this operation.
            // * (Internet Explorer / Edge) Unspecified error.
            // * (Internet Explorer / Edge) The GPU device instance has been suspended. Use GetDeviceRemovedReason to determine the appropriate action.
            // * (Safari) IndexSizeError: Index or size was negative, or greater than the allowed value.
            // There is nothing we can do about this. It's okay though, there just won't be a thumbnail.
            ctx.drawImage( image, x, y, width, height );
        } catch ( err ) {
            return null;
        }

        return $canvas;
    };

    /**
     * Return a browser-scaled image element, given an image and constraints.
     *
     * @private
     * @param {HTMLImageElement} image
     * @param {Object} constraints Width and height properties
     * @return {HTMLImageElement} with same src, but different attrs
     */
    mw.UploadWizardUpload.prototype.getBrowserScaledImageElement = function ( image, constraints ) {
        var scaling = this.getScalingFromConstraints( image, constraints );
        return $( '<img>' )
            .attr( {
                width: parseInt( image.width * scaling, 10 ),
                height: parseInt( image.height * scaling, 10 ),
                src: image.src
            } );
    };

    /**
     * Return an element suitable for the preview of a certain size. Uses canvas when possible
     *
     * @private
     * @param {HTMLImageElement} image
     * @param {number} width
     * @param {number} height
     * @return {HTMLCanvasElement|HTMLImageElement}
     */
    mw.UploadWizardUpload.prototype.getScaledImageElement = function ( image, width, height ) {
        var constraints = {},
            transform;

        if ( width ) {
            constraints.width = width;
        }
        if ( height ) {
            constraints.height = height;
        }

        if ( mw.canvas.isAvailable() && !CSS.supports( 'image-orientation', 'from-image' ) ) {
            transform = this.getTransformedCanvasElement( image, constraints );
            if ( transform ) {
                return transform;
            }
        }

        // No canvas support or canvas drawing failed mysteriously, fall back
        return this.getBrowserScaledImageElement( image, constraints );
    };

    /**
     * Acquire a thumbnail for this upload.
     *
     * @param {number} width
     * @param {number} height
     * @return {jQuery.Promise} Promise resolved with the HTMLImageElement or HTMLCanvasElement
     *   containing a thumbnail, or resolved with `null` when one can't be produced
     */
    mw.UploadWizardUpload.prototype.getThumbnail = function ( width, height ) {
        var upload = this,
            deferred = $.Deferred();

        if ( this.thumbnailPromise[ width + 'x' + height ] ) {
            return this.thumbnailPromise[ width + 'x' + height ];
        }
        this.thumbnailPromise[ width + 'x' + height ] = deferred.promise();

        /**
         * @param {HTMLImageElement|null} image
         */
        function imageCallback( image ) {
            if ( image === null ) {
                upload.ui.setStatus( 'mwe-upwiz-thumbnail-failed' );
                deferred.resolve( image );
                return;
            }

            image = upload.getScaledImageElement( image, width, height );
            deferred.resolve( image );
        }

        this.extractMetadataFromJpegMeta()
            .then( upload.makePreview.bind( upload, width ) )
            .done( imageCallback )
            .fail( () => {
                // Can't generate the thumbnail locally, get the thumbnail via API after
                // the file is uploaded. Queries are cached, so if this thumbnail was
                // already fetched for some reason, we'll get it immediately.
                if ( upload.state !== 'new' && upload.state !== 'transporting' && upload.state !== 'error' ) {
                    upload.getApiThumbnail( width, height ).done( imageCallback );
                } else {
                    upload.once( 'success', () => {
                        upload.getApiThumbnail( width, height ).done( imageCallback );
                    } );
                }
            } );

        return this.thumbnailPromise[ width + 'x' + height ];
    };

    /**
     * Notification that the file input has changed and it's fine...set info.
     */
    mw.UploadWizardUpload.prototype.fileChangedOk = function () {
        this.ui.fileChangedOk( this.imageinfo, this.file );
    };

    /**
     * Make a preview for the file.
     *
     * @private
     * @param {number} width
     * @return {jQuery.Promise}
     */
    mw.UploadWizardUpload.prototype.makePreview = function ( width ) {
        var first, video, url, dataUrlReader,
            deferred = $.Deferred(),
            upload = this;

        // do preview if we can
        if ( this.isPreviewable() ) {
            // open video and get frame via canvas
            if ( this.isVideo() ) {
                first = true;
                video = document.createElement( 'video' );

                video.addEventListener( 'loadedmetadata', () => {
                    // seek 2 seconds into video or to half if shorter
                    video.currentTime = Math.min( 2, video.duration / 2 );
                    video.volume = 0;
                } );
                video.addEventListener( 'seeked', () => {
                    // Firefox 16 sometimes does not work on first seek, seek again
                    if ( first ) {
                        first = false;
                        video.currentTime = Math.min( 2, video.duration / 2 );

                    } else {
                        // Chrome sometimes shows black frames if grabbing right away.
                        // wait 500ms before grabbing frame
                        setTimeout( () => {
                            var context,
                                canvas = document.createElement( 'canvas' );
                            canvas.width = width;
                            canvas.height = Math.round( canvas.width * video.videoHeight / video.videoWidth );
                            context = canvas.getContext( '2d' );
                            try {
                                // More ridiculous exceptions, see the comment in #getTransformedCanvasElement
                                context.drawImage( video, 0, 0, canvas.width, canvas.height );
                            } catch ( err ) {
                                deferred.reject();
                            }
                            upload.loadImage( canvas.toDataURL(), deferred );
                            upload.URL().revokeObjectURL( video.url );
                        }, 500 );
                    }
                } );
                url = this.URL().createObjectURL( this.file );
                video.src = url;
                // If we can't get a frame within 10 seconds, something is probably seriously wrong.
                // This can happen for broken files where we can't actually seek to the time we wanted.
                setTimeout( () => {
                    deferred.reject();
                    upload.URL().revokeObjectURL( video.url );
                }, 10000 );
            } else {
                dataUrlReader = new FileReader();
                dataUrlReader.onload = function () {
                    // this step (inserting image-as-dataurl into image object) is slow for large images, which
                    // is why this is optional and has a control attached to it to load the preview.
                    upload.loadImage( dataUrlReader.result, deferred );
                };
                dataUrlReader.readAsDataURL( this.file );
            }
        } else {
            deferred.reject();
        }

        return deferred.promise();
    };

    /**
     * Loads an image preview.
     *
     * @param {string} url
     * @param {jQuery.Deferred} deferred
     */
    mw.UploadWizardUpload.prototype.loadImage = function ( url, deferred ) {
        var image = document.createElement( 'img' );
        image.onload = function () {
            deferred.resolve( image );
        };
        image.onerror = function () {
            deferred.reject();
        };
        try {
            image.src = url;
        } catch ( er ) {
            // On Internet Explorer 11 and Edge, this occasionally causes an exception (possibly
            // localised) like "Not enough storage is available to complete this operation". (T136239)
            deferred.reject();
        }
    };

    /**
     * Check if the file is previewable.
     *
     * @return {boolean}
     */
    mw.UploadWizardUpload.prototype.isPreviewable = function () {
        return this.file && mw.fileApi.isPreviewableFile( this.file );
    };

    /**
     * Finds the right URL object to use.
     *
     * @return {URL}
     */
    mw.UploadWizardUpload.prototype.URL = function () {
        // This functionality is missing on IE 11

        return window.URL || window.webkitURL || window.mozURL;
    };

    /**
     * Checks if this upload is a video.
     *
     * @return {boolean}
     */
    mw.UploadWizardUpload.prototype.isVideo = function () {
        return mw.fileApi.isPreviewableVideo( this.file );
    };

}( mw.uploadWizard ) );