diff --git a/standalone/gradient-editor.css b/standalone/gradient-editor.css new file mode 100644 index 0000000..5bb9507 --- /dev/null +++ b/standalone/gradient-editor.css @@ -0,0 +1,172 @@ +:root { + --bg: #0b0b10; + --panel: #151722; + --muted: #8f95b2; + --text: #e9ecff; + --accent: #5a67ff; +} + +html, +body { + height: 100%; + margin: 0; + background: var(--bg); + color: var(--text); + font: 14px / 1.5 system-ui, Segoe UI, Roboto, Inter, Arial, sans-serif; +} + +.wrap { + max-width: 980px; + margin: 24px auto; + padding: 16px; +} + +h1 { + font-size: 18px; + font-weight: 600; + margin: 0 0 12px; +} + +.grid { + display: grid; + grid-template-columns: 1.2fr 1fr; + gap: 16px; +} + +.card { + background: var(--panel); + border-radius: 12px; + padding: 12px; + box-shadow: 0 2px 16px rgba(0, 0, 0, 0.25); +} + +canvas { + display: block; + border-radius: 10px; +} + +#gradientCanvas { + width: 100%; + height: 96px; + background: #111; +} + +#picker { + width: 100%; + height: 180px; + cursor: crosshair; +} + +.toolbar { + display: flex; + gap: 8px; + align-items: center; + margin: 8px 0; +} + +.btn { + background: #23263a; + border: 1px solid #2f3350; + color: var(--text); + padding: 6px 10px; + border-radius: 8px; + cursor: pointer; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +table { + width: 100%; + border-collapse: separate; + border-spacing: 0 6px; +} + +th, +td { + font-size: 13px; +} + +th { + color: var(--muted); + font-weight: 600; + text-align: left; +} + +tr { + background: #1a1d2c; +} + +tr.sel { + outline: 2px solid var(--accent); +} + +td { + padding: 8px; +} + +input[type="text"], +input[type="number"] { + width: 100%; + box-sizing: border-box; + background: #0e1120; + border: 1px solid #2b3050; + color: var(--text); + border-radius: 6px; + padding: 6px 8px; +} + +.swatch { + width: 24px; + height: 24px; + border-radius: 6px; + border: 1px solid #00000055; + display: inline-block; + vertical-align: middle; +} + +.row-actions { + display: flex; + gap: 6px; +} + +.muted { + color: var(--muted); +} + +.small { + font-size: 12px; +} + +.export-form { + margin: 0; +} + + +.export-dialog { + width: min(90vw, 1200px); + overscroll-behavior: contain; +} + +.export-dialog textarea { + width: 100%; + height: 60vh; + box-sizing: border-box; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; + line-height: 1.4; + padding: 10px; + resize: vertical; + + overflow: auto; + overscroll-behavior: contain; +} + +.export-toolbar { + justify-content: flex-end; + margin-top: 8px; +} + + diff --git a/standalone/gradient-editor.js b/standalone/gradient-editor.js new file mode 100644 index 0000000..323666c --- /dev/null +++ b/standalone/gradient-editor.js @@ -0,0 +1,413 @@ +const exportShaderBtn = document.getElementById('exportShader'); +const exportJSONBtn = document.getElementById('exportJSON'); + +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;ia+b,0) || 1; + const len = seg.map(s=>s/sum); + const t=[0]; + for(let i=0;ihexToRgb(p.hex) || {r:0,g:0,b:0}); + + let si = 0; // segment index + for(let x=0;x 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 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" + }); +}); diff --git a/standalone/gradient_editor.html b/standalone/gradient_editor.html index f022424..d9f3bd7 100644 --- a/standalone/gradient_editor.html +++ b/standalone/gradient_editor.html @@ -1,353 +1,70 @@ - - - Gradient Editor - + + + Gradient Editor + + -
-

Histogram Gradient Editor

-
-
-
- - - Click the color canvas to set the selected point's color. -
- -
-
- -
Simple picker: hue horizontally; white overlay at top; black overlay at bottom.
-
-
+
+

Histogram Gradient Editor

-
- - - - - - - - - - - -
#SwatchHexWeightPosition
-
- ChatGPT (GPT-5). Gradient editor development discussion. OpenAI, September 2025. +
+
+
+ + + Click the color canvas to set the selected point's color. +
+ +
-
+
+ +
Hue horizontal; white top; black bottom.
+
+
- +
+ + + +
+ + +