mirror of https://github.com/ekimekim/wubloader
Implement video editing
parent
4d7300fefa
commit
e91654dfc1
@ -0,0 +1,169 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>VST Video Editor</title>
|
||||
|
||||
<link rel="stylesheet" href="styles/thrimbletrimmer.css" />
|
||||
<link rel="stylesheet" href="videojs/video-js.min.css" />
|
||||
|
||||
<script src="videojs/video.min.js"></script>
|
||||
<script src="scripts/common.js"></script>
|
||||
<script src="scripts/edit.js"></script>
|
||||
<script src="scripts/keyboard-shortcuts.js"></script>
|
||||
|
||||
<!-- TODO: Change client ID back to main wubloader one -->
|
||||
<meta name="google-signin-client_id" content="769576855778-phhnmkk2k8h6m1b1sb964ulrq0ulgesm.apps.googleusercontent.com">
|
||||
<script src="https://apis.google.com/js/platform.js?onload=onGLoad" async defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="errors"></div>
|
||||
<div id="stream-time-settings">
|
||||
<div>
|
||||
<span class="field-label">Stream</span>
|
||||
<span id="stream-time-setting-stream"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="field-label">Start Time</span>
|
||||
<span id="stream-time-setting-start"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="field-label">End Time</span>
|
||||
<span id="stream-time-setting-end"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<video id="video" class="video-js" controls preload="auto"></video>
|
||||
<div id="clip-bar"></div>
|
||||
<img id="waveform" alt="Waveform for the video">
|
||||
|
||||
<div id="editor-help">
|
||||
<a href="#" id="editor-help-link">Help</a>
|
||||
<div id="editor-help-box" class="hidden">
|
||||
<h2>Keyboard Shortcuts</h2>
|
||||
<ul>
|
||||
<li>Number keys (0-9): Jump to that 10% interval of the video (0% - 90%)</li>
|
||||
<li>K or Space: Toggle pause</li>
|
||||
<li>J: Back 10 seconds</li>
|
||||
<li>L: Forward 10 seconds</li>
|
||||
<li>Left arrow: Back 5 seconds</li>
|
||||
<li>Right arrow: Forward 5 seconds</li>
|
||||
<li>Comma (,): Back 1 frame</li>
|
||||
<li>Period (.): Forward 1 frame</li>
|
||||
<li>Equals (=): Increase playback speed one step</li>
|
||||
<li>Hyphen (-): Decrease playback speed one step</li>
|
||||
<li>Left bracket ([): Set start point for current range (indicated by arrow) to current video time</li>
|
||||
<li>Right bracket (]): Send end point for current range to current video time</li>
|
||||
<li>O: Switch the current range up one</li>
|
||||
<li>P: Switch the current range down one, adding a new range if the current range was the last one</li>
|
||||
</ul>
|
||||
<h2>Transition Types</h2>
|
||||
<p>A visual explanation of the different transition types can be found <a href="https://trac.ffmpeg.org/wiki/Xfade">on the FFMpeg website</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="range-definitions">
|
||||
<div class="range-definition-times">
|
||||
<input type="text" class="range-definition-start">
|
||||
<img src="images/pencil.png" alt="Set range start point to current video time" class="range-definition-set-start click">
|
||||
<div class="range-definition-between-time-gap"></div>
|
||||
<input type="text" class="range-definition-end">
|
||||
<img src="images/pencil.png" alt="Set range end point to current video time" class="range-definition-set-end click">
|
||||
<div class="range-definition-icon-gap"></div>
|
||||
<img src="images/arrow.png" alt="Range affected by keyboard shortcuts" title="Range affected by keyboard shortcuts" class="range-definition-current">
|
||||
</div>
|
||||
</div>
|
||||
<img src="images/plus.png" alt="Add range" id="add-range-definition" class="click" tabindex=0>
|
||||
|
||||
<div id="video-info">
|
||||
<div>
|
||||
<label for="video-info-title">Title:</label>
|
||||
<div id="video-info-title-full">
|
||||
<span id="video-info-title-prefix"></span>
|
||||
<input type="text" id="video-info-title">
|
||||
</div>
|
||||
</div>
|
||||
<div id="video-info-description-entry">
|
||||
<label for="video-info-description">Description:</label>
|
||||
<textarea id="video-info-description"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="video-info-tags">Tags (comma-separated):</label>
|
||||
<input type="text" id="video-info-tags">
|
||||
</div>
|
||||
<div id="video-info-editor-notes-container" class="hidden">
|
||||
<div id="video-info-editor-notes-header">Editor Notes:</div>
|
||||
<div id="video-info-editor-notes"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="submission">
|
||||
<div id="submission-toolbar">
|
||||
<button id="submit-button">Submit</button>
|
||||
<button id="save-button">Save Draft</button>
|
||||
<a id="advanced-submission" href="#">Advanced Submission Options</a>
|
||||
</div>
|
||||
<div id="advanced-submission-options" class="hidden">
|
||||
<div>
|
||||
<label for="advanced-submission-option-allow-holes">Allow holes</label>
|
||||
<input type="checkbox" id="advanced-submission-option-allow-holes">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="advanced-submission-option-upload-location">Upload location:</label>
|
||||
<select id="advanced-submission-option-upload-location"></select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="advanced-submission-option-uploader-allow">Uploader allowlist:</label>
|
||||
<input type="text" id="advanced-submission-option-uploader-allow">
|
||||
</div>
|
||||
</div>
|
||||
<div id="submission-response"></div>
|
||||
</div>
|
||||
|
||||
<div id="download">
|
||||
<label for="download-type-select">Download type:</label>
|
||||
<select id="download-type-select">
|
||||
<option value="rough" selected>Rough (fastest, pads start and end by a few seconds)</option>
|
||||
<option value="fast">Fast (may have artifacts a few seconds in from start and end)</option>
|
||||
<option value="mpegts">MPEG-TS (slow, consumes server resources)</option>
|
||||
</select>
|
||||
<a id="download-link">Download Video</a>
|
||||
</div>
|
||||
|
||||
<div id="data-correction">
|
||||
<div id="data-correction-toolbar">
|
||||
<a id="manual-link-update" href="#">Manual Link Update</a>
|
||||
|
|
||||
<a id="cancel-video-upload" href="#">Cancel Upload</a>
|
||||
|
|
||||
<a id="reset-entire-video" href="#">Force Reset Row</a>
|
||||
</div>
|
||||
<div id="data-correction-manual-link" class="hidden">
|
||||
<input type="text" id="data-correction-manual-link-entry">
|
||||
<label for="data-correction-manual-link-youtube">Is YouTube upload (add to playlists)?</label>
|
||||
<input type="checkbox" id="data-correction-manual-link-youtube">
|
||||
<button id="data-correction-manual-link-submit">Set Link</button>
|
||||
<div id="data-correction-manual-link-response"></div>
|
||||
</div>
|
||||
<div id="data-correction-force-reset-confirm" class="hidden">
|
||||
<p>Are you sure you want to reset this event?</p>
|
||||
<p>This will set the row back to Unedited and forget about any video that may already exist.</p>
|
||||
<p>This is intended as a last-ditch effort to clear a malfunctioning cutter, or if a video needs to be reedited and replaced.</p>
|
||||
<p><strong>It is your responsibility to deal with any video that may have already been uploaded.</strong></p>
|
||||
<p>
|
||||
<button id="data-correction-force-reset-yes">Yes, reset it!</button>
|
||||
<button id="data-correction-force-reset-no">Oh, never mind!</button>
|
||||
</p>
|
||||
</div>
|
||||
<div id="data-correction-cancel-response"></div>
|
||||
</div>
|
||||
|
||||
<div id="google-authentication">
|
||||
<!-- TODO: Test with authentication on and ensure failed video submissions for authentication show the correct error message -->
|
||||
<div id="google-auth-sign-in" class="g-signin2" data-onsuccess="googleOnSignIn"></div>
|
||||
<a href="#" id="google-auth-sign-out" class="hidden">Sign Out of Google Account</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
Binary file not shown.
After Width: | Height: | Size: 637 B |
Binary file not shown.
After Width: | Height: | Size: 4.7 KiB |
Binary file not shown.
After Width: | Height: | Size: 643 B |
Binary file not shown.
After Width: | Height: | Size: 4.7 KiB |
@ -0,0 +1,819 @@
|
||||
var googleUser = null;
|
||||
var videoInfo;
|
||||
var currentRange = 1;
|
||||
|
||||
window.addEventListener("DOMContentLoaded", async (event) => {
|
||||
commonPageSetup();
|
||||
await loadVideoInfo();
|
||||
|
||||
updateDownloadLink();
|
||||
|
||||
const addRangeIcon = document.getElementById("add-range-definition");
|
||||
addRangeIcon.addEventListener("click", (_event) => { addRangeDefinition(); });
|
||||
addRangeIcon.addEventListener("keypress", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
addRangeDefinition();
|
||||
}
|
||||
});
|
||||
|
||||
for (const rangeStartSet of document.getElementsByClassName("range-definition-set-start")) {
|
||||
rangeStartSet.addEventListener("click", getRangeSetClickHandler("start"));
|
||||
}
|
||||
for (const rangeEndSet of document.getElementsByClassName("range-definition-set-end")) {
|
||||
rangeEndSet.addEventListener("click", getRangeSetClickHandler("end"));
|
||||
}
|
||||
for (const rangeStart of document.getElementsByClassName("range-definition-start")) {
|
||||
rangeStart.addEventListener("change", (_event) => { rangeDataUpdated(); });
|
||||
}
|
||||
for (const rangeEnd of document.getElementsByClassName("range-definition-end")) {
|
||||
rangeEnd.addEventListener("change", (_event) => { rangeDataUpdated(); });
|
||||
}
|
||||
|
||||
document.getElementById("submit-button").addEventListener("click", (_event) => { submitVideo(); });
|
||||
document.getElementById("save-button").addEventListener("click", (_event) => { saveVideoDraft(); });
|
||||
|
||||
document.getElementById("advanced-submission").addEventListener("click", (_event) => {
|
||||
const advancedOptionsContainer = document.getElementById("advanced-submission-options");
|
||||
advancedOptionsContainer.classList.toggle("hidden");
|
||||
});
|
||||
|
||||
document.getElementById("advanced-submission-option-allow-holes").addEventListener("change", () => { updateDownloadLink(); });
|
||||
document.getElementById("download-type-select").addEventListener("change", () => { updateDownloadLink(); });
|
||||
|
||||
document.getElementById("manual-link-update").addEventListener("click", (_event) => {
|
||||
const manualLinkDataContainer = document.getElementById("data-correction-manual-link");
|
||||
manualLinkDataContainer.classList.toggle("hidden");
|
||||
});
|
||||
document.getElementById("data-correction-manual-link-submit").addEventListener("click", (_event) => { setManualVideoLink(); });
|
||||
|
||||
document.getElementById("cancel-video-upload").addEventListener("click", (_event) => { cancelVideoUpload(); });
|
||||
|
||||
document.getElementById("reset-entire-video").addEventListener("click", (_event) => {
|
||||
const forceResetConfirmationContainer = document.getElementById("data-correction-force-reset-confirm");
|
||||
forceResetConfirmationContainer.classList.remove("hidden");
|
||||
});
|
||||
document.getElementById("data-correction-force-reset-yes").addEventListener("click", (_event) => { resetVideoRow(); });
|
||||
document.getElementById("data-correction-force-reset-no").addEventListener("click", (_event) => {
|
||||
const forceResetConfirmationContainer = document.getElementById("data-correction-force-reset-confirm");
|
||||
forceResetConfirmationContainer.classList.add("hidden");
|
||||
});
|
||||
|
||||
document.getElementById("google-auth-sign-out").addEventListener("click", (_event) => { googleSignOut(); });
|
||||
});
|
||||
|
||||
async function loadVideoInfo() {
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
if (!queryParams.has("id")) {
|
||||
addError("No video ID specified. Failed to load video data.");
|
||||
return;
|
||||
}
|
||||
const videoID = queryParams.get("id");
|
||||
const dataResponse = await fetch("/thrimshim/" + videoID);
|
||||
if (!dataResponse.ok) {
|
||||
addError("Failed to load video data. This probably means that the URL is out of date (video ID changed) or that everything is broken (or that the Wubloader host is down).");
|
||||
return;
|
||||
}
|
||||
videoInfo = await dataResponse.json();
|
||||
initializeVideoInfo();
|
||||
}
|
||||
|
||||
async function initializeVideoInfo() {
|
||||
globalStreamName = videoInfo.video_channel;
|
||||
globalStartTimeString = videoInfo.event_start;
|
||||
globalEndTimeString = videoInfo.event_end;
|
||||
globalBusStartTime = new Date(videoInfo.bustime_start);
|
||||
|
||||
document.getElementById("stream-time-setting-stream").innerText = globalStreamName;
|
||||
document.getElementById("stream-time-setting-start").innerText = getBusTimeFromTimeString(globalStartTimeString);
|
||||
document.getElementById("stream-time-setting-end").innerText = getBusTimeFromTimeString(globalEndTimeString);
|
||||
|
||||
updateWaveform();
|
||||
|
||||
const titlePrefixElem = document.getElementById("video-info-title-prefix");
|
||||
titlePrefixElem.innerText = videoInfo.title_prefix;
|
||||
|
||||
const titleElem = document.getElementById("video-info-title");
|
||||
if (videoInfo.video_title) {
|
||||
titleElem.value = videoInfo.video_title;
|
||||
} else {
|
||||
titleElem.value = videoInfo.description;
|
||||
}
|
||||
|
||||
const descriptionElem = document.getElementById("video-info-description");
|
||||
if (videoInfo.video_description) {
|
||||
descriptionElem.value = videoInfo.video_description;
|
||||
} else {
|
||||
descriptionElem.value = videoInfo.description;
|
||||
}
|
||||
|
||||
const tagsElem = document.getElementById("video-info-tags");
|
||||
if (videoInfo.video_tags) {
|
||||
tagsElem.value = videoInfo.video_tags.join(",");
|
||||
} else {
|
||||
tagsElem.value = videoInfo.tags.join(",");
|
||||
}
|
||||
|
||||
if (videoInfo.notes) {
|
||||
const notesTextElem = document.getElementById("video-info-editor-notes");
|
||||
notesTextElem.innerText = videoInfo.notes;
|
||||
|
||||
const notesContainer = document.getElementById("video-info-editor-notes-container");
|
||||
notesContainer.classList.remove("hidden");
|
||||
}
|
||||
|
||||
let modifiedAdvancedOptions = false;
|
||||
if (videoInfo.allow_holes) {
|
||||
const allowHolesCheckbox = document.getElementById("advanced-submission-option-allow-holes");
|
||||
allowHolesCheckbox.checked = true;
|
||||
modifiedAdvancedOptions = true;
|
||||
}
|
||||
|
||||
const uploadLocationSelection = document.getElementById("advanced-submission-option-upload-location");
|
||||
for (locationName of videoInfo.upload_locations) {
|
||||
const option = document.createElement("option");
|
||||
option.value = locationName;
|
||||
option.innerText = locationName;
|
||||
if (videoInfo.upload_location === locationName) {
|
||||
option.selected = true;
|
||||
}
|
||||
uploadLocationSelection.appendChild(option);
|
||||
}
|
||||
if (uploadLocationSelection.options.selectedIndex > 0) {
|
||||
modifiedAdvancedOptions = true;
|
||||
}
|
||||
|
||||
if (videoInfo.uploader_whitelist) {
|
||||
modifiedAdvancedOptions = true;
|
||||
const uploaderAllowlistBox = document.getElementById("advanced-submission-option-uploader-allow");
|
||||
uploaderAllowlistBox.value = videoInfo.uploader_whitelist.join(",");
|
||||
}
|
||||
|
||||
if (modifiedAdvancedOptions) {
|
||||
const advancedSubmissionContainer = document.getElementById("advanced-submission-options");
|
||||
advancedSubmissionContainer.classList.remove("hidden");
|
||||
}
|
||||
|
||||
await loadVideoPlayerFromDefaultPlaylist();
|
||||
|
||||
const player = getVideoJS();
|
||||
player.on("loadedmetadata", () => {
|
||||
// For now, there's only one range in the data we receive from thrimshim, so we'll populate that as-is here.
|
||||
// This will need to be updated when thrimshim supports multiple video ranges.
|
||||
const rangeDefinitionsContainer = document.getElementById("range-definitions");
|
||||
const rangeDefinitionStart = rangeDefinitionsContainer.getElementsByClassName("range-definition-start")[0];
|
||||
const rangeDefinitionEnd = rangeDefinitionsContainer.getElementsByClassName("range-definition-end")[0];
|
||||
rangeDefinitionStart.addEventListener("change", (_event) => { rangeDataUpdated(); });
|
||||
rangeDefinitionEnd.addEventListener("change", (_event) => { rangeDataUpdated(); });
|
||||
if (videoInfo.video_start) {
|
||||
rangeDefinitionStart.value = videoHumanTimeFromWubloaderTime(videoInfo.video_start);
|
||||
}
|
||||
if (videoInfo.video_end) {
|
||||
rangeDefinitionEnd.value = videoHumanTimeFromWubloaderTime(videoInfo.video_end);
|
||||
}
|
||||
if (videoInfo.video_start && videoInfo.video_end) {
|
||||
rangeDataUpdated();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateWaveform() {
|
||||
let waveformURL = "/waveform/" + globalStreamName + "/" + videoInfo.video_quality + ".png?";
|
||||
|
||||
const queryStringParts = startAndEndTimeQueryStringParts();
|
||||
waveformURL += queryStringParts.join("&");
|
||||
|
||||
const waveformElem = document.getElementById("waveform");
|
||||
waveformElem.src = waveformURL;
|
||||
}
|
||||
|
||||
function googleOnSignIn(googleUserData) {
|
||||
googleUser = googleUserData;
|
||||
const signInElem = document.getElementById("google-auth-sign-in");
|
||||
const signOutElem = document.getElementById("google-auth-sign-out");
|
||||
signInElem.classList.add("hidden");
|
||||
signOutElem.classList.remove("hidden");
|
||||
}
|
||||
|
||||
async function googleSignOut() {
|
||||
if (googleUser) {
|
||||
googleUser = null;
|
||||
await gapi.auth2.getAuthInstance().signOut();
|
||||
const signInElem = document.getElementById("google-auth-sign-in");
|
||||
const signOutElem = document.getElementById("google-auth-sign-out");
|
||||
signInElem.classList.remove("hidden");
|
||||
signOutElem.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function getStartTime() {
|
||||
if (!globalStartTimeString) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(globalStartTimeString + "Z");
|
||||
// To account for various things (stream delay, just slightly off logging, etc.), we pad the start time by one minute
|
||||
date.setMinutes(date.getMinutes() - 1);
|
||||
return date;
|
||||
}
|
||||
|
||||
function getEndTime() {
|
||||
if (!globalEndTimeString) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(globalEndTimeString + "Z");
|
||||
// To account for various things (stream delay, just slightly off logging, etc.), we pad the end time by one minute.
|
||||
// To account for the fact that we don't record seconds, but the event could've ended any time in the recorded minute, we pad by an additional minute.
|
||||
date.setMinutes(date.getMinutes() + 2);
|
||||
return date;
|
||||
}
|
||||
|
||||
function getBusTimeFromTimeString(timeString) {
|
||||
const time = new Date(timeString + "Z");
|
||||
const busTimeMilliseconds = time - globalBusStartTime;
|
||||
let remainingBusTimeSeconds = busTimeMilliseconds / 1000;
|
||||
|
||||
const hours = Math.floor(remainingBusTimeSeconds / 3600);
|
||||
remainingBusTimeSeconds %= 3600;
|
||||
let minutes = Math.floor(remainingBusTimeSeconds / 60);
|
||||
let seconds = remainingBusTimeSeconds % 60;
|
||||
|
||||
while (minutes.toString().length < 2) {
|
||||
minutes = `0${minutes}`;
|
||||
}
|
||||
while (seconds.toString().length < 2) {
|
||||
seconds = `0${seconds}`;
|
||||
}
|
||||
|
||||
return `${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
async function submitVideo() {
|
||||
return sendVideoData(true, false);
|
||||
}
|
||||
|
||||
async function saveVideoDraft() {
|
||||
return sendVideoData(false, false);
|
||||
}
|
||||
|
||||
async function sendVideoData(edited, overrideChanges) {
|
||||
const submissionResponseElem = document.getElementById("submission-response");
|
||||
submissionResponseElem.classList.value = ["submission-response-pending"];
|
||||
submissionResponseElem.innerText = "Submitting video...";
|
||||
|
||||
const rangesData = [];
|
||||
for (const rangeContainer of document.getElementById("range-definitions").children) {
|
||||
const rangeStart = rangeContainer.getElementsByClassName("range-definition-start")[0].value;
|
||||
const rangeEnd = rangeContainer.getElementsByClassName("range-definition-end")[0].value;
|
||||
const rangeStartSubmit = wubloaderTimeFromVideoHumanTime(rangeStart);
|
||||
const rangeEndSubmit = wubloaderTimeFromVideoHumanTime(rangeEnd);
|
||||
|
||||
if (edited && (!rangeStartSubmit || !rangeEndSubmit)) {
|
||||
submissionResponseElem.classList.value = ["submission-response-error"];
|
||||
let errorMessage;
|
||||
if (!rangeStartSubmit && !rangeEndSubmit) {
|
||||
errorMessage = `The range endpoints "${rangeStartSubmit}" and "${rangeEndSubmit}" are not valid.`;
|
||||
} else if (!rangeStartSubmit) {
|
||||
errorMessage = `The range endpoint "${rangeStartSubmit} is not valid.`;
|
||||
} else {
|
||||
errorMessage = `The range endpoint "${rangeEndSubmit}" is not valid.`;
|
||||
}
|
||||
submissionResponseElem.innerText = errorMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
let transitionType = rangeContainer.getElementsByClassName("range-definition-transition-type");
|
||||
let transitionDuration = rangeContainer.getElementsByClassName("range-definition-transition-duration");
|
||||
if (transitionType.length > 0 && transitionDuration.length > 0) {
|
||||
transitionType = transitionType[0].value;
|
||||
transitionDuration = transitionDuration[0].value;
|
||||
|
||||
if (edited && transitionType !== "" && transitionDuration === "") {
|
||||
submissionResponseElem.classList.value = ["submission-response-error"];
|
||||
submissionResponseElem.innerText = "A non-cut transition was specified with no duration";
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
transitionType = null;
|
||||
transitionDuration = null;
|
||||
}
|
||||
|
||||
rangesData.push({
|
||||
start: rangeStartSubmit,
|
||||
end: rangeEndSubmit,
|
||||
transition: transitionType,
|
||||
duration: transitionDuration
|
||||
});
|
||||
}
|
||||
|
||||
// Currently this only supports one range. When multiple ranges are supported, expand this.
|
||||
const videoStart = rangesData[0].start;
|
||||
const videoEnd = rangesData[0].end;
|
||||
const videoTitle = document.getElementById("video-info-title").value;
|
||||
const videoDescription = document.getElementById("video-info-description").value;
|
||||
const videoTags = document.getElementById("video-info-tags").value.split(",");
|
||||
const allowHoles = document.getElementById("advanced-submission-option-allow-holes").checked;
|
||||
const uploadLocation = document.getElementById("advanced-submission-option-upload-location").value;
|
||||
const uploaderAllowlistValue = document.getElementById("advanced-submission-option-uploader-allow").value;
|
||||
const uploaderAllowlist = (uploaderAllowlistValue) ? uploaderAllowlistValue.split(",") : null;
|
||||
const state = (edited) ? "EDITED" : "UNEDITED";
|
||||
|
||||
const editData = {
|
||||
video_start: videoStart,
|
||||
video_end: videoEnd,
|
||||
video_title: videoTitle,
|
||||
video_description: videoDescription,
|
||||
video_tags: videoTags,
|
||||
allow_holes: allowHoles,
|
||||
upload_location: uploadLocation,
|
||||
video_channel: globalStreamName,
|
||||
video_quality: videoInfo.video_quality,
|
||||
uploader_whitelist: uploaderAllowlist,
|
||||
state: state,
|
||||
|
||||
// We also provide some sheet column values to verify data hasn't changed.
|
||||
sheet_name: videoInfo.sheet_name,
|
||||
event_start: videoInfo.event_start,
|
||||
event_end: videoInfo.event_end,
|
||||
category: videoInfo.category,
|
||||
description: videoInfo.description,
|
||||
notes: videoInfo.notes,
|
||||
tags: videoInfo.tags
|
||||
};
|
||||
if (googleUser) {
|
||||
editData.token = googleUser.getAuthResponse().id_token;
|
||||
}
|
||||
if (overrideChanges) {
|
||||
editData.overrideChanges = true;
|
||||
}
|
||||
|
||||
const submitResponse = await fetch(`/thrimshim/${videoInfo.id}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(editData)
|
||||
});
|
||||
|
||||
if (submitResponse.ok) {
|
||||
submissionResponseElem.classList.value = ["submission-response-success"];
|
||||
if (edited) {
|
||||
submissionResponseElem.innerText = `Submitted edit from ${videoStart} to ${videoEnd}`;
|
||||
} else {
|
||||
submissionResponseElem.innerText = "Saved draft";
|
||||
}
|
||||
} else {
|
||||
submissionResponseElem.classList.value = ["submission-response-error"];
|
||||
if (submitResponse.status === 409) {
|
||||
const serverErrorNode = document.createTextNode(await submitResponse.text());
|
||||
const submitButton = document.createElement("button");
|
||||
submitButton.text = "Submit Anyway";
|
||||
submitButton.addEventListener("click", (_event) => { sendVideoData(edited, true); });
|
||||
submissionResponseElem.innerHTML = "";
|
||||
submissionResponseElem.appendChild(serverErrorNode);
|
||||
submissionResponseElem.appendChild(submitButton);
|
||||
} else {
|
||||
submissionResponseElem.innerText = `${submitResponse.statusText}: ${await submitResponse.text()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateDownloadLink() {
|
||||
// Currently this only supports one range. When download links can download multiple ranges, this should be updated.
|
||||
const firstRangeStartField = document.getElementsByClassName("range-definition-start")[0];
|
||||
const firstRangeEndField = document.getElementsByClassName("range-definition-end")[0];
|
||||
|
||||
const startTime = (firstRangeStartField.value) ? wubloaderTimeFromVideoHumanTime(firstRangeStartField.value) : getStartTime();
|
||||
const endTime = (firstRangeEndField.value) ? wubloaderTimeFromVideoHumanTime(firstRangeEndField.value) : getEndTime();
|
||||
|
||||
const downloadType = document.getElementById("download-type-select").value;
|
||||
const allowHoles = document.getElementById("advanced-submission-option-allow-holes").checked;
|
||||
|
||||
const downloadURL = generateDownloadURL(startTime, endTime, downloadType, allowHoles, videoInfo.video_quality);
|
||||
document.getElementById("download-link").href = downloadURL;
|
||||
}
|
||||
|
||||
async function setManualVideoLink() {
|
||||
let uploadLocation;
|
||||
if (document.getElementById("data-correction-manual-link-youtube").checked) {
|
||||
uploadLocation = "youtube-manual";
|
||||
} else {
|
||||
uploadLocation = "manual";
|
||||
}
|
||||
|
||||
const link = document.getElementById("data-correction-manual-link-entry").value;
|
||||
|
||||
const request = {
|
||||
link: link,
|
||||
upload_location: uploadLocation
|
||||
};
|
||||
if (googleUser) {
|
||||
request.token = googleUser.getAuthResponse().id_token;
|
||||
}
|
||||
|
||||
const responseElem = document.getElementById("data-correction-manual-link-response");
|
||||
responseElem.innerText = "Submitting link...";
|
||||
|
||||
const response = await fetch(`/thrimshim/manual-link/${videoInfo.id}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
responseElem.innerText = `Manual link set to ${link}`;
|
||||
} else {
|
||||
responseElem.innerText = `${response.statusText}: ${await response.text()}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelVideoUpload() {
|
||||
const request = {};
|
||||
if (googleUser) {
|
||||
request.token = googleUser.getAuthResponse().id_token;
|
||||
}
|
||||
|
||||
const responseElem = document.getElementById("data-correction-cancel-response");
|
||||
responseElem.innerText = "Submitting cancel request...";
|
||||
|
||||
const response = await fetch(`/thrimshim/reset/${videoInfo.id}?force=false`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
responseElem.innerText = "Row has been cancelled. Reloading...";
|
||||
setTimeout(() => { window.location.reload(); }, 1000);
|
||||
} else {
|
||||
responseElem.innerText = `${response.statusText}: ${await response.text()}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetVideoRow() {
|
||||
const request = {};
|
||||
if (googleUser) {
|
||||
request.token = googleUser.getAuthResponse().id_token;
|
||||
}
|
||||
|
||||
const responseElem = document.getElementById("data-correction-cancel-response");
|
||||
responseElem.innerText = "Submitting reset request...";
|
||||
|
||||
const response = await fetch(`/thrimshim/reset/${videoInfo.id}?force=true`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
responseElem.innerText = "Row has been reset. Reloading...";
|
||||
setTimeout(() => { window.location.reload(); }, 1000);
|
||||
} else {
|
||||
responseElem.innerText = `${response.statusText}: ${await response.text()}`;
|
||||
}
|
||||
}
|
||||
|
||||
function addRangeDefinition() {
|
||||
const newRangeDOM = rangeDefinitionDOM();
|
||||
const rangeContainer = document.getElementById("range-definitions");
|
||||
rangeContainer.appendChild(newRangeDOM);
|
||||
}
|
||||
|
||||
const RANGE_TRANSITION_TYPES = [
|
||||
"fade",
|
||||
"wipeleft",
|
||||
"wiperight",
|
||||
"wipeup",
|
||||
"wipedown",
|
||||
"slideleft",
|
||||
"slideright",
|
||||
"slideup",
|
||||
"slidedown",
|
||||
"circlecrop",
|
||||
"rectcrop",
|
||||
"distance",
|
||||
"fadeblack",
|
||||
"fadewhite",
|
||||
"radial",
|
||||
"smoothleft",
|
||||
"smoothright",
|
||||
"smoothup",
|
||||
"smoothdown",
|
||||
"circleopen",
|
||||
"circleclose",
|
||||
"vertopen",
|
||||
"vertclose",
|
||||
"horzopen",
|
||||
"horzclose",
|
||||
"dissolve",
|
||||
"pixelize",
|
||||
"diagtl",
|
||||
"diagtr",
|
||||
"diagbl",
|
||||
"diagbr",
|
||||
"hlslice",
|
||||
"hrslice",
|
||||
"vuslice",
|
||||
"vdslice",
|
||||
"hblur",
|
||||
"fadegrays",
|
||||
"wipetl",
|
||||
"wipetr",
|
||||
"wipebl",
|
||||
"wipebr",
|
||||
"squeezeh",
|
||||
"squeezev"
|
||||
];
|
||||
|
||||
function rangeDefinitionDOM() {
|
||||
const container = document.createElement("div");
|
||||
container.classList.add("range-definition-removable");
|
||||
|
||||
const transitionContainer = document.createElement("div");
|
||||
transitionContainer.classList.add("range-definition-transition");
|
||||
|
||||
const transitionSelection = document.createElement("select");
|
||||
transitionSelection.classList.add("range-definition-transition-type");
|
||||
const noTransitionOption = document.createElement("option");
|
||||
noTransitionOption.value = "";
|
||||
noTransitionOption.innerText = "No transition (hard cut)";
|
||||
transitionSelection.appendChild(noTransitionOption);
|
||||
for (transitionType of RANGE_TRANSITION_TYPES) {
|
||||
const transitionOption = document.createElement("option");
|
||||
transitionOption.value = transitionType;
|
||||
transitionOption.innerText = transitionType;
|
||||
transitionSelection.appendChild(transitionOption);
|
||||
}
|
||||
transitionSelection.addEventListener("change", (_event) => { rangeDataUpdated(); });
|
||||
transitionContainer.appendChild(transitionSelection);
|
||||
|
||||
const transitionDurationInput = document.createElement("input");
|
||||
transitionDurationInput.type = "number";
|
||||
transitionDurationInput.min = 0;
|
||||
transitionDurationInput.step = "any";
|
||||
transitionDurationInput.placeholder = "Duration (seconds)";
|
||||
transitionDurationInput.classList.add("range-definition-transition-duration");
|
||||
transitionContainer.appendChild(transitionDurationInput);
|
||||
|
||||
container.appendChild(transitionContainer);
|
||||
|
||||
const rangeContainer = document.createElement("div");
|
||||
rangeContainer.classList.add("range-definition-times");
|
||||
const rangeStart = document.createElement("input");
|
||||
rangeStart.type = "text";
|
||||
rangeStart.classList.add("range-definition-start");
|
||||
const rangeStartSet = document.createElement("img");
|
||||
rangeStartSet.src = "images/pencil.png";
|
||||
rangeStartSet.alt = "Set range start point to current video time"
|
||||
rangeStartSet.classList.add("range-definition-set-start");
|
||||
rangeStartSet.classList.add("click");
|
||||
const rangeEnd = document.createElement("input");
|
||||
rangeEnd.type = "text";
|
||||
rangeEnd.classList.add("range-definition-end");
|
||||
const rangeEndSet = document.createElement("img");
|
||||
rangeEndSet.src = "images/pencil.png";
|
||||
rangeEndSet.alt = "Set range end point to current video time";
|
||||
rangeEndSet.classList.add("range-definition-set-end");
|
||||
rangeEndSet.classList.add("click");
|
||||
const removeRange = document.createElement("img");
|
||||
removeRange.alt = "Remove range";
|
||||
removeRange.src = "images/minus.png";
|
||||
removeRange.classList.add("range-definition-remove");
|
||||
removeRange.classList.add("click");
|
||||
|
||||
rangeStartSet.addEventListener("click", getRangeSetClickHandler("start"));
|
||||
|
||||
rangeEndSet.addEventListener("click", getRangeSetClickHandler("end"));
|
||||
|
||||
removeRange.addEventListener("click", (event) => {
|
||||
let rangeContainer = event.currentTarget;
|
||||
while (rangeContainer && !rangeContainer.classList.contains("range-definition-removable")) {
|
||||
rangeContainer = rangeContainer.parentElement;
|
||||
}
|
||||
if (rangeContainer) {
|
||||
const rangeParent = rangeContainer.parentNode;
|
||||
for (let rangeNum = 0; rangeNum < rangeParent.children.length; rangeNum++) {
|
||||
if (rangeContainer === rangeParent.children[rangeNum]) {
|
||||
if (rangeNum + 1 <= currentRange) { // currentRange is 1-indexed to index into DOM with querySelector
|
||||
currentRange--;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
rangeParent.removeChild(rangeContainer);
|
||||
updateCurrentRangeIndicator();
|
||||
rangeDataUpdated();
|
||||
}
|
||||
});
|
||||
|
||||
const currentRangeMarker = document.createElement("img");
|
||||
currentRangeMarker.alt = "Range affected by keyboard shortcuts";
|
||||
currentRangeMarker.title = "Range affected by keyboard shortcuts";
|
||||
currentRangeMarker.src = "images/arrow.png";
|
||||
currentRangeMarker.classList.add("range-definition-current");
|
||||
currentRangeMarker.classList.add("hidden");
|
||||
|
||||
rangeContainer.appendChild(rangeStart);
|
||||
rangeContainer.appendChild(rangeStartSet);
|
||||
rangeContainer.appendChild(rangeEnd);
|
||||
rangeContainer.appendChild(rangeEndSet);
|
||||
rangeContainer.appendChild(removeRange);
|
||||
rangeContainer.appendChild(currentRangeMarker);
|
||||
|
||||
container.appendChild(rangeContainer);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
function getRangeSetClickHandler(startOrEnd) {
|
||||
return function(event) {
|
||||
const setButton = event.currentTarget;
|
||||
const setField = setButton.parentElement.getElementsByClassName(`range-definition-${startOrEnd}`)[0];
|
||||
|
||||
const player = getVideoJS();
|
||||
const videoPlayerTime = player.currentTime();
|
||||
|
||||
setField.value = videoHumanTimeFromVideoPlayerTime(videoPlayerTime);
|
||||
};
|
||||
}
|
||||
|
||||
function moveToNextRange() {
|
||||
currentRange++;
|
||||
if (currentRange > document.getElementById("range-definitions").children.length) {
|
||||
addRangeDefinition();
|
||||
}
|
||||
updateCurrentRangeIndicator();
|
||||
}
|
||||
|
||||
function moveToPreviousRange() {
|
||||
if (currentRange <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentRange--;
|
||||
updateCurrentRangeIndicator();
|
||||
}
|
||||
|
||||
function updateCurrentRangeIndicator() {
|
||||
for (let arrowElem of document.getElementsByClassName("range-definition-current")) {
|
||||
arrowElem.classList.add("hidden");
|
||||
}
|
||||
document.querySelector(`#range-definitions > div:nth-child(${currentRange}) .range-definition-current`).classList.remove("hidden");
|
||||
}
|
||||
|
||||
function rangeDataUpdated() {
|
||||
const clipBar = document.getElementById("clip-bar");
|
||||
clipBar.innerHTML = "";
|
||||
|
||||
const player = getVideoJS();
|
||||
const videoDuration = player.duration();
|
||||
|
||||
for (let rangeDefinition of document.getElementById("range-definitions").children) {
|
||||
const rangeStartField = rangeDefinition.getElementsByClassName("range-definition-start")[0];
|
||||
const rangeEndField = rangeDefinition.getElementsByClassName("range-definition-end")[0];
|
||||
const rangeStart = videoPlayerTimeFromVideoHumanTime(rangeStartField.value);
|
||||
const rangeEnd = videoPlayerTimeFromVideoHumanTime(rangeEndField.value);
|
||||
|
||||
if (rangeStart === null || rangeEnd === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rangeStartPercentage = rangeStart / videoDuration * 100;
|
||||
const rangeEndPercentage = rangeEnd / videoDuration * 100;
|
||||
const widthPercentage = rangeEndPercentage - rangeStartPercentage;
|
||||
|
||||
const marker = document.createElement("div");
|
||||
marker.style.width = `${widthPercentage}%`;
|
||||
marker.style.left = `${rangeStartPercentage}%`;
|
||||
clipBar.appendChild(marker);
|
||||
}
|
||||
updateDownloadLink();
|
||||
}
|
||||
|
||||
function setCurrentRangeStartToVideoTime() {
|
||||
const rangeStartField = document.querySelector(`#range-definitions > div:nth-child(${currentRange}) .range-definition-start`);
|
||||
const player = getVideoJS();
|
||||
rangeStartField.value = videoHumanTimeFromVideoPlayerTime(player.currentTime());
|
||||
rangeDataUpdated();
|
||||
}
|
||||
|
||||
function setCurrentRangeEndToVideoTime() {
|
||||
const rangeEndField = document.querySelector(`#range-definitions > div:nth-child(${currentRange}) .range-definition-end`);
|
||||
const player = getVideoJS();
|
||||
rangeEndField.value = videoHumanTimeFromVideoPlayerTime(player.currentTime());
|
||||
rangeDataUpdated();
|
||||
}
|
||||
|
||||
function videoPlayerTimeFromWubloaderTime(wubloaderTime) {
|
||||
const videoPlaylist = getPlaylistData();
|
||||
const wubloaderDateObj = new Date(`${wubloaderTime}Z`);
|
||||
let highestDiscontinuitySegmentBefore = 0;
|
||||
for (start of videoPlaylist.discontinuityStarts) {
|
||||
const discontinuityStartSegment = videoPlaylist.segments[start];
|
||||
if (discontinuityStartSegment.dateTimeObject < wubloaderDateObj && discontinuityStartSegment.dateTimeObject > videoPlaylist.segments[highestDiscontinuitySegmentBefore].dateTimeObject) {
|
||||
highestDiscontinuitySegmentBefore = start;
|
||||
}
|
||||
}
|
||||
|
||||
let highestDiscontinuitySegmentStart = 0;
|
||||
for (let segment = 0; segment < highestDiscontinuitySegmentBefore; segment++) {
|
||||
highestDiscontinuitySegmentStart += videoPlaylist.segments[segment].duration;
|
||||
}
|
||||
return highestDiscontinuitySegmentStart + secondsDifference(videoPlaylist.segments[highestDiscontinuitySegmentBefore].dateTimeObject, wubloaderDateObj);
|
||||
}
|
||||
|
||||
function wubloaderTimeFromVideoPlayerTime(videoPlayerTime) {
|
||||
const videoPlaylist = getPlaylistData();
|
||||
let segmentStartTime = 0;
|
||||
let segmentDateObj;
|
||||
// Segments have start and end video player times on them, but only if the segments are already loaded.
|
||||
// This is not the case before the video is loaded for the first time, or outside the video's buffer if it hasn't played that far/part.
|
||||
for (segment of videoPlaylist.segments) {
|
||||
const segmentEndTime = segmentStartTime + segment.duration;
|
||||
if (segmentStartTime <= videoPlayerTime && segmentEndTime > videoPlayerTime) {
|
||||
segmentDateObj = segment.dateTimeObject;
|
||||
break;
|
||||
}
|
||||
segmentStartTime = segmentEndTime;
|
||||
}
|
||||
if (segmentDateObj === undefined) {
|
||||
return null;
|
||||
}
|
||||
let wubloaderDateObj = new Date(segmentDateObj);
|
||||
const offset = videoPlayerTime - segmentStartTime;
|
||||
const offsetMilliseconds = offset * 1000;
|
||||
wubloaderDateObj.setMilliseconds(wubloaderDateObj.getMilliseconds() + offsetMilliseconds);
|
||||
return wubloaderDateObj;
|
||||
}
|
||||
|
||||
function videoHumanTimeFromVideoPlayerTime(videoPlayerTime) {
|
||||
const minutes = Math.floor(videoPlayerTime / 60);
|
||||
let seconds = Math.floor(videoPlayerTime % 60);
|
||||
let milliseconds = Math.floor((videoPlayerTime * 1000) % 1000);
|
||||
|
||||
while (seconds.toString().length < 2) {
|
||||
seconds = `0${seconds}`;
|
||||
}
|
||||
while (milliseconds.toString().length < 3) {
|
||||
milliseconds = `0${milliseconds}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${seconds}.${milliseconds}`;
|
||||
}
|
||||
|
||||
function videoPlayerTimeFromVideoHumanTime(videoHumanTime) {
|
||||
let timeParts = videoHumanTime.split(":", 2);
|
||||
let minutes;
|
||||
let seconds;
|
||||
|
||||
if (timeParts.length < 2) {
|
||||
minutes = 0;
|
||||
seconds = +timeParts[0];
|
||||
} else {
|
||||
minutes = parseInt(timeParts[0]);
|
||||
seconds = +timeParts[1];
|
||||
}
|
||||
if (isNaN(minutes) || isNaN(seconds)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return minutes * 60 + seconds;
|
||||
}
|
||||
|
||||
function videoHumanTimeFromWubloaderTime(wubloaderTime) {
|
||||
const videoPlayerTime = videoPlayerTimeFromWubloaderTime(wubloaderTime);
|
||||
return videoHumanTimeFromVideoPlayerTime(videoPlayerTime);
|
||||
}
|
||||
|
||||
function wubloaderTimeFromVideoHumanTime(videoHumanTime) {
|
||||
const videoPlayerTime = videoPlayerTimeFromVideoHumanTime(videoHumanTime);
|
||||
if (videoPlayerTime === null) {
|
||||
return null;
|
||||
}
|
||||
return wubloaderTimeFromVideoPlayerTime(videoPlayerTime);
|
||||
}
|
||||
|
||||
function getPlaylistData() {
|
||||
const player = getVideoJS();
|
||||
// Currently, this only supports a single playlist. We only give one playlist (or master playlist file) to VideoJS,
|
||||
// so this should be fine for now. If we need to support multiple playlists in the future (varying quality levels,
|
||||
// etc.), this and all callers will need to be updated.
|
||||
return player.tech("OK").vhs.playlists.master.playlists[0];
|
||||
}
|
||||
|
||||
function secondsDifference(date1, date2) {
|
||||
if (date2 > date1) {
|
||||
return (date2 - date1) / 1000;
|
||||
}
|
||||
return (date1 - date2) / 1000;
|
||||
}
|
Loading…
Reference in New Issue