Generate thumbnails for new video attachments, video quotes

This commit is contained in:
Scott Nonnenberg 2018-04-25 18:32:46 -07:00
parent 0e99ca61a2
commit ac0b50d20f
No known key found for this signature in database
GPG key ID: 5F82280C35134661
5 changed files with 222 additions and 109 deletions

View file

@ -237,7 +237,12 @@
</div> </div>
</script> </script>
<script type='text/x-tmpl-mustache' id='attachment-preview'> <script type='text/x-tmpl-mustache' id='attachment-preview'>
<img src='{{ source }}' class='preview' /> <div class='image-container'>
<img src='{{ source }}' class='preview' />
<div class='outer'>
<div class='play icon'></div>
</div>
</div>
<a class='x close' alt='remove attachment' href='#'></a> <a class='x close' alt='remove attachment' href='#'></a>
</script> </script>
<script type='text/x-tmpl-mustache' id='file-view'> <script type='text/x-tmpl-mustache' id='file-view'>

View file

@ -629,7 +629,11 @@
const attachmentWithData = await loadAttachmentData(attachment); const attachmentWithData = await loadAttachmentData(attachment);
const { data, contentType } = attachmentWithData; const { data, contentType } = attachmentWithData;
const objectUrl = this.makeObjectUrl(data, contentType); const objectUrl = this.makeObjectUrl(data, contentType);
const thumbnail = await Whisper.FileInputView.makeThumbnail(128, objectUrl);
const thumbnail = Signal.Util.GoogleChrome.isImageTypeSupported(contentType)
? await Whisper.FileInputView.makeThumbnail(128, objectUrl)
: await Whisper.FileInputView.makeVideoThumbnail(128, objectUrl);
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl);
const arrayBuffer = await this.blobToArrayBuffer(thumbnail); const arrayBuffer = await this.blobToArrayBuffer(thumbnail);
@ -654,7 +658,8 @@
attachments: await Promise.all((attachments || []).map(async (attachment) => { attachments: await Promise.all((attachments || []).map(async (attachment) => {
const { contentType } = attachment; const { contentType } = attachment;
const willMakeThumbnail = const willMakeThumbnail =
Signal.Util.GoogleChrome.isImageTypeSupported(contentType); Signal.Util.GoogleChrome.isImageTypeSupported(contentType) ||
Signal.Util.GoogleChrome.isVideoTypeSupported(contentType);
return { return {
contentType, contentType,
@ -1154,9 +1159,14 @@
const { attachments, id, author } = quote; const { attachments, id, author } = quote;
const first = attachments[0]; const first = attachments[0];
if (!first || first.thumbnail) {
return true;
}
// Maybe in the future we could try to pull the thumbnail from a video ourselves, // Maybe in the future we could try to pull the thumbnail from a video ourselves,
// but for now we will rely on incoming thumbnails only. // but for now we will rely on incoming thumbnails only.
if (!Signal.Util.GoogleChrome.isImageTypeSupported(first.contentType)) { if (!Signal.Util.GoogleChrome.isImageTypeSupported(first.contentType) &&
!Signal.Util.GoogleChrome.isVideoTypeSupported(first.contentType)) {
return false; return false;
} }
@ -1194,9 +1204,12 @@
const { attachments } = quote; const { attachments } = quote;
const first = attachments[0]; const first = attachments[0];
// Maybe in the future we could try to pull thumbnails video ourselves, if (!first || first.thumbnail) {
// but for now we will rely on incoming thumbnails only. return;
if (!first || !Signal.Util.GoogleChrome.isImageTypeSupported(first.contentType)) { }
if (!Signal.Util.GoogleChrome.isImageTypeSupported(first.contentType) &&
!Signal.Util.GoogleChrome.isVideoTypeSupported(first.contentType)) {
return; return;
} }
@ -1230,18 +1243,26 @@
if (!thumbnail) { if (!thumbnail) {
return false; return false;
} }
const thumbnailWithData = await loadAttachmentData(thumbnail); try {
thumbnailWithData.objectUrl = this.makeObjectUrl( const thumbnailWithData = await loadAttachmentData(thumbnail);
thumbnailWithData.data, thumbnailWithData.objectUrl = this.makeObjectUrl(
thumbnailWithData.contentType thumbnailWithData.data,
); thumbnailWithData.contentType
);
// If we update this data in place, there's the risk that this data could be // If we update this data in place, there's the risk that this data could be
// saved back to the database // saved back to the database
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
message.quoteThumbnail = thumbnailWithData; message.quoteThumbnail = thumbnailWithData;
return true; return true;
} catch (error) {
console.log(
'loadQuoteThumbnail: had trouble loading thumbnail data from disk',
error && error.stack ? error.stack : error
);
return false;
}
}, },
async processQuotes(messages) { async processQuotes(messages) {
const lookup = this.makeMessagesLookup(messages); const lookup = this.makeMessagesLookup(messages);
@ -1259,21 +1280,16 @@
return; return;
} }
// 1. Check to see if we've already loaded the target message into memory // 1. Load provided thumbnail
const gotThumbnail = await this.loadQuoteThumbnail(message, quote);
// 2. Check to see if we've already loaded the target message into memory
const { author, id } = quote; const { author, id } = quote;
const key = this.makeKey(author, id); const key = this.makeKey(author, id);
const quotedMessage = lookup[key]; const quotedMessage = lookup[key];
if (quotedMessage) { if (quotedMessage) {
// eslint-disable-next-line no-param-reassign
await this.loadQuotedMessage(message, quotedMessage); await this.loadQuotedMessage(message, quotedMessage);
// Note: in the future when we generate our own thumbnail we won't need to rely
// on incoming thumbnail if we have our local message in hand.
if (!message.quotedMessage.imageUrl) {
await this.loadQuoteThumbnail(message, quote);
}
this.forceRender(message); this.forceRender(message);
return; return;
} }
@ -1286,28 +1302,15 @@
} }
// We've don't want to go to the database or load thumbnails a second time. // We've don't want to go to the database or load thumbnails a second time.
if (message.quoteIsProcessed) { if (message.quoteIsProcessed || gotThumbnail) {
return; return;
} }
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
message.quoteIsProcessed = true; message.quoteIsProcessed = true;
// 2. Go to the database for the real referenced attachment // 3. As a last resort, go to the database to generate a thumbnail on-demand
const loaded = await this.loadQuotedMessageFromDatabase(message, id); const loaded = await this.loadQuotedMessageFromDatabase(message, id);
if (loaded) { if (loaded) {
// Note: in the future when we generate our own thumbnail we won't need to rely
// on incoming thumbnail if we have our local message in hand.
if (!message.quotedMessageFromDatabase.imageUrl) {
await this.loadQuoteThumbnail(message, quote);
}
this.forceRender(message);
return;
}
// 3. Finally, use the provided thumbnail
const gotThumbnail = await this.loadQuoteThumbnail(message, quote);
if (gotThumbnail) {
this.forceRender(message); this.forceRender(message);
} }
}); });

