From 2391a73ced99ea394cf8ccf2320b1dff74abc5d4 Mon Sep 17 00:00:00 2001 From: Mike Lang Date: Fri, 25 Oct 2024 07:35:47 +1100 Subject: [PATCH] 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. --- thrimbletrimmer/edit.html | 1 + thrimbletrimmer/scripts/edit.js | 168 +++++++++++++++++++++++--------- 2 files changed, 122 insertions(+), 47 deletions(-) diff --git a/thrimbletrimmer/edit.html b/thrimbletrimmer/edit.html index 66a2b6f..4166b18 100644 --- a/thrimbletrimmer/edit.html +++ b/thrimbletrimmer/edit.html @@ -231,6 +231,7 @@ + diff --git a/thrimbletrimmer/scripts/edit.js b/thrimbletrimmer/scripts/edit.js index 93ee0b7..528e09f 100644 --- a/thrimbletrimmer/scripts/edit.js +++ b/thrimbletrimmer/scripts/edit.js @@ -261,24 +261,37 @@ window.addEventListener("DOMContentLoaded", async (event) => { document .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 timeEntryElement = document.getElementById("video-info-thumbnail-time"); - const imageTime = wubloaderTimeFromVideoHumanTime(timeEntryElement.value); - if (imageTime === null) { - imageElement.classList.add("hidden"); - addError("Couldn't preview thumbnail; couldn't parse thumbnail frame timestamp"); - return; + 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 imageTime = wubloaderTimeFromVideoHumanTime(timeEntryElement.value); + if (imageTime === null) { + imageElement.classList.add("hidden"); + addError("Couldn't preview thumbnail; couldn't parse thumbnail frame timestamp"); + return; + } + const imageTemplate = document.getElementById("video-info-thumbnail-template").value; + const [crop, loc] = getTemplatePosition(); + const query = new URLSearchParams({ + timestamp: imageTime, + template: imageTemplate, + crop: crop.join(","), + location: loc.join(","), + }); + imageElement.src = `/thumbnail/${globalStreamName}/source.png?${query}`; } - const imageTemplate = document.getElementById("video-info-thumbnail-template").value; - const [crop, loc] = getTemplatePosition(); - const query = new URLSearchParams({ - timestamp: imageTime, - template: imageTemplate, - crop: crop.join(","), - location: loc.join(","), - }); - imageElement.src = `/thumbnail/${globalStreamName}/source.png?${query}`; imageElement.classList.remove("hidden"); }); @@ -870,6 +883,11 @@ function updateThumbnailInputState(event) { unhideIDs.push("video-info-thumbnail-time-options"); unhideIDs.push("video-info-thumbnail-position-options"); 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") { unhideIDs.push("video-info-thumbnail-custom-options"); } @@ -1148,7 +1166,7 @@ async function sendVideoData(newState, overrideChanges) { 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 thumbnailTime = null; let thumbnailImage = null; @@ -1171,37 +1189,17 @@ async function sendVideoData(newState, overrideChanges) { return; } } - if (thumbnailMode === "CUSTOM") { - const fileInput = document.getElementById("video-info-thumbnail-custom"); - if (fileInput.files.length === 0) { - if (!videoInfo.thumbnail_image) { - submissionError("A thumbnail file was not provided for the custom thumbnail"); - return; - } - thumbnailImage = videoInfo.thumbnail_image; - } else { - 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,") { - submissionError("An error occurred converting the uploaded image to base64."); - return; - } - thumbnailImage = fileLoadData.substring(22); + try { + if (thumbnailMode === "ONEOFF") { + thumbnailImage = await renderThumbnail(); + thumbnailMode = "CUSTOM"; + } + if (thumbnailMode === "CUSTOM") { + thumbnailImage = await uploadedImageToBase64(); } + } catch (e) { + submissionError(`${e}`); + return; } const videoTitle = document.getElementById("video-info-title").value; @@ -1397,6 +1395,82 @@ function generateDownloadURL(timeRanges, transitions, downloadType, allowHoles, 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() { const downloadType = document.getElementById("download-type-select").value; const allowHoles = document.getElementById("advanced-submission-option-allow-holes").checked;