thrimletrimmer: Support one-off overlays for thumbnails

By picking the "one-off overlay" option for a thumbnail, you swap specifying a template name
for being able to upload a one-off template that is then combined with the requested frame.

The rendering is done by restreamer, and we do it explicitly whenever
a) Generate Thumbnail Preview is pressed
b) The video is submitted

The rendered thumbnail is then included in the submission as a "custom" thumbnail.

The default thumbnail template params (crop and location) do not change
when this mode is selected, so they'll effectively be the default params of the previously-selected
template. In most cases this will be what you want since almost all our templates share the same
params, and custom one-offs will too.
pull/419/head
Mike Lang 1 month ago committed by Mike Lang
parent 867ec8411b
commit 2391a73ced

@ -231,6 +231,7 @@
<option value="NONE">No custom thumbnail</option> <option value="NONE">No custom thumbnail</option>
<option value="BARE">Use video frame</option> <option value="BARE">Use video frame</option>
<option value="TEMPLATE" selected>Use video frame in image template</option> <option value="TEMPLATE" selected>Use video frame in image template</option>
<option value="ONEOFF">Use video frame with a custom one-off overlay</option>
<option value="CUSTOM">Use a custom thumbnail image</option> <option value="CUSTOM">Use a custom thumbnail image</option>
</select> </select>
</div> </div>

