Implement video editing

pull/235/head
ElementalAlchemist 3 years ago committed by Mike Lang
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

@ -44,9 +44,9 @@
<video id="video" class="video-js" controls preload="auto"></video>
<div id="keyboard-help">
<a href="#" id="keyboard-help-link">Help</a>
<div id="keyboard-help-box" class="hidden">
<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>

@ -5,14 +5,26 @@ var globalEndTimeString = 0;
const VIDEO_FRAMES_PER_SECOND = 30;
const TIME_FRAME_UTC = 1;
const TIME_FRAME_BUS = 2;
const TIME_FRAME_AGO = 3;
const PLAYBACK_RATES = [0.5, 1, 1.25, 1.5, 2];
function commonPageSetup() {
const helpLink = document.getElementById("editor-help-link");
helpLink.addEventListener("click", toggleHelpDisplay);
}
function toggleHelpDisplay() {
const helpBox = document.getElementById("editor-help-box");
if (helpBox.classList.contains("hidden")) {
const helpLink = document.getElementById("editor-help-link");
helpBox.style.top = `${helpLink.offsetTop + helpLink.offsetHeight}px`;
helpBox.classList.remove("hidden");
} else {
helpBox.classList.add("hidden");
}
}
function getVideoJS() {
return videojs("video");
return videojs.getPlayer("video");
}
function addError(errorText) {
@ -32,12 +44,12 @@ function addError(errorText) {
errorHost.appendChild(errorElement);
}
function loadVideoPlayer(playlistURL) {
async function loadVideoPlayer(playlistURL) {
let rangedPlaylistURL = assembleVideoPlaylistURL(playlistURL);
let defaultOptions = {
sources: [{ src: rangedPlaylistURL }],
liveui: true,
liveui: false,
controls: true,
autoplay: false,
playbackRates: PLAYBACK_RATES,
@ -51,23 +63,25 @@ function loadVideoPlayer(playlistURL) {
};
const player = videojs("video", defaultOptions);
player.ready(() => {
player.volume(0.5); // Initialize to half volume
return new Promise((resolve, reject) => {
player.ready(() => {
player.volume(0.5); // Initialize to half volume
resolve();
});
});
}
async function loadVideoPlayerFromDefaultPlaylist() {
const playlistURL = `/playlist/${globalStreamName}.m3u8`;
await loadVideoPlayer(playlistURL);
}
function updateVideoPlayer(newPlaylistURL) {
let rangedPlaylistURL = assembleVideoPlaylistURL(newPlaylistURL);
const player = getVideoJS();
player.src({ src: rangedPlaylistURL });
}
function updateStoredTimeSettings() {
globalStreamName = document.getElementById("stream-time-setting-stream").value;
globalStartTimeString = document.getElementById("stream-time-setting-start").value;
globalEndTimeString = document.getElementById("stream-time-setting-end").value;
}
function parseInputTimeAsNumberOfSeconds(inputTime) {
// We need to handle inputs like "-0:10:15" in a way that consistently makes the time negative.
// Since we can't assign the negative sign to any particular part, we'll check for the whole thing here.
@ -81,77 +95,50 @@ function parseInputTimeAsNumberOfSeconds(inputTime) {
return (parseInt(parts[0]) + (parts[1] || 0) / 60 + (parts[2] || 0) / 3600) * 60 * 60 * direction;
}
function getSelectedTimeConversion() {
const radioSelection = document.querySelectorAll("#stream-time-frame-of-reference > input");
for (radioItem of radioSelection) {
if (radioItem.checked) {
return +radioItem.value;
}
}
// This selection shouldn't ever become fully unchecked. We'll return the bus time by default
// if it does because why not?
return TIME_FRAME_BUS;
}
// Gets the start time of the video from settings. Returns an invalid date object if the user entered bad data.
function getStartTime() {
switch (getSelectedTimeConversion()) {
case 1:
return new Date(globalStartTimeString + "Z");
case 2:
return new Date(globalBusStartTime.getTime() + (1000 * parseInputTimeAsNumberOfSeconds(globalStartTimeString)));
case 3:
return new Date(new Date().getTime() - (1000 * parseInputTimeAsNumberOfSeconds(globalStartTimeString)));
}
}
// Gets the end time of the video from settings. Returns null if there's no end time. Returns an invalid date object if the user entered bad data.
function getEndTime() {
if (globalEndTimeString === "") {
function getWubloaderTimeFromDate(date) {
if (!date) {
return null;
}
switch (getSelectedTimeConversion()) {
case 1:
return new Date(globalEndTimeString + "Z");
case 2:
return new Date(globalBusStartTime.getTime() + (1000 * parseInputTimeAsNumberOfSeconds(globalEndTimeString)));
case 3:
return new Date(new Date().getTime() - (1000 * parseInputTimeAsNumberOfSeconds(globalEndTimeString)));
}
return date.toISOString().substring(0, 19); // Trim milliseconds and "Z" marker
}
function getWubloaderTimeFromDate(date) {
function getWubloaderTimeFromDateWithMilliseconds(date) {
if (!date) {
return null;
}
return date.toISOString().substring(0, 19); // Trim milliseconds and "Z" marker
return date.toISOString().substring(0, 23); // Trim "Z" marker and smaller than milliseconds
}
function assembleVideoPlaylistURL(basePlaylistURL) {
let playlistURL = basePlaylistURL;
let startTime = getStartTime();
let endTime = getEndTime();
const queryStringParts = startAndEndTimeQueryStringParts();
if (queryStringParts) {
playlistURL += "?" + queryStringParts.join("&");
}
return playlistURL;
}
function startAndEndTimeQueryStringParts() {
const startTime = getStartTime();
const endTime = getEndTime();
let queryStringParts = [];
if (startTime) {
queryStringParts.push("start=" + getWubloaderTimeFromDate(startTime));
queryStringParts.push(`start=${getWubloaderTimeFromDate(startTime)}`);
}
if (endTime) {
queryStringParts.push("end=" + getWubloaderTimeFromDate(endTime));
}
if (queryStringParts) {
playlistURL += "?" + queryStringParts.join("&");
queryStringParts.push(`end=${getWubloaderTimeFromDate(endTime)}`);
}
return playlistURL;
return queryStringParts;
}
function generateDownloadURL(startTime, endTime, downloadType, allowHoles) {
function generateDownloadURL(startTime, endTime, downloadType, allowHoles, quality) {
const startURLTime = getWubloaderTimeFromDate(startTime);
const endURLTime = getWubloaderTimeFromDate(endTime);
const queryParts = ["start=" + startURLTime, "end=" + endURLTime, "type=" + downloadType, "allow_holes=" + allowHoles];
const queryParts = [`start=${startURLTime}`, `end=${endURLTime}`, `type=${downloadType}`, `allow_holes=${allowHoles}`];
const downloadURL = "/cut/" + globalStreamName + "/source.ts?" + queryParts.join("&");
const downloadURL = `/cut/${globalStreamName}/${quality}.ts?${queryParts.join("&")}`;
return downloadURL;
}
}

@ -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;
}

@ -88,6 +88,26 @@ document.addEventListener("keypress", (event) => {
case "-":
decreaseSpeed(player);
break;
case "[":
if (typeof setCurrentRangeStartToVideoTime === "function") {
setCurrentRangeStartToVideoTime();
}
break;
case "]":
if (typeof setCurrentRangeEndToVideoTime === "function") {
setCurrentRangeEndToVideoTime();
}
break;
case "o":
if (typeof moveToPreviousRange === "function") {
moveToPreviousRange();
}
break;
case "p":
if (typeof moveToNextRange === "function") {
moveToNextRange();
}
break;
default:
break;
}

@ -1,7 +1,12 @@
var globalLoadedVideoPlayer = false;
var globalVideoTimeReference = TIME_FRAME_AGO;
const TIME_FRAME_UTC = 1;
const TIME_FRAME_BUS = 2;
const TIME_FRAME_AGO = 3;
// Here's all the stuff that runs immediately when the page is loaded.
window.addEventListener("DOMContentLoaded", async (event) => {
commonPageSetup();
const timeSettingsForm = document.getElementById("stream-time-settings");
timeSettingsForm.addEventListener("submit", (event) => {
event.preventDefault();
@ -9,9 +14,6 @@ window.addEventListener("DOMContentLoaded", async (event) => {
});
await loadDefaults();
updateTimeSettings();
const helpLink = document.getElementById("keyboard-help-link");
helpLink.addEventListener("click", toggleHelpDisplay);
});
async function loadDefaults() {
@ -28,6 +30,33 @@ async function loadDefaults() {
globalBusStartTime = new Date(defaultData.bustime_start);
}
// Gets the start time of the video from settings. Returns an invalid date object if the user entered bad data.
function getStartTime() {
switch (globalVideoTimeReference) {
case 1:
return new Date(globalStartTimeString + "Z");
case 2:
return new Date(globalBusStartTime.getTime() + (1000 * parseInputTimeAsNumberOfSeconds(globalStartTimeString)));
case 3:
return new Date(new Date().getTime() - (1000 * parseInputTimeAsNumberOfSeconds(globalStartTimeString)));
}
}
// Gets the end time of the video from settings. Returns null if there's no end time. Returns an invalid date object if the user entered bad data.
function getEndTime() {
if (globalEndTimeString === "") {
return null;
}
switch (globalVideoTimeReference) {
case 1:
return new Date(globalEndTimeString + "Z");
case 2:
return new Date(globalBusStartTime.getTime() + (1000 * parseInputTimeAsNumberOfSeconds(globalEndTimeString)));
case 3:
return new Date(new Date().getTime() - (1000 * parseInputTimeAsNumberOfSeconds(globalEndTimeString)));
}
}
function updateTimeSettings() {
updateStoredTimeSettings();
if (globalLoadedVideoPlayer) {
@ -38,31 +67,33 @@ function updateTimeSettings() {
}
updateDownloadLink();
}
function loadVideoPlayerFromDefaultPlaylist() {
const playlistURL = "/playlist/" + globalStreamName + ".m3u8";
loadVideoPlayer(playlistURL);
if (getEndTime() < getStartTime()) {
addError("End time is before the start time. This will prevent video loading and cause other problems.");
}
}
function updateSegmentPlaylist() {
const playlistURL = "/playlist/" + globalStreamName + ".m3u8";
const playlistURL = `/playlist/${globalStreamName}.m3u8`;
updateVideoPlayer(playlistURL);
}
function toggleHelpDisplay() {
const helpBox = document.getElementById("keyboard-help-box");
if (helpBox.classList.contains("hidden")) {
const helpLink = document.getElementById("keyboard-help-link");
helpBox.style.top = (helpLink.offsetTop + helpLink.offsetHeight) + "px";
helpBox.classList.remove("hidden");
} else {
helpBox.classList.add("hidden");
}
}
function updateDownloadLink() {
const downloadLink = document.getElementById("download");
const downloadURL = generateDownloadURL(getStartTime(), getEndTime(), "rough", true);
const downloadURL = generateDownloadURL(getStartTime(), getEndTime(), "rough", true, "source");
downloadLink.href = downloadURL;
}
function updateStoredTimeSettings() {
globalStreamName = document.getElementById("stream-time-setting-stream").value;
globalStartTimeString = document.getElementById("stream-time-setting-start").value;
globalEndTimeString = document.getElementById("stream-time-setting-end").value;
const radioSelection = document.querySelectorAll("#stream-time-frame-of-reference > input");
for (radioItem of radioSelection) {
if (radioItem.checked) {
globalVideoTimeReference = +radioItem.value;
break;
}
}
}

@ -1,3 +1,7 @@
a, .click {
cursor: pointer;
}
#errors {
color: #b00;
display: flex;
@ -21,36 +25,168 @@
margin-bottom: 10px;
}
#stream-time-settings > div {
margin: 0 2px;
}
.field-label {
display: block;
}
#video {
width: 100%;
max-height: 60vh;
margin-bottom: 20px;
max-height: 50vh;
}
#keyboard-help {
/* START BLOCK
* We want to style the VideoJS player controls to have a full-screen-width progress bar.
* Since we're taking the progress bar out, we also need to do a couple other restylings.
*/
#video .vjs-control-bar .vjs-time-control {
display: block; /* We want to display these */
}
#video .vjs-control-bar .vjs-progress-control {
position: absolute;
bottom: 26px; /* Aligns the bar to the top of the control bar */
left: 0;
right: 0;
width: 100%;
height: 10px;
}
#video .vjs-control-bar .vjs-progress-control .vjs-progress-holder {
margin-left: 0px;
margin-right: 0px;
}
#video .vjs-control-bar .vjs-remaining-time {
/* Right-align the controls we want to be right-aligned by using this to shove
* the rest of the controls to the right
*/
flex-grow: 1;
text-align: left;
}
/* END BLOCK */
#clip-bar {
width: 100%;
height: 5px;
background-color: #bbb;
position: relative;
}
#clip-bar > div {
position: absolute;
background-color: #d80;
height: 100%;
}
#waveform {
width: 100%;
}
#editor-help {
z-index: 1;
}
#keyboard-help-link {
#editor-help-link {
float: right;
z-index: 5;
}
#keyboard-help-box {
#editor-help-box {
position: absolute;
right: 0;
border: 1px solid #000;
padding: 2px;
}
#keyboard-help-box h2 {
margin: 0 0 3px 0;
#editor-help-box h2 {
margin: 3px 0;
}
#range-definitions {
display: flex;
flex-direction: column;
gap: 1px;
}
.range-definition-times {
display: flex;
align-items: center;
gap: 4px;
}
.range-definition-start, .range-definition-end {
width: 100px;
}
.range-definition-between-time-gap {
width: 5px;
}
.range-definition-icon-gap {
width: 16px;
}
.range-definition-transition {
margin-bottom: 2px;
}
.range-definition-transition-type {
margin-right: 4px;
}
#video-info > div {
margin: 5px 0;
}
#video-info-title-full {
display: flex;
align-items: center;
white-space: pre;
}
#video-info-title {
flex-grow: 1;
}
#video-info-description-entry {
display: flex;
align-items: flex-start;
gap: 5px;
}
#video-info-description {
flex-grow: 1;
}
#video-info-editor-notes-container {
border: 1px solid #666;
background-color: #eee;
}
.submission-response-error {
white-space: pre-wrap;
}
.hidden {
display: none;
}
#submission {
margin: 5px 0;
}
#download {
margin: 5px 0;
}
#data-correction {
margin: 5px 0;
}
#data-correction-force-reset-confirm p {
margin: 5px 0;
}
Loading…
Cancel
Save