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:
parent
dd07e0dbd0
commit
f144c64e13
|
@ -31,6 +31,7 @@ import discourseLater from "discourse-common/lib/later";
|
||||||
import Component from "@ember/component";
|
import Component from "@ember/component";
|
||||||
import Composer from "discourse/models/composer";
|
import Composer from "discourse/models/composer";
|
||||||
import ComposerUploadUppy from "discourse/mixins/composer-upload-uppy";
|
import ComposerUploadUppy from "discourse/mixins/composer-upload-uppy";
|
||||||
|
import ComposerVideoThumbnailUppy from "discourse/mixins/composer-video-thumbnail-uppy";
|
||||||
import EmberObject from "@ember/object";
|
import EmberObject from "@ember/object";
|
||||||
import I18n from "I18n";
|
import I18n from "I18n";
|
||||||
import { ajax } from "discourse/lib/ajax";
|
import { ajax } from "discourse/lib/ajax";
|
||||||
|
@ -98,8 +99,10 @@ export function addComposerUploadMarkdownResolver(resolver) {
|
||||||
export function cleanUpComposerUploadMarkdownResolver() {
|
export function cleanUpComposerUploadMarkdownResolver() {
|
||||||
uploadMarkdownResolvers = [];
|
uploadMarkdownResolvers = [];
|
||||||
}
|
}
|
||||||
|
export default Component.extend(
|
||||||
export default Component.extend(ComposerUploadUppy, {
|
ComposerUploadUppy,
|
||||||
|
ComposerVideoThumbnailUppy,
|
||||||
|
{
|
||||||
classNameBindings: ["showToolbar:toolbar-visible", ":wmd-controls"],
|
classNameBindings: ["showToolbar:toolbar-visible", ":wmd-controls"],
|
||||||
|
|
||||||
editorClass: ".d-editor",
|
editorClass: ".d-editor",
|
||||||
|
@ -143,7 +146,9 @@ export default Component.extend(ComposerUploadUppy, {
|
||||||
|
|
||||||
@discourseComputed
|
@discourseComputed
|
||||||
showLink() {
|
showLink() {
|
||||||
return this.currentUser && this.currentUser.link_posting_access !== "none";
|
return (
|
||||||
|
this.currentUser && this.currentUser.link_posting_access !== "none"
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
@observes("focusTarget")
|
@observes("focusTarget")
|
||||||
|
@ -325,7 +330,10 @@ export default Component.extend(ComposerUploadUppy, {
|
||||||
.querySelector(".d-editor-input")
|
.querySelector(".d-editor-input")
|
||||||
?.removeEventListener("scroll", this._handleInputOrPreviewScroll);
|
?.removeEventListener("scroll", this._handleInputOrPreviewScroll);
|
||||||
|
|
||||||
event.target?.addEventListener("scroll", this._handleInputOrPreviewScroll);
|
event.target?.addEventListener(
|
||||||
|
"scroll",
|
||||||
|
this._handleInputOrPreviewScroll
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
_syncScroll($callback, $input, $preview) {
|
_syncScroll($callback, $input, $preview) {
|
||||||
|
@ -426,7 +434,9 @@ export default Component.extend(ComposerUploadUppy, {
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
_throttledSyncEditorAndPreviewScroll(event) {
|
_throttledSyncEditorAndPreviewScroll(event) {
|
||||||
const $preview = $(this.element.querySelector(".d-editor-preview-wrapper"));
|
const $preview = $(
|
||||||
|
this.element.querySelector(".d-editor-preview-wrapper")
|
||||||
|
);
|
||||||
|
|
||||||
throttle(
|
throttle(
|
||||||
this,
|
this,
|
||||||
|
@ -625,7 +635,10 @@ export default Component.extend(ComposerUploadUppy, {
|
||||||
},
|
},
|
||||||
|
|
||||||
commitAltText(buttonWrapper) {
|
commitAltText(buttonWrapper) {
|
||||||
const index = parseInt(buttonWrapper.getAttribute("data-image-index"), 10);
|
const index = parseInt(
|
||||||
|
buttonWrapper.getAttribute("data-image-index"),
|
||||||
|
10
|
||||||
|
);
|
||||||
const matchingPlaceholder =
|
const matchingPlaceholder =
|
||||||
this.get("composer.reply").match(IMAGE_MARKDOWN_REGEX);
|
this.get("composer.reply").match(IMAGE_MARKDOWN_REGEX);
|
||||||
const match = matchingPlaceholder[index];
|
const match = matchingPlaceholder[index];
|
||||||
|
@ -756,8 +769,14 @@ export default Component.extend(ComposerUploadUppy, {
|
||||||
preview?.removeEventListener("click", this._handleImageScaleButtonClick);
|
preview?.removeEventListener("click", this._handleImageScaleButtonClick);
|
||||||
preview?.removeEventListener("click", this._handleAltTextEditButtonClick);
|
preview?.removeEventListener("click", this._handleAltTextEditButtonClick);
|
||||||
preview?.removeEventListener("click", this._handleAltTextOkButtonClick);
|
preview?.removeEventListener("click", this._handleAltTextOkButtonClick);
|
||||||
preview?.removeEventListener("click", this._handleAltTextCancelButtonClick);
|
preview?.removeEventListener(
|
||||||
preview?.removeEventListener("keypress", this._handleAltTextInputKeypress);
|
"click",
|
||||||
|
this._handleAltTextCancelButtonClick
|
||||||
|
);
|
||||||
|
preview?.removeEventListener(
|
||||||
|
"keypress",
|
||||||
|
this._handleAltTextInputKeypress
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
onExpandPopupMenuOptions(toolbarEvent) {
|
onExpandPopupMenuOptions(toolbarEvent) {
|
||||||
|
@ -878,7 +897,8 @@ export default Component.extend(ComposerUploadUppy, {
|
||||||
|
|
||||||
// Paint category, tag, and other data source hashtags
|
// Paint category, tag, and other data source hashtags
|
||||||
let unseenHashtags;
|
let unseenHashtags;
|
||||||
const hashtagContext = this.site.hashtag_configurations["topic-composer"];
|
const hashtagContext =
|
||||||
|
this.site.hashtag_configurations["topic-composer"];
|
||||||
if (this.siteSettings.enable_experimental_hashtag_autocomplete) {
|
if (this.siteSettings.enable_experimental_hashtag_autocomplete) {
|
||||||
unseenHashtags = linkSeenHashtagsInContext(hashtagContext, preview);
|
unseenHashtags = linkSeenHashtagsInContext(hashtagContext, preview);
|
||||||
} else {
|
} else {
|
||||||
|
@ -917,6 +937,8 @@ export default Component.extend(ComposerUploadUppy, {
|
||||||
// Short upload urls need resolution
|
// Short upload urls need resolution
|
||||||
resolveAllShortUrls(ajax, this.siteSettings, preview);
|
resolveAllShortUrls(ajax, this.siteSettings, preview);
|
||||||
|
|
||||||
|
this._generateVideoThumbnail();
|
||||||
|
|
||||||
preview.addEventListener("click", this._handleImageScaleButtonClick);
|
preview.addEventListener("click", this._handleImageScaleButtonClick);
|
||||||
this._registerImageAltTextButtonClick(preview);
|
this._registerImageAltTextButtonClick(preview);
|
||||||
|
|
||||||
|
@ -924,4 +946,5 @@ export default Component.extend(ComposerUploadUppy, {
|
||||||
this.afterRefresh($preview);
|
this.afterRefresh($preview);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
|
@ -1007,6 +1007,29 @@ class Post < ActiveRecord::Base
|
||||||
upload = nil
|
upload = nil
|
||||||
upload = Upload.find_by(sha1: sha1) if sha1.present?
|
upload = Upload.find_by(sha1: sha1) if sha1.present?
|
||||||
upload ||= Upload.get_from_url(src)
|
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?
|
upload_ids << upload.id if upload.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1531,6 +1531,45 @@ RSpec.describe Post do
|
||||||
expect(post.revisions.pluck(:number)).to eq([1, 2])
|
expect(post.revisions.pluck(:number)).to eq([1, 2])
|
||||||
end
|
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
|
describe "uploads" do
|
||||||
fab!(:video_upload) { Fabricate(:upload, extension: "mp4") }
|
fab!(:video_upload) { Fabricate(:upload, extension: "mp4") }
|
||||||
fab!(:image_upload) { Fabricate(:upload) }
|
fab!(:image_upload) { Fabricate(:upload) }
|
||||||
|
|
Loading…
Reference in New Issue