Files
WLED/wled00/data/pixelforge/pixelforge.htm
Damian Schneider f6a43f4dfa Extend scrolling text FX with custom fonts and international UTF-8 character support (#5372)
* new font format: *.wbf with a 12byte header, bit packed data and support for variable char width
* add support to load custom fonts from file system
* UTF-8 to unicode support functions
* support for any unicode offset in char 128-255 enables many international chars
* update built-in fonts with similar but nicer ones
* update pixelforge scrolling text tool with a preview and support for custom fonts
* accompanied by Font Factory tool (pixelforge) to easily create custom fonts from various formats
2026-03-29 12:10:23 +02:00

1412 lines
44 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="author" content="@dedehai" />
<link rel="shortcut icon" href="favicon.ico">
<title>WLED PixelForge</title>
<style>
body {
max-width: 800px;
margin: 0 auto;
}
/* Header styles */
.title {
font-size: 32px;
font-weight: bold;
color: #fff;
padding-top: 20px;
}
h3 {
margin-bottom: 0;
}
/* shimmer text animation */
.title .sh {
background: linear-gradient(90deg,
#7b47db 0%, #ff6b6b 20%, #feca57 40%, #48dbfb 60%, #7b47db 100%);
background-size: 200% 100%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer 4s ease-in-out 5;
font-size: 36px;
}
@keyframes shimmer { 50% { background-position: 600% 0; } }
/* image grid */
.g {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 10px;
margin: 20px 0;
padding: 0 5px;
}
/* shared border styles */
.it, #cv, #pv, .dp, button, .btn {
border: 2px solid #555;
}
.it:hover, .dp:hover, button:hover, .btn:hover {
border-color: #48a;
}
/* shared transitions */
.it, .dp {
transition: all 0.3s ease;
}
.it {
aspect-ratio: 1;
border-radius: 4px;
background-size: 100% 100%;
background-position: center;
cursor: pointer;
image-rendering: pixelated;
}
/* shared flex centering */
.it.loading, .crw, .cs {
display: flex;
align-items: center;
justify-content: center;
}
.it.loading {
background: #222;
}
.it.loading::before {
content: "Loading...";
}
/* context menu */
.cm {
position: fixed;
}
.cm button {
display: block;
width: 100%;
text-align: left;
cursor: pointer;
border-radius: 1px;
font-size: 14px;
margin: 0;
}
.cm button:hover {
background: #555;
}
.cm button.danger {
color: #b21;
}
/* Editor styles */
.ed {
display: none;
margin: 20px 0;
padding: 20px;
background: #222;
}
.ed.active {
display: block;
}
input[type="color"] {
border: 0;
}
/* canvas wrap */
.cw {
display: flex;
flex-direction: column;
gap: 5px;
align-items: center;
width: 100%;
}
/* shared canvas styles */
#cv, #pv {
background: #333;
}
#cv {
cursor: crosshair;
max-width: 100%;
border-radius: 8px;
}
#pv {
image-rendering: pixelated;
border-radius: 4px;
}
/* toast */
.t {
position: fixed;
top: 20px;
right: 20px;
background: #555;
color: #fff;
padding: 12px 20px;
border-radius: 8px;
font-size: 14px;
z-index: 888;
border: 2px solid #888;
}
/* drop zone */
.dp {
border: 2px dashed #555;
border-radius: 8px;
padding: 40px 20px;
background: #222;
cursor: pointer;
margin: 20px auto;
max-width: 80%;
}
.dp:hover {
background: #333;
}
/* buttons */
button, .btn {
border: 2px solid #333;
}
/* sliders */
.slc {
text-align: center;
margin-bottom: 5px;
}
.slc label {
display: block;
margin-bottom: 5px;
}
.sl {
width: 100%;
max-width: 500px;
}
/* controls row */
.crw {
gap: 8px;
flex-wrap: wrap;
margin: 15px 0;
}
/* tabs */
.tabc {
display: none;
}
.tabc.active {
display: block;
}
.tb {
display: flex;
gap: 4px;
border-bottom: 2px solid #555;
margin: 20px 0 0 0;
padding: 0 20px;
}
.tb button {
flex: 1;
background: #111;
border: none;
border-radius: 8px 8px 0 0;
padding: 12px 24px;
margin: 0;
color: #888;
}
.tb button:hover {
background: #222;
color: #aaa;
}
.tb button.active {
background: #333;
color: #fff;
border-bottom: 2px solid #333;
}
/* text tool */
.cs {
flex-direction: column;
gap: 0;
}
.fr {
display: grid;
grid-template-columns: 100px 1fr;
align-items: center;
text-align: left;
gap: 8px;
max-width: 500px;
}
.tk {
color: #8cf;
text-decoration: underline;
cursor: pointer;
}
</style>
</head>
<body>
<div class="cont">
<div class="title">WLED<span class="sh">PixelForge</span></div>
<div class="tb">
<button class="active" id="tImg">Image Tool</button>
<button id="tTxt">Scrolling Text</button>
<button id="tOth">Other Tools</button>
</div>
<div id="iTab" class="tabc active">
<h3 style="margin-top:20px;">Target Segment</h3>
<select id="seg"></select>
<h3>Images on Device</h3>
<div class="g" id="gr"></div>
<h3>Upload New Image</h3>
<div id="drop" class="dp">
<p>Drop image or click to select</p>
</div>
<input type="file" id="src" accept="image/*" style="display:none">
<div class="ed" id="ed">
<h3 style="margin-top:0;padding-top:0;border-top:0">Crop & Adjust Image</h3>
<div class="crw">
<button class="sml" id="matchAspect">Match Aspect Ratio</button>
<button class="sml" id="matchSize">Match Size (1:1)</button>
<button class="sml" id="fullSize">Full Size</button>
<button class="sml" id="resetCrop">Reset</button>
</div>
<div class="cw">
<div style="width:100%">
<div class="slc">
<label>Rotation: <span id="rotVal">0</span>° <input type="checkbox" id="snap">snap</label>
<input type="range" id="rotSl" min="0" max="359" value="0" class="sl">
</div>
<div class="slc">
<label>Zoom: </label>
<input type="range" id="zoom" min="0" max="100" value="0" class="sl">
</div>
<canvas id="cv" width="500" height="500"></canvas>
</div>
<small>Preview at target resolution</small>
<canvas id="pv"></canvas>
<div class="slc">
<label>Dark Pixel Cutoff</label>
<input type="range" id="bt" min="0" max="255" value="0" class="sl">
<div style="display:flex;align-items:center;justify-content:center;margin-bottom:15px">
<label for="bg" style="margin-right:10px">Background Color</label>
<input type="color" id="bg" value="#000000">
</div>
</div>
<div style="display:none" id="sz">
<div>
<label>Output size:</label>
<input type="number" id="w" value="16" min="1" size="5">
x
<input type="number" id="h" value="16" min="1" size="5">
</div>
</div>
</div>
</div>
<div class="row" style="margin-top:20px">
<div class="col">
<label for="fn">Filename</label>
<input type="text" id="fn" placeholder="image" maxlength="26">
<small>.gif will be added</small>
</div>
</div>
<button class="btn" id="up">Convert & Upload to WLED</button>
</div>
</div>
<div id="xTab" class="tabc">
<h3 style="margin-top:20px;">Target Segment</h3>
<select id="segT"></select>
<div id="ti">
<h3>Text to show</h3>
<div class="col" style="display:flex;gap:10px;align-items:center;justify-content:center;flex-wrap:wrap">
<input type="text" id="txt" placeholder="Enter text" maxlength="64" style="margin-left:15px;flex:1;min-width:300px;">
<button class="btn" id="aTxt"></button>
</div>
<small style="display:block;text-align:center;">Preview - actual display may differ</small>
<canvas id="tpv" width="100" height="50" style="background:#000;display:block;margin:10px auto;border-radius:8px;width:300px;height:150px;"></canvas>
<h3>Settings</h3>
<div class="cs">
<div class="fr">Speed <input type="range" id="sx" min="0" max="255"></div>
<div class="fr">Y Offset <input type="range" id="ix" min="0" max="255"></div>
<div class="fr">Trail <input type="range" id="c1" min="0" max="255" value="0"></div>
<div class="fr">Font <input type="range" id="c2" min="3" max="255" step="63"></div>
<div class="fr">Rotate <input type="range" id="c3" min="3" max="31" step="7"></div>
</div>
<div class="col" style="display:flex;gap:20px;justify-content:center;">
<label style="display:flex;align-items:center;gap:5px">
<input type="checkbox" id="o1"> Gradient
</label>
<label style="display:flex;align-items:center;gap:5px">
<input type="checkbox" id="o2"> Custom Font
</label>
<label style="display:flex;align-items:center;gap:5px">
<input type="checkbox" id="o3"> Reverse
</label>
</div>
<h3>Available Tokens</h3>
<div style="padding:15px;">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px;text-align:left">
<div><a href="#" class="tk" data-t="#TIME">#TIME</a> - HH:MM AM/PM</div>
<div><a href="#" class="tk" data-t="#HHMM">#HHMM</a> - HH:MM</div>
<div><a href="#" class="tk" data-t="#DATE">#DATE</a> - DD.MM.YYYY</div>
<div><a href="#" class="tk" data-t="#DDMM">#DDMM</a> - Day.Month</div>
<div><a href="#" class="tk" data-t="#MMDD">#MMDD</a> - Month/Day</div>
<div><a href="#" class="tk" data-t="#YYYY">#YYYY</a> - Year</div>
<div><a href="#" class="tk" data-t="#YY">#YY</a> - Year 2-digit</div>
<div><a href="#" class="tk" data-t="#HH">#HH</a> - Hours</div>
<div><a href="#" class="tk" data-t="#MM">#MM</a> - Minutes</div>
<div><a href="#" class="tk" data-t="#SS">#SS</a> - Seconds</div>
<div><a href="#" class="tk" data-t="#MO">#MO</a> - Month number</div>
<div><a href="#" class="tk" data-t="#DD">#DD</a> - Day number</div>
<div><a href="#" class="tk" data-t="#MON">#MON</a> - Month (Jan)</div>
<div><a href="#" class="tk" data-t="#MONL">#MONL</a> - Month (January)</div>
<div><a href="#" class="tk" data-t="#DAY">#DAY</a> - Weekday (Mon)</div>
<div><a href="#" class="tk" data-t="#DDDD">#DDDD</a> - Weekday (Monday)</div>
</div>
<div style="margin:10px;">
<strong>Tips:</strong></small><br>
• Mix text and tokens: "It's #HHMM O'Clock" or "#HH:#MM:#SS"<br>
• Add '0' suffix for leading zeros: #TIME0, #HH0, etc.
</div>
<hr>
<h3>Custom Fonts</h3>
<small>FX uses first 5 fonts only</small><br>
<div id="fList" style="display:flex; flex-wrap:wrap; gap:8px; padding:10px; justify-content:center;"></div>
<button class="btn sml" id="dcf" style="margin:5px auto; display:block; font-size:11px;">Download Classic WLED Fonts</button>
</div>
</div>
<div id="ti1D" style="display:none;">Not available in 1D</div>
</div>
<div id="oTab" class="tabc">
<div id="tools">
<div style="padding:20px;text-align:center;">Loading tools...</div>
</div>
</div>
<div style="margin:20px 0">
<button class="btn" onclick="window.location.href=getURL('/')">Back to the controls</button>
</div>
<div id="ov"></div>
<div id="mem" style="display:none;font-size:12px;color:#aaa;"></div>
<script>
const imgageFX = 53; // image effect number
const txtFX = 122; // scrolling text effect number
const getId = (i) => document.getElementById(i); // getId() is defined in common.js, but needed before it is loaded
/* canvases */
const cv=getId('cv'),cx=cv.getContext('2d',{willReadFrequently:true});
const pv=getId('pv'),pvx=pv.getContext('2d',{willReadFrequently:true});
let rv, rvc; // off screen canvas for drawing resized & rotated image (created in init())
/* globals */
let sI=null,sF=null,cI=null,bS=1,iS=1,pX=0,pY=0,rot=0;
let cr={x:50,y:50,w:200,h:150},drag=false,dH=null,oX=0,oY=0;
let pan=false,psX=0,psY=0,poX=0,poY=0;
let iL=[]; // image list
let gF=null,gI=null,aT=null;
let fL; // file list
const classics=['console_font_4x6.wbf','console_font_5x12.wbf','console_font_5x8.wbf','console_font_6x8.wbf','console_font_7x9.wbf']; // classic WLED fonts list
let pT = []; // local tools list from JSON
let wv = [0, 0]; // wled version [major, minor], updated in fsMem(), used to check tool compatibility
const remoteURL = 'https://dedehai.github.io/pftools.json'; // Change to your actual repo
const toolsjson = 'pftools.json';
// note: the pftools.json must use major.minor for tool versions (e.g. 0.95 or 1.1), otherwise the update check won't work
// also the code assumes that the tool url points to a gz file
// load external resources in sequence to avoid 503 errors if heap is low, repeats indefinitely until loaded
(function loadFiles() {
const s = document.createElement('script');
s.src = 'common.js';
s.onerror = () => setTimeout(loadFiles, 100);
s.onload = () => {
loadResources(['style.css','omggif.js'], init); // load omggif.js then call init()
};
document.head.appendChild(s);
})();
/* init */
async function init() {
getLoc();
// create off screen canvas
rv = cE('canvas');
rvc = rv.getContext('2d',{willReadFrequently:true});
rv.width = cv.width; rv.height = cv.height;
await flU(); // update file list
tabSw(localStorage.tab||'img'); // switch to last open tab or image tab by default
await segLoad(); // load available segments
await fsMem(); // update & show file system memory info, also updates wled version (wv)
await loadTools(); // load additional tools list from pftools.json
}
/* update file list */
async function flU(){
try{
const r = await fetch(getURL('/edit?list=/&cb=' + Date.now()));
fL = await r.json();
}catch(e){console.error(e);}
}
/* toast */
function msg(m,t=''){
const el=cE('div');el.className='t';el.textContent=m;
if(t==='err')el.style.background='#a00';
d.body.appendChild(el);setTimeout(()=>el.remove(),3000);
}
/* "loading" overlay */
function ovShow(){getId('ov').classList.add('loading');getId('ov').style.display='block';}
function ovHide(){getId('ov').classList.remove('loading');getId('ov').style.display='none';}
/* segments */
function segLoad(){
const s1=getId('seg'),v1=s1.value,s2=getId('segT'),v2=s2.value;
fetch(getURL('/json/state')).then(r=>r.json()).then(j=>{
s1.innerHTML=''; s2.innerHTML='';
if(j.seg&&j.seg.length){
j.seg.forEach(({id,n,start,stop,startY,stopY,fx})=>{
const w=stop-start,h=(stopY-startY)||1;
const t = (n || `Segment ${id}`) + (h>1 ? ` (${w}x${h})` : ` (${w}px)`) + (fx===imgageFX ? ' [Image]' : (fx===txtFX ? ' [Scrolling Text]' : ''));
const o=new Option(t,id);
o.dataset.w=w; o.dataset.h=h; o.dataset.fx=fx||0;
s1.add(o); // gif tool
s2.add(o.cloneNode(true)); // scrolling text tool
});
}else{
const o=new Option('Segment 0',0);
s1.add(o); s2.add(o.cloneNode(true));
}
if(v1) s1.value=v1; if(v2) s2.value=v2;
s2.onchange(); // trigger on load to toggle show/hide of text tool
txtSegLoad(); // load settings for currently selected segment
const o=s1.options[s1.selectedIndex];
if(o){ getId('w').value=o.dataset.w||16; getId('h').value=o.dataset.h||16; }
}).catch(console.error);
}
/* which seg is showing image fx 53 */
function curImgSeg(){
const sel=getId('seg');
for(let i=0;i<sel.options.length;i++){
if(parseInt(sel.options[i].dataset.fx)===imgageFX) return parseInt(sel.options[i].value);
}
return null;
}
/* seg change -> update target size */
getId('seg').onchange = () =>{
const o=getId('seg').selectedOptions[0];
getId('w').value=o.dataset.w;
getId('h').value=o.dataset.h;
if(cI) crDraw();
};
getId('segT').onchange = () => {
const is2D = (getId('segT').selectedOptions[0].dataset.h || 1) > 1;
getId('ti').style.display = is2D ? 'block' : 'none';
getId('ti1D').style.display = is2D ? 'none' : 'block';
txtSegLoad(); // update controls to match selected segment
};
/* image list */
async function imgLoad(){
try{
await flU(); // update file list
const grid=getId('gr');
const types=['gif','png','jpg','jpeg','bmp'];
const imgs=fL.filter(f=>types.includes(f.name.split('.').pop()?.toLowerCase()));
const newList=imgs.map(f=>f.name.replace('/',''));
const miss=newList.filter(n=>!iL.includes(n));
if(iL.length===0){
grid.innerHTML='';
iL=[...newList];
if(!imgs.length){
grid.innerHTML='<div style="grid-column:1/-1;text-align:center;color:#aaa;padding:20px">No images</div>';
return;
}
await imgLoad2(imgs);
}else if(miss.length>0){
const missData=imgs.filter(f=>miss.includes(f.name.replace('/','')));
iL=[...newList];
await imgLoad2(missData);
}
}catch(e){console.error(e);}
}
/* load images into grid */
async function imgLoad2(imgs){
const grid=getId('gr');
for(const f of imgs){
const name=f.name.replace('/',''),url=getURL(`/${name}`);
const isGif=name.toLowerCase().endsWith('.gif');
const it=cE('div');it.className='it loading';
it.dataset.name=name;it.dataset.url=url;
it.onclick=()=>{ if(isGif) imgPlay(url,name); else unsup(url,name); };
it.oncontextmenu=e=>{e.preventDefault();sI={name,url};menuShow(e.pageX,e.pageY);};
grid.appendChild(it);
await new Promise(res=>{
const im=new Image();
im.onload=()=>{
it.style.backgroundImage=`url('${encodeURI(url)}?cb=${Date.now()}')`;
if(!isGif) it.style.border="5px solid red";
it.classList.remove('loading'); res();
const kb=Math.round(f.size/1024);
it.title=`${name}\n${im.width}x${im.height}\n${kb} KB`;
};
im.onerror=()=>{it.classList.remove('loading');it.style.background='#222';res();};
im.src=encodeURI(url)+'?cb='+Date.now();
});
}
}
function imgRm(nm){
iL=iL.filter(n=>n!==nm);
const grid=getId('gr');
grid.querySelectorAll('.it').forEach(it=>{ if(it.dataset.name===nm) it.remove(); });
//if(iL.length===0){
// grid.innerHTML='<div style="grid-column:1/-1;text-align:center;color:#aaa;padding:20px">No images found</div>';
//}
}
// check for tool compatibility with current wled version (wled_min requirement in pftools.json)
function compTool(t) {
if (!t.wled_min || !wv) return true;
const m = t.wled_min.match(/\d+/g);
const a = wv, b = [m[0]|0, m[1]|0];
return a[0] > b[0] || (a[0] === b[0] && a[1] >= b[1]);
}
/* additional tools: loaded from pftools.json, store json locally for offline use*/
async function loadTools() {
try {
const res = await fetch(getURL('/' + toolsjson + '?cb=' + Date.now())); // load local tools list
pT = res.ok ? await res.json() : [];
} catch (e) {}
renderTools(); // render whatever we have
try {
const rT = await (await fetch(remoteURL + '?cb=' + Date.now())).json();
let changed = false;
rT.forEach(rt => {
let lt = pT.find(t => t.id === rt.id);
if (!lt) {
pT.push(rt); // new tool available
changed = true;
} else {
// check version
if (isNewer(rt.ver, lt.ver)) {
lt.pending = rt; // mark update as pending, keep old info until user clicks update button
changed = true;
}
}
});
if (changed) {
await saveToolsjson(); // save updated json
renderTools();
}
} catch(e){console.error(e);}
}
async function saveToolsjson() {
const fd = new FormData();
fd.append("data", new Blob([JSON.stringify(pT)], {type:'application/json'}), toolsjson);
await fetch(getURL("/upload"), { method: "POST", body: fd });
}
// tool versions must be in format major.minor (e.g. 0.95 or 1.1)
function isNewer(vN, vO) {
return parseFloat(vN) > parseFloat(vO);
}
function renderTools() {
let h = '';
pT.forEach(t => {
const installed = fL.some(f => f.name.includes(t.file)); // check if tool file exists (either .htm or .htm.gz)
const target = (installed && t.pending) ? t.pending : t; // if update pending and tool is installed, show update info
const compatible = compTool(target); // check if compatible with current wled version
h += `<div class="ed active" style="margin-bottom:10px; border-radius:20px; text-align:left;">
<div style="display:flex; justify-content:space-between;">
<h3>${esc(t.name)} <small style="font-size:10px">v${esc(t.ver)}</small></h3>
${installed ? `<button class="sml" style="height:40px;" onclick="deleteFile('${esc(t.file)}')">✕</button>` : ''}
</div>
${t.desc}
<div style="font-size:10px; color:#888;">
by ${esc(t.author)} | <a href="${safeUrl(t.source)}" target="_blank">${safeUrl(t.source)}</a>
</div>
${
compatible ?
`<div class="crw">
${installed ?
`<button class="btn" onclick="window.location.href=getURL('/${esc(t.file)}')">Open</button>`
: `<button class="btn" onclick="insT('${esc(t.id)}')">Install</button>`
}
${t.pending && installed ?
`<button class="btn" style="color:#fb2" onclick="insT('${esc(t.id)}')">Update v${esc(t.pending.ver)}</button>`
: ''
}
</div>`
: `<div style="color:#f44;font-size:12px;"> Requires WLED ${esc(target.wled_min)} </div>`
}
</div>`;
});
getId('tools').innerHTML = h || 'No tools found (offline?).';
}
// install or update tool
async function insT(id) {
const t = pT.find(x => x.id == id);
ovShow();
try {
const src = t.pending || t;
const f = await fetch(src.url); // url in json must be pointing to a gz file
if (!f.ok) throw new Error("Download failed " + f.status);
const fd = new FormData();
fd.append("data", await f.blob(), src.file + '.gz'); // always use gz for file name (source MUST be gz)
const u = await fetch(getURL("/upload"), { method: "POST", body: fd });
alert(u.ok ? "Tool installed!" : "Install failed");
if (u.ok && t.pending) {
// save and remove update info after successful update
Object.assign(t, t.pending);
delete t.pending;
}
await saveToolsjson();
await flU(); // refresh file list
renderTools();
} catch(e) { alert("Error " + e.message); }
fsMem(); // refresh memory info after upload
ovHide();
}
/* fs/mem info & wled version */
async function fsMem(){
try{
const r=await fetch(getURL('/json/info'));
const info=await r.json();
if (info){
if (info.fs) {
getId("mem").textContent=`by @dedehai | Memory: ${info.fs.u} KB / ${info.fs.t} KB`;
getId("mem").style.display="block";
}
if (info.ver) {
const m = info.ver.match(/\d+/g); // extract all numbers from version string (e.g. "16.1.0-beta" → [16, 1])
wv = [parseInt(m[0]) || 0, parseInt(m[1]) || 0];
}
}
}catch(e){console.error(e);}
}
/* drag-drop + file input */
getId('drop').onclick=()=>{getId('src').value='';getId('src').click();};
getId('drop').ondragover=e=>{e.preventDefault();getId('drop').classList.add('active');};
getId('drop').ondragleave=()=>getId('drop').classList.remove('active');
getId('drop').ondrop=e=>{e.preventDefault();getId('drop').classList.remove('active');getId('src').files=e.dataTransfer.files;fileHandle();};
getId('src').onchange=fileHandle;
/* file handler */
function fileHandle() {
const file = getId('src').files[0];
if (!file) return;
sF = file; gI = null; gF = [];
getId('sz').style.display = 'block';
const isGif = file.type === 'image/gif';
const rdr = new FileReader();
rdr.onload = e => {
if (isGif) {
try {
const arr = new Uint8Array(e.target.result);
const gif = new GifReader(arr);
gI = { width: gif.width, height: gif.height, numFrames: gif.numFrames() };
const ac = cE('canvas'); ac.width = gif.width; ac.height = gif.height;
const acx = ac.getContext('2d', { willReadFrequently: true });
let saved = null;
for (let i = 0; i < gI.numFrames; i++) {
const fi = gif.frameInfo(i), disp = fi.disposal || 0;
if (disp === 3) saved = acx.getImageData(0, 0, gif.width, gif.height);
const tp = new Uint8Array(gif.width * gif.height * 4);
gif.decodeAndBlitFrameRGBA(i, tp);
const tc = cE('canvas'); tc.width = gif.width; tc.height = gif.height;
const tctx = tc.getContext('2d');
const tid = new ImageData(new Uint8ClampedArray(tp), gif.width, gif.height);
tctx.putImageData(tid, 0, 0);
acx.drawImage(tc, 0, 0);
const full = acx.getImageData(0, 0, gif.width, gif.height);
gF.push({ pixels: new Uint8Array(full.data), delay: fi.delay || 10 });
if (disp === 2) acx.clearRect(0, 0, gif.width, gif.height);
else if (disp === 3 && saved) acx.putImageData(saved, 0, 0);
}
const tc = cE('canvas'); tc.width = gI.width; tc.height = gI.height;
const tctx = tc.getContext('2d');
tctx.putImageData(new ImageData(new Uint8ClampedArray(gF[0].pixels), gI.width, gI.height), 0, 0);
imgShow(tc.toDataURL(), file.name);
} catch (err) {
msg('GIF load failed', 'err');
console.error(err);
}
} else {
// static image → treat as single-frame GIF
const im = new Image();
im.onload = () => {
const c = cE('canvas');
c.width = im.width; c.height = im.height;
const ctx = c.getContext('2d');
ctx.drawImage(im, 0, 0);
const id = ctx.getImageData(0, 0, im.width, im.height);
gF = [{ pixels: new Uint8Array(id.data), delay: 0 }];
gI = { width: im.width, height: im.height, numFrames: 1 };
imgShow(c.toDataURL(), file.name);
};
im.src = e.target.result;
}
};
isGif ? rdr.readAsArrayBuffer(file) : rdr.readAsDataURL(file);
}
/* display image on canvas */
function imgShow(src, name) {
cI = new Image();
cI.onload = () => {
getId('ed').classList.add('active');
getId('drop').innerHTML = `<p>Image loaded: ${name}<br><small>Drop another to replace</small></p>`;
getId('fn').value = name.split('.')[0].substring(0, 16);
viewReset();
cr.w = cv.width * 0.8; cr.h = cv.height * 0.8;
cr.x = (cv.width - cr.w) / 2; cr.y = (cv.height - cr.h) / 2;
crClamp();
gifStart(); // handles both single- and multi-frame
};
cI.src = src;
}
function gifStart() {
if (aT) clearInterval(aT);
if (!gF || !gI || gF.length === 0) return crDraw();
let idx = 0;
const tc = cE('canvas');
tc.width = gI.width;
tc.height = gI.height;
const tctx = tc.getContext('2d');
const step = () => {
const id = new ImageData(new Uint8ClampedArray(gF[idx].pixels), gI.width, gI.height);
tctx.putImageData(id, 0, 0);
cI.src = tc.toDataURL();
idx = (idx + 1) % gF.length;
};
cI.onload = () => crDraw();
step();
if (gF.length > 1) {
const avg = gF.reduce((s, f) => s + f.delay, 0) / gF.length;
aT = setInterval(step, Math.max(avg * 10, 50));
}
}
function gifStop(){ if(aT){ clearInterval(aT); aT=null; } }
/* formats not supported by WLED */
function unsup(url, name) {
alert(`Image format not supported.\nPlease convert to GIF or use PIXEL MAGIC TOOL`);
fetch(url).then(r => r.blob()).then(b => {
const f = new File([b], name, { type: b.type });
const dt = new DataTransfer();
dt.items.add(f);
getId('src').files = dt.files;
fileHandle();
}).catch(() => msg('Failed to load image', 'err'));
}
/* size change -> redraw */
getId('w').oninput=()=>{if(cI)crDraw();};
getId('h').oninput=()=>{if(cI)crDraw();};
/* crop helpers */
function crClamp(){
cr.w=Math.max(30,Math.min(cr.w,cv.width));
cr.h=Math.max(30,Math.min(cr.h,cv.height));
cr.x=Math.max(0,Math.min(cr.x,cv.width-cr.w));
cr.y=Math.max(0,Math.min(cr.y,cv.height-cr.h));
}
function viewReset(){
bS=Math.min(cv.width/cI.width,cv.height/cI.height);
iS=bS;
pX=(cv.width-cI.width*iS)/2;
pY=(cv.height-cI.height*iS)/2;
}
/* zoom */
getId('zoom').oninput=()=>{
if(!cI)return;
const t=getId('zoom').value/100,ns=bS*Math.pow(40,t);
const cxm=cv.width/2,cym=cv.height/2;
const dx=cxm-pX,dy=cym-pY,f=ns/iS;
pX=cxm-dx*f; pY=cym-dy*f; iS=ns;
crClamp(); crDraw();
};
/* rotation */
function rotUpd(v){
if(getId('snap').checked) v = Math.round(v/15)*15 % 360; // snap to multiples of 15°
rot = v;
getId('rotVal').textContent = v;
if(cI) crDraw();
}
getId('rotSl').oninput = ()=> rotUpd(+getId('rotSl').value);
/* color change */
getId('bg').oninput=crDraw;
/* quick controls */
getId('matchAspect').onclick=e=>{
e.preventDefault();
const r=+getId('w').value/+getId('h').value;
cr.h=cr.w/r; crClamp(); crDraw();
};
getId('matchSize').onclick=e=>{
e.preventDefault();
if(!cI)return;
cr.w=+getId('w').value*iS; cr.h=+getId('h').value*iS;
crClamp(); crDraw();
};
getId('fullSize').onclick=e=>{
e.preventDefault();
if(!cI)return;
cr.x=0; cr.y=0; cr.w=cv.width; cr.h=cv.height;
crClamp(); crDraw();
};
getId('resetCrop').onclick=e=>{
e.preventDefault();
if(!cI)return;
cr.w=cv.width*0.8; cr.h=cv.height*0.8;
cr.x=(cv.width-cr.w)/2; cr.y=(cv.height-cr.h)/2;
crClamp(); crDraw();
};
/* crop handles */
function crHandles(r){
const s=40,o=s/2,ox=r.x-o,oy=r.y-o,ow=r.w+s,oh=r.h+s;
return{
nw:{x:ox,y:oy,w:s,h:s}, ne:{x:ox+ow-s,y:oy,w:s,h:s},
sw:{x:ox,y:oy+oh-s,w:s,h:s}, se:{x:ox+ow-s,y:oy+oh-s,w:s,h:s},
n:{x:ox+s/2,y:oy,w:ow-s,h:s}, s:{x:ox+s/2,y:oy+oh-s,w:ow-s,h:s},
w:{x:ox,y:oy+s/2,w:s,h:oh-s}, e:{x:ox+ow-s,y:oy+s/2,w:s,h:oh-s}
};
}
function crHit(mx,my){
const h=crHandles(cr);
for(const k in h){let r=h[k];if(mx>=r.x&&mx<=r.x+r.w&&my>=r.y&&my<=r.y+r.h)return k;}
return null;
}
/* event coord */
function posGet(e){
const r=cv.getBoundingClientRect();
const sx=cv.width/r.width,sy=cv.height/r.height;
if(e.touches){
return {x:(e.touches[0].clientX-r.left)*sx,y:(e.touches[0].clientY-r.top)*sy};
}else{
return {x:(e.clientX-r.left)*sx,y:(e.clientY-r.top)*sy};
}
}
/* actions */
function actStart(mx,my){
dH=crHit(mx,my);
if(dH){drag=true;return;}
if(mx>cr.x&&mx<cr.x+cr.w&&my>cr.y&&my<cr.y+cr.h){
drag=true;dH="move";oX=mx-cr.x;oY=my-cr.y;
}else{
pan=true;psX=mx;psY=my;poX=pX;poY=pY;
}
}
function actMove(mx,my){
if(drag){
switch(dH){
case "move":cr.x=mx-oX;cr.y=my-oY;break;
case "nw":cr.w+=(cr.x-mx);cr.h+=(cr.y-my);cr.x=mx;cr.y=my;break;
case "ne":cr.w=mx-cr.x;cr.h+=(cr.y-my);cr.y=my;break;
case "sw":cr.w+=(cr.x-mx);cr.x=mx;cr.h=my-cr.y;break;
case "se":cr.w=mx-cr.x;cr.h=my-cr.y;break;
case "n":cr.h+=(cr.y-my);cr.y=my;break;
case "s":cr.h=my-cr.y;break;
case "w":cr.w+=(cr.x-mx);cr.x=mx;break;
case "e":cr.w=mx-cr.x;break;
}
crClamp(); crDraw();
}else if(pan){
pX=poX+(mx-psX); pY=poY+(my-psY);
crClamp(); crDraw();
}
}
function actEnd(){ drag=false; dH=null; pan=false; }
/* mouse */
cv.onmousedown=e=>{if(!cI)return;const {x,y}=posGet(e);actStart(x,y);};
cv.onmousemove=e=>{if(!cI)return;const {x,y}=posGet(e);actMove(x,y);};
cv.onmouseup=actEnd;
/* touch */
cv.ontouchstart=e=>{
if(!cI||e.touches.length!==1)return;
e.preventDefault();
const {x,y}=posGet(e); actStart(x,y);
};
cv.ontouchmove=e=>{
if(!cI||e.touches.length!==1)return;
e.preventDefault();
const {x,y}=posGet(e); actMove(x,y);
};
cv.ontouchend=e=>{e.preventDefault();actEnd();};
cv.ontouchcancel=e=>{e.preventDefault();actEnd();};
/* draw + preview */
function crDraw(){
if(!cI) return;
// render rotated image to offscreen
rvc.clearRect(0,0,rv.width,rv.height);
rvc.fillStyle = getId('bg').value;
rvc.fillRect(0,0,rv.width,rv.height);
rvc.imageSmoothingEnabled = false;
rvc.save();
const dw = cI.width * iS, dh = cI.height * iS;
rvc.translate(pX + dw/2, pY + dh/2);
rvc.rotate(rot * Math.PI / 180);
rvc.drawImage(cI, -dw/2, -dh/2, dw, dh);
rvc.restore();
// copy offscreen to visible
cx.clearRect(0,0,cv.width,cv.height);
cx.drawImage(rv, 0, 0);
// overlay crop frame (only on visible)
cx.lineWidth=3; cx.setLineDash([6,4]); cx.shadowColor="#000"; cx.shadowBlur=2;
cx.strokeStyle="#FFF"; cx.beginPath(); cx.roundRect(cr.x,cr.y,cr.w,cr.h,6); cx.stroke();
cx.shadowColor="#000F";
prevUpd();
}
getId('bt').addEventListener('input',()=>{prevUpd();});
function blackTh(c){
let t=+getId('bt').value,
dt=c.getImageData(0,0,c.canvas.width,c.canvas.height),
b=getId('bg').value.match(/\w\w/g).map(x=>parseInt(x,16));
for(let i=0;i<dt.data.length;i+=4)
if(dt.data[i]<t&&dt.data[i+1]<t&&dt.data[i+2]<t)
dt.data[i]=b[0],dt.data[i+1]=b[1],dt.data[i+2]=b[2];
c.putImageData(dt,0,0);
}
function prevUpd(){
if(!cI)return;
let w=+getId('w').value,h=+getId('h').value;
// Temporary canvas at target size
const tc = cE('canvas'); tc.width = w; tc.height = h;
const tcx = tc.getContext('2d');
tcx.fillStyle=getId('bg').value;
tcx.fillRect(0,0,w,h); // fill background (for transparent images)
tcx.imageSmoothingEnabled = false;
tcx.drawImage(rv, cr.x, cr.y, cr.w, cr.h, 0, 0, w, h); // sample cropped area from off screen canvas
blackTh(tcx);
// scale/stretch to preview canvas, limit to 256px in largest dimension but keep aspect ratio
const ratio = h/w;
if(ratio > 1) {
pv.height = 256;
pv.width = Math.max(4, Math.round(256 / ratio)); // min 4px width for better visibility
} else {
pv.width = 256;
pv.height = Math.max(4, Math.round(256 * ratio));
}
pvx.imageSmoothingEnabled=false;
pvx.drawImage(tc,0,0,w,h,0,0,pv.width,pv.height);
}
// generate gif palette using median-cut algorithm, palette is padded to power of 2 size (gif standard)
function genPal(pix){
const map=new Map();
for(let i=0;i<pix.length;i+=4){
const c=(pix[i]<<16)|(pix[i+1]<<8)|pix[i+2];
map.set(c,(map.get(c)||0)+1);
}
let buckets=[Array.from(map,([rgb,count])=>({r:rgb>>16&255,g:rgb>>8&255,b:rgb&255,count}))];
while(buckets.length<256&&buckets.some(b=>b.length>1)){
buckets.sort((a,b)=>b.length-a.length);
const b=buckets.shift();
const ch=['r','g','b'].map(k=>({k,range:Math.max(...b.map(c=>c[k]))-Math.min(...b.map(c=>c[k]))}))
.reduce((a,b)=>a.range>b.range?a:b).k;
b.sort((a,c)=>a[ch]-c[ch]);
const m=b.length>>1;
buckets.push(b.slice(0,m),b.slice(m));
}
const pal=buckets.map(b=>{
const t=b.reduce((s,c)=>s+c.count,0);
const r=Math.round(b.reduce((s,c)=>s+c.r*c.count,0)/t);
const g=Math.round(b.reduce((s,c)=>s+c.g*c.count,0)/t);
const bl=Math.round(b.reduce((s,c)=>s+c.b*c.count,0)/t);
return(r<<16)|(g<<8)|bl;
});
let p2=1;
while(p2<pal.length)p2<<=1; // make it power of 2
for(let i=pal.length;i<p2;i++)pal[i]=pal[pal.length-1];
const idx=new Uint8Array(pix.length/4);
for(let i=0,j=0;i<pix.length;i+=4,j++){
let r=pix[i],g=pix[i+1],b=pix[i+2],best=0,min=1e9;
for(let k=0;k<buckets.length;k++){
const p=pal[k],pr=p>>16&255,pg=p>>8&255,pb=p&255;
const d=(r-pr)**2+(g-pg)**2+(b-pb)**2;
if(d<min){min=d;best=k;}
}
idx[j]=best;
}
return{indexed:idx,palette:pal};
}
// calculate optimal grid dimensions for 1D pixel data (gif is restricted to 320x320)
function grid(length) {
if (length <= 320) return { w: length, h: 1 };
let best = [1, length], waste = length;
// find best matching width/height with least wasted pixels (should never be more than 1)
for (let w = 320; w >= 2; w--) {
const h = Math.ceil(length / w);
const wst = w * h - length;
if (wst < waste) {
best = [w, h];
waste = wst;
if (!waste) break;
}
}
return { w: best[0], h: best[1] };
}
/* create GIF and upload */
getId('up').onclick = async () => {
if (!gF || !gI) return; // no image
const w = +getId('w').value, h = +getId('h').value, fn = getId('fn').value.trim() || 'image';
const filename = `${fn}.gif`;
const repl = iL.includes(filename);
if (repl && !confirm(`${filename} already exists. Overwrite?`)) return;
ovShow();
try {
const tc = cE('canvas'); tc.width = gI.width; tc.height = gI.height;
const tctx = tc.getContext('2d');
const cc = cE('canvas'); cc.width = w; cc.height = h;
const cctx = cc.getContext('2d');
cctx.imageSmoothingEnabled = false;
const frames = [];
for (let i = 0; i < gF.length; i++) {
// put current GIF frame into tc
const id = new ImageData(new Uint8ClampedArray(gF[i].pixels), gI.width, gI.height);
tctx.putImageData(id, 0, 0);
// render this frame into the offscreen rotated canvas (no overlay)
rvc.clearRect(0, 0, rv.width, rv.height);
rvc.fillStyle = getId('bg').value;
rvc.fillRect(0, 0, rv.width, rv.height);
rvc.imageSmoothingEnabled = false;
rvc.save();
const dw = gI.width * iS, dh = gI.height * iS;
rvc.translate(pX + dw / 2, pY + dh / 2);
rvc.rotate(rot * Math.PI / 180);
rvc.drawImage(tc, -dw / 2, -dh / 2, dw, dh);
rvc.restore();
// sample the crop from the offscreen (already rotated) canvas into output size
cctx.fillStyle = getId('bg').value;
cctx.fillRect(0, 0, w, h);
cctx.imageSmoothingEnabled = false;
cctx.drawImage(rv, cr.x, cr.y, cr.w, cr.h, 0, 0, w, h);
blackTh(cctx);
const fd = cctx.getImageData(0, 0, w, h);
frames.push({ data: fd.data, delay: gF[i].delay });
}
const g = grid(w); // calculate optimal grid size: 1D image is saved as "2D gif" if w > 320 (WLED gif size limit), 2D can not be larger than 256x256 so is unchange
let gw = g.w, gh = Math.max(g.h, h); // gif width and height
const all = new Uint8Array(frames.length * gw * gh * 4);
// concat all frames to single array to create palette from all colors
frames.forEach((f, i) => {
const off = i * gw * gh * 4;
for (let j = 0; j < gw * gh; j++) {
const src = (j < w*h ? j : w*h-1) * 4; // pad with last pixel of source frame if out of bounds (for 1D images)
all[off + j*4] = f.data[src]; all[off + j*4 + 1] = f.data[src + 1]; all[off + j*4 + 2] = f.data[src + 2]; all[off + j*4 + 3] = 255;
}
});
const { indexed, palette } = genPal(all);
const gifData = [];
const wr = new GifWriter(gifData, gw, gh, { palette, loop: 0 });
for (let i = 0; i < frames.length; i++) {
const framePixels = indexed.slice(i * gw * gh, (i + 1) * gw * gh); // slice global array back into per-frame data
wr.addFrame(0, 0, gw, gh, framePixels, { delay: frames[i].delay, disposal: 2 });
}
wr.end();
const fU = new File([new Uint8Array(gifData)], filename, { type: 'image/gif' });
const fd = new FormData();
fd.append('file', fU, filename);
const r = await fetch(getURL('/upload'), { method: 'POST', body: fd });
if (r.ok) {
msg(`${filename} uploaded`);
if (repl) imgRm(filename);
await imgLoad();
getId('src').value = '';
getId('drop').innerHTML = '<p>Drop image or click to select</p>';
} else msg('Upload failed', 'err');
} catch (e) {
msg(`Error: ${e.message}`, 'err');
} finally {
fsMem(); // refresh memory info after upload
ovHide();
}
};
/* play on device */
async function imgPlay(url,name){
const tgt=+getId('seg').value,cur=curImgSeg();
if(cur!==null && cur!==tgt){
if(!confirm(`Segment ${cur} is currently displaying an image. Switch image display to segment ${tgt}?`))return;
}
ovShow();
try{
const j={
on:true,
seg: cur!==null && cur!==tgt
? [{id:cur,fx:0,n:""},{id:tgt,fx:53,frz:false,sx:128,n:name}]
: {id:tgt,fx:53,frz:false,sx:128,n:name}
};
const r=await fetch(getURL('/json/state'),{method:'POST',body:JSON.stringify(j)});
const out=await r.json();
if(out.success){
msg(`Playing ${name}`);
await segLoad();
}else msg('Failed to play','err');
}catch(e){msg(`Error: ${e.message}`,'err');}
finally{ovHide();}
}
/* ctx menu */
function menuShow(x,y){
menuClose();
const m=cE('div');
m.className='cm';
m.style.left=x+'px'; m.style.top=y+'px';
m.innerHTML=`
<button onclick="imgDl()">Download</button>
<button class="danger" onclick="deleteFile(sI.name)">Delete</button>`;
d.body.appendChild(m);
setTimeout(()=>{
const h=e=>{
if(!e.target.closest('.cm')){menuClose();d.removeEventListener('click',h);}
};
d.addEventListener('click',h);
},100);
}
function menuClose(){d.querySelectorAll('.cm').forEach(m=>m.remove());}
async function imgDl(){
try{
const r=await fetch(sI.url),b=await r.blob();
const u=URL.createObjectURL(b),a=cE('a');
a.href=u;a.download=sI.name;a.click();
URL.revokeObjectURL(u); msg('Downloaded');
}catch(e){msg('Download failed','err');}
menuClose();
}
async function deleteFile(name){
name = name.replace('/',''); // remove leading slash if present (just in case)
if (fL.some(f => f.name.replace('/','') === `${name}.gz`))
name += '.gz'; // if .gz version of file exists, delete that (handles tools which are stored gzipped on device)
if(!confirm(`Delete ${name}?`))return;
ovShow();
try{
const r = await fetch(getURL(`/edit?func=delete&path=/${name}`));
if(r.ok){
msg('Deleted');
imgRm(name); // remove image from grid (if this was not an image, this does nothing)
}
else msg('Delete failed! File in use?','err');
}catch(e){msg('Delete failed','err');}
finally{ovHide();}
fsMem(); // refresh memory info after delete
menuClose(); // close menu (used for image delete button)
await flU(); // update file list
renderTools(); // re-render tools list
}
/* tab select and additional tools */
function tabSw(tab) {
'iTab,xTab,oTab,tImg,tTxt,tOth'.split(',').forEach((id,i)=>{
getId(id).classList.toggle('active', tab===['img','txt','oth'][i%3]);
});
localStorage.tab=tab;
({txt:()=>{txtSegLoad(); scanFonts();}, img:imgLoad}[tab]||(()=>{}))(); // on tab switch, load images and available fonts
}
'Img,Txt,Oth'.split(',').forEach((s,i)=>{
getId('t'+s).onclick=()=>tabSw(['img','txt','oth'][i]);
});
/* tokens insert */
function txtIns(el,t){
const s=el.selectionStart??el.value.length,e=el.selectionEnd??el.value.length,v=el.value;
const nv=(v.slice(0,s)+t+v.slice(e)).slice(0,64);
const p=Math.min(s+t.length,nv.length);
el.value=nv;el.focus();el.selectionStart=el.selectionEnd=p;
}
document.addEventListener('click',e=>{
const a=e.target.closest('.tk'); if(!a) return;
e.preventDefault(); txtIns(getId('txt'),a.dataset.t);
// txtUp();
});
/* load seg settings into text UI */
async function txtSegLoad(){
const id=+getId('segT').value;
try{
const r=await fetch(getURL('/json/state')),j=await r.json();
if(j.seg&&j.seg[id]){
const s=j.seg[id];
if(s.fx!==txtFX) return; // only update when this segment uses scrolling text effect
getId('txt').value=s.n||'';
getId('sx').value=s.sx||128;
getId('ix').value=s.ix||128;
getId('c1').value=s.c1||0;
getId('c2').value=s.c2||128;
getId('c3').value=s.c3||16;
getId('o1').checked=!(!s.o1);
getId('o3').checked=!(!s.o3);
}
}catch(e){console.error(e);}
}
/* auto apply on change */
['sx','ix','c1','c2','c3','o1','o2','o3'].forEach(id=>{ getId(id).onchange=txtUp; });
/* send text settings */
function txtUp(){
const id=+getId('segT').value,txt=getId('txt').value.trim().slice(0,64);
const j={on:true,seg:{id,fx:122,n:txt,sx:+getId('sx').value,ix:+getId('ix').value,c1:+getId('c1').value,c2:+getId('c2').value,c3:+getId('c3').value,o1:getId('o1').checked,o2:getId('o2').checked,o3:getId('o3').checked}};
fetch(getURL('/json/state'),{method:'POST',body:JSON.stringify(j)})
.then(r => { if(r.ok) segLoad(); })
.catch(console.error);
}
/* apply button */
getId('aTxt').onclick=async()=>{
const id=+getId('segT').value,txt=getId('txt').value.trim();
ovShow();
try{
const j={on:true,seg:{id,fx:122,n:txt,sx:+getId('sx').value,ix:+getId('ix').value,c1:+getId('c1').value,c2:+getId('c2').value,c3:+getId('c3').value,o1:getId('o1').checked,o3:getId('o3').checked}};
const r=await fetch(getURL('/json/state'),{method:'POST',body:JSON.stringify(j)});
const out=await r.json();
if(out.success!==false){ msg(`Applied to Segment ${id}`); await segLoad(); }
else msg('Failed to apply','err');
}catch(e){ msg(`Error: ${e.message}`,'err'); }
finally{ ovHide(); }
};
// load available fonts (files ending with .wbf) and display in list
function scanFonts(){
const wbf=(fL||[]).filter(f=>f.name.endsWith('.wbf'));
getId('fList').innerHTML=wbf.map(f=>
`<span>${f.name} <b onclick="delFont('${f.name}')" style="cursor:pointer;color:#8cf;padding:0 3px">✖</b></span>`
).join('')||'<span style="color:#888">None</span>';
getId('dcf').style.display=classics.every(fn=>wbf.some(f=>f.name===fn))?'none':'block'; // hide "Download classic fonts" button if all are present
}
// delete font file
async function delFont(name){
if(!confirm(`Delete ${name}?`))return;
const r=await fetch(getURL(`/edit?func=delete&path=/${name}`));
r.ok?msg('Deleted'):msg('Delete failed','err');
await flU(); scanFonts();
}
// download classic WLED fonts from github and upload to FS
getId('dcf').onclick=async()=>{
if(!confirm('Download classic WLED fonts?'))return;
ovShow(); let ok=0,fail=0;
for(const fn of classics){
try{
const r=await fetch(`https://dedehai.github.io/wbfFonts/${fn}`);
if(!r.ok){fail++;continue;}
const fd=new FormData();
fd.append('data',await r.blob(),fn);
(await fetch(getURL('/upload'),{method:'POST',body:fd})).ok?ok++:fail++;
}catch(e){msg(`Failed ${fn}: ${e.message}`,'err');}
}
ovHide(); msg('Fonts installed');
await flU(); scanFonts();
};
/* preview canvas - actual display may differ */
const tpv = getId('tpv'), tpx = tpv.getContext('2d');
function draw() {
// fade out to simulate "trail"
let tr = 1 - (getId('c1').value / 280); // c1 controls trail length
//tpx.fillStyle = `rgba(0,0,0,${tr.toFixed(2)})`;
tpx.fillStyle = `rgba(0,0,0,${tr})`;
tpx.fillRect(0,0,100,50);
let txt = getId('txt').value || 'WLED'; // note: decoding tokens is not implemented as it just takes a lot of code for little benefit
let sx = getId('sx').value;
let ix = getId('ix').value, c2 = getId('c2').value, c3 = getId('c3').value;
let fzs = [8, 12, 14, 20, 28][Math.floor(c2/52)] || 8; // Font sizes 1-5
let rot = -(Math.round((c3)/7.5)-2) * Math.PI/2; // -2 to 2 maps to 180 to -180
let t = (Date.now() * (0.02 + (sx / 255) * 0.1)) % (100 + txt.length * fzs);
let x = getId('o3').checked ? (t - (txt.length * fzs)) : 100 - t;
let y = 25 + (ix - 128) / 5;
// draw char-by-char just like the FX
tpx.fillStyle = "#fff";
tpx.font = fzs + "px monospace";
tpx.textAlign = "center";
tpx.textBaseline = "middle";
for(let i=0; i<txt.length; i++) {
let charX = x + (i * fzs); // simple letter spacing
if(charX < -20 || charX > 120) continue;
tpx.save();
tpx.translate(charX, y);
tpx.rotate(rot);
tpx.fillStyle = "#fff";
if (getId('o1').checked) { // gradient color
let g = tpx.createLinearGradient(0, -fzs/2, 0, fzs/2);
g.addColorStop(0, "#f5f"); g.addColorStop(1, "#8cf");
tpx.fillStyle = g;
}
tpx.fillText(txt[i], 0, 0);
tpx.restore();
}
requestAnimationFrame(draw);
}
draw();
</script>
</body>
</html>