Generate thumbnail images for video uploads (#19801)

* FEATURE: Generate thumbnail images for uploaded videos

Topics in Discourse have a topic thumbnail feature which allows themes
to show a preview image before viewing the actual Topic.

This PR allows for the ability to generate a thumbnail image from an
uploaded video that can be use for the topic preview.
This commit is contained in:
Blake Erickson 2023-03-09 09:26:47 -07:00 committed by GitHub
parent dd07e0dbd0
commit f144c64e13
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 970 additions and 757 deletions

View File

@ -0,0 +1,128 @@
import Mixin from "@ember/object/mixin";
import ExtendableUploader from "discourse/mixins/extendable-uploader";
import UppyS3Multipart from "discourse/mixins/uppy-s3-multipart";
import Uppy from "@uppy/core";
import DropTarget from "@uppy/drop-target";
import XHRUpload from "@uppy/xhr-upload";
import { warn } from "@ember/debug";
import I18n from "I18n";
import getURL from "discourse-common/lib/get-url";
import { bind } from "discourse-common/utils/decorators";
import { inject as service } from "@ember/service";
export default Mixin.create(ExtendableUploader, UppyS3Multipart, {
dialog: service(),
uploadRootPath: "/uploads",
uploadTargetBound: false,
useUploadPlaceholders: true,
@bind
_generateVideoThumbnail() {
if (!this.siteSettings.enable_diffhtml_preview) {
return;
}
let videos = document.getElementsByClassName("video-container");
if (!videos) {
return;
}
// Only generate a topic thumbnail for the first video
let video_container = videos[0];
if (!video_container) {
return;
}
let video = video_container.querySelector("video:first-of-type");
if (!video) {
return;
}
let video_src = video.getElementsByTagName("source")[0].src;
let video_sha1 = video_src
.substring(video_src.lastIndexOf("/") + 1)
.split(".")[0];
// Wait for the video element to load, otherwise the canvas will be empty
video.oncanplay = () => {
let canvas = document.createElement("canvas");
let ctx = canvas.getContext("2d");
let videoHeight, videoWidth;
videoHeight = video.videoHeight;
videoWidth = video.videoWidth;
canvas.width = videoWidth;
canvas.height = videoHeight;
ctx.drawImage(video, 0, 0, videoWidth, videoHeight);
// upload video thumbnail
canvas.toBlob((blob) => {
this._uppyInstance = new Uppy({
id: `screenshot-placeholder`,
meta: {
upload_type: `thumbnail`,
video_sha1,
},
autoProceed: true,
});
if (this.siteSettings.enable_upload_debug_mode) {
this._instrumentUploadTimings();
}
if (this.siteSettings.enable_direct_s3_uploads) {
this._useS3MultipartUploads();
} else {
this._useXHRUploads();
}
this._uppyInstance.use(DropTarget, { target: this.element });
this._uppyInstance.on("upload", () => {
this.set("uploading", true);
});
this._uppyInstance.on("upload-success", () => {
this.set("uploading", false);
});
this._uppyInstance.on("upload-error", (file, error, response) => {
let message = I18n.t("wizard.upload_error");
if (response.body.errors) {
message = response.body.errors.join("\n");
}
// eslint-disable-next-line no-console
console.error(message);
this.set("uploading", false);
});
try {
this._uppyInstance.addFile({
source: `${this.id} thumbnail`,
name: video_sha1,
type: blob.type,
data: blob,
});
} catch (err) {
warn(`error adding files to uppy: ${err}`, {
id: "discourse.upload.uppy-add-files-error",
});
}
});
};
},
// This should be overridden in a child component if you need to
// hook into uppy events and be sure that everything is already
// set up for _uppyInstance.
_uppyReady() {},
_useXHRUploads() {
this._uppyInstance.use(XHRUpload, {
endpoint: getURL(`/uploads.json?client_id=${this.messageBus.clientId}`),
headers: {
"X-CSRF-Token": this.session.csrfToken,
},
});
},
});

View File

@ -1007,6 +1007,29 @@ class Post < ActiveRecord::Base
upload = nil
upload = Upload.find_by(sha1: sha1) if sha1.present?
upload ||= Upload.get_from_url(src)
# Link any video thumbnails
if SiteSetting.enable_diffhtml_preview && upload.present? &&
(FileHelper.supported_video.include? upload.extension)
# Video thumbnails have the filename of the video file sha1 with a .png or .jpg extension.
# This is because at time of upload in the composer we don't know the topic/post id yet
# and there is no thumbnail info added to the markdown to tie the thumbnail to the topic/post after
# creation.
thumbnail =
Upload.where("original_filename like ?", "#{upload.sha1}.%").first if upload.sha1.present?
if thumbnail.present?
upload_ids << thumbnail.id if thumbnail.present?
if self.is_first_post? #topic
self.topic.update_column(:image_upload_id, thumbnail.id)
extra_sizes =
ThemeModifierHelper.new(
theme_ids: Theme.user_selectable.pluck(:id),
).topic_thumbnail_sizes
self.topic.generate_thumbnails!(extra_sizes: extra_sizes)
end
end
end
upload_ids << upload.id if upload.present?
end

View File

@ -1531,6 +1531,45 @@ RSpec.describe Post do
expect(post.revisions.pluck(:number)).to eq([1, 2])
end
describe "video_thumbnails" do
before { SiteSetting.enable_diffhtml_preview = true }
fab!(:video_upload) { Fabricate(:upload, extension: "mp4") }
fab!(:image_upload) { Fabricate(:upload) }
fab!(:image_upload_2) { Fabricate(:upload) }
let(:base_url) { "#{Discourse.base_url_no_prefix}#{Discourse.base_path}" }
let(:video_url) { "#{base_url}#{video_upload.url}" }
let(:raw_video) { <<~RAW }
<video width="100%" height="100%" controls>
<source src="#{video_url}">
<a href="#{video_url}">#{video_url}</a>
</video>
RAW
let(:post) { Fabricate(:post, raw: raw_video) }
it "has a topic thumbnail" do
# Thumbnails are tied to a specific video file by using the
# video's sha1 as the image filename
image_upload.original_filename = "#{video_upload.sha1}.png"
image_upload.save!
post.link_post_uploads
post.topic.reload
expect(post.topic.topic_thumbnails.length).to eq(1)
end
it "only applies for video uploads" do
image_upload.original_filename = "#{image_upload_2.sha1}.png"
image_upload.save!
post.link_post_uploads
post.topic.reload
expect(post.topic.topic_thumbnails.length).to eq(0)
end
end
describe "uploads" do
fab!(:video_upload) { Fabricate(:upload, extension: "mp4") }
fab!(:image_upload) { Fabricate(:upload) }