diff --git a/docker-compose.jsonnet b/docker-compose.jsonnet
index 3031ee9..efd93b4 100644
--- a/docker-compose.jsonnet
+++ b/docker-compose.jsonnet
@@ -66,7 +66,9 @@
// Thrimbletrimmer (and probably not useful otherwise) to enable live updates
// to Thrimbletrimmer without restarting/rebuilding Wubloader.
// If you wish to use this, set this to the path containing the Thrimbletrimmer
- // web (HTML, CSS, JavaScript) files to serve (e.g. "/path/to/wubloader/thrimbletrimmer/").
+ // web (HTML, CSS, JavaScript) files to serve (e.g. "/path/to/wubloader/thrimbletrimmer/dist/").
+ // This directory should be the build target when building Thrimbletrimmer manually; by default,
+ // this is the /dist/ subdirectory.
thrimbletrimmer_web_dev_path:: null,
// The host's port to expose each service on.
diff --git a/thrimbletrimmer/.gitignore b/thrimbletrimmer/.gitignore
new file mode 100644
index 0000000..3a8ec2b
--- /dev/null
+++ b/thrimbletrimmer/.gitignore
@@ -0,0 +1,3 @@
+node_modules
+dist
+package-lock.json
\ No newline at end of file
diff --git a/thrimbletrimmer/.prettierignore b/thrimbletrimmer/.prettierignore
index 36beca7..6c8051d 100644
--- a/thrimbletrimmer/.prettierignore
+++ b/thrimbletrimmer/.prettierignore
@@ -1,4 +1 @@
-scripts/hls.min.js
-scripts/luxon.min.js
-scripts/jcrop.js
-styles/jcrop.css
+src/external
\ No newline at end of file
diff --git a/thrimbletrimmer/clock.html b/thrimbletrimmer/clock.html
deleted file mode 100644
index c99e542..0000000
--- a/thrimbletrimmer/clock.html
+++ /dev/null
@@ -1,56 +0,0 @@
-
-
-
-
-
- Keyboard Shortcuts
-
- Number keys (0-9): Jump to that 10% interval of the video (0% - 90%)
- K or Space: Toggle pause
- M: Toggle mute
- J: Back 10 seconds
- L: Forward 10 seconds
- Left arrow: Back 5 seconds
- Right arrow: Forward 5 seconds
- Shift+J: Back 1 second
- Shift+L: Forward 1 second
- Comma (,): Back 1 frame
- Period (.): Forward 1 frame
- Equals (=): Increase playback speed one step
- Hyphen (-): Decrease playback speed one step
- Shift+=: 2x or maximum playback speed
- Shift+-: Minimum playback speed
- Backspace: Reset playback speed to 1x
-
- Left bracket ([): Set start point for active range (indicated by arrow) to active video
- time
-
- Right bracket (]): Set end point for active range to active video time
- O: Set active range one above current active range
-
- P: Set active range one below current active range, adding a new range if the current
- active range is the last one
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Add chapter markers to video description
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Title:
-
-
-
-
-
Abbreviated title:
-
-
Description:
-
-
Tags (comma-separated):
-
-
Thumbnail:
-
-
-
- No custom thumbnail
- Use video frame
- Use video frame in image template
- Use video frame with a custom one-off overlay
- Use a custom thumbnail image
-
-
-
-
-
-
-
-
-
-
-
-
- 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.
-
-
- Update Source Images
-
-
- Reset Crop To Defaults
-
-
-
-
-
-
-
-
-
Aspect Ratio
-
-
- --Match->
-
-
-
-
- <-Match--
-
-
-
-
-
- Lock
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Generate Thumbnail Preview
-
-
-
-
-
-
-
-
-
-
-
-
- Allow holes
-
-
-
-
- Make unlisted
-
-
-
-
- Upload location:
-
-
-
-
- Uploader allowlist:
-
-
-
-
-
-
-
-
Download type:
-
- Smart (experimental but preferred option)
- Rough (raw content, pads start and end by a few seconds)
- Fast (deprecated, use if smart is broken)
- MPEG-TS (slow, consumes server resources)
-
-
Download Video
-
Download Current Frame as Image
-
-
-
-
-
-
-
Is YouTube upload (add to playlists)?
-
-
Set Link
-
-
-
-
Are you sure you want to reset this event?
-
- This will set the row back to Unedited and forget about any video that may already
- exist.
-
-
- This is intended as a last-ditch effort to clear a malfunctioning cutter, or if a video
- needs to be reedited and replaced.
-
-
- It is your responsibility to deal with any video that may have already been
- uploaded.
-
-
- Yes, reset it!
- Oh, never mind!
-
-
-
-
-
-
+
-
-
+
diff --git a/thrimbletrimmer/images/arrow.png b/thrimbletrimmer/images/arrow.png
deleted file mode 100644
index cf40987..0000000
Binary files a/thrimbletrimmer/images/arrow.png and /dev/null differ
diff --git a/thrimbletrimmer/images/minus.png b/thrimbletrimmer/images/minus.png
deleted file mode 100644
index 6bf7fe7..0000000
Binary files a/thrimbletrimmer/images/minus.png and /dev/null differ
diff --git a/thrimbletrimmer/images/pencil.png b/thrimbletrimmer/images/pencil.png
deleted file mode 100644
index 3cd7dab..0000000
Binary files a/thrimbletrimmer/images/pencil.png and /dev/null differ
diff --git a/thrimbletrimmer/images/play_to.png b/thrimbletrimmer/images/play_to.png
deleted file mode 100644
index 7df95b0..0000000
Binary files a/thrimbletrimmer/images/play_to.png and /dev/null differ
diff --git a/thrimbletrimmer/images/plus.png b/thrimbletrimmer/images/plus.png
deleted file mode 100644
index 5d86af7..0000000
Binary files a/thrimbletrimmer/images/plus.png and /dev/null differ
diff --git a/thrimbletrimmer/images/video-controls/fullscreen.png b/thrimbletrimmer/images/video-controls/fullscreen.png
deleted file mode 100644
index 664bc2a..0000000
Binary files a/thrimbletrimmer/images/video-controls/fullscreen.png and /dev/null differ
diff --git a/thrimbletrimmer/images/video-controls/pause.png b/thrimbletrimmer/images/video-controls/pause.png
deleted file mode 100644
index 8de238f..0000000
Binary files a/thrimbletrimmer/images/video-controls/pause.png and /dev/null differ
diff --git a/thrimbletrimmer/images/video-controls/play.png b/thrimbletrimmer/images/video-controls/play.png
deleted file mode 100644
index 30d233a..0000000
Binary files a/thrimbletrimmer/images/video-controls/play.png and /dev/null differ
diff --git a/thrimbletrimmer/images/video-controls/volume-mute.png b/thrimbletrimmer/images/video-controls/volume-mute.png
deleted file mode 100644
index 1f907e4..0000000
Binary files a/thrimbletrimmer/images/video-controls/volume-mute.png and /dev/null differ
diff --git a/thrimbletrimmer/images/video-controls/volume.png b/thrimbletrimmer/images/video-controls/volume.png
deleted file mode 100644
index aeb1254..0000000
Binary files a/thrimbletrimmer/images/video-controls/volume.png and /dev/null differ
diff --git a/thrimbletrimmer/index.html b/thrimbletrimmer/index.html
index fe5eab3..08c6c8c 100644
--- a/thrimbletrimmer/index.html
+++ b/thrimbletrimmer/index.html
@@ -1,170 +1,12 @@
-
+
-
- Keyboard Shortcuts
-
- Number keys (0-9): Jump to that 10% interval of the video (0% - 90%)
- K or Space: Toggle pause
- M: Toggle mute
- J: Back 10 seconds
- L: Forward 10 seconds
- Left arrow: Back 5 seconds
- Right arrow: Forward 5 seconds
- Shift+J: Back 1 second
- Shift+L: Forward 1 second
- Comma (,): Back 1 frame
- Period (.): Forward 1 frame
- Equals (=): Increase playback speed one step
- Hyphen (-): Decrease playback speed one step
- Shift+=: 2x or maximum playback speed
- Shift+-: Minimum playback speed
- Backspace: Reset playback speed to 1x
-
-
-
-
-
-
-
-
-
-
-
-
-
- /
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
+
diff --git a/thrimbletrimmer/package.json b/thrimbletrimmer/package.json
new file mode 100644
index 0000000..cec134e
--- /dev/null
+++ b/thrimbletrimmer/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "thrimbletrimmer",
+ "version": "4.0.0",
+ "description": "Video editor frontend for Wubloader",
+ "type": "module",
+ "scripts": {
+ "start": "vite",
+ "dev": "vite",
+ "build": "vite build",
+ "serve": "vite preview"
+ },
+ "license": "MIT",
+ "devDependencies": {
+ "sass": "1.80.6",
+ "solid-devtools": "0.30.1",
+ "typescript": "5.6.3",
+ "url": "0.11.4",
+ "vite": "5.4.10",
+ "vite-plugin-solid": "2.10.2"
+ },
+ "dependencies": {
+ "solid-js": "1.9.3"
+ }
+}
diff --git a/thrimbletrimmer/scripts/chat-load.js b/thrimbletrimmer/scripts/chat-load.js
deleted file mode 100644
index 0f6b4ee..0000000
--- a/thrimbletrimmer/scripts/chat-load.js
+++ /dev/null
@@ -1,49 +0,0 @@
-self.importScripts("luxon.min.js", "common-worker.js");
-
-var DateTime = luxon.DateTime;
-luxon.Settings.defaultZone = "utc";
-
-self.onmessage = async (event) => {
- const chatLoadData = event.data;
-
- const segmentMetadata = chatLoadData.segmentMetadata;
- for (const segmentData of segmentMetadata) {
- segmentData.rawStart = DateTime.fromMillis(segmentData.rawStart);
- segmentData.rawEnd = DateTime.fromMillis(segmentData.rawEnd);
- }
-
- const fetchURL = `/${chatLoadData.stream}/chat.json?start=${chatLoadData.start}&end=${chatLoadData.end}`;
- const chatResponse = await fetch(fetchURL);
- if (!chatResponse.ok) {
- return;
- }
- const chatRawData = await chatResponse.json();
-
- const chatData = [];
- for (const chatLine of chatRawData) {
- if (
- chatLine.command !== "PRIVMSG" &&
- chatLine.command !== "CLEARMSG" &&
- chatLine.command !== "CLEARCHAT" &&
- chatLine.command !== "USERNOTICE"
- ) {
- continue;
- }
- const when = DateTime.fromSeconds(chatLine.time);
- const displayWhen = videoHumanTimeFromDateTimeWithFragments(segmentMetadata, when);
- // Here, we just push each line successively into the list. This assumes data is provided to us in chronological order.
- chatData.push({ message: chatLine, when: when.toMillis(), displayWhen: displayWhen });
- }
- self.postMessage(chatData);
-};
-
-function videoHumanTimeFromDateTimeWithFragments(fragmentMetadata, dateTime) {
- for (const segmentData of fragmentMetadata) {
- if (dateTime >= segmentData.rawStart && dateTime <= segmentData.rawEnd) {
- const playerTime =
- segmentData.playerStart + dateTime.diff(segmentData.rawStart).as("seconds");
- return videoHumanTimeFromVideoPlayerTime(playerTime);
- }
- }
- return null;
-}
diff --git a/thrimbletrimmer/scripts/common-worker.js b/thrimbletrimmer/scripts/common-worker.js
deleted file mode 100644
index 532d556..0000000
--- a/thrimbletrimmer/scripts/common-worker.js
+++ /dev/null
@@ -1,21 +0,0 @@
-function videoHumanTimeFromVideoPlayerTime(videoPlayerTime) {
- const hours = Math.floor(videoPlayerTime / 3600);
- let minutes = Math.floor((videoPlayerTime % 3600) / 60);
- let seconds = Math.floor(videoPlayerTime % 60);
- let milliseconds = Math.floor((videoPlayerTime * 1000) % 1000);
-
- while (minutes.toString().length < 2) {
- minutes = `0${minutes}`;
- }
- while (seconds.toString().length < 2) {
- seconds = `0${seconds}`;
- }
- while (milliseconds.toString().length < 3) {
- milliseconds = `0${milliseconds}`;
- }
-
- if (hours > 0) {
- return `${hours}:${minutes}:${seconds}.${milliseconds}`;
- }
- return `${minutes}:${seconds}.${milliseconds}`;
-}
diff --git a/thrimbletrimmer/scripts/common.js b/thrimbletrimmer/scripts/common.js
deleted file mode 100644
index 852da85..0000000
--- a/thrimbletrimmer/scripts/common.js
+++ /dev/null
@@ -1,736 +0,0 @@
-var DateTime = luxon.DateTime;
-var Interval = luxon.Interval;
-luxon.Settings.defaultZone = "utc";
-
-var globalBusStartTime = DateTime.fromISO("1970-01-01T00:00:00");
-var globalStreamName = "";
-var globalStartTimeString = "";
-var globalEndTimeString = "";
-
-var globalPlayer = null;
-var globalSetUpControls = false;
-var globalSeekTimer = null;
-
-var globalChatData = [];
-var globalLoadChatWorker = null;
-
-Hls.DefaultConfig.maxBufferHole = 600;
-
-const VIDEO_FRAMES_PER_SECOND = 30;
-
-const PLAYBACK_RATES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 4, 8];
-
-function commonPageSetup() {
- if (!Hls.isSupported()) {
- addError(
- "Your browser doesn't support MediaSource extensions. Video playback and editing won't work.",
- );
- }
-
- globalLoadChatWorker = new Worker("scripts/chat-load.js");
-}
-
-function addError(errorText) {
- const errorElement = document.createElement("div");
- errorElement.innerText = errorText;
-
- const dismissElement = document.createElement("a");
- dismissElement.classList.add("error-dismiss");
- dismissElement.innerText = "[X]";
- errorElement.appendChild(dismissElement);
- dismissElement.addEventListener("click", (event) => {
- const errorHost = document.getElementById("errors");
- errorHost.removeChild(errorElement);
- });
-
- const errorHost = document.getElementById("errors");
- errorHost.appendChild(errorElement);
-}
-
-async function loadVideoPlayer(playlistURL) {
- let rangedPlaylistURL = assembleVideoPlaylistURL(playlistURL);
- const videoElement = document.getElementById("video");
-
- videoElement.addEventListener("loadedmetadata", (_event) => {
- setUpVideoControls();
- sendChatLogLoadData();
- });
-
- videoElement.addEventListener("loadeddata", (_event) => {
- const qualitySelector = document.getElementById("video-controls-quality");
- globalPlayer.currentLevel = +qualitySelector.value;
- });
-
- globalPlayer = new Hls();
- globalPlayer.attachMedia(video);
- return new Promise((resolve, _reject) => {
- globalPlayer.on(Hls.Events.MEDIA_ATTACHED, () => {
- const startTime = getStartTime();
- const endTime = getEndTime();
- if (endTime && endTime.diff(startTime).milliseconds < 0) {
- addError(
- "End time is before the start time. This will prevent video loading and cause other problems.",
- );
- }
- globalPlayer.loadSource(rangedPlaylistURL);
-
- globalPlayer.on(Hls.Events.ERROR, (_event, data) => {
- if (data.fatal) {
- switch (data.type) {
- case Hls.ErrorTypes.NETWORK_ERROR:
- if (data.reason === "no level found in manifest") {
- addError(
- "There is no video data between the specified start and end times. Change the times so that there is video content to play.",
- );
- } else {
- console.log("A fatal network error occurred; retrying", data);
- globalPlayer.startLoad();
- }
- break;
- case Hls.ErrorTypes.MEDIA_ERROR:
- console.log("A fatal media error occurred; retrying", data);
- globalPlayer.recoverMediaError();
- break;
- default:
- console.log("A fatal error occurred; resetting video player", data);
- addError(
- "Some sort of video player error occurred. Thrimbletrimmer is resetting the video player.",
- );
- resetVideoPlayer();
- }
- } else {
- console.log("A non-fatal video player error occurred; HLS.js will retry", data);
- }
- });
-
- resolve();
- });
- });
-}
-
-async function loadVideoPlayerFromDefaultPlaylist() {
- const playlistURL = `/playlist/${globalStreamName}.m3u8`;
- await loadVideoPlayer(playlistURL);
-}
-
-function resetVideoPlayer() {
- updateSegmentPlaylist();
-}
-
-function updateSegmentPlaylist() {
- const videoElement = document.getElementById("video");
- const currentPlaybackRate = videoElement.playbackRate;
- globalPlayer.destroy();
- loadVideoPlayerFromDefaultPlaylist();
- // The playback rate isn't maintained when destroying and reattaching hls.js
- videoElement.playbackRate = currentPlaybackRate;
-}
-
-function setUpVideoControls() {
- // Setting this up so it's removed from the event doesn't work; loadedmetadata fires twice anyway.
- // We still need to prevent double-setup, so here we are.
- if (globalSetUpControls) {
- return;
- }
- globalSetUpControls = true;
-
- const videoElement = document.getElementById("video");
-
- const playPauseButton = document.getElementById("video-controls-play-pause");
- if (videoElement.paused) {
- playPauseButton.src = "images/video-controls/play.png";
- } else {
- playPauseButton.src = "images/video-controls/pause.png";
- }
-
- const togglePlayState = (_event) => {
- if (videoElement.paused) {
- videoElement.play();
- } else {
- videoElement.pause();
- }
- };
- playPauseButton.addEventListener("click", togglePlayState);
- videoElement.addEventListener("click", (event) => {
- if (!videoElement.controls) {
- togglePlayState(event);
- }
- });
-
- videoElement.addEventListener("play", (_event) => {
- playPauseButton.src = "images/video-controls/pause.png";
- });
- videoElement.addEventListener("pause", (_event) => {
- playPauseButton.src = "images/video-controls/play.png";
- });
-
- const currentTime = document.getElementById("video-controls-current-time");
- currentTime.innerText = videoHumanTimeFromVideoPlayerTime(videoElement.currentTime);
- videoElement.addEventListener("timeupdate", (_event) => {
- currentTime.innerText = videoHumanTimeFromVideoPlayerTime(videoElement.currentTime);
- });
-
- const duration = document.getElementById("video-controls-duration");
- duration.innerText = videoHumanTimeFromVideoPlayerTime(videoElement.duration);
- videoElement.addEventListener("durationchange", (_event) => {
- duration.innerText = videoHumanTimeFromVideoPlayerTime(videoElement.duration);
- });
-
- const volumeMuted = document.getElementById("video-controls-volume-mute");
- if (videoElement.muted) {
- volumeMuted.src = "images/video-controls/volume-mute.png";
- } else {
- volumeMuted.src = "images/video-controls/volume.png";
- }
- const volumeLevel = document.getElementById("video-controls-volume-level");
- const defaultVolume = +(localStorage.getItem("volume") ?? 0.5);
- if (isNaN(defaultVolume)) {
- defaultVolume = 0.5;
- } else if (defaultVolume < 0) {
- defaultVolume = 0;
- } else if (defaultVolume > 1) {
- defaultVolume = 1;
- }
- videoElement.volume = defaultVolume;
- volumeLevel.value = videoElement.volume;
-
- volumeMuted.addEventListener("click", (_event) => {
- videoElement.muted = !videoElement.muted;
- });
- volumeLevel.addEventListener("click", (event) => {
- videoElement.volume = event.offsetX / event.target.offsetWidth;
- videoElement.muted = false;
- });
- videoElement.addEventListener("volumechange", (_event) => {
- if (videoElement.muted) {
- volumeMuted.src = "images/video-controls/volume-mute.png";
- } else {
- volumeMuted.src = "images/video-controls/volume.png";
- }
- volumeLevel.value = videoElement.volume;
- localStorage.setItem("volume", videoElement.volume);
- });
-
- const playbackSpeed = document.getElementById("video-controls-playback-speed");
- for (const speed of PLAYBACK_RATES) {
- const speedOption = document.createElement("option");
- speedOption.value = speed;
- speedOption.innerText = `${speed}x`;
- if (speed === 1) {
- speedOption.selected = true;
- }
- playbackSpeed.appendChild(speedOption);
- }
- playbackSpeed.addEventListener("change", (_event) => {
- const speed = +playbackSpeed.value;
- videoElement.playbackRate = speed;
- });
-
- const quality = document.getElementById("video-controls-quality");
- const defaultQuality = localStorage.getItem("quality");
- for (const [qualityIndex, qualityLevel] of globalPlayer.levels.entries()) {
- const qualityOption = document.createElement("option");
- qualityOption.value = qualityIndex;
- qualityOption.innerText = qualityLevel.name;
- if (qualityLevel.name === defaultQuality) {
- qualityOption.selected = true;
- }
- quality.appendChild(qualityOption);
- }
- localStorage.setItem("quality", quality.options[quality.options.selectedIndex].innerText);
- quality.addEventListener("change", (_event) => {
- globalPlayer.currentLevel = +quality.value;
- localStorage.setItem("quality", quality.options[quality.options.selectedIndex].innerText);
- });
-
- const fullscreen = document.getElementById("video-controls-fullscreen");
- fullscreen.addEventListener("click", (_event) => {
- if (document.fullscreenElement) {
- document.exitFullscreen();
- } else {
- videoElement.requestFullscreen();
- }
- });
- videoElement.addEventListener("fullscreenchange", (_event) => {
- if (document.fullscreenElement) {
- videoElement.controls = true;
- } else {
- videoElement.controls = false;
- }
- });
-
- const playbackPosition = document.getElementById("video-controls-playback-position");
- playbackPosition.max = videoElement.duration;
- playbackPosition.value = videoElement.currentTime;
- videoElement.addEventListener("durationchange", (_event) => {
- playbackPosition.max = videoElement.duration;
- });
- videoElement.addEventListener("timeupdate", (_event) => {
- playbackPosition.value = videoElement.currentTime;
- });
- playbackPosition.addEventListener("click", (event) => {
- const newPosition = (event.offsetX / event.target.offsetWidth) * videoElement.duration;
- videoElement.currentTime = newPosition;
- playbackPosition.value = newPosition;
- });
-
- /* Sometimes a mysterious issue occurs loading segments of the video when seeking.
- * When this happens, twiddling the qualities tends to fix it. Here, we attempt to
- * detect this situation and fix it automatically.
- */
- videoElement.addEventListener("seeking", (_event) => {
- // If we don't get a "seeked" event soon after the "seeking" event, we assume there's
- // a loading error.
- // To handle this, we set up a timed handler to pick this up.
- if (globalSeekTimer !== null) {
- clearTimeout(globalSeekTimer);
- globalSeekTimer = null;
- }
- globalSeekTimer = setTimeout(() => {
- const currentLevel = globalPlayer.currentLevel;
- globalPlayer.currentLevel = -1;
- globalPlayer.currentLevel = currentLevel;
- }, 500);
- });
- videoElement.addEventListener("seeked", (_event) => {
- // Since we got the seek, cancel the timed twiddling of qualities
- if (globalSeekTimer !== null) {
- clearTimeout(globalSeekTimer);
- globalSeekTimer = null;
- }
- });
-}
-
-function dateTimeMathObjectFromBusTime(busTime) {
- // 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.
- let direction = 1;
- if (busTime.startsWith("-")) {
- busTime = busTime.slice(1);
- direction = -1;
- }
-
- const parts = busTime.split(":", 3);
- const hours = parseInt(parts[0]) * direction;
- const minutes = (parts[1] || 0) * direction;
- const seconds = (parts[2] || 0) * direction;
- return { hours: hours, minutes: minutes, seconds: seconds };
-}
-
-function dateTimeFromBusTime(busTime) {
- return globalBusStartTime.plus(dateTimeMathObjectFromBusTime(busTime));
-}
-
-function busTimeFromDateTime(dateTime) {
- const diff = dateTime.diff(globalBusStartTime);
- return formatIntervalForDisplay(diff);
-}
-
-function formatIntervalForDisplay(interval) {
- if (interval.milliseconds < 0) {
- const negativeInterval = interval.negate();
- return `-${negativeInterval.toFormat("hh:mm:ss.SSS")}`;
- }
- return interval.toFormat("hh:mm:ss.SSS");
-}
-
-function dateTimeFromWubloaderTime(wubloaderTime) {
- return DateTime.fromISO(wubloaderTime);
-}
-
-function wubloaderTimeFromDateTime(dateTime) {
- if (!dateTime) {
- return null;
- }
- // Not using ISO here because Luxon doesn't give us a quick way to print an ISO8601 string with no offset.
- return dateTime.toFormat("yyyy-LL-dd'T'HH:mm:ss.SSS");
-}
-
-function busTimeFromWubloaderTime(wubloaderTime) {
- if (wubloaderTime === "") {
- return "";
- }
- const dt = dateTimeFromWubloaderTime(wubloaderTime);
- return busTimeFromDateTime(dt);
-}
-
-function videoHumanTimeFromVideoPlayerTime(videoPlayerTime) {
- const hours = Math.floor(videoPlayerTime / 3600);
- let minutes = Math.floor((videoPlayerTime % 3600) / 60);
- let seconds = Math.floor(videoPlayerTime % 60);
- let milliseconds = Math.floor((videoPlayerTime * 1000) % 1000);
-
- while (minutes.toString().length < 2) {
- minutes = `0${minutes}`;
- }
- while (seconds.toString().length < 2) {
- seconds = `0${seconds}`;
- }
- while (milliseconds.toString().length < 3) {
- milliseconds = `0${milliseconds}`;
- }
-
- if (hours > 0) {
- return `${hours}:${minutes}:${seconds}.${milliseconds}`;
- }
- return `${minutes}:${seconds}.${milliseconds}`;
-}
-
-function videoPlayerTimeFromVideoHumanTime(videoHumanTime) {
- let timeParts = videoHumanTime.split(":", 3);
- let hours;
- let minutes;
- let seconds;
-
- if (timeParts.length < 2) {
- hours = 0;
- minutes = 0;
- seconds = +timeParts[0];
- } else if (timeParts.length < 3) {
- hours = 0;
- minutes = parseInt(timeParts[0]);
- seconds = +timeParts[1];
- } else {
- hours = parseInt(timeParts[0]);
- minutes = parseInt(timeParts[1]);
- seconds = +timeParts[2];
- }
- if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) {
- return null;
- }
-
- return hours * 3600 + minutes * 60 + seconds;
-}
-
-function dateTimeFromVideoPlayerTime(videoPlayerTime) {
- const segmentList = getSegmentList();
- let segmentStartTime;
- let segmentStartISOTime;
- for (const segment of segmentList) {
- const segmentEndTime = segment.start + segment.duration;
- if (videoPlayerTime >= segment.start && videoPlayerTime < segmentEndTime) {
- segmentStartTime = segment.start;
- segmentStartISOTime = segment.rawProgramDateTime;
- break;
- }
- }
- if (segmentStartISOTime === undefined) {
- return null;
- }
- const wubloaderDateTime = DateTime.fromISO(segmentStartISOTime);
- const offset = videoPlayerTime - segmentStartTime;
- return wubloaderDateTime.plus({ seconds: offset });
-}
-
-function videoPlayerTimeFromDateTime(dateTime) {
- const segmentTimes = getSegmentTimes();
- for (const segmentData of segmentTimes) {
- const segmentStart = segmentData.rawStart;
- const segmentEnd = segmentData.rawEnd;
- if (dateTime >= segmentStart && dateTime <= segmentEnd) {
- return segmentData.playerStart + dateTime.diff(segmentStart).as("seconds");
- }
- }
- return null;
-}
-
-function videoHumanTimeFromDateTime(dateTime) {
- const videoPlayerTime = videoPlayerTimeFromDateTime(dateTime);
- if (videoPlayerTime === null) {
- return null;
- }
- return videoHumanTimeFromVideoPlayerTime(videoPlayerTime);
-}
-
-function assembleVideoPlaylistURL(basePlaylistURL) {
- let playlistURL = basePlaylistURL;
-
- const query = startAndEndTimeQuery();
- if (query.toString() !== "") {
- playlistURL += "?" + query.toString();
- }
- return playlistURL;
-}
-
-function startAndEndTimeQuery() {
- const startTime = getStartTime();
- const endTime = getEndTime();
-
- const query = new URLSearchParams();
- if (startTime) {
- query.append("start", wubloaderTimeFromDateTime(startTime));
- }
- if (endTime) {
- query.append("end", wubloaderTimeFromDateTime(endTime));
- }
- return query;
-}
-
-function getSegmentList() {
- return globalPlayer.latencyController.levelDetails.fragments;
-}
-
-function hasSegmentList() {
- if (
- globalPlayer &&
- globalPlayer.latencyController &&
- globalPlayer.latencyController.levelDetails &&
- globalPlayer.latencyController.levelDetails.fragments
- ) {
- return true;
- }
- return false;
-}
-
-function getSegmentTimes() {
- const segmentList = getSegmentList();
- const segmentTimes = [];
- for (const segment of segmentList) {
- const segmentStart = DateTime.fromISO(segment.rawProgramDateTime);
- const segmentEnd = segmentStart.plus({ seconds: segment.duration });
- segmentTimes.push({ rawStart: segmentStart, rawEnd: segmentEnd, playerStart: segment.start });
- }
- return segmentTimes;
-}
-
-function downloadFrame() {
- const videoElement = document.getElementById("video");
- const dateTime = dateTimeFromVideoPlayerTime(videoElement.currentTime);
- const url = `/frame/${globalStreamName}/source.png?timestamp=${wubloaderTimeFromDateTime(
- dateTime,
- )}`;
- // Avoid : as it causes problems on Windows
- const filename = `${dateTime.toFormat("yyyy-LL-dd'T'HH-mm-ss.SSS")}.png`;
- triggerDownload(url, filename);
-}
-
-function triggerDownload(url, filename) {
- // URL must be same-origin.
- const link = document.createElement("a");
- link.setAttribute("download", filename);
- link.href = url;
- link.setAttribute("target", "_blank");
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
-}
-
-function sendChatLogLoadData() {
- let startTime = getStartTime();
- let endTime = getEndTime();
- if (!startTime || !endTime) {
- return;
- }
- startTime = wubloaderTimeFromDateTime(startTime);
- endTime = wubloaderTimeFromDateTime(endTime);
- const segmentMetadata = getSegmentTimes();
- for (const segmentData of segmentMetadata) {
- segmentData.rawStart = segmentData.rawStart.toMillis();
- segmentData.rawEnd = segmentData.rawEnd.toMillis();
- }
-
- const message = {
- stream: globalStreamName,
- start: startTime,
- end: endTime,
- segmentMetadata: segmentMetadata,
- };
- globalLoadChatWorker.postMessage(message);
-}
-
-function updateChatDataFromWorkerResponse(chatData) {
- for (const chatLine of chatData) {
- chatLine.when = DateTime.fromMillis(chatLine.when);
- }
- globalChatData = chatData;
-}
-
-function renderChatMessage(chatMessageData) {
- const chatMessage = chatMessageData.message;
- if (chatMessage.command !== "PRIVMSG" || chatMessage.params[0] !== `#${globalStreamName}`) {
- return null;
- }
-
- const sendTimeElement = document.createElement("div");
- sendTimeElement.classList.add("chat-replay-message-time");
- sendTimeElement.innerText = chatMessageData.displayWhen;
-
- const senderNameElement = createMessageSenderElement(chatMessageData);
-
- const messageTextElement = document.createElement("div");
- messageTextElement.classList.add("chat-replay-message-text");
-
- if (chatMessage.tags.hasOwnProperty("reply-parent-msg-id")) {
- const replyParentID = chatMessage.tags["reply-parent-msg-id"];
- const replyParentSender = chatMessage.tags["reply-parent-display-name"];
- let replyParentMessageText = chatMessage.tags["reply-parent-msg-body"];
- const replyContainer = document.createElement("div");
- const replyTextContainer = document.createElement("a");
-
- if (replyParentMessageText.startsWith("\u0001ACTION")) {
- replyContainer.classList.add("chat-replay-message-text-action");
- const substringEnd = replyParentMessageText.endsWith("\u0001")
- ? replyParentMessageText.length - 1
- : replyParentMessageText;
- replyParentMessageText = replyParentMessageText.substring(7, substringEnd);
- }
-
- replyTextContainer.href = `#chat-replay-message-${replyParentID}`;
- replyTextContainer.innerText = `Replying to ${replyParentSender}: ${replyParentMessageText}`;
- replyContainer.appendChild(replyTextContainer);
- replyContainer.classList.add("chat-replay-message-reply");
- messageTextElement.appendChild(replyContainer);
- }
-
- addChatMessageTextToElement(chatMessageData, messageTextElement);
-
- const messageContainer = createMessageContainer(chatMessageData, false);
- messageContainer.appendChild(sendTimeElement);
- messageContainer.appendChild(senderNameElement);
- messageContainer.appendChild(messageTextElement);
- return messageContainer;
-}
-
-function renderSystemMessages(chatMessageData) {
- const chatMessage = chatMessageData.message;
- if (chatMessage.command !== "USERNOTICE" || chatMessage.params[0] != `#${globalStreamName}`) {
- return [];
- }
-
- const messages = [];
-
- const sendTimeElement = document.createElement("div");
- sendTimeElement.classList.add("chat-replay-message-time");
- sendTimeElement.innerText = chatMessageData.displayWhen;
-
- const systemTextElement = document.createElement("div");
- systemTextElement.classList.add("chat-replay-message-text");
- systemTextElement.classList.add("chat-replay-message-system");
- let systemMsg = chatMessage.tags["system-msg"];
- if (!systemMsg && chatMessage.tags["msg-id"] === "announcement") {
- systemMsg = "Announcement";
- }
- systemTextElement.appendChild(document.createTextNode(systemMsg));
-
- const firstMessageContainer = createMessageContainer(chatMessageData, true);
- firstMessageContainer.appendChild(sendTimeElement);
- firstMessageContainer.appendChild(systemTextElement);
- messages.push(firstMessageContainer);
-
- if (chatMessage.params.length === 1) {
- return messages;
- }
-
- const emptySendTimeElement = document.createElement("div");
- emptySendTimeElement.classList.add("chat-replay-message-time");
-
- const senderNameElement = createMessageSenderElement(chatMessageData);
-
- const messageTextElement = document.createElement("div");
- messageTextElement.classList.add("chat-replay-message-text");
- addChatMessageTextToElement(chatMessageData, messageTextElement);
-
- const secondMessageContainer = createMessageContainer(chatMessageData, false);
- secondMessageContainer.appendChild(emptySendTimeElement);
- secondMessageContainer.appendChild(senderNameElement);
- secondMessageContainer.appendChild(messageTextElement);
- messages.push(secondMessageContainer);
-
- return messages;
-}
-
-function createMessageContainer(chatMessageData, isSystemMessage) {
- const chatMessage = chatMessageData.message;
- const messageContainer = document.createElement("div");
- messageContainer.classList.add("chat-replay-message");
- if (chatMessage.tags.hasOwnProperty("id")) {
- if (isSystemMessage) {
- messageContainer.id = `chat-replay-message-system-${chatMessage.tags.id}`;
- } else {
- messageContainer.id = `chat-replay-message-${chatMessage.tags.id}`;
- }
- }
- messageContainer.dataset.sender = chatMessage.sender;
- return messageContainer;
-}
-
-function getMessageDisplayName(chatMessageData) {
- const chatMessage = chatMessageData.message;
- if (chatMessage.tags.hasOwnProperty("display-name")) {
- return chatMessage.tags["display-name"];
- }
- return chatMessage.sender;
-}
-
-function createMessageSenderElement(chatMessageData) {
- const chatMessage = chatMessageData.message;
- const senderNameElement = document.createElement("div");
- senderNameElement.classList.add("chat-replay-message-sender");
- if (chatMessage.tags.hasOwnProperty("color")) {
- senderNameElement.style.color = chatMessage.tags.color;
- }
- senderNameElement.innerText = getMessageDisplayName(chatMessageData);
- return senderNameElement;
-}
-
-function addChatMessageTextToElement(chatMessageData, messageTextElement) {
- const chatMessage = chatMessageData.message;
-
- let chatMessageText = chatMessage.params[1];
- if (chatMessageText.startsWith("\u0001ACTION")) {
- messageTextElement.classList.add("chat-replay-message-text-action");
- const substringEnd = chatMessageText.endsWith("\u0001")
- ? chatMessageText.length - 1
- : chatMessageText.length;
- chatMessageText = chatMessageText.substring(7, substringEnd);
- }
-
- if (chatMessage.tags.emotes) {
- const emoteDataStrings = chatMessage.tags.emotes.split("/");
- let emotePositions = [];
- for (const emoteDataString of emoteDataStrings) {
- const emoteData = emoteDataString.split(":", 2);
- const emoteID = emoteData[0];
- const emotePositionList = emoteData[1].split(",").map((val) => {
- const positions = val.split("-");
- return { emote: emoteID, start: +positions[0], end: +positions[1] };
- });
- emotePositions = emotePositions.concat(emotePositionList);
- }
- emotePositions.sort((a, b) => a.start - b.start);
-
- let messageText = [chatMessageText];
- while (emotePositions.length > 0) {
- const emoteData = emotePositions.pop(); // Pop the highest-index element from the array
- let text = messageText.shift();
- const textAndEmote = [text.substring(0, emoteData.start)];
-
- const emoteImg = document.createElement("img");
- emoteImg.src = `https://static-cdn.jtvnw.net/emoticons/v2/${emoteData.emote}/default/dark/1.0`;
- const emoteText = text.substring(emoteData.start, emoteData.end + 1);
- emoteImg.alt = emoteText;
- emoteImg.title = emoteText;
- const emoteContainer = document.createElement("span");
- emoteContainer.classList.add("chat-replay-message-emote");
- emoteContainer.appendChild(emoteImg);
- textAndEmote.push(emoteContainer);
-
- const remainingText = text.substring(emoteData.end + 1);
- if (remainingText !== "") {
- textAndEmote.push(remainingText);
- }
- messageText = textAndEmote.concat(messageText);
- }
-
- for (const messagePart of messageText) {
- if (typeof messagePart === "string") {
- const node = document.createTextNode(messagePart);
- messageTextElement.appendChild(node);
- } else {
- messageTextElement.appendChild(messagePart);
- }
- }
- } else {
- messageTextElement.appendChild(document.createTextNode(chatMessageText));
- }
-}
diff --git a/thrimbletrimmer/scripts/edit.js b/thrimbletrimmer/scripts/edit.js
deleted file mode 100644
index 5e976cf..0000000
--- a/thrimbletrimmer/scripts/edit.js
+++ /dev/null
@@ -1,2406 +0,0 @@
-var googleUser = null;
-var videoInfo;
-var currentRange = 1;
-let knownTransitions = [];
-let thumbnailTemplates = {};
-let globalPageState = 0;
-
-const CHAPTER_MARKER_DELIMITER = "\n==========\n";
-const CHAPTER_MARKER_DELIMITER_PARTIAL = "==========";
-
-const PAGE_STATE = {
- CLEAN: 0,
- DIRTY: 1,
- SUBMITTING: 2,
- CONFIRMING: 3,
-};
-
-// References to Jcrop "stages" for the advanced thumbnail editor crop tool
-let videoFrameStage;
-let templateStage;
-
-window.addEventListener("DOMContentLoaded", async (event) => {
- commonPageSetup();
- globalLoadChatWorker.onmessage = (event) => {
- updateChatDataFromWorkerResponse(event.data);
- renderChatLog();
- };
- window.addEventListener("beforeunload", handleLeavePage);
-
- const timeUpdateForm = document.getElementById("stream-time-settings");
- timeUpdateForm.addEventListener("submit", async (event) => {
- event.preventDefault();
-
- if (!videoInfo) {
- addError(
- "Time updates are ignored before the video metadata has been retrieved from Wubloader.",
- );
- return;
- }
-
- const newStartField = document.getElementById("stream-time-setting-start");
- const newStart = dateTimeFromBusTime(newStartField.value);
- if (!newStart) {
- addError("Failed to parse start time");
- return;
- }
-
- const newEndField = document.getElementById("stream-time-setting-end");
- let newEnd = null;
- if (newEndField.value !== "") {
- newEnd = dateTimeFromBusTime(newEndField.value);
- if (!newEnd) {
- addError("Failed to parse end time");
- return;
- }
- }
-
- const oldStart = getStartTime();
- const startAdjustment = newStart.diff(oldStart).as("seconds");
- let newDuration = newEnd === null ? Infinity : newEnd.diff(newStart).as("seconds");
-
- // The video duration isn't precisely the video times, but can be padded by up to the
- // segment length on either side.
- const segmentList = getSegmentList();
- newDuration += segmentList[0].duration;
- newDuration += segmentList[segmentList.length - 1].duration;
-
- // Abort for ranges that exceed new times
- const rangeDefinitionsElements = document.getElementById("range-definitions").children;
- for (const rangeContainer of rangeDefinitionsElements) {
- const rangeStartField = rangeContainer.getElementsByClassName("range-definition-start")[0];
- const rangeEndField = rangeContainer.getElementsByClassName("range-definition-end")[0];
- const rangeStart = videoPlayerTimeFromVideoHumanTime(rangeStartField.value);
- const rangeEnd = videoPlayerTimeFromVideoHumanTime(rangeEndField.value);
-
- if (rangeStart !== null && rangeStart < startAdjustment) {
- addError("The specified video load time excludes part of an edited clip range.");
- return;
- }
- if (rangeEnd !== null && rangeEnd + startAdjustment > newDuration) {
- addError("The specified video load time excludes part of an edited clip range.");
- return;
- }
- }
-
- const rangesData = [];
- for (const rangeContainer of rangeDefinitionsElements) {
- const rangeStartField = rangeContainer.getElementsByClassName("range-definition-start")[0];
- const rangeEndField = rangeContainer.getElementsByClassName("range-definition-end")[0];
-
- const rangeStartTimeString = rangeStartField.value;
- const rangeEndTimeString = rangeEndField.value;
-
- const rangeStartTime = dateTimeFromVideoHumanTime(rangeStartTimeString);
- const rangeEndTime = dateTimeFromVideoHumanTime(rangeEndTimeString);
-
- rangesData.push({ start: rangeStartTime, end: rangeEndTime });
- }
-
- const videoElement = document.getElementById("video");
- const currentVideoPosition = dateTimeFromVideoPlayerTime(videoElement.currentTime);
-
- globalStartTimeString = wubloaderTimeFromDateTime(newStart);
- globalEndTimeString = wubloaderTimeFromDateTime(newEnd);
-
- updateSegmentPlaylist();
-
- globalPlayer.once(Hls.Events.LEVEL_LOADED, (_data) => {
- const newVideoPosition = videoPlayerTimeFromDateTime(currentVideoPosition);
- if (newVideoPosition !== null) {
- videoElement.currentTime = newVideoPosition;
- }
-
- let rangeErrorCount = 0;
- for (const [rangeIndex, rangeData] of rangesData.entries()) {
- const rangeContainer = rangeDefinitionsElements[rangeIndex];
- const rangeStartField = rangeContainer.getElementsByClassName("range-definition-start")[0];
- const rangeEndField = rangeContainer.getElementsByClassName("range-definition-end")[0];
-
- if (rangeData.start) {
- rangeStartField.value = videoHumanTimeFromDateTime(rangeData.start);
- } else {
- rangeErrorCount++;
- }
-
- if (rangeData.end) {
- rangeEndField.value = videoHumanTimeFromDateTime(rangeData.end);
- } else {
- rangeErrorCount++;
- }
- }
- if (rangeErrorCount > 0) {
- addError(
- "Some ranges couldn't be updated for the new video time endpoints. Please verify the time range values.",
- );
- }
-
- rangeDataUpdated();
- });
-
- const waveformImage = document.getElementById("waveform");
- if (newEnd === null) {
- waveformImage.classList.add("hidden");
- } else {
- updateWaveform();
- waveformImage.classList.remove("hidden");
- }
- });
-
- loadTransitions(); // Intentionally not awaiting, fire and forget
- await loadVideoInfo();
-
- document.getElementById("stream-time-setting-start-pad").addEventListener("click", (_event) => {
- const startTimeField = document.getElementById("stream-time-setting-start");
- let startTime = startTimeField.value;
- startTime = dateTimeFromBusTime(startTime);
- startTime = startTime.minus({ minutes: 1 });
- startTimeField.value = busTimeFromDateTime(startTime);
- });
-
- document.getElementById("stream-time-setting-end-pad").addEventListener("click", (_event) => {
- const endTimeField = document.getElementById("stream-time-setting-end");
- let endTime = endTimeField.value;
- endTime = dateTimeFromBusTime(endTime);
- endTime = endTime.plus({ minutes: 1 });
- endTimeField.value = busTimeFromDateTime(endTime);
- });
-
- const addRangeIcon = document.getElementById("add-range-definition");
- if (canEditVideo()) {
- addRangeIcon.addEventListener("click", (_event) => {
- addRangeDefinition();
- handleFieldChange(event);
- });
- addRangeIcon.addEventListener("keypress", (event) => {
- if (event.key === "Enter") {
- addRangeDefinition();
- handleFieldChange(event);
- }
- });
- } else {
- addRangeIcon.classList.add("hidden");
- }
-
- const enableChaptersElem = document.getElementById("enable-chapter-markers");
- enableChaptersElem.addEventListener("change", (event) => {
- changeEnableChaptersHandler();
- handleFieldChange(event);
- });
-
- if (canEditVideo()) {
- 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 rangeStartPlay of document.getElementsByClassName("range-definition-play-start")) {
- rangeStartPlay.addEventListener("click", rangePlayFromStartHandler);
- }
- for (const rangeEndPlay of document.getElementsByClassName("range-definition-play-end")) {
- rangeEndPlay.addEventListener("click", rangePlayFromEndHandler);
- }
- for (const rangeStart of document.getElementsByClassName("range-definition-start")) {
- rangeStart.addEventListener("change", (event) => {
- rangeDataUpdated();
- handleFieldChange(event);
- });
- }
- for (const rangeEnd of document.getElementsByClassName("range-definition-end")) {
- rangeEnd.addEventListener("change", (event) => {
- rangeDataUpdated();
- handleFieldChange(event);
- });
- }
- if (canEditMetadata()) {
- for (const addChapterMarker of document.getElementsByClassName(
- "add-range-definition-chapter-marker",
- )) {
- addChapterMarker.addEventListener("click", addChapterMarkerHandler);
- }
- }
-
- 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 =
- videoInfo.title_prefix + document.getElementById("video-info-title").value;
- handleFieldChange(event);
- });
- document.getElementById("video-info-description").addEventListener("input", (event) => {
- validateVideoDescription();
- handleFieldChange(event);
- });
- document
- .getElementById("video-info-thumbnail-template")
- .addEventListener("change", handleFieldChange);
- document
- .getElementById("video-info-thumbnail-mode")
- .addEventListener("change", updateThumbnailInputState);
- document
- .getElementById("video-info-thumbnail-time")
- .addEventListener("change", handleFieldChange);
-
- if (canEditMetadata()) {
- document.getElementById("video-info-thumbnail-time-set").addEventListener("click", (_event) => {
- const field = document.getElementById("video-info-thumbnail-time");
- const videoPlayer = document.getElementById("video");
- const videoPlayerTime = videoPlayer.currentTime;
- field.value = videoHumanTimeFromVideoPlayerTime(videoPlayerTime);
- });
- document
- .getElementById("video-info-thumbnail-time-play")
- .addEventListener("click", (_event) => {
- const field = document.getElementById("video-info-thumbnail-time");
- const thumbnailTime = videoPlayerTimeFromVideoHumanTime(field.value);
- if (thumbnailTime === null) {
- addError("Couldn't play from thumbnail frame; failed to parse time");
- return;
- }
- const videoPlayer = document.getElementById("video");
- videoPlayer.currentTime = thumbnailTime;
- });
- }
-
- 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 timeEntryElement = document.getElementById("video-info-thumbnail-time");
- const imageTime = wubloaderTimeFromVideoHumanTime(timeEntryElement.value);
- if (imageTime === null) {
- videoFrameImageElement.classList.add("hidden");
- addError("Couldn't preview thumbnail; couldn't parse thumbnail frame timestamp");
- return;
- }
- const videoFrameQuery = new URLSearchParams({
- timestamp: imageTime,
- });
- videoFrameImageElement.src = `/frame/${globalStreamName}/source.png?${videoFrameQuery}`;
- videoFrameImageElement.classList.remove("hidden");
-
- const templateImageElement = document.getElementById(
- "video-info-thumbnail-template-overlay-image",
- );
-
- const thumbnailMode = document.getElementById("video-info-thumbnail-mode").value;
- if (thumbnailMode === "TEMPLATE") {
- const imageTemplate = document.getElementById("video-info-thumbnail-template").value;
- templateImageElement.src = `/thrimshim/template/${imageTemplate}.png`;
- } else if (thumbnailMode === "ONEOFF") {
- const templateData = await uploadedImageToBase64();
- templateImageElement.src = `data:image/png;base64,${templateData}`;
- } else {
- console.log(`WARNING: Source images updated but thumbnailMode = ${thumbnailMode}`);
- }
- templateImageElement.classList.remove("hidden");
-
- 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-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")
- .addEventListener("click", async (_event) => {
- const imageElement = document.getElementById("video-info-thumbnail-template-preview-image");
- const thumbnailMode = document.getElementById("video-info-thumbnail-mode").value;
-
- if (thumbnailMode === "ONEOFF") {
- try {
- const data = await renderThumbnail();
- imageElement.src = `data:image/png;base64,${data}`;
- } catch (e) {
- imageElement.classList.add("hidden");
- addError(`${e}`);
- return;
- }
- } else {
- const timeEntryElement = document.getElementById("video-info-thumbnail-time");
- const imageTime = wubloaderTimeFromVideoHumanTime(timeEntryElement.value);
- if (imageTime === null) {
- imageElement.classList.add("hidden");
- addError("Couldn't preview thumbnail; couldn't parse thumbnail frame timestamp");
- return;
- }
- const imageTemplate = document.getElementById("video-info-thumbnail-template").value;
- const [crop, loc] = getTemplatePosition();
- const query = new URLSearchParams({
- timestamp: imageTime,
- template: imageTemplate,
- crop: crop.join(","),
- location: loc.join(","),
- });
- imageElement.src = `/thumbnail/${globalStreamName}/source.png?${query}`;
- }
- imageElement.classList.remove("hidden");
- });
-
- const thumbnailTemplateSelection = document.getElementById("video-info-thumbnail-template");
- const thumbnailTemplatesListResponse = await fetch("/thrimshim/templates");
- if (thumbnailTemplatesListResponse.ok) {
- const thumbnailTemplatesList = await thumbnailTemplatesListResponse.json();
- const templateNames = thumbnailTemplatesList.map((t) => t.name);
- templateNames.sort();
- for (const template of thumbnailTemplatesList) {
- thumbnailTemplates[template.name] = template;
- }
- for (const templateName of templateNames) {
- const templateOption = document.createElement("option");
- templateOption.innerText = templateName;
- templateOption.value = templateName;
- templateOption.title = thumbnailTemplates[templateName].description;
- if (templateName === videoInfo.thumbnail_template) {
- templateOption.selected = true;
- }
- thumbnailTemplateSelection.appendChild(templateOption);
- }
- setDefaultCrop(false);
- } else {
- addError("Failed to load thumbnail templates list");
- }
- if (videoInfo.thumbnail_crop !== null) {
- for (let i = 0; i < 4; i++) {
- document.getElementById(`video-info-thumbnail-crop-${i}`).value = videoInfo.thumbnail_crop[i];
- }
- }
- 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-mode").value = videoInfo.thumbnail_mode;
- updateThumbnailInputState();
- // Ensure that changing values on load doesn't set keep the page dirty.
- globalPageState = PAGE_STATE.CLEAN;
-
- document.getElementById("video-info-thumbnail-template-default-crop").addEventListener("click", (_event) => {
- setDefaultCrop(true);
- });
-
- document.getElementById("submit-button").addEventListener("click", (_event) => {
- submitVideo();
- });
- document.getElementById("save-button").addEventListener("click", (_event) => {
- saveVideoDraft();
- });
- document.getElementById("submit-changes-button").addEventListener("click", (_event) => {
- submitVideoChanges();
- });
-
- 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("download-frame").addEventListener("click", (_event) => {
- downloadFrame();
- });
-
- 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 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.",
- );
- return;
- }
- knownTransitions = await response.json();
- updateTransitionTypes();
-}
-
-// Update the given list of transition type