music video renderer web app deftoku.digi.rip
audio visualizer webapp
1

Configure Feed

Select the types of activity you want to include in your feed.

feat: custom image support

digi.rip (Jun 21, 2026, 12:45 PM EDT) 7cd755ba c88d5861

+126 -22
+6
index.html
··· 69 69 70 70 <div class="menu-group" id="vis-group"> 71 71 <select name="visualizer" id="vis-select"></select> 72 + <div class="menu-group" id="img-group" style="display: none"> 73 + <button class="menu-trigger" data-menu="images"> 74 + images 75 + </button> 76 + <div class="menu-panel hidden" id="panel-images"></div> 77 + </div> 72 78 <div id="vis-params"></div> 73 79 </div> 74 80 </div>
+66 -3
src/controller.ts
··· 37 37 type VisualizerName = keyof typeof visualizers; 38 38 39 39 // visualizer opts registry, bundles draw & params 40 - type VisualizerOptions = Record<string, string | number>; 40 + type VisualizerOptions = Record<string, string | number | HTMLImageElement>; 41 + 42 + const imageCache = new Map<string, HTMLImageElement>(); 41 43 42 44 type VisualizerDef = { 43 45 draw: ( 44 46 canvas: HTMLCanvasElement, 45 47 freq: Uint8Array, 46 48 options: VisualizerOptions, 49 + dt?: number, 47 50 ) => void; 48 51 preview: ( 49 52 canvas: HTMLCanvasElement, ··· 182 185 if (!def || def.params.length === 0) return; 183 186 184 187 for (const p of def.params) { 188 + if (p.type === "img") continue; // handled below as group 189 + 185 190 const label = document.createElement("label"); 186 191 label.className = "menu-label"; 187 192 label.textContent = p.label; ··· 229 234 container.appendChild(input); 230 235 } 231 236 } 237 + 238 + // populate the images menu panel (static HTML, menubar system handles open/close) 239 + const imgGroup = document.querySelector<HTMLDivElement>("#img-group")!; 240 + const imgPanel = document.querySelector<HTMLDivElement>("#panel-images")!; 241 + imgPanel.innerHTML = ""; 242 + const imgParams = def.params.filter((p) => p.type === "img"); 243 + if (imgParams.length > 0) { 244 + imgGroup.style.display = ""; 245 + for (const p of imgParams) { 246 + const rowLabel = document.createElement("div"); 247 + rowLabel.className = "menu-label"; 248 + rowLabel.textContent = p.label; 249 + 250 + const fileInput = document.createElement("input"); 251 + fileInput.type = "file"; 252 + fileInput.id = `param-${p.key}`; 253 + fileInput.accept = "image/*"; 254 + fileInput.hidden = true; 255 + 256 + const uploadBtn = document.createElement("button"); 257 + uploadBtn.className = "menu-item"; 258 + uploadBtn.textContent = "upload"; 259 + uploadBtn.addEventListener("click", () => fileInput.click()); 260 + 261 + const removeBtn = document.createElement("button"); 262 + removeBtn.className = "menu-item"; 263 + removeBtn.textContent = "remove"; 264 + removeBtn.addEventListener("click", () => { 265 + imageCache.delete(p.key); 266 + uploadBtn.textContent = "upload"; 267 + fileInput.value = ""; 268 + }); 269 + 270 + fileInput.addEventListener("change", () => { 271 + const file = fileInput.files?.[0]; 272 + if (!file) return; 273 + uploadBtn.textContent = file.name; 274 + const reader = new FileReader(); 275 + reader.onload = () => { 276 + const img = new Image(); 277 + img.onload = () => imageCache.set(p.key, img); 278 + img.src = reader.result as string; 279 + }; 280 + reader.readAsDataURL(file); 281 + }); 282 + 283 + imgPanel.appendChild(rowLabel); 284 + imgPanel.appendChild(uploadBtn); 285 + imgPanel.appendChild(removeBtn); 286 + imgPanel.appendChild(fileInput); 287 + } 288 + } else { 289 + imgGroup.style.display = "none"; 290 + } 232 291 } 233 292 234 293 function currentOptions(): VisualizerOptions { ··· 237 296 for (const p of def.params) { 238 297 const input = document.querySelector<HTMLInputElement>(`#param-${p.key}`); 239 298 if (p.type === "number") opts[p.key] = Number(input?.value ?? p.default); 240 - if (p.type === "color") opts[p.key] = input?.value ?? p.default; 241 - if (p.type === "text") opts[p.key] = input?.value ?? p.default; 299 + else if (p.type === "color") opts[p.key] = input?.value ?? p.default; 300 + else if (p.type === "text") opts[p.key] = input?.value ?? p.default; 301 + else if (p.type === "img") { 302 + const img = imageCache.get(p.key); 303 + if (img) opts[p.key] = img; 304 + } 242 305 } 243 306 return opts; 244 307 }
+1 -1
src/render.ts
··· 8 8 } from "mediabunny"; 9 9 import { extractFreqFrames } from "./import.ts"; 10 10 11 - type VisualizerOptions = Record<string, string | number>; 11 + type VisualizerOptions = Record<string, string | number | HTMLImageElement>; 12 12 13 13 export async function render( 14 14 canvas: HTMLCanvasElement,
+2 -2
src/visualizers/ascii/bars.ts
··· 31 31 export function draw( 32 32 canvas: HTMLCanvasElement, 33 33 freq: Uint8Array, 34 - options: Record<string, string | number>, 34 + options: Record<string, string | number | HTMLImageElement>, 35 35 ) { 36 36 const ctx = canvas.getContext("2d"); 37 37 if (!ctx) return; ··· 74 74 export function preview( 75 75 canvas: HTMLCanvasElement, 76 76 _freq: Uint8Array, 77 - getOptions: () => Record<string, string | number>, 77 + getOptions: () => Record<string, string | number | HTMLImageElement>, 78 78 fps: number, 79 79 ) { 80 80 let phase = 0;
+2 -2
src/visualizers/classic/bars.ts
··· 16 16 export function draw( 17 17 canvas: HTMLCanvasElement, 18 18 freq: Uint8Array, 19 - options: Record<string, string | number>, 19 + options: Record<string, string | number | HTMLImageElement>, 20 20 ) { 21 21 const ctx = canvas.getContext("2d"); 22 22 if (!ctx) return; ··· 41 41 export function preview( 42 42 canvas: HTMLCanvasElement, 43 43 _freq: Uint8Array, 44 - getOptions: () => Record<string, string | number>, 44 + getOptions: () => Record<string, string | number | HTMLImageElement>, 45 45 fps: number, 46 46 ) { 47 47 let phase = 0;
+2 -2
src/visualizers/media/cd-bottom.ts
··· 25 25 export function draw( 26 26 canvas: HTMLCanvasElement, 27 27 _freq: Uint8Array, 28 - options: Record<string, string | number>, 28 + options: Record<string, string | number | HTMLImageElement>, 29 29 dt = 1, 30 30 ) { 31 31 const ctx = canvas.getContext("2d"); ··· 217 217 export function preview( 218 218 canvas: HTMLCanvasElement, 219 219 freq: Uint8Array, 220 - getOptions: () => Record<string, string | number>, 220 + getOptions: () => Record<string, string | number | HTMLImageElement>, 221 221 fps: number, 222 222 ) { 223 223 angle = 0;
+47 -12
src/visualizers/media/cd-top.ts
··· 10 10 11 11 export const params = [ 12 12 { 13 + key: "labelImg", 14 + type: "img", 15 + default: "", 16 + label: "label img", 17 + }, 18 + { 19 + key: "bgImg", 20 + type: "img", 21 + default: "", 22 + label: "bg img", 23 + }, 24 + { 13 25 key: "speed", 14 26 type: "number", 15 27 default: 0.2, ··· 25 37 export function draw( 26 38 canvas: HTMLCanvasElement, 27 39 _freq: Uint8Array, 28 - options: Record<string, string | number>, 40 + options: Record<string, string | number | HTMLImageElement>, 29 41 dt = 1, 30 42 ) { 31 43 const ctx = canvas.getContext("2d"); 32 44 if (!ctx) return; 33 45 34 46 // background 35 - ctx.fillStyle = "black"; 36 - ctx.fillRect(0, 0, canvas.width, canvas.height); 47 + const bgImg = options["bgImg"] as HTMLImageElement | undefined; 48 + if (bgImg && bgImg.complete && bgImg.naturalWidth > 0) { 49 + const scale = Math.max( 50 + canvas.width / bgImg.naturalWidth, 51 + canvas.height / bgImg.naturalHeight, 52 + ); 53 + const dw = bgImg.naturalWidth * scale; 54 + const dh = bgImg.naturalHeight * scale; 55 + const dx = (canvas.width - dw) / 2; 56 + const dy = (canvas.height - dh) / 2; 57 + ctx.drawImage(bgImg, dx, dy, dw, dh); 58 + } else { 59 + ctx.fillStyle = "black"; 60 + ctx.fillRect(0, 0, canvas.width, canvas.height); 61 + } 37 62 38 63 const cx = canvas.width / 2; 39 64 const cy = canvas.height / 2; ··· 101 126 ctx.beginPath(); 102 127 ctx.arc(0, 0, hubOuterRadius, 0, Math.PI * 2); 103 128 ctx.arc(0, 0, hubInnerRadius, 0, Math.PI * 2, true); 104 - ctx.fillStyle = "rgba(200,200,255,0.3)"; 129 + ctx.fillStyle = "rgba(200,200,255,0.8)"; 105 130 ctx.fill(); 106 131 107 132 // hub ring - outer edge 108 133 ctx.beginPath(); 109 134 ctx.arc(0, 0, hubOuterRadius, 0, Math.PI * 2); 110 - ctx.strokeStyle = "rgba(80,100,160,0.3)"; 135 + ctx.strokeStyle = "rgba(80,100,160,0.7)"; 111 136 ctx.lineWidth = 1.5; 112 137 ctx.stroke(); 113 - 114 - // center 115 - ctx.fillStyle = "black"; 116 - ctx.beginPath(); 117 - ctx.arc(0, 0, holeRadius, 0, Math.PI * 2); 118 - ctx.fill(); 119 138 120 139 function labelText( 121 140 angle: number, ··· 141 160 labelText(0, "CD-RW", 0.23, 0.4, outerRadius * -0.70); 142 161 labelText(0, "700MB", 0.23, 0.40, outerRadius * .45); 143 162 163 + // label image drawn over text 164 + const labelImg = options["labelImg"] as HTMLImageElement | undefined; 165 + if (labelImg && labelImg.complete && labelImg.naturalWidth > 0) { 166 + ctx.save(); 167 + ctx.beginPath(); 168 + ctx.arc(0, 0, outerRadius, 0, Math.PI * 2); 169 + ctx.arc(0, 0, hubInnerRadius, 0, Math.PI * 2, true); 170 + ctx.clip(); 171 + // scale image width to match disc diameter exactly, disc clip crops height 172 + const imgScale = (outerRadius * 2) / labelImg.naturalWidth; 173 + const iw = outerRadius * 2; 174 + const ih = labelImg.naturalHeight * imgScale; 175 + ctx.drawImage(labelImg, -outerRadius, -ih / 2, iw, ih); 176 + ctx.restore(); 177 + } 178 + 144 179 function outerRingText( 145 180 angle: number, 146 181 label: string, ··· 228 263 export function preview( 229 264 canvas: HTMLCanvasElement, 230 265 freq: Uint8Array, 231 - getOptions: () => Record<string, string | number>, 266 + getOptions: () => Record<string, string | number | HTMLImageElement>, 232 267 fps: number, 233 268 ) { 234 269 angle = 0;