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.previewing &&
!options.discourse.limitedSiteSettings.enableDiffhtmlPreview !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> <span class="placeholder-icon video"></span>
</div>`; </div>`;
} else { } else {

View File

@ -80,6 +80,15 @@ export default {
); );
containers.forEach((container) => { 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"), const wrapper = document.createElement("div"),
overlay = document.createElement("div"); overlay = document.createElement("div");

View File

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

View File

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

View File

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

View File

@ -1048,14 +1048,16 @@ class Post < ActiveRecord::Base
.where("original_filename like ?", "#{upload.sha1}.%") .where("original_filename like ?", "#{upload.sha1}.%")
.order(id: :desc) .order(id: :desc)
.first if upload.sha1.present? .first if upload.sha1.present?
if thumbnail.present? && self.is_first_post? && !self.topic.image_upload_id if thumbnail.present?
upload_ids << thumbnail.id upload_ids << thumbnail.id
self.topic.update_column(:image_upload_id, thumbnail.id) if self.is_first_post? && !self.topic.image_upload_id
extra_sizes = self.topic.update_column(:image_upload_id, thumbnail.id)
ThemeModifierHelper.new( extra_sizes =
theme_ids: Theme.user_selectable.pluck(:id), ThemeModifierHelper.new(
).topic_thumbnail_sizes theme_ids: Theme.user_selectable.pluck(:id),
self.topic.generate_thumbnails!(extra_sizes: extra_sizes) ).topic_thumbnail_sizes
self.topic.generate_thumbnails!(extra_sizes: extra_sizes)
end
end end
end end
upload_ids << upload.id if upload.present? upload_ids << upload.id if upload.present?

View File

@ -302,6 +302,7 @@ module PrettyText
add_rel_attributes_to_user_content(doc, add_nofollow) add_rel_attributes_to_user_content(doc, add_nofollow)
strip_hidden_unicode_bidirectional_characters(doc) strip_hidden_unicode_bidirectional_characters(doc)
sanitize_hotlinked_media(doc) sanitize_hotlinked_media(doc)
add_video_placeholder_image(doc)
add_mentions(doc, user_id: opts[:user_id]) if SiteSetting.enable_mentions add_mentions(doc, user_id: opts[:user_id]) if SiteSetting.enable_mentions
@ -442,6 +443,21 @@ module PrettyText
links links
end 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) def self.extract_mentions(cooked)
mentions = mentions =
cooked 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. # 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 # 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) sign_in(current_user)
visit "/new-topic" 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") } attach_file(file_path_1) { composer.click_toolbar_button("upload") }
expect(composer).to have_no_in_progress_uploads 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 composer.submit
expect(find("#topic-title")).to have_content("Video upload test") 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 end
end end