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;