@ -261,8 +261,20 @@ window.addEventListener("DOMContentLoaded", async (event) => {
document document
.getElementById("video-info-thumbnail-template-preview-generate") .getElementById("video-info-thumbnail-template-preview-generate")
.addEventListener("click", (_event) => { .addEventListener("click", async (_event) => {
const imageElement = document.getElementById("video-info-thumbnail-template-preview-image"); const imageElement = document.getElementById("video-info-thumbnail-template-preview-image");
const thumbnailMode = document.getElementById("video-info-thumbnail-mode").value;
if (thumbnailMode === "ONEOFF") {
try {
const data = await renderThumbnail();
imageElement.src = `data:image/png;base64,${data}`;
} catch (e) {
imageElement.classList.add("hidden");
addError(`${e}`);
return;
}
} else {
const timeEntryElement = document.getElementById("video-info-thumbnail-time"); const timeEntryElement = document.getElementById("video-info-thumbnail-time");
const imageTime = wubloaderTimeFromVideoHumanTime(timeEntryElement.value); const imageTime = wubloaderTimeFromVideoHumanTime(timeEntryElement.value);
if (imageTime === null) { if (imageTime === null) {
@ -279,6 +291,7 @@ window.addEventListener("DOMContentLoaded", async (event) => {
location: loc.join(","), location: loc.join(","),
}); });
imageElement.src = `/thumbnail/${globalStreamName}/source.png?${query}`; imageElement.src = `/thumbnail/${globalStreamName}/source.png?${query}`;
}
imageElement.classList.remove("hidden"); imageElement.classList.remove("hidden");
}); });
@ -870,6 +883,11 @@ function updateThumbnailInputState(event) {
unhideIDs.push("video-info-thumbnail-time-options"); unhideIDs.push("video-info-thumbnail-time-options");
unhideIDs.push("video-info-thumbnail-position-options"); unhideIDs.push("video-info-thumbnail-position-options");
unhideIDs.push("video-info-thumbnail-template-preview"); unhideIDs.push("video-info-thumbnail-template-preview");
} else if (newValue === "ONEOFF") {
unhideIDs.push("video-info-thumbnail-time-options");
unhideIDs.push("video-info-thumbnail-position-options");
unhideIDs.push("video-info-thumbnail-custom-options");
unhideIDs.push("video-info-thumbnail-template-preview");
} else if (newValue === "CUSTOM") { } else if (newValue === "CUSTOM") {
unhideIDs.push("video-info-thumbnail-custom-options"); unhideIDs.push("video-info-thumbnail-custom-options");
} }
@ -1148,7 +1166,7 @@ async function sendVideoData(newState, overrideChanges) {
videoDescription = videoDescription + CHAPTER_MARKER_DELIMITER + chapterTextList.join("\n"); videoDescription = videoDescription + CHAPTER_MARKER_DELIMITER + chapterTextList.join("\n");
} }
const thumbnailMode = document.getElementById("video-info-thumbnail-mode").value; let thumbnailMode = document.getElementById("video-info-thumbnail-mode").value;
let thumbnailTemplate = null; let thumbnailTemplate = null;
let thumbnailTime = null; let thumbnailTime = null;
let thumbnailImage = null; let thumbnailImage = null;
@ -1171,38 +1189,18 @@ async function sendVideoData(newState, overrideChanges) {
return; return;
} }
} }
if (thumbnailMode === "CUSTOM") { try {
const fileInput = document.getElementById("video-info-thumbnail-custom"); if (thumbnailMode === "ONEOFF") {
if (fileInput.files.length === 0) { thumbnailImage = await renderThumbnail();
if (!videoInfo.thumbnail_image) { thumbnailMode = "CUSTOM";
submissionError("A thumbnail file was not provided for the custom thumbnail");
return;
} }
thumbnailImage = videoInfo.thumbnail_image; if (thumbnailMode === "CUSTOM") {
} else { thumbnailImage = await uploadedImageToBase64();
const fileHandle = fileInput.files[0];
const fileReader = new FileReader();
let loadPromiseResolve;
const loadPromise = new Promise((resolve, _reject) => {
loadPromiseResolve = resolve;
});
fileReader.addEventListener("loadend", (event) => {
loadPromiseResolve();
});
fileReader.readAsDataURL(fileHandle);
await loadPromise;
const fileLoadData = fileReader.result;
if (fileLoadData.error) {
submissionError(`An error (${fileLoadData.error.name}) occurred loading the custom thumbnail: ${fileLoadData.error.message}`);
return;
} }
if (fileLoadData.substring(0, 22) !== "data:image/png;base64,") { } catch (e) {
submissionError("An error occurred converting the uploaded image to base64."); submissionError(`${e}`);
return; return;
} }
thumbnailImage = fileLoadData.substring(22);
}
}
const videoTitle = document.getElementById("video-info-title").value; const videoTitle = document.getElementById("video-info-title").value;
const videoTags = document.getElementById("video-info-tags").value.split(","); const videoTags = document.getElementById("video-info-tags").value.split(",");
@ -1397,6 +1395,82 @@ function generateDownloadURL(timeRanges, transitions, downloadType, allowHoles,
return downloadURL; return downloadURL;
} }
// Reads file data from the custom thumbnail upload input, and returns base64 string.
// Throws on error.
async function uploadedImageToBase64() {
const fileInput = document.getElementById("video-info-thumbnail-custom");
if (fileInput.files.length === 0) {
throw new Error("A file was not provided for the thumbnail");
}
const fileHandle = fileInput.files[0];
const fileReader = new FileReader();
let loadPromiseResolve;
const loadPromise = new Promise((resolve, _reject) => {
loadPromiseResolve = resolve;
});
fileReader.addEventListener("loadend", (event) => {
loadPromiseResolve();
});
fileReader.readAsDataURL(fileHandle);
await loadPromise;
const fileLoadData = fileReader.result;
if (fileLoadData.error) {
throw new Error(
`An error (${fileLoadData.error.name}) occurred loading the thumbnail: ${fileLoadData.error.message}`
);
}
if (fileLoadData.substring(0, 22) !== "data:image/png;base64,") {
throw new Error("An error occurred converting the uploaded image to base64.");
}
return fileLoadData.substring(22);
}
// Submits a thumbnail to restreamer to be rendered, and returns the result as base64.
// Throws on error.
async function renderThumbnail() {
const thumbnailTime = wubloaderTimeFromVideoHumanTime(
document.getElementById("video-info-thumbnail-time").value,
);
if (thumbnailTime === null) {
throw new Error("The thumbnail time is invalid");
}
const [thumbnailCrop, thumbnailLocation] = getTemplatePosition();
if (thumbnailCrop === null || thumbnailLocation === null) {
throw new Error("The thumbnail crop/location options are invalid");
}
const query = new URLSearchParams({
timestamp: thumbnailTime,
crop: thumbnailCrop.join(","),
location: thumbnailLocation.join(","),
});
const templateData = await uploadedImageToBase64();
// Client-side javascript makes it shockingly hard to correctly decode base64.
// See https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem
// The "cleanest" solution is to "fetch" the data URL containing base64 data.
const datares = await fetch(`data:application/octet-stream;base64,${templateData}`);
const body = new Uint8Array(await datares.arrayBuffer());
const res = await fetch(`/thumbnail/${globalStreamName}/source.png?${query}`, {
method: "POST",
body,
});
if (!res.ok) {
throw new Error(`Rendering thumbnail failed with ${res.status} ${res.statusText}`);
}
// Converting the result into base64 is similarly painful.
const blob = await res.blob();
const data = await new Promise(resolve => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
if (data.substring(0, 22) !== "data:image/png;base64,") {
throw new Error("An error occurred converting the uploaded image to base64.");
}
return data.substring(22);
}
function updateDownloadLink() { function updateDownloadLink() {
const downloadType = document.getElementById("download-type-select").value; const downloadType = document.getElementById("download-type-select").value;
const allowHoles = document.getElementById("advanced-submission-option-allow-holes").checked; const allowHoles = document.getElementById("advanced-submission-option-allow-holes").checked;

Loading…
Cancel
Save