diff --git a/thrimbletrimmer/edit.html b/thrimbletrimmer/edit.html index 3916f3c..2d36c3a 100644 --- a/thrimbletrimmer/edit.html +++ b/thrimbletrimmer/edit.html @@ -5,7 +5,7 @@ VST Video Editor - + @@ -258,15 +258,17 @@
Advanced Templating Options - Crop specifies the region of the video frame to capture.
- Location specifies the region within the template image where the cropped image will be placed.
- Regions are given as pixel coordinates of the top-left and bottom-right corners.
- Note that if the regions are different sizes, the image will be stretched.
+ Crop specifies the region of the video frame to capture.
+ Location specifies the region within the template image where the cropped image will + be placed.
+ Regions are given as pixel coordinates of the top-left and bottom-right corners. +
+ Note that if the regions are different sizes, the image will be stretched.
-
+
@@ -274,23 +276,41 @@ id="video-info-thumbnail-template-video-source-image" class="hidden" alt="Thumbnail preview image" - height="360" width="640" + height="360" + width="640" /> -
+
Crop: - - + + to - - -
+ + +
-
{ } } - document.getElementById("range-definition-chapter-marker-first-description").addEventListener("input", (event) => { - validateChapterDescription(event.target); - }); + document + .getElementById("range-definition-chapter-marker-first-description") + .addEventListener("input", (event) => { + validateChapterDescription(event.target); + }); document.getElementById("video-info-title").addEventListener("input", (event) => { validateVideoTitle(); document.getElementById("video-info-title-abbreviated").innerText = @@ -269,7 +271,9 @@ window.addEventListener("DOMContentLoaded", async (event) => { document .getElementById("video-info-thumbnail-template-source-image-update") .addEventListener("click", async (_event) => { - const videoFrameImageElement = document.getElementById("video-info-thumbnail-template-video-source-image"); + const videoFrameImageElement = document.getElementById( + "video-info-thumbnail-template-video-source-image", + ); const timeEntryElement = document.getElementById("video-info-thumbnail-time"); const imageTime = wubloaderTimeFromVideoHumanTime(timeEntryElement.value); @@ -285,55 +289,84 @@ window.addEventListener("DOMContentLoaded", async (event) => { videoFrameImageElement.src = `/frame/${globalStreamName}/source.png?${videoFrameQuery}`; videoFrameImageElement.classList.remove("hidden"); - const templateImageElement = document.getElementById("video-info-thumbnail-template-overlay-image"); + const templateImageElement = document.getElementById( + "video-info-thumbnail-template-overlay-image", + ); templateImageElement.src = `/thrimshim/template/${imageTemplate}.png`; templateImageElement.classList.remove("hidden"); - const aspectRatioControls = document.getElementById("video-info-thumbnail-aspect-ratio-controls"); + const aspectRatioControls = document.getElementById( + "video-info-thumbnail-aspect-ratio-controls", + ); aspectRatioControls.classList.remove("hidden"); createTemplateCropWidgets(); }); - document.getElementById("video-info-thumbnail-crop-0").addEventListener("input", updateTemplateCropWidgets); - document.getElementById("video-info-thumbnail-crop-1").addEventListener("input", updateTemplateCropWidgets); - document.getElementById("video-info-thumbnail-crop-2").addEventListener("input", updateTemplateCropWidgets); - document.getElementById("video-info-thumbnail-crop-3").addEventListener("input", updateTemplateCropWidgets); - document.getElementById("video-info-thumbnail-location-0").addEventListener("input", updateTemplateCropWidgets); - document.getElementById("video-info-thumbnail-location-1").addEventListener("input", updateTemplateCropWidgets); - document.getElementById("video-info-thumbnail-location-2").addEventListener("input", updateTemplateCropWidgets); - document.getElementById("video-info-thumbnail-location-3").addEventListener("input", updateTemplateCropWidgets); - - document.getElementById("video-info-thumbnail-lock-aspect-ratio").addEventListener("change", updateTemplateCropAspectRatio); - - document.getElementById("video-info-thumbnail-aspect-ratio-match-right").addEventListener("click", function(){ - // Calculate and copy the aspect ratio from the video field to the template - const videoFieldX1 = document.getElementById("video-info-thumbnail-crop-0"); - const videoFieldY1 = document.getElementById("video-info-thumbnail-crop-1"); - const videoFieldX2 = document.getElementById("video-info-thumbnail-crop-2"); - const videoFieldY2 = document.getElementById("video-info-thumbnail-crop-3"); - const videoFieldAspectRatio = (videoFieldX2.value-videoFieldX1.value)/(videoFieldY2.value-videoFieldY1.value); - - templateStage.setOptions({aspectRatio: videoFieldAspectRatio}); - - // Re-apply the locked/unlocked status - updateTemplateCropAspectRatio(); - }); + document + .getElementById("video-info-thumbnail-crop-0") + .addEventListener("input", updateTemplateCropWidgets); + document + .getElementById("video-info-thumbnail-crop-1") + .addEventListener("input", updateTemplateCropWidgets); + document + .getElementById("video-info-thumbnail-crop-2") + .addEventListener("input", updateTemplateCropWidgets); + document + .getElementById("video-info-thumbnail-crop-3") + .addEventListener("input", updateTemplateCropWidgets); + document + .getElementById("video-info-thumbnail-location-0") + .addEventListener("input", updateTemplateCropWidgets); + document + .getElementById("video-info-thumbnail-location-1") + .addEventListener("input", updateTemplateCropWidgets); + document + .getElementById("video-info-thumbnail-location-2") + .addEventListener("input", updateTemplateCropWidgets); + document + .getElementById("video-info-thumbnail-location-3") + .addEventListener("input", updateTemplateCropWidgets); - document.getElementById("video-info-thumbnail-aspect-ratio-match-left").addEventListener("click", function(){ - // Calculate and copy the aspect ratio from the template to the video field - const templateFieldX1 = document.getElementById("video-info-thumbnail-location-0"); - const templateFieldY1 = document.getElementById("video-info-thumbnail-location-1"); - const templateFieldX2 = document.getElementById("video-info-thumbnail-location-2"); - const templateFieldY2 = document.getElementById("video-info-thumbnail-location-3"); - const templateFieldAspectRatio = (templateFieldX2.value-templateFieldX1.value)/(templateFieldY2.value-templateFieldY1.value); + document + .getElementById("video-info-thumbnail-lock-aspect-ratio") + .addEventListener("change", updateTemplateCropAspectRatio); - videoFrameStage.setOptions({aspectRatio: templateFieldAspectRatio}); + document + .getElementById("video-info-thumbnail-aspect-ratio-match-right") + .addEventListener("click", function () { + // Calculate and copy the aspect ratio from the video field to the template + const videoFieldX1 = document.getElementById("video-info-thumbnail-crop-0"); + const videoFieldY1 = document.getElementById("video-info-thumbnail-crop-1"); + const videoFieldX2 = document.getElementById("video-info-thumbnail-crop-2"); + const videoFieldY2 = document.getElementById("video-info-thumbnail-crop-3"); + const videoFieldAspectRatio = + (videoFieldX2.value - videoFieldX1.value) / (videoFieldY2.value - videoFieldY1.value); + + templateStage.setOptions({ aspectRatio: videoFieldAspectRatio }); + + // Re-apply the locked/unlocked status + updateTemplateCropAspectRatio(); + }); - // Re-apply the locked/unlocked status - updateTemplateCropAspectRatio(); - }); + document + .getElementById("video-info-thumbnail-aspect-ratio-match-left") + .addEventListener("click", function () { + // Calculate and copy the aspect ratio from the template to the video field + const templateFieldX1 = document.getElementById("video-info-thumbnail-location-0"); + const templateFieldY1 = document.getElementById("video-info-thumbnail-location-1"); + const templateFieldX2 = document.getElementById("video-info-thumbnail-location-2"); + const templateFieldY2 = document.getElementById("video-info-thumbnail-location-3"); + const templateFieldAspectRatio = + (templateFieldX2.value - templateFieldX1.value) / + (templateFieldY2.value - templateFieldY1.value); + + videoFrameStage.setOptions({ aspectRatio: templateFieldAspectRatio }); + + // Re-apply the locked/unlocked status + updateTemplateCropAspectRatio(); + }); document .getElementById("video-info-thumbnail-template-preview-generate") @@ -375,7 +408,7 @@ window.addEventListener("DOMContentLoaded", async (event) => { const thumbnailTemplatesListResponse = await fetch("/thrimshim/templates"); if (thumbnailTemplatesListResponse.ok) { const thumbnailTemplatesList = await thumbnailTemplatesListResponse.json(); - const templateNames = thumbnailTemplatesList.map(t => t.name); + const templateNames = thumbnailTemplatesList.map((t) => t.name); templateNames.sort(); for (const template of thumbnailTemplatesList) { thumbnailTemplates[template.name] = template; @@ -401,7 +434,8 @@ window.addEventListener("DOMContentLoaded", async (event) => { } if (videoInfo.thumbnail_location !== null) { for (let i = 0; i < 4; i++) { - document.getElementById(`video-info-thumbnail-location-${i}`).value = videoInfo.thumbnail_location[i]; + document.getElementById(`video-info-thumbnail-location-${i}`).value = + videoInfo.thumbnail_location[i]; } } document.getElementById("video-info-thumbnail-mode").value = videoInfo.thumbnail_mode; @@ -479,11 +513,12 @@ window.addEventListener("DOMContentLoaded", async (event) => { }); }); - async function loadTransitions() { const response = await fetch("/thrimshim/transitions"); if (!response.ok) { - addError("Failed to fetch possible transition types. This probably means the wubloader host is down."); + addError( + "Failed to fetch possible transition types. This probably means the wubloader host is down.", + ); return; } knownTransitions = await response.json(); @@ -776,8 +811,12 @@ async function initializeVideoInfo() { if (rangeIndex > 0) { const transition = videoInfo.video_transitions[rangeIndex - 1]; const transitionType = rangeContainer.getElementsByClassName("range-transition-type")[0]; - const transitionDuration = rangeContainer.getElementsByClassName("range-transition-duration")[0]; - const transitionDurationSection = rangeContainer.getElementsByClassName("range-transition-duration-section")[0]; + const transitionDuration = rangeContainer.getElementsByClassName( + "range-transition-duration", + )[0]; + const transitionDurationSection = rangeContainer.getElementsByClassName( + "range-transition-duration-section", + )[0]; if (transition === null) { transitionType.value = ""; transitionDuration.value = ""; @@ -796,7 +835,7 @@ async function initializeVideoInfo() { const option = document.createElement("option"); option.value = type; option.textContent = type; - transitionType.append(option) + transitionType.append(option); } // Set type and duration. transitionType.value = type; @@ -1062,11 +1101,13 @@ function validateChapterDescription(chapterDescField) { if (chapterDesc.indexOf("<") !== -1 || chapterDesc.indexOf(">") !== -1) { chapterDescField.classList.add("input-error"); chapterDescField.title = "Chapter description may not contain angle brackets (< or >)"; - } else if (Array.from(chapterDesc).some(c => c.charCodeAt(0) > 127)) { // any char is non-ascii + } else if (Array.from(chapterDesc).some((c) => c.charCodeAt(0) > 127)) { + // any char is non-ascii // We don't know what chars are safe outside the ascii range, so we just warn on any of them. // We know emoji are not safe. chapterDescField.classList.add("input-error"); - chapterDescField.title = "Chapter descriptions with non-ascii characters may cause issues; proceed with caution"; + chapterDescField.title = + "Chapter descriptions with non-ascii characters may cause issues; proceed with caution"; } else { chapterDescField.classList.remove("input-error"); chapterDescField.title = ""; @@ -1115,7 +1156,9 @@ async function sendVideoData(newState, overrideChanges) { const transitionTypeElements = rangeContainer.getElementsByClassName("range-transition-type"); if (transitionTypeElements.length > 0) { const transitionType = transitionTypeElements[0].value; - const transitionDurationStr = rangeContainer.getElementsByClassName("range-transition-duration")[0].value; + const transitionDurationStr = rangeContainer.getElementsByClassName( + "range-transition-duration", + )[0].value; if (transitionType === "") { transitions.push(null); } else { @@ -1123,11 +1166,13 @@ async function sendVideoData(newState, overrideChanges) { // but 0 is an error here anyway. // Note that !(x > 0) is not equivalent to (x <= 0) due to NaN. const transitionDuration = Number(transitionDurationStr); - if ( !(transitionDuration > 0) ) { - submissionError(`Couldn't submit edits: Invalid transition duration: "${transitionDurationStr}"`); + if (!(transitionDuration > 0)) { + submissionError( + `Couldn't submit edits: Invalid transition duration: "${transitionDurationStr}"`, + ); return; } - transitions.push([transitionType, transitionDuration]) + transitions.push([transitionType, transitionDuration]); // Since we're overlapping with the previous range, this range's start time is // actually earlier. This matters for chapter markers. rangeStartInFinalVideo -= transitionDuration; @@ -1186,7 +1231,9 @@ async function sendVideoData(newState, overrideChanges) { continue; } if (startFieldTime < rangeStartPlayer || startFieldTime > rangeEndPlayer) { - submissionError(`The chapter at "${startField.value}" is outside its containing time range.`); + submissionError( + `The chapter at "${startField.value}" is outside its containing time range.`, + ); return; } const chapterStartTime = rangeStartInFinalVideo + startFieldTime - rangeStartPlayer; @@ -1482,24 +1529,24 @@ async function uploadedImageToBase64() { const fileHandle = fileInput.files[0]; const fileReader = new FileReader(); let loadPromiseResolve; - const loadPromise = new Promise((resolve, _reject) => { + const loadPromise = new Promise((resolve, _reject) => { loadPromiseResolve = resolve; }); - fileReader.addEventListener("loadend", (event) => { + 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}` + `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); } @@ -1536,14 +1583,14 @@ async function renderThumbnail() { } // Converting the result into base64 is similarly painful. const blob = await res.blob(); - const data = await new Promise(resolve => { + 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); } @@ -1558,13 +1605,15 @@ function updateDownloadLink() { const transitionTypeElements = rangeContainer.getElementsByClassName("range-transition-type"); if (transitionTypeElements.length > 0) { const transitionType = transitionTypeElements[0].value; - const transitionDurationStr = rangeContainer.getElementsByClassName("range-transition-duration")[0].value; + const transitionDurationStr = rangeContainer.getElementsByClassName( + "range-transition-duration", + )[0].value; if (transitionType === "") { transitions.push(""); } else { let transitionDuration = Number(transitionDurationStr); // We don't have a sensible way to error out here, so default invalid durations to 1s - if ( !(transitionDuration > 0) ) { + if (!(transitionDuration > 0)) { transitionDuration = 1; } transitions.push(`${transitionType},${transitionDuration}`); @@ -1711,9 +1760,12 @@ function makeElement(tag, classes = [], values = {}) { function rangeDefinitionDOM() { // Shortcut builder for image-based buttons - const button = (cls, src, alt) => makeElement("img", [cls, "click"], { - src, alt, title: alt, - }); + const button = (cls, src, alt) => + makeElement("img", [cls, "click"], { + src, + alt, + title: alt, + }); const rangeContainer = makeElement("div", ["range-definition-removable"]); @@ -1731,7 +1783,10 @@ function rangeDefinitionDOM() { updateTransitionTypes([transitionType]); // Duration always starts hidden because type always starts as cut. - const transitionDurationSection = makeElement("div", ["range-transition-duration-section", "hidden"]); + const transitionDurationSection = makeElement("div", [ + "range-transition-duration-section", + "hidden", + ]); // Add/remove hidden when type changes transitionType.addEventListener("change", (event) => { if (transitionType.value === "") { @@ -1754,7 +1809,7 @@ function rangeDefinitionDOM() { transitionContainer.append("Transition: ", transitionType, transitionDurationSection); const rangeTimesContainer = makeElement("div", ["range-definition-times"]); - const rangeStart = makeElement("input", ["range-definition-start"], {type: "text"}); + const rangeStart = makeElement("input", ["range-definition-start"], { type: "text" }); const rangeStartSet = button( "range-definition-set-start", "images/pencil.png", @@ -1766,7 +1821,7 @@ function rangeDefinitionDOM() { "Play from start point", ); const rangeTimeGap = makeElement("div", ["range-definition-between-time-gap"]); - const rangeEnd = makeElement("input", ["range-definition-end"], {type: "text"}); + const rangeEnd = makeElement("input", ["range-definition-end"], { type: "text" }); const rangeEndSet = button( "range-definition-set-end", "images/pencil.png", @@ -1777,11 +1832,7 @@ function rangeDefinitionDOM() { "images/play_to.png", "Play from end point", ); - const removeRange = button( - "range-definition-remove", - "images/minus.png", - "Remove range", - ); + const removeRange = button("range-definition-remove", "images/minus.png", "Remove range"); if (canEditVideo()) { rangeStartSet.addEventListener("click", getRangeSetClickHandler("start")); @@ -2233,26 +2284,26 @@ function isNonVideoInput(element) { return element.id.startsWith("data-correction-force-reset"); } -/** +/** * Helper function to create the Jcrop widgets the first time the user chooses - * to load the advanced template cropping tool images in a given session. + * to load the advanced template cropping tool images in a given session. */ function createTemplateCropWidgets() { if (videoFrameStage == null) { - videoFrameStage = Jcrop.attach('video-info-thumbnail-template-video-source-image'); - videoFrameStage.listen('crop.update',function(widget,e){ + videoFrameStage = Jcrop.attach("video-info-thumbnail-template-video-source-image"); + videoFrameStage.listen("crop.update", function (widget, e) { const pos = widget.pos; const fieldX1 = document.getElementById("video-info-thumbnail-crop-0"); const fieldY1 = document.getElementById("video-info-thumbnail-crop-1"); const fieldX2 = document.getElementById("video-info-thumbnail-crop-2"); const fieldY2 = document.getElementById("video-info-thumbnail-crop-3"); // 640x320 -> 1920x1080 - fieldX1.value = Math.round(pos.x*3); - fieldY1.value = Math.round(pos.y*3); - fieldX2.value = Math.round((pos.x+pos.w)*3); - fieldY2.value = Math.round((pos.y+pos.h)*3); + fieldX1.value = Math.round(pos.x * 3); + fieldY1.value = Math.round(pos.y * 3); + fieldX2.value = Math.round((pos.x + pos.w) * 3); + fieldY2.value = Math.round((pos.y + pos.h) * 3); }); - videoFrameStage.listen('crop.change',function(widget,e){ + videoFrameStage.listen("crop.change", function (widget, e) { // This only fires when the user is finished dragging, not every time the size // of the cropped area updates. This avoids the template area updating every // instant due to minute changes in the aspect ratio, which causes it to shrink @@ -2261,18 +2312,18 @@ function createTemplateCropWidgets() { }); } if (templateStage == null) { - templateStage = Jcrop.attach('video-info-thumbnail-template-overlay-image'); - templateStage.listen('crop.update',function(widget,e){ + templateStage = Jcrop.attach("video-info-thumbnail-template-overlay-image"); + templateStage.listen("crop.update", function (widget, e) { const pos = widget.pos; const fieldX1 = document.getElementById("video-info-thumbnail-location-0"); const fieldY1 = document.getElementById("video-info-thumbnail-location-1"); const fieldX2 = document.getElementById("video-info-thumbnail-location-2"); const fieldY2 = document.getElementById("video-info-thumbnail-location-3"); // 640x320 -> 1280x720 - fieldX1.value = Math.round(pos.x*2); - fieldY1.value = Math.round(pos.y*2); - fieldX2.value = Math.round((pos.x+pos.w)*2); - fieldY2.value = Math.round((pos.y+pos.h)*2); + fieldX1.value = Math.round(pos.x * 2); + fieldY1.value = Math.round(pos.y * 2); + fieldX2.value = Math.round((pos.x + pos.w) * 2); + fieldY2.value = Math.round((pos.y + pos.h) * 2); }); } @@ -2290,7 +2341,12 @@ function updateTemplateCropWidgets() { const videoFieldX2 = document.getElementById("video-info-thumbnail-crop-2"); const videoFieldY2 = document.getElementById("video-info-thumbnail-crop-3"); // Video frame: 640x360 -> 1920x1080 - const videoFrameRect = Jcrop.Rect.create(videoFieldX1.value/3, videoFieldY1.value/3, (videoFieldX2.value-videoFieldX1.value)/3, (videoFieldY2.value-videoFieldY1.value)/3); + const videoFrameRect = Jcrop.Rect.create( + videoFieldX1.value / 3, + videoFieldY1.value / 3, + (videoFieldX2.value - videoFieldX1.value) / 3, + (videoFieldY2.value - videoFieldY1.value) / 3, + ); if (videoFrameStage.active == null) { videoFrameStage.newWidget(videoFrameRect); } else { @@ -2303,7 +2359,12 @@ function updateTemplateCropWidgets() { const templateFieldX2 = document.getElementById("video-info-thumbnail-location-2"); const templateFieldY2 = document.getElementById("video-info-thumbnail-location-3"); // Template: 640x360 -> 1280x720 - const templateRect = Jcrop.Rect.create(templateFieldX1.value/2, templateFieldY1.value/2, (templateFieldX2.value-templateFieldX1.value)/2, (templateFieldY2.value-templateFieldY1.value)/2); + const templateRect = Jcrop.Rect.create( + templateFieldX1.value / 2, + templateFieldY1.value / 2, + (templateFieldX2.value - templateFieldX1.value) / 2, + (templateFieldY2.value - templateFieldY1.value) / 2, + ); if (templateStage.active == null) { templateStage.newWidget(templateRect); } else { @@ -2321,11 +2382,12 @@ function updateTemplateCropAspectRatio() { const videoFieldY1 = document.getElementById("video-info-thumbnail-crop-1"); const videoFieldX2 = document.getElementById("video-info-thumbnail-crop-2"); const videoFieldY2 = document.getElementById("video-info-thumbnail-crop-3"); - const videoFieldAspectRatio = (videoFieldX2.value-videoFieldX1.value)/(videoFieldY2.value-videoFieldY1.value); - videoFrameStage.setOptions({aspectRatio: videoFieldAspectRatio}); - templateStage.setOptions({aspectRatio: videoFieldAspectRatio}); + const videoFieldAspectRatio = + (videoFieldX2.value - videoFieldX1.value) / (videoFieldY2.value - videoFieldY1.value); + videoFrameStage.setOptions({ aspectRatio: videoFieldAspectRatio }); + templateStage.setOptions({ aspectRatio: videoFieldAspectRatio }); } else { - videoFrameStage.setOptions({aspectRatio: null}); - templateStage.setOptions({aspectRatio: null}); + videoFrameStage.setOptions({ aspectRatio: null }); + templateStage.setOptions({ aspectRatio: null }); } } diff --git a/thrimbletrimmer/scripts/stream.js b/thrimbletrimmer/scripts/stream.js index e71e502..91c269b 100644 --- a/thrimbletrimmer/scripts/stream.js +++ b/thrimbletrimmer/scripts/stream.js @@ -137,7 +137,7 @@ function generateDownloadURL(startTime, endTime, downloadType, allowHoles, quali const query = new URLSearchParams({ type: downloadType, - allow_holes: allowHoles + allow_holes: allowHoles, }); if (startURLTime) { query.append("start", startURLTime);