461 lines
13 KiB
JavaScript
461 lines
13 KiB
JavaScript
const exportShaderBtn = document.getElementById('exportShader');
|
||
const exportJSONBtn = document.getElementById('exportJSON');
|
||
const exportESBtn = document.getElementById('exportES');
|
||
|
||
const exportDialog = document.getElementById('exportDialog');
|
||
const exportText = document.getElementById('exportText');
|
||
|
||
exportText.addEventListener('wheel', (e) => {
|
||
const atTop = exportText.scrollTop === 0;
|
||
const atBottom =
|
||
exportText.scrollTop + exportText.clientHeight >= exportText.scrollHeight;
|
||
|
||
if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) {
|
||
e.preventDefault();
|
||
}
|
||
}, { passive: false });
|
||
|
||
|
||
const gradientCanvas = document.getElementById('gradientCanvas');
|
||
const gctx = gradientCanvas.getContext('2d', { willReadFrequently: true });
|
||
const pickerCanvas = document.getElementById('picker');
|
||
const pctx = pickerCanvas.getContext('2d', { willReadFrequently: true });
|
||
const addBtn = document.getElementById('addPoint');
|
||
const removeBtn = document.getElementById('removePoint');
|
||
const tbody = document.querySelector('#pointsTable tbody');
|
||
|
||
// Helpers
|
||
const clamp = (v,min,max)=>Math.min(max,Math.max(min,v));
|
||
const lerp = (a,b,t)=>a+(b-a)*t;
|
||
const toHex2 = (n)=>n.toString(16).padStart(2,'0');
|
||
function rgbToHex(r,g,b){ return '#' + toHex2(r) + toHex2(g) + toHex2(b); }
|
||
function hexToRgb(hex){
|
||
if(!hex) return null;
|
||
hex = hex.trim();
|
||
const m3 = /^#?([0-9a-fA-F]{3})$/;
|
||
const m6 = /^#?([0-9a-fA-F]{6})$/;
|
||
let m;
|
||
if(m = hex.match(m3)){
|
||
const s=m[1];
|
||
const r=parseInt(s[0]+s[0],16), g=parseInt(s[1]+s[1],16), b=parseInt(s[2]+s[2],16);
|
||
return {r,g,b};
|
||
}
|
||
if(m = hex.match(m6)){
|
||
const s=m[1];
|
||
const r=parseInt(s.slice(0,2),16), g=parseInt(s.slice(2,4),16), b=parseInt(s.slice(4,6),16);
|
||
return {r,g,b};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const pointsTable = document.getElementById('pointsTable');
|
||
|
||
pointsTable.addEventListener('wheel', (e) => {
|
||
const { target } = e;
|
||
|
||
if (target.type === 'number') {
|
||
|
||
e.preventDefault();
|
||
|
||
const dir = Math.sign(e.deltaY);
|
||
if (dir === 0) return;
|
||
|
||
const step = e.shiftKey ? 0.10 : 0.01;
|
||
|
||
const weight = parseFloat(target.value);
|
||
target.value = Math.max(0.01, +(weight - dir * step).toFixed(4));
|
||
target.dispatchEvent(new Event('input', { bubbles: true }));
|
||
|
||
} else {
|
||
return;
|
||
}
|
||
|
||
}, { passive: false });
|
||
|
||
|
||
function drawPicker(){
|
||
const w=pickerCanvas.width, h=pickerCanvas.height;
|
||
const hue = pctx.createLinearGradient(0,0,w,0);
|
||
hue.addColorStop(0/6,'#f00');
|
||
hue.addColorStop(1/6,'#ff0');
|
||
hue.addColorStop(2/6,'#0f0');
|
||
hue.addColorStop(3/6,'#0ff');
|
||
hue.addColorStop(4/6,'#00f');
|
||
hue.addColorStop(5/6,'#f0f');
|
||
hue.addColorStop(1,'#f00');
|
||
pctx.fillStyle=hue; pctx.fillRect(0,0,w,h);
|
||
const whiteGrad=pctx.createLinearGradient(0,0,0,h);
|
||
whiteGrad.addColorStop(0,'rgba(255,255,255,1)');
|
||
whiteGrad.addColorStop(0.5,'rgba(255,255,255,0)');
|
||
pctx.fillStyle=whiteGrad; pctx.fillRect(0,0,w,h);
|
||
const blackGrad=pctx.createLinearGradient(0,0,0,h);
|
||
blackGrad.addColorStop(0.5,'rgba(0,0,0,0)');
|
||
blackGrad.addColorStop(1,'rgba(0,0,0,1)');
|
||
pctx.fillStyle=blackGrad; pctx.fillRect(0,0,w,h);
|
||
}
|
||
|
||
// Model: list of control points, each { hex, weight }
|
||
let points = [
|
||
{ hex:'#008', weight:0.75 }, // was #00F, now #008
|
||
{ hex:'#8000ff', weight:0.75 },
|
||
{ hex:'#ff0080', weight:1.00 },
|
||
{ hex:'#ff8000', weight:0.75 },
|
||
{ hex:'#ffffff', weight:1.00 },
|
||
];
|
||
|
||
// Selection state
|
||
let selected = 0;
|
||
|
||
function segmentWeights(pts){
|
||
const n=pts.length; const seg=[];
|
||
for(let i=0;i<n-1;i++) seg.push((pts[i].weight + pts[i+1].weight)/2);
|
||
return seg;
|
||
}
|
||
|
||
function positionsFromWeights(pts){
|
||
const seg = segmentWeights(pts);
|
||
const sum = seg.reduce((a,b)=>a+b,0) || 1;
|
||
const len = seg.map(s=>s/sum);
|
||
const t=[0];
|
||
for(let i=0;i<len.length;i++) t.push(t[t.length-1]+len[i]);
|
||
t[t.length-1]=1; // exact endpoint
|
||
return t; // size == pts.length
|
||
}
|
||
|
||
function drawGradient(){
|
||
const w = gradientCanvas.width, h = gradientCanvas.height;
|
||
const t = positionsFromWeights(points);
|
||
|
||
const img = gctx.createImageData(w,1);
|
||
const data = img.data;
|
||
|
||
// Precompute RGB from hex
|
||
const rgb = points.map(p=>hexToRgb(p.hex) || {r:0,g:0,b:0});
|
||
|
||
let si = 0; // segment index
|
||
for(let x=0;x<w;x++){
|
||
const u = x/(w-1);
|
||
// advance segment
|
||
while(si < t.length-2 && u > t[si+1]) si++;
|
||
const a = t[si], b = t[si+1];
|
||
const v = b>a ? (u - a)/(b - a) : 0;
|
||
const r = Math.round(lerp(rgb[si].r, rgb[si+1].r, v));
|
||
const g = Math.round(lerp(rgb[si].g, rgb[si+1].g, v));
|
||
const bch = Math.round(lerp(rgb[si].b, rgb[si+1].b, v));
|
||
const k = x*4;
|
||
data[k+0]=r; data[k+1]=g; data[k+2]=bch; data[k+3]=255;
|
||
}
|
||
// scale to full height
|
||
gctx.putImageData(img,0,0);
|
||
gctx.imageSmoothingEnabled=false;
|
||
gctx.drawImage(gradientCanvas,0,0,w,h);
|
||
}
|
||
|
||
function setSelected(i){
|
||
if (selected === i) return;
|
||
const rows = [...tbody.querySelectorAll('tr')];
|
||
if (rows[selected]) rows[selected].classList.remove('sel');
|
||
selected = i;
|
||
if (rows[selected]) rows[selected].classList.add('sel');
|
||
}
|
||
|
||
function refreshPositions(){
|
||
const t = positionsFromWeights(points);
|
||
const rows = tbody.querySelectorAll('tr');
|
||
for (let j = 0; j < rows.length; j++) {
|
||
const posCell = rows[j].lastElementChild; // position column
|
||
if (posCell) posCell.textContent = t[j].toFixed(6);
|
||
}
|
||
drawGradient();
|
||
}
|
||
|
||
// Update swatches (and non-focused hex inputs) to match current point colors,
|
||
// then redraw the gradient. Does not rebuild the table or steal focus.
|
||
function refreshColors(){
|
||
const rows = tbody.querySelectorAll('tr');
|
||
for (let j = 0; j < points.length; j++) {
|
||
const row = rows[j];
|
||
if (!row) continue;
|
||
|
||
const swatch = row.querySelector('.swatch');
|
||
if (swatch) swatch.style.background = points[j].hex;
|
||
|
||
const hexInput = row.querySelector('input[type="text"]');
|
||
if (hexInput && document.activeElement !== hexInput) {
|
||
hexInput.value = points[j].hex; // keep focused input untouched
|
||
}
|
||
}
|
||
drawGradient();
|
||
}
|
||
|
||
|
||
function updateTable(){
|
||
tbody.innerHTML='';
|
||
const t = positionsFromWeights(points);
|
||
points.forEach((p,i)=>{
|
||
const tr=document.createElement('tr');
|
||
if(i===selected) tr.classList.add('sel');
|
||
|
||
const tdIdx=document.createElement('td'); tdIdx.textContent=i+1; tr.appendChild(tdIdx);
|
||
|
||
const tdSw=document.createElement('td');
|
||
const sw=document.createElement('span'); sw.className='swatch'; sw.style.background=p.hex; tdSw.appendChild(sw); tr.appendChild(tdSw);
|
||
|
||
const tdHex=document.createElement('td');
|
||
const inpHex=document.createElement('input'); inpHex.type='text'; inpHex.value=p.hex; inpHex.placeholder='#rrggbb';
|
||
|
||
|
||
/* Possible refactor:
|
||
inpHex.addEventListener('change', () => {
|
||
const rgb = hexToRgb(inpHex.value);
|
||
if (rgb) {
|
||
p.hex = normalizeHex(inpHex.value);
|
||
drawGradient();
|
||
// remove updateTable() here
|
||
} else {
|
||
inpHex.value = p.hex;
|
||
}
|
||
});
|
||
*/
|
||
|
||
|
||
tdHex.appendChild(inpHex); tr.appendChild(tdHex);
|
||
|
||
const tdW=document.createElement('td');
|
||
const inpW=document.createElement('input'); inpW.type='number'; inpW.min='0.01'; inpW.step='0.01'; inpW.value=p.weight.toString();
|
||
|
||
tdW.appendChild(inpW); tr.appendChild(tdW);
|
||
|
||
const tdPos=document.createElement('td');
|
||
tdPos.textContent = t[i].toFixed(6);
|
||
tr.appendChild(tdPos);
|
||
|
||
tr.addEventListener('click', (e) => {
|
||
setSelected(i);
|
||
});
|
||
|
||
|
||
inpHex.addEventListener('focus', () => setSelected(i));
|
||
|
||
inpHex.addEventListener('change',()=>{
|
||
const rgb=hexToRgb(inpHex.value);
|
||
if(rgb){ p.hex = normalizeHex(inpHex.value); refreshColors(); }
|
||
else { inpHex.value=p.hex; }
|
||
});
|
||
|
||
inpHex.addEventListener('input',()=>{
|
||
const rgb=hexToRgb(inpHex.value);
|
||
if(rgb){ p.hex = normalizeHex(inpHex.value); refreshColors(); }
|
||
|
||
});
|
||
|
||
inpW.addEventListener('focus', () => setSelected(i));
|
||
inpW.addEventListener('input',()=>{ p.weight = Math.max(0.01, parseFloat(inpW.value)||1); refreshPositions(); });
|
||
inpW.addEventListener('change',()=>{ p.weight = Math.max(0.01, parseFloat(inpW.value)||1); refreshPositions(); });
|
||
|
||
|
||
|
||
tbody.appendChild(tr);
|
||
});
|
||
removeBtn.disabled = points.length<=2 || selected==null;
|
||
}
|
||
|
||
function normalizeHex(h){
|
||
h=h.trim(); if(h[0]!=='#') h='#'+h; // allow missing '#'
|
||
const rgb=hexToRgb(h); if(!rgb) return '#000000';
|
||
return rgbToHex(rgb.r,rgb.g,rgb.b);
|
||
}
|
||
|
||
function updateSelection(){ updateTable(); drawGradient(); }
|
||
|
||
function addPointAfter(idx){
|
||
const t = positionsFromWeights(points);
|
||
const left = Math.max(0, Math.min(idx, points.length-1));
|
||
const right = Math.min(left+1, points.length-1);
|
||
const u = (t[left] + t[right]) / 2;
|
||
const col = sampleGradient(u);
|
||
points.splice(left+1,0,{ hex: col, weight: 1.0 });
|
||
selected = left+1; updateSelection();
|
||
}
|
||
|
||
function sampleGradient(u){
|
||
const t = positionsFromWeights(points);
|
||
const rgb = points.map(p => hexToRgb(p.hex));
|
||
let i = 0;
|
||
while (i < t.length - 2 && u > t[i+1]) i++;
|
||
const a = t[i], b = t[i+1];
|
||
const v = b > a ? (u - a) / (b - a) : 0;
|
||
const rr = Math.round(lerp(rgb[i].r, rgb[i+1].r, v));
|
||
const gg = Math.round(lerp(rgb[i].g, rgb[i+1].g, v));
|
||
const bb = Math.round(lerp(rgb[i].b, rgb[i+1].b, v));
|
||
return rgbToHex(rr, gg, bb);
|
||
}
|
||
|
||
addBtn.addEventListener('click',()=>{
|
||
const idx = selected ?? (points.length-2);
|
||
addPointAfter(idx);
|
||
});
|
||
|
||
removeBtn.addEventListener('click',()=>{
|
||
if(points.length<=2 || selected==null) return;
|
||
points.splice(selected,1);
|
||
selected = Math.max(0, Math.min(selected, points.length-1));
|
||
updateSelection();
|
||
});
|
||
|
||
pickerCanvas.addEventListener('click', (e)=>{
|
||
if(selected==null) return;
|
||
const rect = pickerCanvas.getBoundingClientRect();
|
||
const x = Math.floor((e.clientX - rect.left) * (pickerCanvas.width/rect.width));
|
||
const y = Math.floor((e.clientY - rect.top) * (pickerCanvas.height/rect.height));
|
||
const px = pctx.getImageData(x,y,1,1).data;
|
||
const hex = rgbToHex(px[0],px[1],px[2]);
|
||
points[selected].hex = hex; updateSelection();
|
||
});
|
||
|
||
// Initial render
|
||
drawPicker();
|
||
drawGradient();
|
||
updateTable();
|
||
|
||
function buildESGradientFunction() {
|
||
// t[0] = 0, t[n‑1] = 1
|
||
const t = positionsFromWeights(points); // [0, 0.222222, 0.481481, 0.740741, 1, …]
|
||
const rgb = points.map(p => hexToRgb(p.hex)); // [{r,g,b}, …]
|
||
|
||
const out = [];
|
||
out.push("function gradient(t) {");
|
||
out.push(" t = Math.max(0, Math.min(1, t));");
|
||
|
||
const segCount = points.length - 1; // number of segments
|
||
for (let i = 0; i < segCount; i++) {
|
||
const a = t[i];
|
||
const b = t[i + 1];
|
||
const c0 = rgb[i];
|
||
const c1 = rgb[i + 1];
|
||
|
||
// Condition
|
||
const cond =
|
||
i === 0
|
||
? `if (t < ${b})`
|
||
: i === segCount - 1
|
||
? `else`
|
||
: `else if (t < ${b})`;
|
||
|
||
out.push(` ${cond} {`);
|
||
const ratio = `(t - ${a}) / (${b} - ${a})`;
|
||
out.push(` const r = Math.round(${c0.r} + (${c1.r} - ${c0.r}) * ${ratio});`);
|
||
out.push(` const g = Math.round(${c0.g} + (${c1.g} - ${c0.g}) * ${ratio});`);
|
||
out.push(` const b = Math.round(${c0.b} + (${c1.b} - ${c0.b}) * ${ratio});`);
|
||
out.push(" return { r, g, b };");
|
||
out.push(" }");
|
||
}
|
||
|
||
out.push("}");
|
||
return out.join("\n");
|
||
}
|
||
|
||
function buildShaderFunction() {
|
||
const t = positionsFromWeights(points);
|
||
const rgb = points.map(p => hexToRgb(p.hex));
|
||
|
||
let out = [];
|
||
out.push("float3 gradient(float t)");
|
||
out.push("{");
|
||
out.push("\tt = saturate(t);");
|
||
|
||
for (let i = 0; i < points.length - 1; i++) {
|
||
const a = t[i].toFixed(6);
|
||
const b = t[i+1].toFixed(6);
|
||
const c0 = rgb[i];
|
||
const c1 = rgb[i+1];
|
||
|
||
// FIX: last segment must be unconditional
|
||
out.push(
|
||
i === 0
|
||
? `\tif (t < ${b})`
|
||
: (i === points.length - 2
|
||
? `\telse`
|
||
: `\telse if (t < ${b})`)
|
||
);
|
||
|
||
out.push("\t\treturn lerp(");
|
||
out.push(`\t\t\tfloat3(${(c0.r/255).toFixed(6)}, ${(c0.g/255).toFixed(6)}, ${(c0.b/255).toFixed(6)}),`);
|
||
out.push(`\t\t\tfloat3(${(c1.r/255).toFixed(6)}, ${(c1.g/255).toFixed(6)}, ${(c1.b/255).toFixed(6)}),`);
|
||
out.push(`\t\t\t(t - ${a}) / (${b} - ${a})`);
|
||
out.push("\t\t);");
|
||
}
|
||
|
||
out.push("}");
|
||
return out.join("\n");
|
||
}
|
||
|
||
|
||
function showExportDialog({ title, text, filename, mime }) {
|
||
const dlg = document.getElementById('exportDialog');
|
||
const ta = document.getElementById('exportText');
|
||
const h = document.getElementById('exportTitle');
|
||
const copy = document.getElementById('copyExport');
|
||
const dl = document.getElementById('downloadExport');
|
||
|
||
h.textContent = title;
|
||
ta.value = text;
|
||
|
||
copy.onclick = async () => {
|
||
await navigator.clipboard.writeText(text);
|
||
};
|
||
|
||
dl.onclick = () => {
|
||
downloadText(filename, text, mime);
|
||
};
|
||
|
||
dlg.showModal();
|
||
}
|
||
|
||
function downloadText(filename, text, mime = "text/plain") {
|
||
const blob = new Blob([text], { type: mime });
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = filename;
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
}
|
||
|
||
|
||
function buildJSON() {
|
||
return JSON.stringify(
|
||
{ points: points.map(p => ({ hex: p.hex, weight: p.weight })) },
|
||
null,
|
||
2
|
||
);
|
||
}
|
||
|
||
|
||
exportShaderBtn.addEventListener('click', () => {
|
||
showExportDialog({
|
||
title: "OBS shader: gradient()",
|
||
text: buildShaderFunction(),
|
||
filename: "gradient.hlsl",
|
||
mime: "text/plain"
|
||
});
|
||
});
|
||
|
||
exportJSONBtn.addEventListener('click', () => {
|
||
showExportDialog({
|
||
title: "Gradient JSON",
|
||
text: buildJSON(),
|
||
filename: "gradient.json",
|
||
mime: "application/json"
|
||
});
|
||
});
|
||
|
||
|
||
exportESBtn.addEventListener('click', () => {
|
||
showExportDialog({
|
||
title: "EcmaScript: gradient()",
|
||
text: buildESGradientFunction(),
|
||
filename: "gradient.js",
|
||
mime: "application/js"
|
||
});
|
||
});
|