View file

@ -181,10 +181,6 @@
URL.revokeObjectURL(this.quoteThumbnail.objectUrl); URL.revokeObjectURL(this.quoteThumbnail.objectUrl);
this.quoteThumbnail = null; this.quoteThumbnail = null;
} }
if (this.quotedMessageFromDatabase) {
this.quotedMessageFromDatabase.unload();
this.quotedMessageFromDatabase = null;
}
if (this.quotedMessage) { if (this.quotedMessage) {
this.quotedMessage = null; this.quotedMessage = null;
} }

View file

@ -3,6 +3,8 @@
/* global i18n: false */ /* global i18n: false */
/* global loadImage: false */ /* global loadImage: false */
/* global Backbone: false */ /* global Backbone: false */
/* global _: false */
/* global Signal: false */
// eslint-disable-next-line func-names // eslint-disable-next-line func-names
(function () { (function () {
@ -61,6 +63,63 @@
})); }));
} }
function makeVideoScreenshot(objectUrl) {
return new Promise(((resolve, reject) => {
const video = document.createElement('video');
function capture() {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
const image = window.dataURLToBlobSync(canvas.toDataURL('image/png'));
video.removeEventListener('canplay', capture);
resolve(image);
}
video.addEventListener('canplay', capture);
video.addEventListener('error', (error) => {
console.log(
'makeVideoThumbnail error',
error && error.stack ? error.stack : error
);
reject(error);
});
video.src = objectUrl;
}));
}
function makeObjectUrl(data, contentType) {
const blob = new Blob([data], {
type: contentType,
});
return URL.createObjectURL(blob);
}
function blobToArrayBuffer(blob) {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = e => resolve(e.target.result);
fileReader.onerror = reject;
fileReader.onabort = reject;
fileReader.readAsArrayBuffer(blob);
});
}
async function makeVideoThumbnail(size, videoObjectUrl) {
const blob = await makeVideoScreenshot(videoObjectUrl);
const arrayBuffer = await blobToArrayBuffer(blob);
const screenshotObjectUrl = makeObjectUrl(arrayBuffer, 'image/png');
return makeThumbnail(size, screenshotObjectUrl);
}
Whisper.FileInputView = Backbone.View.extend({ Whisper.FileInputView = Backbone.View.extend({
tagName: 'span', tagName: 'span',
className: 'file-input', className: 'file-input',
@ -103,10 +162,18 @@
} }
}, },
addThumb(src) { addThumb(src, options = {}) {
_.defaults(options, { addPlayIcon: false });
this.$('.avatar').hide(); this.$('.avatar').hide();
this.thumb.src = src; this.thumb.src = src;
this.$('.attachment-previews').append(this.thumb.render().el); this.$('.attachment-previews').append(this.thumb.render().el);
if (options.addPlayIcon) {
this.$el.addClass('video-attachment');
} else {
this.$el.removeClass('video-attachment');
}
this.thumb.$('img')[0].onload = () => { this.thumb.$('img')[0].onload = () => {
this.$el.trigger('force-resize'); this.$el.trigger('force-resize');
}; };
@ -160,69 +227,81 @@
})); }));
}, },
previewImages() { async previewImages() {
this.clearForm(); this.clearForm();
const file = this.file || this.$input.prop('files')[0]; const file = this.file || this.$input.prop('files')[0];
if (!file) { return; } if (!file) {
return;
let type = file.type.split('/')[0];
if (file.type === 'image/tiff') {
type = 'file';
} }
switch (type) {
case 'audio': this.addThumb('images/audio.svg'); break; const contentType = file.type;
case 'video': this.addThumb('images/video.svg'); break;
const renderVideoPreview = async () => {
// we use the variable on this here to ensure cleanup if we're interrupted
this.previewObjectUrl = URL.createObjectURL(file);
const thumbnail = await makeVideoScreenshot(this.previewObjectUrl);
URL.revokeObjectURL(this.previewObjectUrl);
const arrayBuffer = await blobToArrayBuffer(thumbnail);
this.previewObjectUrl = makeObjectUrl(arrayBuffer, 'image/png');
this.addThumb(this.previewObjectUrl, { addPlayIcon: true });
};
const renderImagePreview = async () => {
if (!MIME.isJPEG(file.type)) {
this.previewObjectUrl = URL.createObjectURL(file);
this.addThumb(this.previewObjectUrl);
return;
}
const dataUrl = await window.autoOrientImage(file);
this.addThumb(dataUrl);
};
if (Signal.Util.GoogleChrome.isImageTypeSupported(contentType)) {
renderImagePreview();
} else if (Signal.Util.GoogleChrome.isVideoTypeSupported(contentType)) {
renderVideoPreview();
} else if (MIME.isAudio(contentType)) {
this.addThumb('images/audio.svg');
} else {
this.addThumb('images/file.svg');
}
const blob = await this.autoScale(file);
let limitKb = 1000000;
const blobType = file.type === 'image/gif'
? 'gif'
: contentType.split('/')[0];
switch (blobType) {
case 'image': case 'image':
if (!MIME.isJPEG(file.type)) { limitKb = 6000; break;
this.previewObjectUrl = URL.createObjectURL(file); case 'gif':
this.addThumb(this.previewObjectUrl); limitKb = 25000; break;
break; case 'audio':
} limitKb = 100000; break;
case 'video':
// NOTE: Temporarily allow `then` until we convert the entire file limitKb = 100000; break;
// to `async` / `await`:
// eslint-disable-next-line more/no-then
window.autoOrientImage(file)
.then(dataURL => this.addThumb(dataURL));
break;
default: default:
this.addThumb('images/file.svg'); break; limitKb = 100000; break;
}
if ((blob.size / 1024).toFixed(4) >= limitKb) {
const units = ['kB', 'MB', 'GB'];
let u = -1;
let limit = limitKb * 1000;
do {
limit /= 1000;
u += 1;
} while (limit >= 1000 && u < units.length - 1);
const toast = new Whisper.FileSizeToast({
model: { limit, units: units[u] },
});
toast.$el.insertAfter(this.$el);
toast.render();
this.deleteFiles();
} }
// NOTE: Temporarily allow `then` until we convert the entire file
// to `async` / `await`:
// eslint-disable-next-line more/no-then
this.autoScale(file).then((blob) => {
let limitKb = 1000000;
const blobType = file.type === 'image/gif' ? 'gif' : type;
switch (blobType) {
case 'image':
limitKb = 6000; break;
case 'gif':
limitKb = 25000; break;
case 'audio':
limitKb = 100000; break;
case 'video':
limitKb = 100000; break;
default:
limitKb = 100000; break;
}
if ((blob.size / 1024).toFixed(4) >= limitKb) {
const units = ['kB', 'MB', 'GB'];
let u = -1;
let limit = limitKb * 1000;
do {
limit /= 1000;
u += 1;
} while (limit >= 1000 && u < units.length - 1);
const toast = new Whisper.FileSizeToast({
model: { limit, units: units[u] },
});
toast.$el.insertAfter(this.$el);
toast.render();
this.deleteFiles();
}
});
}, },
hasFiles() { hasFiles() {
@ -262,7 +341,7 @@
.then(setFlags(attachmentFlags)); .then(setFlags(attachmentFlags));
}, },
getThumbnail() { async getThumbnail() {
// Scale and crop an image to 256px square // Scale and crop an image to 256px square
const size = 256; const size = 256;
const file = this.file || this.$input.prop('files')[0]; const file = this.file || this.$input.prop('files')[0];
@ -275,11 +354,10 @@
const objectUrl = URL.createObjectURL(file); const objectUrl = URL.createObjectURL(file);
// eslint-disable-next-line more/no-then const arrayBuffer = await makeThumbnail(size, objectUrl);
return makeThumbnail(size, objectUrl).then((arrayBuffer) => { URL.revokeObjectURL(objectUrl);
URL.revokeObjectURL(objectUrl);
return this.readFile(arrayBuffer); return this.readFile(arrayBuffer);
});
}, },
// File -> Promise Attachment // File -> Promise Attachment
@ -370,4 +448,6 @@
}); });
Whisper.FileInputView.makeThumbnail = makeThumbnail; Whisper.FileInputView.makeThumbnail = makeThumbnail;
Whisper.FileInputView.makeVideoThumbnail = makeVideoThumbnail;
Whisper.FileInputView.makeVideoScreenshot = makeVideoScreenshot;
}()); }());

View file

@ -927,6 +927,33 @@ span.status {
form.send { form.send {
background: #ffffff; background: #ffffff;
&.video-attachment {
.image-container {
position: relative;
}
.outer {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
.play.icon {
height: 30px;
width: 30px;
@include color-svg('../images/play.svg', white);
}
}
}
} }
input, textarea { input, textarea {
@ -935,8 +962,10 @@ span.status {
.attachment-previews { .attachment-previews {
padding: 0 36px; padding: 0 36px;
.attachment-preview { .attachment-preview {
padding: 13px 10px 0; padding: 13px 10px 0;
} }
img { img {
border: 2px solid #ddd; border: 2px solid #ddd;