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.

refactor: split controller into modular components, renamed to main

- main (renamed from controller): pure wiring
- visualizers/index: handles visualizer registry and types
- ui/menu-bar: generic menu open/close
- ui/params: owns cachedOpts, builds param controls + vis
- ui/preview: canvas sizing, preview lifecycle
- ui/render: click render → gather state → save

digi.rip (Jun 21, 2026, 7:55 PM EDT) 1f61dc2b f20d8e7b

+426 -360
+1 -1
deno.json
··· 5 5 "nodeModulesDir": "manual", 6 6 "tasks": { 7 7 "dev": "deno run -A dev_server.ts", 8 - "build": "deno run -A npm:esbuild --bundle src/mod.ts --outfile=dist/dist.js --format=esm" 8 + "build": "deno run -A npm:esbuild --bundle src/main.ts --outfile=dist/dist.js --format=esm" 9 9 }, 10 10 "imports": { 11 11 "@std/http": "jsr:@std/http@^1",
+1 -1
dev_server.ts
··· 80 80 "-A", 81 81 "npm:esbuild", 82 82 "--bundle", 83 - "src/mod.ts", 83 + "src/main.ts", 84 84 `--outfile=${DIST_FILE}`, 85 85 "--format=esm", 86 86 "--watch=forever",
+3 -1
index.html
··· 36 36 </div> 37 37 38 38 <div class="menu-group"> 39 - <button class="menu-trigger" data-menu="output">output</button> 39 + <button class="menu-trigger" data-menu="output"> 40 + settings 41 + </button> 40 42 <div class="menu-panel hidden" id="panel-output"> 41 43 <label class="menu-label">resolution</label> 42 44 <select name="resolution" id="res-select">
-349
src/controller.ts
··· 1 - // input/output 2 - import { render } from "./render.ts"; 3 - import { audioBuffer } from "./import.ts"; 4 - import { 5 - VisualizerDef, 6 - VisualizerName, 7 - VisualizerOptions, 8 - visualizers, 9 - } from "./visualizers/index.ts"; 10 - import { 11 - Quality, 12 - QUALITY_HIGH, 13 - QUALITY_LOW, 14 - QUALITY_MEDIUM, 15 - QUALITY_VERY_LOW, 16 - } from "mediabunny"; 17 - 18 - const imageCache = new Map<string, HTMLImageElement>(); 19 - 20 - const qualityMap: Record<string, Quality> = { 21 - "verylow": QUALITY_VERY_LOW, 22 - "low": QUALITY_LOW, 23 - "medium": QUALITY_MEDIUM, 24 - "high": QUALITY_HIGH, 25 - }; 26 - 27 - const EMPTY_FREQ = new Uint8Array(0); 28 - 29 - function fitCanvas(canvas: HTMLCanvasElement) { 30 - const container = canvas.parentElement!; 31 - 32 - const scale = Math.min( 33 - container.clientWidth / canvas.width, 34 - container.clientHeight / canvas.height, 35 - ); 36 - canvas.style.width = `${Math.round(canvas.width * scale)}px`; 37 - canvas.style.height = `${Math.round(canvas.height * scale)}px`; 38 - } 39 - 40 - const load = async () => { 41 - console.log("controller booted"); 42 - 43 - const canvas = document.querySelector("canvas")!; 44 - const resSelect = document.querySelector<HTMLSelectElement>("#res-select")!; 45 - const ratioSelect = document.querySelector<HTMLSelectElement>( 46 - "#ratio-select", 47 - )!; 48 - 49 - globalThis.window.addEventListener("resize", () => fitCanvas(canvas)); 50 - fitCanvas(canvas); 51 - 52 - const select = document.querySelector<HTMLSelectElement>("#vis-select")!; 53 - 54 - function buildVisSelect() { 55 - select.innerHTML = ""; 56 - const byCategory = new Map<string, Array<[string, VisualizerDef]>>(); 57 - for (const [name, def] of Object.entries(visualizers)) { 58 - const cat = def.category ?? "Other"; 59 - if (!byCategory.has(cat)) byCategory.set(cat, []); 60 - byCategory.get(cat)!.push([name, def]); 61 - } 62 - for (const [cat, entries] of byCategory) { 63 - const group = document.createElement("optgroup"); 64 - group.label = cat; 65 - for (const [name] of entries) { 66 - const opt = document.createElement("option"); 67 - opt.value = name; 68 - opt.textContent = name; 69 - group.appendChild(opt); 70 - } 71 - select.appendChild(group); 72 - } 73 - // restore last-used, then fall back to default, then first option 74 - const saved = localStorage.getItem("vis"); 75 - if (saved && visualizers[saved]) { 76 - select.value = saved; 77 - } else { 78 - const def = Object.entries(visualizers).find(([, v]) => v.default); 79 - select.value = def ? def[0] : select.querySelector("option")!.value; 80 - } 81 - } 82 - buildVisSelect(); 83 - 84 - // persist selection 85 - select.addEventListener("change", () => { 86 - localStorage.setItem("vis", select.value); 87 - }); 88 - 89 - let stopPreview: (() => void) | null = null; 90 - 91 - function startPreview(name: string) { 92 - stopPreview?.(); 93 - const fps = Number(fpsSelect.value); 94 - stopPreview = visualizers[name].preview( 95 - canvas, 96 - EMPTY_FREQ, 97 - currentOptions, 98 - fps, 99 - ); 100 - } 101 - 102 - function buildParamsUI(name: string) { 103 - const container = document.querySelector("#vis-params")!; 104 - container.innerHTML = ""; 105 - const def = visualizers[name]; 106 - if (!def || def.params.length === 0) { 107 - cachedOpts = {}; 108 - return; 109 - } 110 - 111 - // seed cache with defaults 112 - cachedOpts = {}; 113 - for (const p of def.params) { 114 - if (p.type === "img") { 115 - const img = imageCache.get(p.key); 116 - if (img) cachedOpts[p.key] = img; 117 - } else { 118 - cachedOpts[p.key] = p.default; 119 - } 120 - } 121 - 122 - for (const p of def.params) { 123 - if (p.type === "img") continue; // handled below as group 124 - 125 - const label = document.createElement("label"); 126 - label.className = "menu-label"; 127 - label.textContent = p.label; 128 - 129 - if (p.type === "number") { 130 - const range = document.createElement("input"); 131 - range.type = "range"; 132 - range.id = `param-${p.key}`; 133 - range.min = String(p.min ?? 0); 134 - range.max = String(p.max ?? 100); 135 - if (p.step !== undefined) range.step = String(p.step); 136 - range.value = String(p.default); 137 - 138 - const number = document.createElement("input"); 139 - number.type = "number"; 140 - number.min = range.min; 141 - number.max = range.max; 142 - if (p.step !== undefined) number.step = range.step; 143 - number.value = range.value; 144 - number.addEventListener("change", () => { 145 - range.value = number.value; 146 - }); 147 - range.addEventListener("input", () => { 148 - number.value = range.value; 149 - cachedOpts[p.key] = Number(range.value); 150 - }); 151 - 152 - container.appendChild(label); 153 - container.appendChild(range); 154 - container.appendChild(number); 155 - } else if (p.type === "color") { 156 - const input = document.createElement("input"); 157 - input.type = "color"; 158 - input.id = `param-${p.key}`; 159 - input.value = String(p.default); 160 - input.addEventListener("input", () => { 161 - cachedOpts[p.key] = input.value; 162 - }); 163 - container.appendChild(label); 164 - container.appendChild(input); 165 - } else if (p.type === "text") { 166 - const input = document.createElement("input"); 167 - input.type = "text"; 168 - input.id = `param-${p.key}`; 169 - input.maxLength = 1; 170 - input.value = String(p.default); 171 - if (p.inputStyle) Object.assign(input.style, p.inputStyle); 172 - input.addEventListener("input", () => { 173 - cachedOpts[p.key] = input.value; 174 - }); 175 - container.appendChild(label); 176 - container.appendChild(input); 177 - } 178 - } 179 - 180 - // populate the images menu panel (static HTML, menubar system handles open/close) 181 - const imgGroup = document.querySelector<HTMLDivElement>("#img-group")!; 182 - const imgPanel = document.querySelector<HTMLDivElement>("#panel-images")!; 183 - imgPanel.innerHTML = ""; 184 - const imgParams = def.params.filter((p) => p.type === "img"); 185 - if (imgParams.length > 0) { 186 - imgGroup.style.display = ""; 187 - for (const p of imgParams) { 188 - const rowLabel = document.createElement("div"); 189 - rowLabel.className = "menu-label"; 190 - rowLabel.textContent = p.label; 191 - 192 - const fileInput = document.createElement("input"); 193 - fileInput.type = "file"; 194 - fileInput.id = `param-${p.key}`; 195 - fileInput.accept = "image/*"; 196 - fileInput.hidden = true; 197 - 198 - const uploadBtn = document.createElement("button"); 199 - uploadBtn.className = "menu-item"; 200 - uploadBtn.textContent = "upload"; 201 - uploadBtn.addEventListener("click", () => fileInput.click()); 202 - 203 - const removeBtn = document.createElement("button"); 204 - removeBtn.className = "menu-item"; 205 - removeBtn.textContent = "remove"; 206 - removeBtn.addEventListener("click", () => { 207 - imageCache.delete(p.key); 208 - delete cachedOpts[p.key]; 209 - uploadBtn.textContent = "upload"; 210 - fileInput.value = ""; 211 - }); 212 - 213 - fileInput.addEventListener("change", () => { 214 - const file = fileInput.files?.[0]; 215 - if (!file) return; 216 - uploadBtn.textContent = file.name; 217 - const reader = new FileReader(); 218 - reader.onload = () => { 219 - const img = new Image(); 220 - img.onload = () => { 221 - imageCache.set(p.key, img); 222 - cachedOpts[p.key] = img; 223 - }; 224 - img.src = reader.result as string; 225 - }; 226 - reader.readAsDataURL(file); 227 - }); 228 - 229 - imgPanel.appendChild(rowLabel); 230 - imgPanel.appendChild(uploadBtn); 231 - imgPanel.appendChild(removeBtn); 232 - imgPanel.appendChild(fileInput); 233 - } 234 - } else { 235 - imgGroup.style.display = "none"; 236 - } 237 - } 238 - 239 - let cachedOpts: VisualizerOptions = {}; 240 - 241 - function currentOptions(): VisualizerOptions { 242 - return cachedOpts; 243 - } 244 - 245 - const fpsSelect = document.querySelector<HTMLSelectElement>("#fps-select")!; 246 - const qualitySelect = document.querySelector<HTMLSelectElement>( 247 - "#quality-select", 248 - )!; 249 - 250 - function updateCanvas() { 251 - const h = Number(resSelect!.value); 252 - canvas!.width = h * Number(ratioSelect!.value); 253 - canvas!.height = h; 254 - fitCanvas(canvas!); 255 - startPreview(select!.value); 256 - } 257 - 258 - resSelect.addEventListener("change", updateCanvas); 259 - ratioSelect.addEventListener("change", updateCanvas); 260 - fpsSelect.addEventListener("change", () => startPreview(select.value)); 261 - 262 - type ShowSaveFilePicker = ( 263 - options?: { 264 - suggestedName?: string; 265 - types?: Array<{ 266 - description?: string; 267 - accept: Record<string, string[]>; 268 - }>; 269 - }, 270 - ) => Promise<FileSystemFileHandle>; 271 - 272 - const renderBtn = document.querySelector<HTMLButtonElement>("#render"); 273 - renderBtn!.addEventListener("click", async () => { 274 - if (!audioBuffer) { 275 - alert("import audio first before rendering"); 276 - return; 277 - } 278 - 279 - const filename = "output.webm"; 280 - 281 - if ("showSaveFilePicker" in globalThis) { 282 - let handle: FileSystemFileHandle; 283 - try { 284 - handle = await ( 285 - globalThis as unknown as { showSaveFilePicker: ShowSaveFilePicker } 286 - ).showSaveFilePicker({ 287 - suggestedName: filename, 288 - types: [ 289 - { description: "WebM Video", accept: { "video/webm": [".webm"] } }, 290 - ], 291 - }); 292 - } catch { 293 - return; 294 - } 295 - 296 - const fps = Number(fpsSelect.value); 297 - const quality = qualityMap[qualitySelect.value]; 298 - const def = visualizers[select.value as VisualizerName]; 299 - const blob = await render( 300 - canvas, 301 - audioBuffer, 302 - def.draw, 303 - fps, 304 - quality, 305 - currentOptions(), 306 - ); 307 - 308 - const writable = await handle.createWritable(); 309 - await writable.write(blob); 310 - await writable.close(); 311 - console.log("render done"); 312 - return; 313 - } 314 - 315 - const fps = Number(fpsSelect.value); 316 - const quality = qualityMap[qualitySelect.value]; 317 - const def = visualizers[select.value as VisualizerName]; 318 - const blob = await render( 319 - canvas, 320 - audioBuffer, 321 - def.draw, 322 - fps, 323 - quality, 324 - currentOptions(), 325 - ); 326 - 327 - const url = URL.createObjectURL(blob); 328 - const a = document.createElement("a"); 329 - a.href = url; 330 - a.download = filename; 331 - a.click(); 332 - URL.revokeObjectURL(url); 333 - console.log("render done"); 334 - }); 335 - 336 - select.addEventListener("change", () => { 337 - buildParamsUI(select.value); 338 - startPreview(select.value); 339 - }); 340 - await document.fonts.ready; 341 - buildParamsUI(select.value); 342 - startPreview(select.value); 343 - }; 344 - 345 - if (document.readyState === "loading") { 346 - document.addEventListener("DOMContentLoaded", load); 347 - } else { 348 - load(); 349 - }
src/import.ts src/input.ts
+79
src/main.ts
··· 1 + import "./ui/menu-bar.ts"; 2 + import { visualizers } from "./visualizers/index.ts"; 3 + import { buildParamsUI, buildVisSelect, currentOptions } from "./ui/params.ts"; 4 + import { fitCanvas, startPreview, updateCanvas } from "./ui/preview.ts"; 5 + import { handleRender } from "./ui/render.ts"; 6 + 7 + const main = async () => { 8 + const canvas = document.querySelector("canvas")!; 9 + const resSelect = document.querySelector<HTMLSelectElement>("#res-select")!; 10 + const ratioSelect = document.querySelector<HTMLSelectElement>( 11 + "#ratio-select", 12 + )!; 13 + const select = document.querySelector<HTMLSelectElement>("#vis-select")!; 14 + const fpsSelect = document.querySelector<HTMLSelectElement>("#fps-select")!; 15 + const qualitySelect = document.querySelector<HTMLSelectElement>( 16 + "#quality-select", 17 + )!; 18 + 19 + globalThis.window.addEventListener("resize", () => fitCanvas(canvas)); 20 + fitCanvas(canvas); 21 + 22 + buildVisSelect(select); 23 + 24 + const onUpdate = () => 25 + updateCanvas( 26 + canvas, 27 + resSelect, 28 + ratioSelect, 29 + select, 30 + fpsSelect, 31 + visualizers, 32 + currentOptions, 33 + ); 34 + 35 + resSelect.addEventListener("change", onUpdate); 36 + ratioSelect.addEventListener("change", onUpdate); 37 + fpsSelect.addEventListener("change", () => 38 + startPreview( 39 + select.value, 40 + canvas, 41 + fpsSelect, 42 + visualizers, 43 + currentOptions, 44 + )); 45 + 46 + const renderBtn = document.querySelector<HTMLButtonElement>("#render"); 47 + renderBtn!.addEventListener( 48 + "click", 49 + () => 50 + handleRender(canvas, fpsSelect, qualitySelect, select, currentOptions), 51 + ); 52 + 53 + select.addEventListener("change", () => { 54 + buildParamsUI(select.value); 55 + startPreview( 56 + select.value, 57 + canvas, 58 + fpsSelect, 59 + visualizers, 60 + currentOptions, 61 + ); 62 + }); 63 + 64 + await document.fonts.ready; 65 + buildParamsUI(select.value); 66 + startPreview( 67 + select.value, 68 + canvas, 69 + fpsSelect, 70 + visualizers, 71 + currentOptions, 72 + ); 73 + }; 74 + 75 + if (document.readyState === "loading") { 76 + document.addEventListener("DOMContentLoaded", main); 77 + } else { 78 + main(); 79 + }
src/menubar.ts src/ui/menu-bar.ts
-3
src/mod.ts
··· 1 - import "./controller.ts"; 2 - import "./import.ts"; 3 - import "./menubar.ts";
+2 -2
src/render.ts src/output.ts
··· 6 6 Quality, 7 7 WebMOutputFormat, 8 8 } from "mediabunny"; 9 - import { extractFreqFrames } from "./import.ts"; 9 + import { extractFreqFrames } from "./input.ts"; 10 10 11 11 type VisualizerOptions = Record<string, string | number | HTMLImageElement>; 12 12 13 - export async function render( 13 + export async function output( 14 14 canvas: HTMLCanvasElement, 15 15 buffer: AudioBuffer, 16 16 draw: (
+184
src/ui/params.ts
··· 1 + import { 2 + VisualizerDef, 3 + VisualizerOptions, 4 + visualizers, 5 + } from "../visualizers/index.ts"; 6 + 7 + const imageCache = new Map<string, HTMLImageElement>(); 8 + 9 + let cachedOpts: VisualizerOptions = {}; 10 + 11 + export function currentOptions(): VisualizerOptions { 12 + return cachedOpts; 13 + } 14 + 15 + export function buildParamsUI(name: string) { 16 + const container = document.querySelector("#vis-params")!; 17 + container.innerHTML = ""; 18 + const def = visualizers[name]; 19 + if (!def || def.params.length === 0) { 20 + cachedOpts = {}; 21 + return; 22 + } 23 + 24 + // seed cache with defaults 25 + cachedOpts = {}; 26 + for (const p of def.params) { 27 + if (p.type === "img") { 28 + const img = imageCache.get(p.key); 29 + if (img) cachedOpts[p.key] = img; 30 + } else { 31 + cachedOpts[p.key] = p.default; 32 + } 33 + } 34 + 35 + for (const p of def.params) { 36 + if (p.type === "img") continue; // handled below as group 37 + 38 + const label = document.createElement("label"); 39 + label.className = "menu-label"; 40 + label.textContent = p.label; 41 + 42 + if (p.type === "number") { 43 + const range = document.createElement("input"); 44 + range.type = "range"; 45 + range.id = `param-${p.key}`; 46 + range.min = String(p.min ?? 0); 47 + range.max = String(p.max ?? 100); 48 + if (p.step !== undefined) range.step = String(p.step); 49 + range.value = String(p.default); 50 + 51 + const number = document.createElement("input"); 52 + number.type = "number"; 53 + number.min = range.min; 54 + number.max = range.max; 55 + if (p.step !== undefined) number.step = range.step; 56 + number.value = range.value; 57 + number.addEventListener("change", () => { 58 + range.value = number.value; 59 + }); 60 + range.addEventListener("input", () => { 61 + number.value = range.value; 62 + cachedOpts[p.key] = Number(range.value); 63 + }); 64 + 65 + container.appendChild(label); 66 + container.appendChild(range); 67 + container.appendChild(number); 68 + } else if (p.type === "color") { 69 + const input = document.createElement("input"); 70 + input.type = "color"; 71 + input.id = `param-${p.key}`; 72 + input.value = String(p.default); 73 + input.addEventListener("input", () => { 74 + cachedOpts[p.key] = input.value; 75 + }); 76 + container.appendChild(label); 77 + container.appendChild(input); 78 + } else if (p.type === "text") { 79 + const input = document.createElement("input"); 80 + input.type = "text"; 81 + input.id = `param-${p.key}`; 82 + input.maxLength = 1; 83 + input.value = String(p.default); 84 + if (p.inputStyle) Object.assign(input.style, p.inputStyle); 85 + input.addEventListener("input", () => { 86 + cachedOpts[p.key] = input.value; 87 + }); 88 + container.appendChild(label); 89 + container.appendChild(input); 90 + } 91 + } 92 + 93 + // populate the images menu panel (static HTML, menubar system handles open/close) 94 + const imgGroup = document.querySelector<HTMLDivElement>("#img-group")!; 95 + const imgPanel = document.querySelector<HTMLDivElement>("#panel-images")!; 96 + imgPanel.innerHTML = ""; 97 + const imgParams = def.params.filter((p) => p.type === "img"); 98 + if (imgParams.length > 0) { 99 + imgGroup.style.display = ""; 100 + for (const p of imgParams) { 101 + const rowLabel = document.createElement("div"); 102 + rowLabel.className = "menu-label"; 103 + rowLabel.textContent = p.label; 104 + 105 + const fileInput = document.createElement("input"); 106 + fileInput.type = "file"; 107 + fileInput.id = `param-${p.key}`; 108 + fileInput.accept = "image/*"; 109 + fileInput.hidden = true; 110 + 111 + const uploadBtn = document.createElement("button"); 112 + uploadBtn.className = "menu-item"; 113 + uploadBtn.textContent = "upload"; 114 + uploadBtn.addEventListener("click", () => fileInput.click()); 115 + 116 + const removeBtn = document.createElement("button"); 117 + removeBtn.className = "menu-item"; 118 + removeBtn.textContent = "remove"; 119 + removeBtn.addEventListener("click", () => { 120 + imageCache.delete(p.key); 121 + delete cachedOpts[p.key]; 122 + uploadBtn.textContent = "upload"; 123 + fileInput.value = ""; 124 + }); 125 + 126 + fileInput.addEventListener("change", () => { 127 + const file = fileInput.files?.[0]; 128 + if (!file) return; 129 + uploadBtn.textContent = file.name; 130 + const reader = new FileReader(); 131 + reader.onload = () => { 132 + const img = new Image(); 133 + img.onload = () => { 134 + imageCache.set(p.key, img); 135 + cachedOpts[p.key] = img; 136 + }; 137 + img.src = reader.result as string; 138 + }; 139 + reader.readAsDataURL(file); 140 + }); 141 + 142 + imgPanel.appendChild(rowLabel); 143 + imgPanel.appendChild(uploadBtn); 144 + imgPanel.appendChild(removeBtn); 145 + imgPanel.appendChild(fileInput); 146 + } 147 + } else { 148 + imgGroup.style.display = "none"; 149 + } 150 + } 151 + 152 + export function buildVisSelect(select: HTMLSelectElement) { 153 + select.innerHTML = ""; 154 + const byCategory = new Map<string, Array<[string, VisualizerDef]>>(); 155 + for (const [name, def] of Object.entries(visualizers)) { 156 + const cat = def.category ?? "Other"; 157 + if (!byCategory.has(cat)) byCategory.set(cat, []); 158 + byCategory.get(cat)!.push([name, def]); 159 + } 160 + for (const [cat, entries] of byCategory) { 161 + const group = document.createElement("optgroup"); 162 + group.label = cat; 163 + for (const [name] of entries) { 164 + const opt = document.createElement("option"); 165 + opt.value = name; 166 + opt.textContent = name; 167 + group.appendChild(opt); 168 + } 169 + select.appendChild(group); 170 + } 171 + // restore last-used, then fall back to default, then first option 172 + const saved = localStorage.getItem("vis"); 173 + if (saved && visualizers[saved]) { 174 + select.value = saved; 175 + } else { 176 + const def = Object.entries(visualizers).find(([, v]) => v.default); 177 + select.value = def ? def[0] : select.querySelector("option")!.value; 178 + } 179 + 180 + // persist selection 181 + select.addEventListener("change", () => { 182 + localStorage.setItem("vis", select.value); 183 + }); 184 + }
+44
src/ui/preview.ts
··· 1 + import type { VisualizerDef, VisualizerOptions } from "../visualizers/index.ts"; 2 + 3 + export const EMPTY_FREQ = new Uint8Array(0); 4 + 5 + export function fitCanvas(canvas: HTMLCanvasElement) { 6 + const container = canvas.parentElement!; 7 + 8 + const scale = Math.min( 9 + container.clientWidth / canvas.width, 10 + container.clientHeight / canvas.height, 11 + ); 12 + canvas.style.width = `${Math.round(canvas.width * scale)}px`; 13 + canvas.style.height = `${Math.round(canvas.height * scale)}px`; 14 + } 15 + 16 + let stopPreview: (() => void) | null = null; 17 + 18 + export function startPreview( 19 + name: string, 20 + canvas: HTMLCanvasElement, 21 + fpsSelect: HTMLSelectElement, 22 + visualizers: Record<string, VisualizerDef>, 23 + getOpts: () => VisualizerOptions, 24 + ) { 25 + stopPreview?.(); 26 + const fps = Number(fpsSelect.value); 27 + stopPreview = visualizers[name].preview(canvas, EMPTY_FREQ, getOpts, fps); 28 + } 29 + 30 + export function updateCanvas( 31 + canvas: HTMLCanvasElement, 32 + resSelect: HTMLSelectElement, 33 + ratioSelect: HTMLSelectElement, 34 + select: HTMLSelectElement, 35 + fpsSelect: HTMLSelectElement, 36 + visualizers: Record<string, VisualizerDef>, 37 + getOpts: () => VisualizerOptions, 38 + ) { 39 + const h = Number(resSelect.value); 40 + canvas.width = h * Number(ratioSelect.value); 41 + canvas.height = h; 42 + fitCanvas(canvas); 43 + startPreview(select.value, canvas, fpsSelect, visualizers, getOpts); 44 + }
+100
src/ui/render.ts
··· 1 + import { audioBuffer } from "../input.ts"; 2 + import { output } from "../output.ts"; 3 + import { 4 + VisualizerName, 5 + VisualizerOptions, 6 + visualizers, 7 + } from "../visualizers/index.ts"; 8 + import { 9 + Quality, 10 + QUALITY_HIGH, 11 + QUALITY_LOW, 12 + QUALITY_MEDIUM, 13 + QUALITY_VERY_LOW, 14 + } from "mediabunny"; 15 + 16 + const qualityMap: Record<string, Quality> = { 17 + "verylow": QUALITY_VERY_LOW, 18 + "low": QUALITY_LOW, 19 + "medium": QUALITY_MEDIUM, 20 + "high": QUALITY_HIGH, 21 + }; 22 + 23 + const filename = "output.webm"; 24 + 25 + type ShowSaveFilePicker = ( 26 + options?: { 27 + suggestedName?: string; 28 + types?: Array<{ 29 + description?: string; 30 + accept: Record<string, string[]>; 31 + }>; 32 + }, 33 + ) => Promise<FileSystemFileHandle>; 34 + 35 + export async function handleRender( 36 + canvas: HTMLCanvasElement, 37 + fpsSelect: HTMLSelectElement, 38 + qualitySelect: HTMLSelectElement, 39 + select: HTMLSelectElement, 40 + getOpts: () => VisualizerOptions, 41 + ) { 42 + if (!audioBuffer) { 43 + alert("import audio first before rendering"); 44 + return; 45 + } 46 + 47 + if ("showSaveFilePicker" in globalThis) { 48 + let handle: FileSystemFileHandle; 49 + try { 50 + handle = await ( 51 + globalThis as unknown as { showSaveFilePicker: ShowSaveFilePicker } 52 + ).showSaveFilePicker({ 53 + suggestedName: filename, 54 + types: [ 55 + { description: "WebM Video", accept: { "video/webm": [".webm"] } }, 56 + ], 57 + }); 58 + } catch { 59 + return; 60 + } 61 + 62 + const fps = Number(fpsSelect.value); 63 + const quality = qualityMap[qualitySelect.value]; 64 + const def = visualizers[select.value as VisualizerName]; 65 + const blob = await output( 66 + canvas, 67 + audioBuffer, 68 + def.draw, 69 + fps, 70 + quality, 71 + getOpts(), 72 + ); 73 + 74 + const writable = await handle.createWritable(); 75 + await writable.write(blob); 76 + await writable.close(); 77 + console.log("render done"); 78 + return; 79 + } 80 + 81 + const fps = Number(fpsSelect.value); 82 + const quality = qualityMap[qualitySelect.value]; 83 + const def = visualizers[select.value as VisualizerName]; 84 + const blob = await output( 85 + canvas, 86 + audioBuffer, 87 + def.draw, 88 + fps, 89 + quality, 90 + getOpts(), 91 + ); 92 + 93 + const url = URL.createObjectURL(blob); 94 + const a = document.createElement("a"); 95 + a.href = url; 96 + a.download = filename; 97 + a.click(); 98 + URL.revokeObjectURL(url); 99 + console.log("render done"); 100 + }
+12 -3
style.css
··· 132 132 133 133 .menu-item { 134 134 display: block; 135 - width: 100%; 135 + width: calc(100% - 16px); 136 + margin: 0 8px; 136 137 text-align: left; 137 - padding: 4px 14px; 138 + padding: 4px 8px; 138 139 border-radius: 0 !important; 139 140 font-size: 0.82em; 140 141 } ··· 162 163 flex-direction: row; 163 164 align-items: center; 164 165 gap: 6px; 166 + padding-left: 10px; 167 + margin-left: 4px; 168 + padding-right: 10px; 169 + margin-right: 4px; 170 + border-left: 1px solid rgba(255, 255, 255, 0.1); 165 171 } 166 172 167 173 #vis-select { 168 174 line-height: 1; 169 - padding: 2px 8px; 175 + padding: 2px 8px 2px 8px; 176 + padding-right: 10px; 177 + border-right: 1px solid rgba(255, 255, 255, 0.1); 170 178 } 171 179 172 180 #vis-params { ··· 175 183 align-items: center; 176 184 gap: 6px; 177 185 line-height: 1; 186 + padding-left: 4px; 178 187 } 179 188 180 189 #vis-params input[type="range"] {