From 05a53924f635f54be97d977f0f097df700d12627 Mon Sep 17 00:00:00 2001 From: ElementalAlchemist Date: Thu, 7 Nov 2024 23:43:20 -0600 Subject: [PATCH] Initial structure and clock This is the start of replacing the old Thrimbletrimmer with a new SolidJS-based Thrimbletrimmer. It includes an implementation of the clock page for a basic implementation of signal-based page. --- docker-compose.jsonnet | 4 +- thrimbletrimmer/.gitignore | 3 + thrimbletrimmer/.prettierignore | 5 +- thrimbletrimmer/clock.html | 56 - thrimbletrimmer/driveclock/bus_day.png | Bin 439 -> 0 bytes thrimbletrimmer/driveclock/bus_night.png | Bin 520 -> 0 bytes thrimbletrimmer/driveclock/db_dawn.png | Bin 196 -> 0 bytes thrimbletrimmer/driveclock/db_day.png | Bin 196 -> 0 bytes thrimbletrimmer/driveclock/db_dusk.png | Bin 196 -> 0 bytes thrimbletrimmer/driveclock/db_night.png | Bin 196 -> 0 bytes thrimbletrimmer/driveclock/drive.html | 54 - thrimbletrimmer/driveclock/drive.js | 122 - thrimbletrimmer/driveclock/stops.png | Bin 7397 -> 0 bytes thrimbletrimmer/edit.html | 481 +--- thrimbletrimmer/images/arrow.png | Bin 637 -> 0 bytes thrimbletrimmer/images/minus.png | Bin 4787 -> 0 bytes thrimbletrimmer/images/pencil.png | Bin 4906 -> 0 bytes thrimbletrimmer/images/play_to.png | Bin 4957 -> 0 bytes thrimbletrimmer/images/plus.png | Bin 4817 -> 0 bytes .../images/video-controls/fullscreen.png | Bin 633 -> 0 bytes .../images/video-controls/pause.png | Bin 600 -> 0 bytes .../images/video-controls/play.png | Bin 667 -> 0 bytes .../images/video-controls/volume-mute.png | Bin 756 -> 0 bytes .../images/video-controls/volume.png | Bin 747 -> 0 bytes thrimbletrimmer/index.html | 166 +- thrimbletrimmer/package.json | 24 + thrimbletrimmer/scripts/chat-load.js | 49 - thrimbletrimmer/scripts/common-worker.js | 21 - thrimbletrimmer/scripts/common.js | 736 ----- thrimbletrimmer/scripts/edit.js | 2406 ----------------- thrimbletrimmer/scripts/hls.min.js | 2 - thrimbletrimmer/scripts/jcrop.js | 2 - thrimbletrimmer/scripts/keyboard-shortcuts.js | 163 -- thrimbletrimmer/scripts/luxon.min.js | 1 - thrimbletrimmer/scripts/stream.js | 345 --- thrimbletrimmer/scripts/thumbnails.js | 444 --- thrimbletrimmer/src/edit.tsx | 7 + thrimbletrimmer/src/editor/Editor.tsx | 7 + thrimbletrimmer/src/external/luxon.min.js | 1 + thrimbletrimmer/src/index.tsx | 7 + thrimbletrimmer/src/restreamer/Restreamer.tsx | 7 + thrimbletrimmer/src/thumbnails.tsx | 7 + .../src/thumbnails/ThumbnailManager.tsx | 7 + thrimbletrimmer/src/utilities/Clock.tsx | 54 + thrimbletrimmer/src/utilities/Utilities.tsx | 12 + thrimbletrimmer/src/utils.tsx | 7 + thrimbletrimmer/styles/jcrop.css | 173 -- thrimbletrimmer/styles/thrimbletrimmer.css | 496 ---- thrimbletrimmer/styles/thumbnails.css | 43 - thrimbletrimmer/thumbnail_manager.html | 155 -- thrimbletrimmer/thumbnails.html | 12 + thrimbletrimmer/tsconfig.json | 14 + thrimbletrimmer/utils.html | 12 + thrimbletrimmer/vite.config.ts | 23 + 54 files changed, 216 insertions(+), 5912 deletions(-) create mode 100644 thrimbletrimmer/.gitignore delete mode 100644 thrimbletrimmer/clock.html delete mode 100644 thrimbletrimmer/driveclock/bus_day.png delete mode 100644 thrimbletrimmer/driveclock/bus_night.png delete mode 100644 thrimbletrimmer/driveclock/db_dawn.png delete mode 100644 thrimbletrimmer/driveclock/db_day.png delete mode 100644 thrimbletrimmer/driveclock/db_dusk.png delete mode 100644 thrimbletrimmer/driveclock/db_night.png delete mode 100644 thrimbletrimmer/driveclock/drive.html delete mode 100644 thrimbletrimmer/driveclock/drive.js delete mode 100644 thrimbletrimmer/driveclock/stops.png delete mode 100644 thrimbletrimmer/images/arrow.png delete mode 100644 thrimbletrimmer/images/minus.png delete mode 100644 thrimbletrimmer/images/pencil.png delete mode 100644 thrimbletrimmer/images/play_to.png delete mode 100644 thrimbletrimmer/images/plus.png delete mode 100644 thrimbletrimmer/images/video-controls/fullscreen.png delete mode 100644 thrimbletrimmer/images/video-controls/pause.png delete mode 100644 thrimbletrimmer/images/video-controls/play.png delete mode 100644 thrimbletrimmer/images/video-controls/volume-mute.png delete mode 100644 thrimbletrimmer/images/video-controls/volume.png create mode 100644 thrimbletrimmer/package.json delete mode 100644 thrimbletrimmer/scripts/chat-load.js delete mode 100644 thrimbletrimmer/scripts/common-worker.js delete mode 100644 thrimbletrimmer/scripts/common.js delete mode 100644 thrimbletrimmer/scripts/edit.js delete mode 100644 thrimbletrimmer/scripts/hls.min.js delete mode 100644 thrimbletrimmer/scripts/jcrop.js delete mode 100644 thrimbletrimmer/scripts/keyboard-shortcuts.js delete mode 100644 thrimbletrimmer/scripts/luxon.min.js delete mode 100644 thrimbletrimmer/scripts/stream.js delete mode 100644 thrimbletrimmer/scripts/thumbnails.js create mode 100644 thrimbletrimmer/src/edit.tsx create mode 100644 thrimbletrimmer/src/editor/Editor.tsx create mode 100644 thrimbletrimmer/src/external/luxon.min.js create mode 100644 thrimbletrimmer/src/index.tsx create mode 100644 thrimbletrimmer/src/restreamer/Restreamer.tsx create mode 100644 thrimbletrimmer/src/thumbnails.tsx create mode 100644 thrimbletrimmer/src/thumbnails/ThumbnailManager.tsx create mode 100644 thrimbletrimmer/src/utilities/Clock.tsx create mode 100644 thrimbletrimmer/src/utilities/Utilities.tsx create mode 100644 thrimbletrimmer/src/utils.tsx delete mode 100644 thrimbletrimmer/styles/jcrop.css delete mode 100644 thrimbletrimmer/styles/thrimbletrimmer.css delete mode 100644 thrimbletrimmer/styles/thumbnails.css delete mode 100644 thrimbletrimmer/thumbnail_manager.html create mode 100644 thrimbletrimmer/thumbnails.html create mode 100644 thrimbletrimmer/tsconfig.json create mode 100644 thrimbletrimmer/utils.html create mode 100644 thrimbletrimmer/vite.config.ts 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 @@ - - - - - Stream Time - - - -
-
seconds of delay
- - - diff --git a/thrimbletrimmer/driveclock/bus_day.png b/thrimbletrimmer/driveclock/bus_day.png deleted file mode 100644 index c85bccd3bbb8ca3508cb50451d0325fe9eb18f6d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 439 zcmeAS@N?(olHy`uVBq!ia0vp^DImqR zJ$J>*t$X)-S^d>zU|?kNba4!+nDh2pG#`^94{Lx>tkcZn|ID>mxw|%|vT(|8mtQ8F z?YG{j@J_CirGi5P10xd)hX5*L6Q7gvDTfpN8n?RmTL0V<{w}KM$;pao zp)D0F4URH;T^Cs5Gi!Obyh_UI>*rSWdx}2nx}SgbDd+Q7+3Tu;s<&0`3xAgVSUbqq hOyR#S)DI_0o-pS3-B=pZ#={BqR zJ$J>*t$X)-S^d>zU|!M}_0oH_U^_971puzNi2B=*oWAF6jA^4+}mg zH>w2H2~YfecKZs8&aTtm!aHyJEcag|_?$-}O*8lQEsw-0XA-O|JFB9fyz&&fyMEtA zY1QHzetwJoPim8t@>ukHY3y|Qr%yi2P1AoZ?rQSnMe!o>rMpTR3{G(fSsi|tm2qIB zIA>gF`IJ@de%rTp$jOwe8of7{`so`N`Y|T_&db?SU9$glz8pUmtn%uONnlKI%sap9 zb#tFN*y-6m{$$A>l+Q2wc*ELs$5&OGZ_A&*{<(1f?G-occU@%$1<8c{&UFl&-=6C0 SY_j$Og}bM#pUXO@geCwmR^d4S diff --git a/thrimbletrimmer/driveclock/db_dawn.png b/thrimbletrimmer/driveclock/db_dawn.png deleted file mode 100644 index 552dc35c887af27018c364e409409d5b5969def0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 196 zcmeAS@N?(olHy`uVBq!ia0y~yU~d4jQ<#{6q}0Bn{y>T+z$e62Tfe=?*HJs~ShStV z?TH5rf#SuUE{-7;bKYKcJ(kE=; z_rRj@F^G}ZZXzzDUNGlCVI7E2vjWNp%R&Y2@P9AQZ^#Jq^8^{~>FVdQ&MBb@01;v| AssI20 diff --git a/thrimbletrimmer/driveclock/db_day.png b/thrimbletrimmer/driveclock/db_day.png deleted file mode 100644 index 5cf7d3a0c9b2b0b5faf2712cabc50091342dca78..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 196 zcmeAS@N?(olHy`uVBq!ia0y~yU~d4jQ<#{6q}0Bn{y>T+z$e7j@!X@Qlclxuj%_a! z+h4z&4Jcmh>EaktG3V`7M_vX)9+r)&l`7tw+!wx|=$km@EJy)CaA30cExSuBeZm%g z4=frVgBW@3CgL*c1#=D*)`18$E1-<9EL7kQ|M&9zhKw*jPms}`u6{1-oD!M<5P>#P diff --git a/thrimbletrimmer/driveclock/db_dusk.png b/thrimbletrimmer/driveclock/db_dusk.png deleted file mode 100644 index b58fb45917fc6ebf2dcf6ab60119e5728be819ff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 196 zcmeAS@N?(olHy`uVBq!ia0y~yU~d4jQ<#{6q}0Bn{y>T+z$e7@{-k|(CuM2p9otwM zYGD$&5hz~l>EaktG3V`7M_vX)9+r)&l`7tw+!wx|=$km@EE`Y(L2zKQ_bt0iEPcWj zeh(}fAA=Zq?Iz+f>IHKS6xM+VH7lTuuq;&I4*&P^{DzD$KTnX+p00i_>zopr06c&; Am;e9( diff --git a/thrimbletrimmer/driveclock/db_night.png b/thrimbletrimmer/driveclock/db_night.png deleted file mode 100644 index c444a53276f8f252ce869ed69771edd918e70bdc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 196 zcmeAS@N?(olHy`uVBq!ia0y~yU~d4jQ<#{6q}0Bn{y>T+z$e62NZ8CkK}tLCn3|Mm z*3vtwK=EQv7srr_Id88z@-i6muxwPVRPo;AzVQ7--^3|r*? - - - - - - -
-
-
-
-
-
- - - - - diff --git a/thrimbletrimmer/driveclock/drive.js b/thrimbletrimmer/driveclock/drive.js deleted file mode 100644 index 788bde4..0000000 --- a/thrimbletrimmer/driveclock/drive.js +++ /dev/null @@ -1,122 +0,0 @@ - -const PAGE_WIDTH = 1920; -const MINUTES_PER_PAGE = 60; -const POINT_WIDTH = PAGE_WIDTH * 8 * 60 / MINUTES_PER_PAGE; -const MILES_PER_PAGE = 45; -const BUS_POSITION_X = 93; -const BASE_ODO = 109.3; -const UPDATE_INTERVAL_MS = 5000 -const WUBLOADER_URL = ""; -const SKY_URLS = { - day: "db_day.png", - dawn: "db_dawn.png", - dusk: "db_dusk.png", - night: "db_night.png", -}; -const BUS_URLS = { - day: "bus_day.png", - dawn: "bus_day.png", - dusk: "bus_day.png", - night: "bus_night.png", -}; - -function setSkyElements(left, right, timeToTransition) { - const leftElement = document.getElementById("timeofday-left"); - const rightElement = document.getElementById("timeofday-right"); - const busElement = document.getElementById("bus"); - - leftElement.style.backgroundImage = `url(${SKY_URLS[left]})`; - rightElement.style.backgroundImage = `url(${SKY_URLS[right]})`; - - if (left === right) { - leftElement.style.width = "100%"; - } else { - const transitionPercent = timeToTransition / MINUTES_PER_PAGE; - leftElement.style.width = `${transitionPercent * 100}%` - } - - bus.style.backgroundImage = `url(${BUS_URLS[left]})`; -} - -function nextSkyTransition(timeofday, clock) { - switch (timeofday) { - case "dawn": - case "day": - return [19 * 60, "dusk"]; // 7pm - case "dusk": - return [20 * 60, "night"]; // 8pm - case "night": - return [6 * 60 + 40, "dawn"]; // 6:40am - } -} - -function setSky(timeofday, clock) { - const [transition, newSky] = nextSkyTransition(timeofday, clock); - // 1440 minutes in 24h, this code will return time remaining even if - // the transition is in the morning and we're currently in the evening. - const timeToTransition = (1440 + transition - clock) % 1440; - if (timeToTransition < MINUTES_PER_PAGE) { - // Transition on screen - setSkyElements(timeofday, newSky, timeToTransition); - } else { - // No transition on screen - setSkyElements(timeofday, timeofday, undefined); - } -} - -function setOdo(odo) { - const distancePixels = PAGE_WIDTH * (odo - BASE_ODO) / MILES_PER_PAGE; - const offset = (BUS_POSITION_X - distancePixels) % POINT_WIDTH; - - const stopsElement = document.getElementById("stops"); - stopsElement.style.backgroundPosition = `${offset}px 0px`; -} - -async function update() { - const busDataResponse = await fetch(`${WUBLOADER_URL}/thrimshim/bus/buscam`); - if (!busDataResponse.ok) { - return; - } - const busData = await busDataResponse.json(); - console.log("Got data:", busData); - setOdo(busData.odometer); - setSky(busData.timeofday, busData.clock_minutes); -} - -// Initial conditions, before the first refresh finishes -setSky("day", 7 * 60); -setOdo(BASE_ODO); - -// Testing mode. Set true to enable. -const test = false; -if (test) { - let h = 0; - // Set to how long 1h of in-game time should take in real time - const hourTimeMs = 1 * 1000; - // Set to how often to update the screen - const interval = 30; - setInterval(() => { - h += interval / hourTimeMs; - setOdo(BASE_ODO + 45 * h); - if (h < 19) { - setSky("day", 60 * h); - } else { - m = (h % 24) * 60; - let tod; - if (m < 6 * 60 + 40) { - tod = "night"; - } else if (m < 19 * 60) { - tod = "dawn"; - } else if (m < 20 * 60) { - tod = "dusk"; - } else { - tod = "night"; - } - setSky(tod, m); - } - }, interval); -} else { - // Do first update immediately, then every UPDATE_INTERVAL_MS - setInterval(update, UPDATE_INTERVAL_MS); - update(); -} diff --git a/thrimbletrimmer/driveclock/stops.png b/thrimbletrimmer/driveclock/stops.png deleted file mode 100644 index 552266e2c402b7f9b1b2b802dda9b577d1ada7f9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7397 zcmeAS@N?(olHy`uVBq!ia0y~yumLg|QaIRvWY@eGtqcq@Ydl>XLn`LHy|uCQ_6e!B zhxN}kFfZ@(VAo^OX^cL!ZGqUjs2f6Ogbp~#%;=f3t8M2&y;7FWNqeHWn&YeH9WiQ{ zXn*|kw1^4UZmm8)Eoj{i7LWxXP{A#w2w{QAXUkIA!HfpY@(hHyLs%7pmyr3T7a>)X zvh_QHM>?x0!qsK>hdpO(;Z7pmjE19`-~Vrl;y=7t->==dyTj4?4NHL6KDFGb?x78Q{25OCwsz_9T3fIfw6(~jM>v3tm79!am`4YivSbEW% z39jcu-H!l z!ynf3&*f{^c%7KB{-?Nq`{AF*Kh63)eap91n)Uq};j`g^x<{~A0Lh->%bS?N+yhgr zO%UP=+O`PZ29sZI2&qZgf`YFTE3Q5)`p5eJ;MG;s3~80`zZA@r!a&9!y!=o=aN!D8 zgvF$?PAqnDiRM4Nns0sIV~ffSH}lqS;3U-yg!+c>4=no1?bm<%eY;!V{Px>5ixF}p zvm$^la&tLSS6}@-=j(^XpHDxwIF`11Zl)B;CLuI<7s?#J@jhSPcD=tWkbZ2@mv2Q$ zqDr;wOMdfxyZHU9`~O+lR2J;t-}~5N-&H+?jU==s4yno;B=e~10D2UjGBd@&W zfldQ@*m4`mjzDO*aI#P)dH?>}Z?ml~iy?$bW=&eX7g8gUtP`wZg7aHj@^pfAHORc0 zU(dJKoB|^zI8f+E?)osV zO#b;>uX*+B!kl#AE2WiA;F@{WJ8$1T0?ez~r;k{^`up|g>B;Y1_Q7nJFx`y<#%5q> z*qM0~5-tg+WRbG=gsE}}UW8PwGD6GyvuV45+3XmwWcc{^)yn4GcYSlV-8Ps0HYdny zx5(FdbMH?Lnsq=Nrs2~?4y1rLFxRz)$oTAZhLq}H?xssfJnzN%h-S=#y|;2e*$7l_ z?2Wdq*>L;r9obh8mu~pJFMj@hjvAQZ3X&978uxaeEt5W8*wfMR_usd7KeM*)uKc11 z^932S{B2-3b#y$oxMv$ZS63$(3a;Jy`Ll)n{{R2}_-Zb7b=mpF8s>+F zrxz#;ti{0eRc=4MBjf+W@>uH?x%|rap8wk;{jvOw_B&W{*6{Q)Wwt2n{`N_I`r4$0 zfo~2se82R&qvD%1EPxVrnmEDOK>CdDX+8)y_}vABc*B}62;PB9FQj21z`)?KYR|WX zz0bO`v)0~a-821OXBA9N!JNVvwwo3Dp3z@(sSne=-wWRdUxYWmCbUzSC*;pA(z@Ki=*X@&!Lw!DGJ7(C_U zg{fg+IN`;Q;OX@46NF1O^k-bHJ`jBF;XBJud%r-F6i|aVrN*WKjrESoe%f#UFC%_` zUGMw*`;TwWzu&SKX7z`@3<|pefmiM!m2@z3iKb61{3b`VVoZe&d=&O9fhh;mRH8*F zZaGj|Z&&jZ?N&^M3hlS5F-3_Nu^f$h;sXm!WrNIU)T0Fn@!Zi|PkdNms2t7p7(qh3 zz-WO_d{|+q9N~4n*L%kATd&2{ZkyuzHyjo?4crv=D1Mw6tw>;jNiscJk&ql@2o0kZ i2_h^=W{vQQ - + - VST Video Editor - - - - - - - - - - - - - - + Thrimbletrimmer - Editor -
-
-
- 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 -
  • -
-
-
-
- Stream - -
-
- - - -
-
- - - -
-
- -
-
- - - -
-
-
- -
-
- - / - -
-
-
- - -
-
- -
-
- -
-
- -
-
- -
- -
-
- Waveform for the video -
-
- -
- - -
-
-
-
-
- - Set range start point to the current video time - Play from start point -
- - Set range end point to the current video time - Play from end point -
- Range affected by keyboard shortcuts -
- - -
-
- Add range -
- -
- - -
- - -
- -
- - - - - -
-
- -
-
- -
-
- - Set video thumbnail frame to current video time - Set video time to video thumbnail frame -
-
-
- Advanced Templating Options - Crop specifies the region of the video frame to capture.
- Location specifies the region within the template image where the cropped image will - be placed.
- Regions are given as pixel coordinates of the top-left and bottom-right corners. -
- Note that if the regions are different sizes, the image will be stretched.
- - - -
- -
-
- -
- Crop: - - - to - - -
-
- - - -
- -
- Location: - - - to - - -
-
-
-
-
- -
- -
- -
-
-
-
- -
- - -
-
- -
- - - Download Video - Download Current Frame as Image -
- -
- - - -
-
- - +
-
-
+ diff --git a/thrimbletrimmer/images/arrow.png b/thrimbletrimmer/images/arrow.png deleted file mode 100644 index cf409879c7c33d045160ad2eb0566ca9f06e6749..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 637 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 Js|#A6`v<;xV{`xj diff --git a/thrimbletrimmer/images/plus.png b/thrimbletrimmer/images/plus.png deleted file mode 100644 index 5d86af7f17cdf8b8b93685f711dbbdab8b27224d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4817 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 T?#<3900000NkvXXu0mjfMqLRa diff --git a/thrimbletrimmer/images/video-controls/pause.png b/thrimbletrimmer/images/video-controls/pause.png deleted file mode 100644 index 8de238f044208ef9521d95c09884402b23de5520..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 600 zcmV-e0;m0nP)EX>4Tx04R}tkv&MmKp2MKrbz@3D;ex)r#C4j(NMQkskRU=q4HZ;jBTl=bb^8^S!16O+6ztI4uKS{5* zwb&6bunk;Xw>4!CxZD8-pA6ZQT`5RYC>DYDGy0}H5V-|eSad^gZEa<4bO1wgWnpw> zWFU8GbZ8()Nlj2!fese{001yaL_t(Y$75g^1p^Hj(FJzz-u)k&LRVK;Msn0K(b@;2 m7K~ajYQd-lqZW*U;Q;{Qi3lLkiMJvE0000EX>4Tx04R}tkv&MmKp2MKrbz@3D;ex)r#C4j(NMQkskRU=q4HZ;jBTl=bb^8^S!16O+6ztI4uKS{5* zwb&6bunk;Xw>4!CxZD8-pA6ZQT`5RYC>DYDGy0}H5V-|hk5?EC-#02y>eSad^gZEa<4bO1wgWnpw> zWFU8GbZ8()Nlj2!fese{0047IL_t(Y$L-ZY3V<*S1i)4DH1}QluXriwyY%8wO4B4D z(mmz01Eo!Yk^F`lc7)dd3sLMsm*r5COFSF319OK|O#8@&HXx1W<+M>~|#r z9n~X}tYQyfx~7sEX>4Tx04R}tkv&MmKp2MKrbz@3D;ex)r#C4j(NMQkskRU=q4HZ;jBTl=bb^8^S!16O+6ztI4uKS{5* zwb&6bunk;Xw>4!CxZD8-pA6ZQT`5RYC>DYDGy0}H5V-|eSad^gZEa<4bO1wgWnpw> zWFU8GbZ8()Nlj2!fese{007KML_t(Y$L*Cp3c^4Tg}=>7;w7vUM0ll@BIG36X{jd> zL>t=yv9R(Kxrc=|5oFbv%_i9R%Gu?8v-88kUv0_EmR0U05$20l03qhVfKc-wKppeS zfHHG4OUq);JGccV*a0rURMM&N7yw(KD=CixNOES@1NJ}$T!9^sNjlaZQx9?m#)a|W z*}M_JyHZClnpf6-k^KZ10UI+L{-((#S#OeNOk9AD(At;)0000EX>4Tx04R}tkv&MmKp2MKrbz@3D;ex)r#C4j(NMQkskRU=q4HZ;jBTl=bb^8^S!16O+6ztI4uKS{5* zwb&6bunk;Xw>4!CxZD8-pA6ZQT`5RYC>DYDGy0}H5V-|eSad^gZEa<4bO1wgWnpw> zWFU8GbZ8()Nlj2!fese{006^DL_t(Y$L*D|4Z<)GMPI04m{icF_;r>+f(}Ypfg(FV zdq{MQz!nrt08J91$b>kyyL`#gFHZm2x;q&rrIMNLht$mi!gomkKH=T~-*7L$nDD`X zzHl>Jx_qOoQ~dV4pnwX<>r(-_q-W3+wdv9oI0Nf%A3UZVoq*EeqXAyP+~K1EO5pDB zQJTC0EI3b0Nd>w(MhO4_002ovPDHLkV1lW_Ml=8b 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 @@ - + - VST Restreamer - - - - - - - - - + Thrimbletrimmer - Restreamer -
-
-
- 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 setDelay(+event.currentTarget.value)} + /> + seconds of delay + + + ); +}; + +export default Clock; diff --git a/thrimbletrimmer/src/utilities/Utilities.tsx b/thrimbletrimmer/src/utilities/Utilities.tsx new file mode 100644 index 0000000..cff6175 --- /dev/null +++ b/thrimbletrimmer/src/utilities/Utilities.tsx @@ -0,0 +1,12 @@ +import { Component } from "solid-js"; +import Clock from "./Clock"; + +const Utilities: Component = () => { + return ( + <> + + + ); +}; + +export default Utilities; diff --git a/thrimbletrimmer/src/utils.tsx b/thrimbletrimmer/src/utils.tsx new file mode 100644 index 0000000..6ed0dda --- /dev/null +++ b/thrimbletrimmer/src/utils.tsx @@ -0,0 +1,7 @@ +import { render } from "solid-js/web"; + +import Utilities from "./utilities/Utilities"; + +const root = document.getElementById("root"); + +render(() => , root!); diff --git a/thrimbletrimmer/styles/jcrop.css b/thrimbletrimmer/styles/jcrop.css deleted file mode 100644 index 7bc80d2..0000000 --- a/thrimbletrimmer/styles/jcrop.css +++ /dev/null @@ -1,173 +0,0 @@ -.jcrop-widget .jcrop-handle { - display: none; - position: absolute; - border: 1px rgba(127, 127, 127, 0.8) solid; - width: 10px; - height: 10px; - box-sizing: border-box; - background: rgba(255, 255, 255, 0.8) -} - -.jcrop-widget .jcrop-handle.nw { - top: -3px; - left: -3px; - cursor: nwse-resize -} - -.jcrop-widget .jcrop-handle.w { - top: 50%; - transform: translateY(-50%); - left: -3px; - cursor: ew-resize -} - -.jcrop-widget .jcrop-handle.sw { - bottom: -3px; - left: -3px; - cursor: nesw-resize -} - -.jcrop-widget .jcrop-handle.ne { - top: -3px; - right: -3px; - cursor: nesw-resize -} - -.jcrop-widget .jcrop-handle.e { - top: 50%; - transform: translateY(-50%); - right: -3px; - cursor: ew-resize -} - -.jcrop-widget .jcrop-handle.se { - bottom: -3px; - right: -3px; - cursor: nwse-resize -} - -.jcrop-widget .jcrop-handle.n { - left: 50%; - transform: translateX(-50%); - top: -3px; - cursor: ns-resize -} - -.jcrop-widget .jcrop-handle.s { - left: 50%; - transform: translateX(-50%); - bottom: -3px; - cursor: ns-resize -} - -.jcrop-widget.active .jcrop-handle { - display: block -} - -.jcrop-widget { - position: absolute; - box-sizing: border-box; - border: 1px white dashed; - opacity: 0.7; - background: transparent; - transition: opacity 1s; - padding: 0; - margin: 0; - cursor: move -} - -.jcrop-widget:hover { - transition: opacity 0.8s; - opacity: 0.8 -} - -.jcrop-shade { - background: rgba(0, 0, 0, 0.5); - transition: opacity 0.4s, background-color 0.7s; - position: absolute -} - -.jcrop-shade.l { - top: 0px; - left: 0px; - height: 100% -} - -.jcrop-shade.r { - top: 0px; - right: 0px; - height: 100% -} - -.jcrop-shade.t { - top: 0px -} - -.jcrop-shade.b { - bottom: 0px -} - -.jcrop-stage { - position: relative; - width: 100% -} - -.jcrop-image-stage img { - position: absolute; - z-index: -1 -} - -.jcrop-ux-inactive-handles .jcrop-widget .jcrop-handle { - display: block -} - -.jcrop-widget img { - width: 100%; - height: auto -} - -.jcrop-ux-fade-more .jcrop-widget { - opacity: 0.25 -} - -.jcrop-ux-fade-more .jcrop-widget:hover { - transition: opacity 0.4s; - opacity: 0.8 -} - -.jcrop-ux-fade-more .jcrop-widget:focus { - transition: opacity 0.5s; - opacity: 1; - outline-style: auto; - outline-width: 3px; - outline-color: rgba(0, 0, 0, 0.3) -} - -.jcrop-ux-fade-more .jcrop-widget { - opacity: 0.25 -} - -.jcrop-ux-fade-more .jcrop-widget:hover { - opacity: 0.65 -} - -.jcrop-ux-keep-current .jcrop-widget.active { - opacity: 1; - outline-style: auto; - outline-width: 3px; - outline-color: rgba(0, 0, 0, 0.3) -} - -.jcrop-ux-no-outline .jcrop-widget { - outline: none !important -} - -.jcrop-disable.jcrop-stage { - opacity: .8 -} - -.jcrop-disable.jcrop-stage .jcrop-widget { - outline: none !important -} - -/*# sourceMappingURL=jcrop.css.map */ diff --git a/thrimbletrimmer/styles/thrimbletrimmer.css b/thrimbletrimmer/styles/thrimbletrimmer.css deleted file mode 100644 index 005381a..0000000 --- a/thrimbletrimmer/styles/thrimbletrimmer.css +++ /dev/null @@ -1,496 +0,0 @@ -body { - /* Firefox has a weird default font, which is a different size from the one in Chrome - * and makes some renderings bad. - */ - font-family: "Arial", sans-serif; - - background: #222; - color: #fff; - height: 100vh; - margin: 0; -} - -a { - color: #ccf; -} - -input, -textarea { - background: #222; - color: #fff; - border-color: #444; -} - -textarea { - /* This will look better if it's consistent with input fields */ - border-style: inset; - border-width: 2px; -} - -button, -select { - background: #333; - color: #fff; -} - -button:active { - background: #000; -} - -a, -.click { - cursor: pointer; -} - -a.click { - text-decoration: underline; -} - -.input-error { - border-color: #b00; -} - -.input-error:focus { - outline: #d00 solid 1px; -} - -#errors { - color: #f33; - display: flex; - flex-direction: column; -} - -#errors > div { - border-bottom: 1px solid #f33; - background: #300; - padding: 4px; -} - -.error-dismiss { - float: right; -} - -#page-container { - position: relative; - max-height: 100vh; - display: flex; - flex-direction: column; - justify-content: flex-start; - align-items: stretch; -} - -#page-container > * { - flex: 0 1 auto; -} - -#editor-help { - position: absolute; - top: 0; - right: 0; - background: #222; - padding: 5px; -} - -#stream-time-settings { - display: flex; - align-items: flex-end; - gap: 5px; - margin-bottom: 10px; - margin-top: 5px; -} - -#stream-time-settings > div { - margin: 0 2px; -} - -.field-label { - display: block; -} - -#video { - width: 100%; - max-height: 50vh; -} - -#video-controls { - font-size: 110%; -} - -#video-controls select { - appearance: none; - font-size: inherit; - background: inherit; - border: none; - text-align: center; -} - -#video-controls option { - background: #222; - padding: 2px; -} - -#video-controls-bar { - display: flex; - align-items: center; - gap: 8px; -} - -#video-controls-spacer { - flex-grow: 1; -} - -#video-controls-volume { - display: flex; - align-items: center; - gap: 2px; -} - -#video-controls-volume-level { - width: 100px; - height: 8px; - border-radius: 0; - border: 0; -} - -#video-controls-playback-position { - height: 10px; - border-radius: 0; - border: 0; -} - -/* For some reason, there's not a cross-browser way to style elements. - * This should be replaced with a cross-browser way of doing this when possible. - * I only implemented WebKit/Blink and Firefox here because if you still use IE, - * I quite frankly don't care about you. - */ - -/* WEBKIT/BLINK SECTION */ -#video-controls-volume-level::-webkit-progress-bar { - background: #ffffff30; -} - -#video-controls-volume-level::-webkit-progress-value { - background: #ffffffc0; -} - -#video-controls-playback-position::-webkit-progress-bar { - background: #ffffff30; -} - -#video-controls-playback-position::-webkit-progress-value { - background: #ffffffc0; -} -/* END WEBKIT/BLINK */ - -/* FIREFOX SECTION */ -#video-controls-volume-level { - background: #ffffff30; -} - -#video-controls-volume-level::-moz-progress-bar { - background: #ffffffc0; -} - -#video-controls-playback-position { - background: #ffffff30; -} - -#video-controls-playback-position::-moz-progress-bar { - background: #ffffffc0; -} -/* END FIREFOX */ - -#video-controls-playback-position { - width: 100%; -} - -#clip-bar { - width: 100%; - min-height: 7px; - background-color: #bbb; - position: relative; -} - -#clip-bar > div { - position: absolute; - background-color: #d80; - height: 100%; -} - -#waveform-container { - position: relative; -} - -#waveform { - width: 100%; - - /* With an unbound height, the waveform can appear a bit away from the video. - * The intended effect still works if we scrunch the height a bit, so here's - * a height bound for the waveform image. - */ - max-height: 100px; - filter: invert(90%); -} - -#waveform-marker { - width: 1px; - height: 100%; - background: #dd8800a0; - position: absolute; - top: 0; -} - -#range-definitions { - display: flex; - flex-direction: column; - gap: 1px; -} - -.range-transition-duration-section { - display: inline-block; -} - -.range-transition-duration { - width: 50px; -} - -.range-definition-times { - display: flex; - align-items: center; - gap: 4px; -} - -.range-definition-start, -.range-definition-end { - width: 100px; - text-align: right; -} - -.range-definition-between-time-gap { - width: 5px; -} - -.range-definition-icon-gap { - width: 16px; -} - -#add-range-definition { - margin-top: 2px; -} - -.range-definition-chapter-markers > div { - display: flex; - align-items: center; - gap: 10px; - margin-left: 30px; -} - -.range-definition-chapter-marker-start-field { - display: flex; - align-items: center; - gap: 4px; -} - -.range-definition-chapter-marker-start { - width: 100px; - text-align: right; -} - -.range-definition-chapter-marker-edit-gap { - width: 16px; -} - -input.range-definition-chapter-marker-description { - width: 500px; -} - -.add-range-definition-chapter-marker { - margin-left: 30px; - margin-bottom: 7px; -} - -#video-info { - margin: 5px 0; - display: grid; - grid-template-columns: 200px 1fr; - grid-template-rows: minmax(min-content, max-content) 1.25em 3em minmax(4em, max-content) 1.25em; - gap: 2px; -} - -#video-info-editor-notes-container { - border: 1px solid #666; - background-color: #125; - grid-column-end: span 2; -} - -/* In order to maintain the grid dimensions, when we hide the editors notes (for there not being them), - * they still need to take up a grid slot. As such, we replace `display: none` in this context with - * an effective equivalent that doesn't remove its rendering entirely. - */ -#video-info-editor-notes-container.hidden { - display: block; - visibility: hidden; - height: 0; -} - -#video-info-title-full { - display: flex; - align-items: center; - white-space: pre; -} - -#video-info-title { - flex-grow: 1; -} - -#video-info-title-abbreviated { - width: 200px; - overflow: hidden; - font-size: 1em; - line-height: 1em; - height: 2em; - text-overflow: ellipsis; - - /* For some reason, all this Webkit-specific-looking stuff is required to show ellipses - on wrapped text. - It also somehow works on Firefox. */ - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} - -.video-info-thumbnail-mode-options { - margin: 2px 0; -} - -.video-info-thumbnail-position { - width: 50px; -} - -#video-info-thumbnail-template-preview-image { - max-width: 320px; -} - -#video-info-thumbnail-template-video-source-image { - max-width: 640px; -} - -#video-info-thumbnail-template-overlay-image { - max-width: 640px; -} - -.video-info-thumbnail-advanced-crop-flex-wrapper { - display: flex; - align-items: center; -} - -.video-info-thumbnail-advanced-crop-flex-column { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; -} - -.video-info-thumbnail-advanced-crop-flex-column div { - margin: 0.5em; -} - -.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; -} - -.submission-response-pending { - color: #cc0; -} - -.submission-response-error { - color: #c00; -} - -.submission-response-success { - color: #0c0; -} - -.time-converter-time { - display: block; - width: 200px; -} - -#chat-replay { - overflow-y: auto; - min-height: 250px; -} - -.chat-replay-message { - display: flex; - align-items: baseline; - gap: 10px; -} - -.chat-replay-message-time { - flex-basis: 110px; - color: #ccc; - text-align: right; -} - -.chat-replay-message-text { - flex-basis: 200px; - flex-grow: 1; -} - -.chat-replay-message-text-action { - font-style: italic; -} - -.chat-replay-message-system { - color: #aaf; -} - -.chat-replay-message-text-action .chat-replay-message-reply:not(.chat-replay-message-text-action) { - font-style: normal; /* Clear the italics from the action */ -} - -.chat-replay-message-emote { - /* - This size is set based on Twitch's 1.0 emote size. - This will need to be updated if that changes. (Otherwise, auto-scrolling will break.) - */ - width: 28px; - height: 28px; -} - -.chat-replay-message-reply { - font-size: 80%; -} - -.chat-replay-message-reply a { - text-decoration: none; -} - -.chat-replay-message-cleared { - opacity: 0.5; -} - -.chat-replay-message-cleared .chat-replay-message-text { - text-decoration: line-through; -} diff --git a/thrimbletrimmer/styles/thumbnails.css b/thrimbletrimmer/styles/thumbnails.css deleted file mode 100644 index c84a6fd..0000000 --- a/thrimbletrimmer/styles/thumbnails.css +++ /dev/null @@ -1,43 +0,0 @@ -.hidden { - display: none; -} - -table > form { - display: table-row; -} - -#template-list-data { - border-collapse: collapse; -} - -#template-list-data td { - border: 1px solid #000; - padding: 2px; -} - -.template-list-preview { - width: 480px; -} - -#template-new-errors, -.template-data-edit-errors { - color: #c00; -} - -#template-new-form-fields { - display: grid; - grid-template-columns: max-content max-content; - gap: 1px; -} - -#template-new-form-fields > div { - display: contents; -} - -.template-coord { - width: 50px; -} - -#google-authentication { - margin-top: 5px; -} diff --git a/thrimbletrimmer/thumbnail_manager.html b/thrimbletrimmer/thumbnail_manager.html deleted file mode 100644 index 166735c..0000000 --- a/thrimbletrimmer/thumbnail_manager.html +++ /dev/null @@ -1,155 +0,0 @@ - - - - - VST Thumbnail Template Management - - - - - - - -
-

Template List

- - - - - - - - - - -
NameDescriptionAttributionCrop CoordinatesLocation CoordinatesPreview
-
- -
-

Add New Template

-
    -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - Crop: - - - - to - - - -
    -
    - Location: - - - - to - - - -
    -
    - - -
    -
    - - - - diff --git a/thrimbletrimmer/thumbnails.html b/thrimbletrimmer/thumbnails.html new file mode 100644 index 0000000..6960870 --- /dev/null +++ b/thrimbletrimmer/thumbnails.html @@ -0,0 +1,12 @@ + + + + + Thrimbletrimmer - Utilities + + +
    + + + + diff --git a/thrimbletrimmer/tsconfig.json b/thrimbletrimmer/tsconfig.json new file mode 100644 index 0000000..25838eb --- /dev/null +++ b/thrimbletrimmer/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "types": ["vite/client"], + "noEmit": true, + "isolatedModules": true + } +} diff --git a/thrimbletrimmer/utils.html b/thrimbletrimmer/utils.html new file mode 100644 index 0000000..321b4ce --- /dev/null +++ b/thrimbletrimmer/utils.html @@ -0,0 +1,12 @@ + + + + + Thrimbletrimmer - Utilities + + +
    + + + + diff --git a/thrimbletrimmer/vite.config.ts b/thrimbletrimmer/vite.config.ts new file mode 100644 index 0000000..92dc5b5 --- /dev/null +++ b/thrimbletrimmer/vite.config.ts @@ -0,0 +1,23 @@ +import { fileURLToPath } from "url"; +import { defineConfig } from "vite"; +import solidPlugin from "vite-plugin-solid"; +import devtools from "solid-devtools/vite"; + +export default defineConfig({ + base: "/thrimbletrimmer/", + plugins: [devtools(), solidPlugin()], + server: { + port: 3000, + }, + build: { + target: "esnext", + rollupOptions: { + input: { + index: fileURLToPath(new URL("index.html", import.meta.url)), + edit: fileURLToPath(new URL("edit.html", import.meta.url)), + utils: fileURLToPath(new URL("utils.html", import.meta.url)), + thumbnails: fileURLToPath(new URL("thumbnails.html", import.meta.url)), + }, + }, + }, +});