FEATURE: Auto generate and display video preview image (#25633)

This change will allow auto generated video thumbnails to be used
instead of the black video thumbnail that overlays videos.

Follow up to: 2443446e62
This commit is contained in:
Blake Erickson 2024-02-14 13:43:53 -07:00
parent aac6036868
commit 0821b2b6fe
8 changed files with 147 additions and 63 deletions

View File

@ -187,7 +187,11 @@ function renderImageOrPlayableMedia(tokens, idx, options, env, slf) {
options.discourse.previewing &&
!options.discourse.limitedSiteSettings.enableDiffhtmlPreview
) {
return `<div class="onebox-placeholder-container">
const origSrc = token.attrGet("data-orig-src");
const origSrcId = origSrc
.substring(origSrc.lastIndexOf("/") + 1)
.split(".")[0];
return `<div class="onebox-placeholder-container" data-orig-src-id="${origSrcId}">
<span class="placeholder-icon video"></span>
</div>`;
} else {

View File

@ -80,6 +80,15 @@ export default {
);
containers.forEach((container) => {
// Add video thumbnail image
if (container.dataset.thumbnailSrc) {
const thumbnail = new Image();
thumbnail.onload = function () {
container.style.backgroundImage = "url('" + thumbnail.src + "')";
};
thumbnail.src = container.dataset.thumbnailSrc;
}
const wrapper = document.createElement("div"),
overlay = document.createElement("div");

View File

@ -31,9 +31,11 @@ export default class ComposerVideoThumbnailUppy extends EmberObject.extend(
uploadRootPath = "/uploads";
uploadTargetBound = false;
useUploadPlaceholders = true;
capabilities = null;
constructor(owner) {
super(...arguments);
this.capabilities = owner.lookup("service:capabilities");
setOwner(this, owner);
}
@ -55,13 +57,17 @@ export default class ComposerVideoThumbnailUppy extends EmberObject.extend(
video.muted = true;
video.playsinline = true;
let videoSha1 = uploadUrl
const videoSha1 = uploadUrl
.substring(uploadUrl.lastIndexOf("/") + 1)
.split(".")[0];
// Wait for the video element to load, otherwise the canvas will be empty.
// iOS Safari prefers onloadedmetadata over oncanplay.
video.onloadedmetadata = () => {
// iOS Safari prefers onloadedmetadata over oncanplay. System tests running in Chrome
// prefer oncanplaythrough.
const eventName = this.capabilities.isIOS
? "onloadedmetadata"
: "oncanplaythrough";
video[eventName] = () => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = video.videoWidth;
@ -70,62 +76,79 @@ export default class ComposerVideoThumbnailUppy extends EmberObject.extend(
// A timeout is needed on mobile.
setTimeout(() => {
ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
// Detect Empty Thumbnail
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// upload video thumbnail
canvas.toBlob((blob) => {
this._uppyInstance = new Uppy({
id: "video-thumbnail",
meta: {
videoSha1,
upload_type: "thumbnail",
},
autoProceed: true,
});
if (this.siteSettings.enable_upload_debug_mode) {
this._instrumentUploadTimings();
let isEmpty = true;
for (let i = 0; i < data.length; i += 4) {
// Check RGB values
if (data[i] !== 0 || data[i + 1] !== 0 || data[i + 2] !== 0) {
isEmpty = false;
break;
}
}
if (this.siteSettings.enable_direct_s3_uploads) {
this._useS3MultipartUploads();
} else {
this._useXHRUploads();
}
if (!isEmpty) {
// upload video thumbnail
canvas.toBlob((blob) => {
this._uppyInstance = new Uppy({
id: "video-thumbnail",
meta: {
videoSha1,
upload_type: "thumbnail",
},
autoProceed: true,
});
this._uppyInstance.on("upload", () => {
this.uploading = true;
});
this._uppyInstance.on("upload-success", () => {
this.uploading = false;
callback();
});
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");
if (this.siteSettings.enable_upload_debug_mode) {
this._instrumentUploadTimings();
}
// eslint-disable-next-line no-console
console.error(message);
this.uploading = false;
callback();
});
if (this.siteSettings.enable_direct_s3_uploads) {
this._useS3MultipartUploads();
} else {
this._useXHRUploads();
}
try {
this._uppyInstance.addFile({
source: `${this.id}-video-thumbnail`,
name: `${videoSha1}`,
type: blob.type,
data: blob,
this._uppyInstance.on("upload", () => {
this.uploading = true;
});
} catch (err) {
warn(`error adding files to uppy: ${err}`, {
id: "discourse.upload.uppy-add-files-error",
this._uppyInstance.on("upload-success", () => {
this.uploading = false;
callback();
});
}
});
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.uploading = false;
callback();
});
try {
this._uppyInstance.addFile({
source: `${this.id}-video-thumbnail`,
name: `${videoSha1}`,
type: blob.type,
data: blob,
});
} catch (err) {
warn(`error adding files to uppy: ${err}`, {
id: "discourse.upload.uppy-add-files-error",
});
}
});
} else {
this.uploading = false;
callback();
}
}, 100);
};
}

View File

@ -1498,8 +1498,8 @@ var bar = 'bar';
assert.cookedOptions(
`![baby shark|video](upload://eyPnj7UzkU0AkGkx2dx8G4YM1Jx.mp4)`,
{ previewing: true },
`<p><div class=\"onebox-placeholder-container\">
<span class=\"placeholder-icon video\"></span>
`<p><div class="onebox-placeholder-container" data-orig-src-id="eyPnj7UzkU0AkGkx2dx8G4YM1Jx">
<span class="placeholder-icon video"></span>
</div></p>`
);
});

View File

@ -935,6 +935,8 @@ aside.onebox.mixcloud-preview {
}
}
.video-placeholder-container {
background-size: cover;
background-position: center;
position: relative;
padding: 0 0 56.25% 0;
width: 100%;
@ -1000,6 +1002,8 @@ iframe.vimeo-onebox {
}
.onebox-placeholder-container {
background-size: cover;
background-position: center;
position: relative;
width: 100%;
padding: 0 0 48.25% 0;

View File

@ -1048,14 +1048,16 @@ class Post < ActiveRecord::Base
.where("original_filename like ?", "#{upload.sha1}.%")
.order(id: :desc)
.first if upload.sha1.present?
if thumbnail.present? && self.is_first_post? && !self.topic.image_upload_id
if thumbnail.present?
upload_ids << thumbnail.id
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)
if self.is_first_post? && !self.topic.image_upload_id
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?

View File

@ -302,6 +302,7 @@ module PrettyText
add_rel_attributes_to_user_content(doc, add_nofollow)
strip_hidden_unicode_bidirectional_characters(doc)
sanitize_hotlinked_media(doc)
add_video_placeholder_image(doc)
add_mentions(doc, user_id: opts[:user_id]) if SiteSetting.enable_mentions
@ -442,6 +443,21 @@ module PrettyText
links
end
def self.add_video_placeholder_image(doc)
doc
.css(".video-placeholder-container")
.each do |video|
video_src = video["data-video-src"]
video_sha1 = File.basename(video_src, File.extname(video_src))
thumbnail = Upload.where("original_filename LIKE ?", "#{video_sha1}.%").last
if thumbnail
video["data-thumbnail-src"] = UrlHelper.absolute(
GlobalPath.upload_cdn_path(thumbnail.url),
)
end
end
end
def self.extract_mentions(cooked)
mentions =
cooked

View File

@ -51,7 +51,7 @@ describe "Uploading files in the composer", type: :system do
# we need to come back to this in a few months and try again.
#
# c.f. https://groups.google.com/g/chromedriver-users/c/1SMbByMfO2U
xit "generates a thumbnail for the video" do
xit "generates a topic preview thumbnail from the video" do
sign_in(current_user)
visit "/new-topic"
@ -62,12 +62,38 @@ describe "Uploading files in the composer", type: :system do
attach_file(file_path_1) { composer.click_toolbar_button("upload") }
expect(composer).to have_no_in_progress_uploads
expect(composer.preview).to have_css(".video-container")
expect(composer.preview).to have_css(".onebox-placeholder-container")
composer.submit
expect(find("#topic-title")).to have_content("Video upload test")
expect(topic.image_upload_id).to eq(Upload.last.id)
# I think topic list previews need to be enabled for this?
#expect(topic.image_upload_id).to eq(Upload.last.id)
end
it "generates a thumbnail from the video" do
sign_in(current_user)
visit "/new-topic"
expect(composer).to be_opened
topic.fill_in_composer_title("Video upload test")
file_path_1 = file_from_fixtures("small.mp4", "media").path
attach_file(file_path_1) { composer.click_toolbar_button("upload") }
expect(composer).to have_no_in_progress_uploads
expect(composer.preview).to have_css(".onebox-placeholder-container")
composer.submit
expect(find("#topic-title")).to have_content("Video upload test")
selector = topic.post_by_number_selector(1)
expect(page).to have_css(selector)
within(selector) do
expect(page).to have_css(".video-placeholder-container[data-thumbnail-src]")
end
end
end
end