58 Commits
Author SHA1 Message Date
sugoidogo 5650f82d77 css update
publish.yaml / release (push) Successful in 32s
2026-07-29 03:42:12 -07:00
sugoidogo bd4628efea Revert "css update"
This reverts commit 3e335e72e3.
2026-07-29 02:06:06 -07:00
sugoidogo e0e6b83162 update obsconnect url
publish.yaml / release (push) Successful in 31s
2026-07-29 00:50:40 -07:00
sugoidogo b088c5bf85 fix wrong assets
publish.yaml / release (push) Successful in 31s
2026-07-28 23:56:54 -07:00
sugoidogo 78e7d06fd5 update defaults to use tubie
publish.yaml / release (push) Successful in 33s
2026-07-28 23:49:24 -07:00
sugoidogo 3e335e72e3 css update 2026-07-28 23:44:49 -07:00
sugoidogo a710dcf130 fix esbuild warning
/ release (push) Successful in 29s
2025-12-20 07:41:19 -08:00
sugoidogo 2134290895 refactor for npm instead of submodule
/ release (push) Successful in 37s
2025-12-20 07:39:13 -08:00
sugoidogo 7cc765ddc4 refactor for new host
/ release (push) Successful in 30s
2025-12-20 07:15:11 -08:00
josephsmendoza 95aa404ecd remove hostname from error tracking for local file 2024-11-08 03:30:53 -08:00
josephsmendoza 488cce9beb add release info to error tracker 2024-11-08 03:19:28 -08:00
josephsmendoza 51038c5e54 warn when OBS connection is missing 2024-10-05 00:43:07 -07:00
josephsmendoza 65d8e3d591 add hostname to sentry init 2024-10-01 00:46:08 -07:00
josephsmendoza a1657fb48c change non-crittical error to warning 2024-09-30 21:06:29 -07:00
josephsmendoza b0b65ff77f add remotejs 2024-09-30 09:46:36 -07:00
josephsmendoza 2260e251d9 remove error popup 2024-09-29 22:48:50 -07:00
SugoiDogoandGitHub d1ee10260b add glitchtip 2024-09-29 22:19:57 -07:00
SugoiDogoandGitHub b574abbc9d add glitchtip 2024-09-29 22:18:40 -07:00
josephsmendoza 756d14b620 v8 release 2023-06-19 13:31:36 -07:00
josephsmendoza be41430c92 show short url when running in OBS 2023-04-15 17:25:18 -07:00
josephsmendoza eaaa3e8667 update readme 2023-01-04 02:37:06 +00:00
josephsmendoza ac2c724069 update url limit 2022-12-20 23:17:49 +00:00
josephsmendoza 7992cdaa6c update hide.css 2022-12-20 23:17:33 +00:00
josephsmendoza 85550498fb use unsafe loading 2022-12-17 04:16:41 +00:00
josephsmendoza 65f8194f3d add submodules to deploy 2022-12-17 03:25:23 +00:00
josephsmendoza 5256dbc0b7 fix submodule 2022-12-17 03:23:35 +00:00
josephsmendoza 89dfaeda90 v7 beta release 2022-12-17 03:21:54 +00:00
josephsmendoza d296629d29 seperate animation from volume event 2022-12-15 23:48:23 +00:00
josephsmendoza 64839c8bd1 dynamic browser kind name and file path name 2022-12-05 22:51:52 +00:00
josephsmendoza 9fa56ec7b9 adjust css for blink images 2022-12-05 07:43:02 +00:00
josephsmendoza 52806907c7 new tubie mascot 2022-12-05 07:37:03 +00:00
josephsmendoza 4ec700af70 blinking update 2022-12-01 09:16:51 +00:00
josephsmendoza 39ca665c57 introducing a basic ass mascot 2022-12-01 09:16:51 +00:00
SugoiDogoandGitHub 6d2e349b55 Update README.md 2022-10-15 18:44:24 -06:00
SugoiDogoandGitHub 6d41d8bbcd Update README.md 2022-10-15 18:17:14 -06:00
josephsmendoza f3ec4618b5 fix settings loading bug 2022-10-15 23:21:16 +00:00
SugoiDogoandGitHub 1cf4474b13 Update README.md 2022-10-15 06:22:50 -06:00
josephsmendoza c6b42ac0b5 delay initial connection 2022-10-09 06:46:22 +00:00
SugoiDogoandGitHub f77038ef57 Update README.md 2022-10-05 16:26:29 -06:00
SugoiDogoandGitHub 960602aee2 Update README.md 2022-10-01 15:53:29 -06:00
josephsmendoza 6d069c5bf8 combine basic and center css files 2022-09-11 10:56:29 +00:00
josephsmendoza 0688855502 restore default position 2022-09-11 10:03:02 +00:00
josephsmendoza 03abaf9f60 update css.html 2022-09-10 12:00:47 +00:00
josephsmendoza ad36c44442 add backoff functions 2022-09-10 12:00:37 +00:00
josephsmendoza 00f39c74d2 add discord-reactive-images style css 2022-09-10 11:40:16 +00:00
josephsmendoza e433023a7a use basic.css and add delay to hide transition 2022-09-10 11:39:06 +00:00
josephsmendoza 2e825ba5d2 resize images to fit container 2022-09-10 11:37:40 +00:00
josephsmendoza 4fe036d7f5 use % instead of vh 2022-09-10 11:36:58 +00:00
josephsmendoza 7defd98af9 don't block state changes for infinite animations 2022-09-10 11:35:43 +00:00
josephsmendoza 18be882141 document pngtube naming scheme 2022-09-06 14:23:18 +00:00
josephsmendoza 4661af0d7c document css requirements 2022-09-06 14:19:10 +00:00
josephsmendoza adf4497563 v6 initial release 2022-09-06 14:15:34 +00:00
SugoiDogoandGitHub 1526fe5623 Create pages.yml 2022-09-06 05:26:07 -06:00
josephsmendoza 25aeee6a68 change default open threshold 2022-09-04 08:10:26 +00:00
josephsmendoza 90618972ea add noise gate button 2022-09-04 08:08:16 +00:00
josephsmendoza 8c7fa019d1 finish v5 2022-09-04 07:26:40 +00:00
josephsmendoza 6e16d6a59f add/remove pngtube via obs-websocket 2022-09-04 01:45:43 +00:00
josephsmendoza b7c9024b1e update readme 2022-08-28 07:45:37 +00:00
25 changed files with 2374 additions and 785 deletions
+14
View File
@@ -0,0 +1,14 @@
on:
push:
tags:
- "*"
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: npm ci
- run: npx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+3 -4
View File
@@ -1,4 +1,3 @@
/_*
/*.pid
/*.log
/v4/yarn
node_modules
.wrangler
assets/*.js*
+1
View File
@@ -0,0 +1 @@
@sugoidogo:registry=https://gitea.sugoidogo.com/api/packages/sugoidogo/npm/
+8 -22
View File
@@ -1,25 +1,11 @@
# PNGTube
Become a PNGTuber without using discord
OBS Browser Source for creating an image based avatar that animates to audio input. Useful for PNG-tubing or as a TTS avatar.
## Usage
0. Check your OBS version by reading the title bar while it's open
- If it's under 27 (26 or lower), you need to update OBS before using this overlay
- If it's 27, install the [Websocket Plugin Beta](https://github.com/obsproject/obs-websocket/releases)
1. Open OBS
2. Check your OBS version by reading the window title
- If it's under 27 (26 or lower), you need to update OBS
- If it's 27, close OBS and install the [OBS Websocket Plugin](https://github.com/obsproject/obs-websocket/releases/tag/5.0.1)
- If it's over 27 (28 or higher), you can skip this step
1. Add a browser source with the url https://sugoidogo.github.io/pngtube2/v4/overlay.html
- **The overlay will be empty when you first add it. This is normal.**
2. Add a custom browser dock (Docks > Custom Browser Docks) with the url https://sugoidogo.github.io/pngtube2/v4/dock.html
- to run multiple instances, add `?id=somename` to both the dock and overlay url.
- replace `somename` with something identifiable
- the dock and overlay must have the same id to be able to change settings
3. Add your images and any password that is set in the websocket settings (Tools > OBS-Websocket Settings)
4. **Add a Noise Gate Filter to the source you plan to use.** The audio levels reported by obs are in a format I don't understand. You are welcome to open an issue if you can detail how to convert the values into decibles, like shown on OBS's audio mixer. For now, you should adjust some settings on the noise gate:
1. Close Threshold: How quiet your audio source must be before it's cut off.
2. Open Threshold: How loud your audio source must be before it's audible again.
- If you don't understand what these numbers mean, look at the audio mixer in obs. It has numbers along the meter to show you where your audio levels are at.
3. Release Time: How long the audio level has to stay under the Close Threshold to get cut off.
- This value is in milliseconds. 1000 milliseconds equals 1 second. Keep in mind that this determines how long it takes for audio to cut off.
5. You're Done! You can close the dock now and open it again later to change settings under the Dock menu in OBS.
## Support
[Get support on Discord](https://discord.gg/zxDnYSvMNw)
[Give support on Patreon](https://www.patreon.com/SugoiDogo)
2. With OBS open, visit the [PNGTube Setup Page](https://pngtube.sugoidogo.com/) and authorize OBS.
3. After configuring all your settings, hit 'Save & Download' to get your avatar.
You can drag & drop this file into your scene in OBS, or create a browser source, check the 'Local file' option, and select your avatar.
+30
View File
@@ -0,0 +1,30 @@
<style>
body {
--blink-min: 1;
--blink-max: 10;
--blink-time: 0.1;
}
#active-blink.blink.active,
#idle-blink.blink:not(.active) {
opacity: 1;
}
</style>
<script type="module">
(function blink(){
const style=getComputedStyle(document.body)
const min=+style.getPropertyValue('--blink-min')
const max=+style.getPropertyValue('--blink-max')
const time=+style.getPropertyValue('--blink-time')
console.debug(min,max,time)
for(const element of document.querySelectorAll('*')){
element.classList.add('blink')
}
setTimeout(()=>{
for(const element of document.querySelectorAll('*')){
element.classList.remove('blink')
}
},time*1000)
setTimeout(blink, (Math.random() * (max - min) + min)*1000)
})()
</script>
+25
View File
@@ -0,0 +1,25 @@
img {
--bounce-height: 5%;
height: calc(100% - var(--bounce-height));
bottom: 0%;
}
.active {
animation: bounce 0.5s;
}
@keyframes bounce {
0% {
bottom: 0%;
animation-timing-function: ease-out;
}
50% {
bottom: var(--bounce-height);
animation-timing-function: ease-in;
}
100% {
bottom: 0%;
}
}
+3
View File
@@ -0,0 +1,3 @@
#idle, #idle-blink {
filter: brightness(50%);
}
+36
View File
@@ -0,0 +1,36 @@
{
"assets":[
{
"id":"active",
"url":"tubie/active.png"
},
{
"id": "active-blink",
"url": "tubie/active-blink.png"
},
{
"id": "idle",
"url": "tubie/idle.png"
},
{
"id": "idle-blink",
"url": "tubie/idle-blink.png"
},
{
"id":"bounce_animation",
"url":"bounce.css"
},
{
"id":"idle_half_brightness",
"url":"brightness.css"
},
{
"id":"blink_animation",
"url":"blink.html"
}
],
"sources":[
"Mic/Aux"
],
"obs_token":"ws://localhost:4455"
}
+13
View File
@@ -0,0 +1,13 @@
img {
bottom:-100%;
transition-property: bottom;
transition-duration: 0.5s;
transition-delay: 1s;
transition-timing-function: ease-in;
}
img.active{
bottom:0%;
transition-delay: 0s;
transition-timing-function: ease-out;
}
+131
View File
@@ -0,0 +1,131 @@
<head>
<link rel="icon" href="data:image/png;base64,iVBORw0KGgo=">
<link rel="stylesheet" href="https://unpkg.com/sakura.css/css/sakura.css" media="screen" />
<link rel="stylesheet" href="https://unpkg.com/sakura.css/css/sakura-dark.css"
media="screen and (prefers-color-scheme: dark)" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<h1>PNGTube</h1>
<select id="instance" hidden></select>
<form name="new_instance" hidden>
<input type="text" name="instance_name" required>
<button type="submit">Create new instance</button>
</form>
<form name="instance_config" hidden>
<button type="button" id="remove_instance">Delete</button>
<br>
<table>
<tr>
<td>
<h2>Assets</h2>
<div id="asset_warning" hidden>
Possible missing assets:
<ul id="missing_assets"></ul>
Make sure all your assets have been added,
<br>
and their id's are set correctly below.
<br>
If you have already done so, ignore this warning.
<br><br>
</div>
<input type="file" id="asset_file_input" multiple>
<ul id="asset_list"></ul>
</td>
<td>
<h2>Activation</h2>
<div id="obs_config">
<span id="obs_status">Connecting to OBS...</span>
<input type="hidden" name="obs_token">
<button type="button" id="obs_auth">Get OBS Authorization</button>
</div>
<br>
<button type="button" id="add_source">Add Source</button>
<ul id="source_list"></ul>
</td>
<td id="instructions">
<h3>Adjusting Sensitivity</h3>
<table>
<thead>
<tr>
<td colspan="2">Noise Gate Settings</td>
</tr>
</thead>
<tr>
<td>Close Threshold</td>
<td>Idle volume level</td>
</tr>
<tr>
<td>Open Threshold</td>
<td>Active volume level</td>
</tr>
<tr>
<td>Hold Time</td>
<td>How long to hold before switching back to idle</td>
</tr>
</table>
<br>
PNGTube will consider your audio sources 'active'
if there is any sound coming through at all.
The volume meters in OBS won't show sounds quieter than -60db,
so even if you think it's silent,
most audio devices have some static noise in them.
The "Adjust Sensitivity" button on the left will open the filter panel,
and add a noise gate if you don't have one already.
</td>
</tr>
</table>
<div id="status">Loading...</div>
<button type="submit">Save & Download</button>
</form>
<datalist id="element_references"></datalist>
<datalist id="obs_sources"></datalist>
<span id="templates" hidden>
<li class="asset">
<form name="assets">
<button type="button" onclick="this.parentElement.parentElement.remove()">remove</button>
<input type="search" name="id" required pattern="[\w-]+" list="element_references">
<span id="filename"></span>
<input type="hidden" name="url">
</form>
</li>
<li class="source">
<button type="button" onclick="this.parentElement.remove()">remove</button>
<input type="search" name="sources" list="obs_sources" required pattern=".+">
<br>
<button type="button" class="sensitivity">Adjust Sensitivity</button>
</li>
</span>
<style>
body {
margin: 0;
}
form[name=new_instance]:not([hidden]),
form[name=instance_config]:not([hidden]){
display: inline;
}
td {
border: 1px solid lightgray;
vertical-align: top;
white-space: nowrap;
width: fit-content;
}
#instructions {
white-space: normal;
width: 20em;
}
button#add_source,
button.sensitivity {
width: 100%;
}
.source {
margin: 10px;
}
form, input[type=search] {
margin-bottom: 0;
}
</style>
<script type="module" src="index.js"></script>
+24
View File
@@ -0,0 +1,24 @@
<style>
body {
display: flex;
justify-content: center;
align-items: center;
}
img {
position: absolute;
bottom: 0%;
width: 100%;
height: 100%;
object-fit: contain;
opacity: 0;
}
#idle:not(.active) {
opacity: 1;
}
#active.active {
opacity: 1;
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 520 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

-330
View File
@@ -1,330 +0,0 @@
<!DOCTYPE html>
<head>
<title>PNGTuber</title>
</head>
<div id="imgdiv">
<img id="img" class="img">
<img id="simg" class="img">
</div>
<div id="controls">
<img id="logo" src="logo.png" />
<p>Volume threshold</p>
<input type="range" id="volume-range" min="1" max="100" oninput="onVolumeRange(this.value)" />
<input type="number" id="volume-number" min="1" max="100" oninput="onVolumeNumber(this.value)" />
<p>Idle brightness</p>
<input type="range" id="brightness-range" min="1" max="100" oninput="onBrightnessRange(this.value)" />
<input type="number" id="brightness-number" min="1" max="100" oninput="onBrightnessNumber(this.value)" />
<p>Animation transition time</p>
<input type="range" id="time-range" min="0" max="1" step="0.01" oninput="onTimeRange(this.value)" />
<input type="number" id="time-number" min="0" max="1" step="0.01" oninput="onTimeNumber(this.value)" />
<p>Silence transition delay</p>
<input type="range" id="delay-range" min="0" max="1" step="0.1" oninput="onDelayRange(this.value)" />
<input type="number" id="delay-number" min="0" max="1" step="0.1" oninput="onDelayNumber(this.value)" />
<p>Jump height</p>
<input type="range" id="height-range" min="0" max="100" oninput="onHeightRange(this.value)" />
<input type="number" id="height-number" min="0" max="100" oninput="onHeightNumber(this.value)" />
<p>Idle image</p>
<input type="file" accept="image/*" oninput="setIdle(this.files[0])" />
<p>Talking image</p>
<input type="file" accept="image/*" oninput="setTalking(this.files[0])" />
</div>
<style>
* {
font-size: 4vmin;
font-family: Arial, Helvetica, sans-serif;
margin: 0px;
}
.img {
position: absolute;
bottom: 0;
margin-left: auto;
margin-right: auto;
max-width: 100vw;
max-height: 100vh;
}
#img {
opacity: 0;
transition-property: top;
}
#imgdiv {
display: flex;
justify-content: center;
align-items: center;
}
#controls:hover {
opacity: 1;
}
#controls {
width: 99vw;
height: 99vh;
position: absolute;
top: 0px;
background-color: rgba(127, 127, 127, 0.5);
opacity: 0;
}
#logo {
max-width: 100vw;
max-height: 25vh;
}
input[type=range] {
width: 75vw;
-webkit-appearance: none;
appearance: none;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 1vw;
height: 3vh;
background-color: black;
}
input[type=range]::-webkit-slider-runnable-track {
-webkit-appearance: none;
appearance: none;
width: 75vw;
height: 3vh;
background-color: white;
}
#volume-range {
--volume: 0%;
position: relative;
width: 75vw;
height: 3vh;
background-color: white;
}
#volume-range::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: var(--volume);
background-color: rgba(0, 255, 0, 0.5);
transition: width 100ms linear;
}
</style>
<script src="https://localforage.github.io/localForage/localforage.min.js"></script>
<script>
const VOLUME = "volume";
const VOLUME_RANGE = document.getElementById("volume-range");
const VOLUME_NUMBER = document.getElementById("volume-number");
const BRIGHTNESS = "brightness";
const BRIGHTNESS_RANGE = document.getElementById("brightness-range");
const BRIGHTNESS_NUMBER = document.getElementById("brightness-number");
const TIME="time";
const TIME_RANGE=document.getElementById("time-range");
const TIME_NUMBER=document.getElementById("time-number");
const DELAY="delay"
const DELAY_RANGE=document.getElementById("delay-range");
const DELAY_NUMBER=document.getElementById("delay-number");
const HEIGHT="height";
const HEIGHT_RANGE=document.getElementById("height-range");
const HEIGHT_NUMBER=document.getElementById("height-number");
const IDLE_IMAGE = "idle-image";
const TALKING_IMAGE = "talking-image";
const IMAGE = document.getElementById("img");
const SIMAGE = document.getElementById("simg");
const DEFAULT_VOLUME = 50;
const DEFAULT_BRIGHTNESS = 90;
const DEFAULT_TIME=0.2;
const DEFAULT_HEIGHT=5;
const DEFAULT_IDLE = "";
const DEFAULT_TALKING = "";
const DEFAULT_DELAY=0.5;
const LEGACY_BRIGHTNESS = "darkness";
const LEGACY_IDLE = "closed";
const LEGACY_TALKING = "open";
const storage = localforage;
let volume;
let brightness;
let time;
let idle;
let talking;
let height;
let delay;
function setHeight(input){
height=input;
IMAGE.style.maxHeight=(100-height)+"vh";
IMAGE.style.top=height+"vh";
SIMAGE.style.maxHeight=(100-height)+"vh";
SIMAGE.style.top=height+"vh";
}
function setTime (input) {
document.getElementById("img").style.transitionDuration=input+'s';
time=input;
}
function setIdle(input) {
const reader = new FileReader();
reader.addEventListener("load", function () {
SIMAGE.src = reader.result;
storage.setItem(IDLE_IMAGE, reader.result);
}, false);
reader.readAsDataURL(input);
}
function setTalking(input) {
const reader = new FileReader();
reader.addEventListener("load", function () {
IMAGE.src = reader.result;
storage.setItem(TALKING_IMAGE, reader.result);
}, false);
reader.readAsDataURL(input);
}
function setBrightness(input){
brightness = input;
SIMAGE.style.setProperty('filter', 'brightness(' + brightness + '%)');
}
function onVolumeRange(input) {
volume = input;
VOLUME_NUMBER.value = input;
storage.setItem(VOLUME, input);
}
function onVolumeNumber(input) {
volume = input;
VOLUME_RANGE.value = input;
storage.setItem(VOLUME, input);
}
function onBrightnessRange(input) {
setBrightness(input)
BRIGHTNESS_NUMBER.value = input;
storage.setItem(BRIGHTNESS, input);
}
function onBrightnessNumber(input) {
setBrightness(input)
BRIGHTNESS_RANGE.value = input;
storage.setItem(BRIGHTNESS, input);
}
function onTimeRange(input) {
setTime(input);
TIME_NUMBER.value=input;
storage.setItem(TIME, input);
}
function onTimeNumber(input){
setTime(input);
TIME_RANGE.value=input;
storage.setItem(TIME, input);
}
function onHeightRange(input){
setHeight(input);
HEIGHT_NUMBER.value=input;
storage.setItem(HEIGHT,input);
}
function onHeightNumber(input){
setHeight(input);
HEIGHT_RANGE.value=input;
storage.setItem(HEIGHT,input);
}
function onDelayRange(input){
delay=input;
DELAY_NUMBER.value=input;
storage.setItem(DELAY,input);
}
function onDelayNumber(input){
delay=input;
DELAY_RANGE.value=input;
storage.setItem(DELAY,input);
}
window.onload = async function () {
try {
volume = await storage.getItem(VOLUME) || DEFAULT_VOLUME;
brightness = await storage.getItem(BRIGHTNESS) || DEFAULT_BRIGHTNESS;
idle = await storage.getItem(IDLE_IMAGE) || DEFAULT_IDLE;
talking = await storage.getItem(TALKING_IMAGE) || DEFAULT_TALKING;
delay = await storage.getItem(DELAY) || DEFAULT_DELAY;
setHeight(await storage.getItem(HEIGHT) || DEFAULT_HEIGHT);
setTime(await storage.getItem(TIME) || DEFAULT_TIME);
VOLUME_RANGE.value = volume;
VOLUME_NUMBER.value = volume;
BRIGHTNESS_RANGE.value = brightness;
BRIGHTNESS_NUMBER.value = brightness;
TIME_RANGE.value=time;
TIME_NUMBER.value=time;
HEIGHT_NUMBER.value=height;
HEIGHT_RANGE.value=height;
DELAY_RANGE.value = delay;
DELAY_NUMBER.value = delay;
IMAGE.src = talking;
SIMAGE.src=idle;
SIMAGE.style.setProperty('filter', 'brightness(' + brightness + '%)');
const volumeVisualizer = document.getElementById("volume-range");
let backoff=null
const volumeCallback = function(averageVolume) {
averageVolume=Math.min(parseInt(averageVolume.data),100)
volumeVisualizer.style.setProperty(
"--volume",
averageVolume + "%"
);
if (averageVolume <= volume) {
if(backoff==null && IMAGE.style.opacity==1){
SIMAGE.style.opacity=1
IMAGE.style.opacity=0
}
} else {
if(backoff!=null){
clearTimeout(backoff)
}
backoff=setTimeout(()=>backoff=null,Math.max(time*2000,delay*1000))
if (IMAGE.style.opacity==0) {
IMAGE.style.opacity=1
SIMAGE.style.opacity=0
IMAGE.style.transitionTimingFunction='ease-out'
IMAGE.style.top="0vh";
setTimeout(()=>{
IMAGE.style.top=height+"vh";
IMAGE.style.transitionTimingFunction='ease-in'
},time*1000);
}
}
};
const startAudio=function (event=null){
if(event){
console.log(event)
}
let ev=new EventSource('http://localhost:5000/audio')
ev.onmessage=volumeCallback;
ev.onerror=startAudio;
window.onbeforeunload=function(event=null){
ev.close()
}
}
startAudio();
} catch (e) {
window.alert(e.message);
throw e;
}
}
</script>
+1653
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"scripts": {
"test": "wrangler dev --ip=localhost"
},
"devDependencies": {
"wrangler": "^4.56.0"
},
"dependencies": {
"@sugoidogo/js-util": "^0.2.0",
"localforage": "^1.10.0",
"obs-websocket-js": "^5.0.7"
}
}
+339
View File
@@ -0,0 +1,339 @@
import { FileReaderAsync, getFormDataDeep } from "@sugoidogo/js-util"
import localforage from "localforage"
import OBSWebSocket from "obs-websocket-js/json"
/** @type {Storage} */
const localStorage = localforage.createInstance({ name: 'pngtube.v7' })
const obs = new OBSWebSocket()
/** @type {HTMLDivElement} */
const status_div = document.querySelector('div#status')
/** @type {HTMLSelectElement} */
const instance_select = document.querySelector('select#instance')
/** @type {HTMLFormElement} */
const new_instance_form = document.querySelector('form[name=new_instance]')
/** @type {HTMLFormElement} */
const instance_config_form = document.querySelector('form[name=instance_config')
/** @type {HTMLInputElement} */
const tts_checkbox = document.querySelector('input[name=tts]')
/** @type {HTMLInputElement} */
const source_input = document.querySelector('input#sources')
/** @type {HTMLUListElement} */
const source_list = document.querySelector('ul#source_list')
/** @type {HTMLUListElement} */
const asset_list = document.querySelector('ul#asset_list')
/** @type {HTMLInputElement} */
const asset_file_input = document.querySelector('input#asset_file_input')
/** @type {HTMLSpanElement} */
const obs_status_span = document.querySelector('span#obs_status')
/** @type {HTMLInputElement} */
const obs_token_input = document.querySelector('input[name=obs_token]')
/** @type {HTMLDataListElement} */
const obs_sources_datalist = document.querySelector('datalist#obs_sources')
/** @type {HTMLSpanElement} */
const templates = document.querySelector('span#templates')
function set_loading(loading) {
if (loading) {
status_div.innerHTML = 'Loading...'
status_div.hidden = false
instance_select.hidden = true
new_instance_form.hidden = true
instance_config_form.hidden = true
} else {
instance_select.hidden = false
status_div.hidden = true
}
}
async function updateReferences() {
let refs = ['idle', 'active']
for (const asset_list_item of asset_list.children) {
const asset_url_input = asset_list_item.querySelector('input[name=url]')
const asset_dataurl = asset_url_input.value
if (asset_dataurl.startsWith('data:text')) {
const asset_text = await fetch(asset_dataurl, { cache: 'no-cache' }).then(response => response.text())
const asset_references = asset_text.matchAll(/#[\w-]+/g)
for (const asset_reference of asset_references) {
refs.push(asset_reference[0].substring(1))
}
}
}
refs = [...new Set(refs)]
/** @type {HTMLDataListElement} */
const element_references_datalist = document.querySelector('datalist#element_references')
while (element_references_datalist.childElementCount > 0) {
element_references_datalist.firstElementChild.remove()
}
for (const ref of refs) {
const option = document.createElement('option')
option.value = ref
element_references_datalist.appendChild(option)
}
for (const asset_list_item of asset_list.children) {
const asset_id_input = asset_list_item.querySelector('input[name=id]')
const asset_id = asset_id_input.value
if (refs.includes(asset_id)) {
refs.splice(refs.indexOf(asset_id), 1)
}
}
const asset_warning_div = document.querySelector('div#asset_warning')
if (refs.length != 0) {
const missing_assets_list = asset_warning_div.querySelector('ul#missing_assets')
while (missing_assets_list.childElementCount > 0) {
missing_assets_list.firstElementChild.remove()
}
for (const ref of refs) {
const listItem = document.createElement('li')
listItem.innerHTML = ref
missing_assets_list.appendChild(listItem)
}
asset_warning_div.hidden = false
} else {
asset_warning_div.hidden = true
}
}
/**
* @param {File|String} source
* @param {String} id
*/
async function add_asset(source, id = undefined) {
const asset_list_item = templates.querySelector('li.asset').cloneNode(true)
const asset_id = asset_list_item.querySelector('input[name=id]')
const asset_file_name = asset_list_item.querySelector('span#filename')
const asset_url = asset_list_item.querySelector('input[name=url]')
const asset_remove_button = asset_list_item.querySelector('button')
asset_remove_button.addEventListener('click', updateReferences)
if (!(source instanceof File)) {
let filename = null
if (!source.startsWith('data:')) {
filename = source.split('/').at(-1)
}
source = await fetch(source, { cache: 'no-cache' }).then(response => response.blob())
if (!filename) {
filename = id + '.' + source.type.split('/')[1]
}
source = new File([source], filename, { type: source.type })
}
asset_file_name.innerHTML = source.name
asset_url.value = await FileReaderAsync.readAs('DataURL', source)
if (id) {
asset_id.value = id
} else {
asset_id.value = source.name.split('.')[0]
}
asset_id.oninput = updateReferences
asset_list.appendChild(asset_list_item)
updateReferences()
}
/** @param {HTMLInputElement} source_input */
async function adjust_sensitivity(source_input) {
obs.call('GetSourceFilterList', { sourceName: source_input.value })
.then((response) => {
for (const filter of response.filters) {
if (filter.filterKind = 'noise_gate_filter') {
obs.call('OpenInputFiltersDialog', { inputName: source_input.value })
return
}
}
obs.call('CreateSourceFilter', {
sourceName: source_input.value,
filterName: 'Noise Gate for PNGTube',
filterKind: 'noise_gate_filter',
filterSettings: {
close_threshold: -60,
open_threshold: -30
}
}).then((response) => {
obs.call('OpenInputFiltersDialog', { inputName: source_input.value })
})
}).catch(error => {
if (error.message = 'Not connected') {
alert('Not connected to OBS')
}
})
}
async function add_source(name = 'Mic/Aux') {
const source_list_item = templates.querySelector('li.source').cloneNode(true)
const source_input = source_list_item.querySelector('input[name=sources]')
const source_sensitivity_button = source_list_item.querySelector('button.sensitivity')
source_input.value = name
source_sensitivity_button.onclick = () => adjust_sensitivity(source_input)
source_list.appendChild(source_list_item)
}
document.querySelector('button#add_source').onclick = () => add_source()
async function load_config(instance_name = instance_select.value) {
set_loading(true)
obs.disconnect()
for (const list of [asset_list, source_list]) {
while (list.childElementCount > 0) {
list.firstElementChild.remove()
}
}
if (instance_name == '') {
set_loading(false)
new_instance_form.hidden = false
return false
}
const loading_promises = []
const config = await localStorage.getItem(instance_name) || await fetch('default.json', { cache: 'no-cache' }).then(response => response.json())
console.debug(config)
if (config.obs_token) {
obs_token_input.value = config.obs_token
obs_init()
}
if (config.assets) {
if (!(config.assets instanceof Array)) {
config.assets = [config.assets]
}
for (const asset of config.assets) {
loading_promises.push(add_asset(asset.url, asset.id))
}
}
if (config.sources) {
if (typeof config.sources == 'string') {
config.sources = [config.sources]
}
for (const source of config.sources) {
loading_promises.push(add_source(source))
}
}
return Promise.allSettled(loading_promises).then(() => {
set_loading(false)
instance_config_form.hidden = false
return config
})
}
instance_select.onchange = () => load_config()
async function instance_select_init() {
while (instance_select.childElementCount > 0) {
instance_select.firstElementChild.remove()
}
for (const instance_name of await localStorage.keys()) {
const option = document.createElement('option')
option.value = instance_name
option.innerHTML = instance_name
instance_select.appendChild(option)
}
const option = document.createElement('option')
option.innerHTML = 'Create New Instance'
option.value = ''
instance_select.appendChild(option)
instance_select.hidden = false
}
async function add_instance(name) {
const option = document.createElement('option')
option.value = name
option.innerHTML = name
instance_select.prepend(option)
instance_select.value = name
load_config()
}
new_instance_form.onsubmit = (event) => {
event.preventDefault()
add_instance(new_instance_form.instance_name.value)
new_instance_form.instance_name.value = ''
}
asset_file_input.onchange = (event) => {
for (const file of asset_file_input.files) {
add_asset(file)
}
asset_file_input.value = ''
}
obs.on('ConnectionClosed', (error) => {
obs_status_span.innerHTML = 'Not connected to OBS'
console.warn(error)
})
obs.on('Identified', () => {
obs_status_span.innerHTML = 'Connected to OBS'
obs.call('GetInputList')
.then((response) => {
for (const input of response.inputs) {
const option = document.createElement('option')
option.value = input.inputName
obs_sources_datalist.append(option)
}
})
})
obs.on('InputCreated', (event) => {
console.debug('source created: ' + event.inputName)
const option = document.createElement('option')
option.value = event.inputName
obs_sources_datalist.append(option)
})
obs.on('InputRemoved', (event) => {
console.debug('source removed: ' + event.inputName)
document.querySelector("option[value='" + event.inputName + "']").remove()
})
obs.on('InputNameChanged', (event) => {
console.debug('source renamed :' + event.oldInputName + " > " + event.inputName)
document.querySelector("option[value='" + event.oldInputName + "']").value = event.inputName
})
async function obs_init() {
let obsurl
let obspassword
const search_params = new URLSearchParams(location.search)
if (search_params.has('obsurl')) {
obsurl = search_params.get('obsurl')
obspassword = search_params.get('obspassword')
const url = new URL(obsurl)
url.password = obspassword
obs_token_input.value = url.href
} else {
if (obs_token_input.value) {
const url = new URL(obs_token_input.value)
obspassword = url.password
url.password = ''
obsurl = url.href
} else {
obs_status_span.innerHTML = 'No OBS Authorization found'
}
}
return obs.connect(obsurl, obspassword)
}
async function obs_auth() {
const url = new URL('https://obsconnect.sugoidogo.com')
url.searchParams.append('redirect_uri', location.href)
location.assign(url)
}
document.querySelector('button#obs_auth').onclick = obs_auth
instance_config_form.onsubmit = (event) => {
event.preventDefault()
const config = getFormDataDeep(instance_config_form)
console.debug(config)
localStorage.setItem(instance_select.value, config)
const url = new URL(location.origin + location.pathname)
if (url.pathname.endsWith('/')) {
url.pathname += 'overlay.html'
} else {
const path = url.pathname.split('/')
path.pop()
path.push('overlay.html')
url.pathname = path.join('/')
}
fetch(url).then(response => response.text())
.then(async html => {
const script = document.createElement('script')
script.type = 'module'
script.innerHTML = 'const config=' + JSON.stringify(config)+'\n'
script.innerHTML+=await fetch(new URL('overlay.js',url)).then(response=>response.text())
html += script.outerHTML
const blob = new Blob([html])
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = 'pngtube-' + instance_select.value + '.html'
a.click()
})
}
async function remove_instance(name = instance_select.value) {
localStorage.removeItem(name)
.then(instance_select_init)
.then(load_config)
}
document.querySelector('button#remove_instance').onclick = () => remove_instance()
instance_select_init()
.then(load_config)
+69
View File
@@ -0,0 +1,69 @@
import { loadAsset } from '@sugoidogo/js-util'
import OBSWebSocket from 'obs-websocket-js/json'
console.debug(config)
if (!(config.assets instanceof Array)) {
config.assets = [config.assets]
}
if (typeof config.sources == 'string') {
config.sources = [config.sources]
}
for (const source of config.assets) {
loadAsset(source.url, source.id, true)
}
// backoff mechanism
let backoff = false
function backoffStart(event) {
if (event instanceof AnimationEvent && getComputedStyle(event.target, event.pseudoElement).animationIterationCount == 'infinite') {
return
}
backoff = true
}
function backoffEnd() {
backoff = false
}
window.ontransitionstart = backoffStart
window.ontransitionend = backoffEnd
window.ontransitioncancel = backoffEnd
window.onanimationstart = backoffStart
window.onanimationend = backoffEnd
window.ontransitioncancel = backoffEnd
// animation function
let active = false
requestAnimationFrame(function animate() {
if (!backoff) {
for (const img of document.querySelectorAll('img')) {
if (active) {
img.classList.add('active')
} else {
img.classList.remove('active')
}
}
}
requestAnimationFrame(animate)
})
// obs connection
const obs = new OBSWebSocket()
obs.on('ConnectionClosed', (error) => {
window.alert('PNGTube error ' + error.code + ': ' + error.message)
})
obs.on('Identified', (event) => {
console.debug(event)
})
obs.on('InputVolumeMeters', (event) => {
const inputs = event.inputs.filter(input => config.sources.includes(input.inputName))
for (const input of inputs) {
for (const channel of input.inputLevelsMul) {
if (channel[0] > 0) {
active = true
return
}
}
}
active = false
})
const token = new URL(config.obs_token)
const password = token.password
token.password = ''
const url = token.href
obs.connect(url, password, { eventSubscriptions: (1 << 16) })
-208
View File
@@ -1,208 +0,0 @@
<!DOCTYPE html>
<body>
<table>
<tr>
<td>Idle Image</td>
<td>
<input type="file" accept="image/*" data-id="idle">
</td>
</tr>
<tr>
<td>Active Image</td>
<td>
<input type="file" accept="image/*" data-id="active">
</td>
</tr>
<tr>
<td>OBS URL</td>
<td>
<input type="url" data-id="url">
</td>
</tr>
<tr>
<td>OBS Password</td>
<td>
<input type="password" data-id="password">
</td>
</tr>
<tr>
<td>Audio Sources</td>
<td>
<select data-id="source"></select>
<button id="addSource">Add</button>
<ul>
<li hidden>
<button id="removeSource">Remove</button>
<label></label>
</li>
</ul>
</td>
</tr>
<tr>
<td>Idle Brightness</td>
<td>
<input type="range" data-id="brightness" min="1" max="100">
<input type="number" data-id="brightness" min="1" max="100">
</td>
</tr>
<tr>
<td>Jump Height</td>
<td>
<input type="range" data-id="height" min="0" max="100" />
<input type="number" data-id="height" min="0" max="100" />
</td>
</tr>
<tr>
<td>Animation Time</td>
<td>
<input type="range" data-id="time" min="0" max="1" step="0.01" />
<input type="number" data-id="time" min="0" max="1" step="0.01" />
</td>
</tr>
</table>
</body>
<script>
window.onload = async function () {
// set common variables
const params = new URLSearchParams(window.location.search)
const JSUID = await (await fetch('jsuid')).text() + params.get('id')
const broadcastChannel = new BroadcastChannel(JSUID)
// register oninput function for all inputs
for (const element of document.querySelectorAll('input')) {
element.oninput = async function () {
const value = element.value
const id = element.dataset['id']
for (const input of element.parentElement.querySelectorAll('input')) {
if (input == element) {
continue
}
input.value = value
}
broadcastChannel.postMessage('set ' + id + ' ' + value)
}
}
// override oninput function for all file inputs
for (const element of document.querySelectorAll('[type=file]')) {
element.oninput = async function () {
for (const file of element.files) {
const id = element.dataset['id']
const fileReader = new FileReader()
fileReader.onload = function () {
const value = fileReader.result
broadcastChannel.postMessage('set ' + id + ' ' + value)
}
fileReader.readAsDataURL(file)
}
}
}
/**
* onclick function for removeSource
* @param {Event} event
*/
async function removeSource(event) {
const button = event.target
const parent = button.parentElement
const label = parent.querySelector('label')
const source = label.innerHTML
parent.remove()
broadcastChannel.postMessage('disable ' + source)
console.log(source)
}
/**
* onclick function for addSource
* @param {Event} event
* @param {String} source
*/
async function addSource(event = null, sourceName = null) {
const select = document.querySelector('[data-id="source"]')
const source = select.value
const parent = select.parentElement
const list = parent.querySelector('ul')
const listItem = parent.querySelector('li').cloneNode(true)
const label = listItem.querySelector('label')
const rbutton = listItem.querySelector('button')
rbutton.onclick = removeSource
label.innerHTML = sourceName || source
listItem.hidden = false
list.appendChild(listItem)
if (sourceName == null) {
broadcastChannel.postMessage('enable ' + source)
}
}
document.querySelector('#addSource').onclick = addSource
// register onmessage function for broadcastChannel
broadcastChannel.onmessage = async function (event) {
const args = event.data.split(' ')
switch (args[0]) {
case 'ping': {
broadcastChannel.postMessage('pong')
}
case 'pong': { // Both overlay and dock are now online
// clear all sources, we're about to get new ones
const selector = document.querySelector('[data-id="source"]')
selector.innerHTML = ''
selector.value = ''
const list=document.querySelector('ul')
const listItem=document.querySelector('li')
list.innerHTML=''
list.appendChild(listItem)
break
}
case 'set': { // sync mechanism
const id = args[1]
const value = args[2]
for (const element of document.querySelectorAll('input[data-id=' + id + ']')) {
/**
* if the value is already set in the ui, reject the change and trigger the input event,
* syncing to the existing value. ranges are set by the browser on creation,
* and their synced number input always comes next, so never reject, instead allowing the number to set the range.
* always reject file inputs, which must be user-selected, triggering sending of any selected files.
*/
if ((element.value == '' || element.type == 'range' || element.tagName.toLowerCase() == 'select') && element.type != 'file') {
element.value = value
} else {
element.oninput()
}
}
break
}
case 'add': {
const value = args[1]
const selector = document.querySelector('[data-id="source"]')
const option = document.createElement('option')
option.value = value
option.innerHTML = value
selector.appendChild(option)
broadcastChannel.postMessage('get source')
break
}
case 'remove': {
const value = args[1]
if (selector.value == value) {
selector.value = ''
}
const selector = document.querySelector('[data-id="source"]')
const option = selector.querySelector('[value="' + value + ']"')
option.remove()
break
}
case 'enable': {
const source = args[1]
addSource(null, source)
}
case 'disable': {
const source=args[1]
const label=document.querySelector('[innerHTML="'+source+'"]')
const listItem=label.parentElement
listItem.remove()
}
default: {
window.alert(JSUID + '-dock did not understand this message:\n' + event.data)
}
}
}
// send startup completed ping
broadcastChannel.postMessage('ping')
}
</script>
-1
View File
@@ -1 +0,0 @@
sugoidogo-pngtube-v4
File diff suppressed because one or more lines are too long
-218
View File
@@ -1,218 +0,0 @@
<!DOCTYPE html>
<body>
<img data-id="idle">
<img data-id="active">
</body>
<style>
body {
display: flex;
justify-content: center;
align-items: center;
}
img {
position: absolute;
bottom: 0vh;
margin-left: auto;
margin-right: auto;
height: 100vh;
}
[data-id="active"] {
opacity: 0;
transition-property: bottom;
}
</style>
<script src="https://cdn.rawgit.com/mozilla/localForage/master/dist/localforage.js"></script>
<script src="obs-ws.min.cjs"></script>
<script>
window.onload = async function () {
// set persistant variables
const params = new URLSearchParams(window.location.search)
const JSUID = await (await fetch('jsuid')).text() + params.get('id')
const broadcastChannel = new BroadcastChannel(JSUID)
localStorage = localforage.createInstance({ name: JSUID })
const obs = new OBSWebSocket()
const SIMAGE = document.querySelector('[data-id="idle"]')
const IMAGE = document.querySelector('[data-id="active"]')
let backoff = false
// set defaults
const config = {
idle: '',
active: '',
url: 'ws://localhost:4455',
password: '',
sources: [],
brightness: 50,
height: 1,
time: 0.2,
}
var sources = []
IMAGE.style.transitionTimingFunction = 'ease-out'
// register ontransitionend function for active image
IMAGE.ontransitionend = async function () {
if (IMAGE.style.transitionTimingFunction == 'ease-out') {
IMAGE.style.transitionTimingFunction = 'ease-in'
} else {
IMAGE.style.transitionTimingFunction = 'ease-out'
}
if (IMAGE.style.bottom == '0vh') {
backoff = false
} else {
IMAGE.style.bottom = '0vh'
}
}
// register oninputvolumemeters for obs
obs.addListener('InputVolumeMeters', function (data) {
// construct event object
const inputs = data.inputs
const event = {}
for (const input of inputs) {
event[input.inputName] = input.inputLevelsMul
}
// remove sources that no longer exist
for (const source of sources) {
if (!source in event) {
sources.splice(sources.indexOf(source), 1)
broadcastChannel.postMessage('remove ' + source)
}
}
// add newly created sources
for (const source in event) {
if (!sources.includes(source)) {
sources.push(source)
//console.log(source)
broadcastChannel.postMessage('add ' + source)
}
}
// if the selected source is missing, exit function
if (!config.source in event) {
return
}
// get the audio level of the selected sources
/**
* AUDIO LEVEL FORMAT
* event[source][channel][?]=float
* each channel contains an array of 3 floats
*/
let level = 0
for (const source of config.sources) {
level += event[source][0][0] || 0
}
// animate the image if neccessary
if (backoff) {
return
}
if (level != 0) {
if (IMAGE.style.opacity == 1) {
return
}
if (config.time != 0) {
backoff = true
}
IMAGE.style.opacity = 1
SIMAGE.style.opacity = 0
IMAGE.style.bottom = config.height + 'vh'
} else {
SIMAGE.style.opacity = 1
IMAGE.style.opacity = 0
}
})
// register onmessage function for broadcastChannel
broadcastChannel.onmessage = async function (event) {
const args = event.data.split(' ')
switch (args[0]) {
case 'ping': {
broadcastChannel.postMessage('pong')
}
case 'pong': { // Both overlay and dock are now online
// trigger sync mechanism
for (const id in config) {
broadcastChannel.postMessage('set ' + id + ' ' + config[id])
}
for (const source of config.sources) {
broadcastChannel.postMessage('enable ' + source)
}
sources = []
break
}
case 'set': {
const id = args[1]
const value = args[2]
for (const element of document.querySelectorAll('[data-id=' + id + ']')) {
element.src = value
}
if (id == 'brightness') {
SIMAGE.style.filter = 'brightness(' + config.brightness + '%)'
}
if (id == 'height') {
IMAGE.style.height = 100 - config.height + 'vh'
SIMAGE.style.height = 100 - config.height + 'vh'
}
if (id == 'time') {
IMAGE.style.transitionDuration = value + 's'
}
if (id == 'password' || id == 'url') { // try to use new connection information
//obs.disconnect()
obs.connect(url = config.url, password = config.password, {
eventSubscriptions: (1 << 16),
})
}
localStorage.setItem(id, value)
config[id] = value
break
}
case 'get': {
const id = args[1]
const value = config[id]
broadcastChannel.postMessage('set ' + id + ' ' + value)
break
}
case 'enable': {
const source = args[1]
config.sources.push(source)
localStorage.setItem('sources', JSON.stringify(config.sources))
break
}
case 'disable': {
const source = args[1]
const index = config.sources.indexOf(source)
config.sources.splice(index, 1)
localStorage.setItem('sources', JSON.stringify(config.sources))
break
}
default: {
window.alert(JSUID + '-overlay did not understand this message:\n' + event.data)
}
}
}
// Sync config. If no value is stored, the default is used.
// The resulting value is then sent to the dock (if loaded),
// which may reject the value if user has already set a different one.
// This mechanism creates a config priority of dock, storage, defaults.
for (const id in config) {
config[id] = await localStorage.getItem(id) || config[id]
broadcastChannel.postMessage('set ' + id + ' ' + config[id])
}
config.sources = JSON.parse(await localStorage.getItem('sources')) || []
const source=await localStorage.getItem('source')
if(source){
config.sources.push(source)
localStorage.removeItem('source')
}
// apply config
IMAGE.src = config.active
IMAGE.style.height = 100 - config.height + 'vh'
IMAGE.style.transitionDuration = config.time + 's'
SIMAGE.src = config.idle
SIMAGE.style.height = 100 - config.height + 'vh'
SIMAGE.style.filter = 'brightness(' + config.brightness + '%)'
// send startup completed ping
broadcastChannel.postMessage('ping')
// attempt obs connection
obs.connect(url = config.url, password = config.password, {
eventSubscriptions: (1 << 16),
})
}
</script>
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "pngtube",
"assets": {"directory": "assets"},
"workers_dev": false,
"keep_vars": true,
"compatibility_date": "2025-12-20",
"build": {
"watch_dir": "./src",
"command": "esbuild --outdir=assets --format=esm --bundle --minify --sourcemap --sources-content src/*"
}
}