Merge pull request #2439 from matrix-org/matthew/retina

Fix for retina thumbnails being massive
This commit is contained in:
Bruno Windels 2019-04-09 16:14:25 +00:00 committed by GitHub
commit 0592a1711a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 115 additions and 19 deletions

View file

@ -83,6 +83,7 @@
"matrix-js-sdk": "1.0.4", "matrix-js-sdk": "1.0.4",
"optimist": "^0.6.1", "optimist": "^0.6.1",
"pako": "^1.0.5", "pako": "^1.0.5",
"png-chunks-extract": "^1.0.0",
"prop-types": "^15.5.8", "prop-types": "^15.5.8",
"qrcode-react": "^0.1.16", "qrcode-react": "^0.1.16",
"qs": "^6.6.0", "qs": "^6.6.0",

View file

@ -25,8 +25,8 @@ import sdk from './index';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
import Modal from './Modal'; import Modal from './Modal';
import RoomViewStore from './stores/RoomViewStore'; import RoomViewStore from './stores/RoomViewStore';
import encrypt from "browser-encrypt-attachment"; import encrypt from "browser-encrypt-attachment";
import extractPngChunks from "png-chunks-extract";
// Polyfill for Canvas.toBlob API using Canvas.toDataURL // Polyfill for Canvas.toBlob API using Canvas.toDataURL
import "blueimp-canvas-to-blob"; import "blueimp-canvas-to-blob";
@ -34,6 +34,10 @@ import "blueimp-canvas-to-blob";
const MAX_WIDTH = 800; const MAX_WIDTH = 800;
const MAX_HEIGHT = 600; const MAX_HEIGHT = 600;
// scraped out of a macOS hidpi (5660ppm) screenshot png
// 5669 px (x-axis) , 5669 px (y-axis) , per metre
const PHYS_HIDPI = [0x00, 0x00, 0x16, 0x25, 0x00, 0x00, 0x16, 0x25, 0x01];
export class UploadCanceledError extends Error {} export class UploadCanceledError extends Error {}
/** /**
@ -97,24 +101,48 @@ function createThumbnail(element, inputWidth, inputHeight, mimeType) {
* @param {File} imageFile The file to load in an image element. * @param {File} imageFile The file to load in an image element.
* @return {Promise} A promise that resolves with the html image element. * @return {Promise} A promise that resolves with the html image element.
*/ */
function loadImageElement(imageFile) { async function loadImageElement(imageFile) {
const deferred = Promise.defer();
// Load the file into an html element // Load the file into an html element
const img = document.createElement("img"); const img = document.createElement("img");
const objectUrl = URL.createObjectURL(imageFile); const objectUrl = URL.createObjectURL(imageFile);
img.src = objectUrl; const imgPromise = new Promise((resolve, reject) => {
// Once ready, create a thumbnail
img.onload = function() { img.onload = function() {
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl);
deferred.resolve(img); resolve(img);
}; };
img.onerror = function(e) { img.onerror = function(e) {
deferred.reject(e); reject(e);
}; };
});
img.src = objectUrl;
return deferred.promise; // check for hi-dpi PNGs and fudge display resolution as needed.
// this is mainly needed for macOS screencaps
let parsePromise;
if (imageFile.type === "image/png") {
// in practice macOS happens to order the chunks so they fall in
// the first 0x1000 bytes (thanks to a massive ICC header).
// Thus we could slice the file down to only sniff the first 0x1000
// bytes (but this makes extractPngChunks choke on the corrupt file)
const headers = imageFile; //.slice(0, 0x1000);
parsePromise = readFileAsArrayBuffer(headers).then(arrayBuffer => {
const buffer = new Uint8Array(arrayBuffer);
const chunks = extractPngChunks(buffer);
for (const chunk of chunks) {
if (chunk.name === 'pHYs') {
if (chunk.data.byteLength !== PHYS_HIDPI.length) return;
const hidpi = chunk.data.every((val, i) => val === PHYS_HIDPI[i]);
return hidpi;
}
}
return false;
});
}
const [hidpi] = await Promise.all([parsePromise, imgPromise]);
const width = hidpi ? (img.width >> 1) : img.width;
const height = hidpi ? (img.height >> 1) : img.height;
return {width, height, img};
} }
/** /**
@ -132,8 +160,8 @@ function infoForImageFile(matrixClient, roomId, imageFile) {
} }
let imageInfo; let imageInfo;
return loadImageElement(imageFile).then(function(img) { return loadImageElement(imageFile).then(function(r) {
return createThumbnail(img, img.width, img.height, thumbnailType); return createThumbnail(r.img, r.width, r.height, thumbnailType);
}).then(function(result) { }).then(function(result) {
imageInfo = result.info; imageInfo = result.info;
return uploadFile(matrixClient, roomId, result.thumbnail); return uploadFile(matrixClient, roomId, result.thumbnail);

View file

@ -150,7 +150,7 @@ export default class MImageBody extends React.Component {
if (this.refs.image) { if (this.refs.image) {
const { naturalWidth, naturalHeight } = this.refs.image; const { naturalWidth, naturalHeight } = this.refs.image;
// this is only used as a fallback in case content.info.w/h is missing
loadedImageDimensions = { naturalWidth, naturalHeight }; loadedImageDimensions = { naturalWidth, naturalHeight };
} }
@ -167,6 +167,14 @@ export default class MImageBody extends React.Component {
} }
_getThumbUrl() { _getThumbUrl() {
// FIXME: the dharma skin lets images grow as wide as you like, rather than capped to 800x600.
// So either we need to support custom timeline widths here, or reimpose the cap, otherwise the
// thumbnail resolution will be unnecessarily reduced.
// custom timeline widths seems preferable.
const pixelRatio = window.devicePixelRatio;
const thumbWidth = 800 * pixelRatio;
const thumbHeight = 600 * pixelRatio;
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
if (content.file !== undefined) { if (content.file !== undefined) {
// Don't use the thumbnail for clients wishing to autoplay gifs. // Don't use the thumbnail for clients wishing to autoplay gifs.
@ -175,14 +183,61 @@ export default class MImageBody extends React.Component {
} }
return this.state.decryptedUrl; return this.state.decryptedUrl;
} else if (content.info && content.info.mimetype === "image/svg+xml" && content.info.thumbnail_url) { } else if (content.info && content.info.mimetype === "image/svg+xml" && content.info.thumbnail_url) {
// special case to return client-generated thumbnails for SVGs, if any, // special case to return clientside sender-generated thumbnails for SVGs, if any,
// given we deliberately don't thumbnail them serverside to prevent // given we deliberately don't thumbnail them serverside to prevent
// billion lol attacks and similar // billion lol attacks and similar
return this.context.matrixClient.mxcUrlToHttp( return this.context.matrixClient.mxcUrlToHttp(
content.info.thumbnail_url, 800, 600, content.info.thumbnail_url,
thumbWidth,
thumbHeight,
); );
} else { } else {
return this.context.matrixClient.mxcUrlToHttp(content.url, 800, 600); // we try to download the correct resolution
// for hi-res images (like retina screenshots).
// synapse only supports 800x600 thumbnails for now though,
// so we'll need to download the original image for this to work
// well for now. First, let's try a few cases that let us avoid
// downloading the original:
if (pixelRatio === 1.0 ||
(!content.info || !content.info.w ||
!content.info.h || !content.info.size)) {
// always thumbnail. it may look a bit worse, but it'll save bandwidth.
// which is probably desirable on a lo-dpi device anyway.
return this.context.matrixClient.mxcUrlToHttp(content.url, thumbWidth, thumbHeight);
} else {
// we should only request thumbnails if the image is bigger than 800x600
// (or 1600x1200 on retina) otherwise the image in the timeline will just
// end up resampled and de-retina'd for no good reason.
// Ideally the server would pregen 1600x1200 thumbnails in order to provide retina
// thumbnails, but we don't do this currently in synapse for fear of disk space.
// As a compromise, let's switch to non-retina thumbnails only if the original
// image is both physically too large and going to be massive to load in the
// timeline (e.g. >1MB).
const isLargerThanThumbnail = (
content.info.w > thumbWidth ||
content.info.h > thumbHeight
);
const isLargeFileSize = content.info.size > 1*1024*1024;
if (isLargeFileSize && isLargerThanThumbnail) {
// image is too large physically and bytewise to clutter our timeline so
// we ask for a thumbnail, despite knowing that it will be max 800x600
// despite us being retina (as synapse doesn't do 1600x1200 thumbs yet).
return this.context.matrixClient.mxcUrlToHttp(
content.url,
thumbWidth,
thumbHeight,
);
} else {
// download the original image otherwise, so we can scale it client side
// to take pixelRatio into account.
// ( no width/height means we want the original image)
return this.context.matrixClient.mxcUrlToHttp(
content.url,
);
}
}
} }
} }

View file

@ -2240,6 +2240,11 @@ counterpart@^0.18.0:
pluralizers "^0.1.7" pluralizers "^0.1.7"
sprintf-js "^1.0.3" sprintf-js "^1.0.3"
crc-32@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-0.3.0.tgz#6a3d3687f5baec41f7e9b99fe1953a2e5d19775e"
integrity sha1-aj02h/W67EH36bmf4ZU6Ll0Zd14=
create-ecdh@^4.0.0: create-ecdh@^4.0.0:
version "4.0.3" version "4.0.3"
resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.3.tgz#c9111b6f33045c4697f144787f9254cdc77c45ff" resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.3.tgz#c9111b6f33045c4697f144787f9254cdc77c45ff"
@ -5863,6 +5868,13 @@ pluralizers@^0.1.7:
resolved "https://registry.yarnpkg.com/pluralizers/-/pluralizers-0.1.7.tgz#8d38dd0a1b660e739b10ab2eab10b684c9d50142" resolved "https://registry.yarnpkg.com/pluralizers/-/pluralizers-0.1.7.tgz#8d38dd0a1b660e739b10ab2eab10b684c9d50142"
integrity sha512-mw6AejUiCaMQ6uPN9ObjJDTnR5AnBSmnHHy3uVTbxrSFSxO5scfwpTs8Dxyb6T2v7GSulhvOq+pm9y+hXUvtOA== integrity sha512-mw6AejUiCaMQ6uPN9ObjJDTnR5AnBSmnHHy3uVTbxrSFSxO5scfwpTs8Dxyb6T2v7GSulhvOq+pm9y+hXUvtOA==
png-chunks-extract@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/png-chunks-extract/-/png-chunks-extract-1.0.0.tgz#fad4a905e66652197351c65e35b92c64311e472d"
integrity sha1-+tSpBeZmUhlzUcZeNbksZDEeRy0=
dependencies:
crc-32 "^0.3.0"
posix-character-classes@^0.1.0: posix-character-classes@^0.1.0:
version "0.1.1" version "0.1.1"
resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"