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.

vis: added cd-flip by gluing cd-top & cd-bottom together

digi.rip (Jun 22, 2026, 6:39 PM EDT) 059ee5be cc80f988

+552 -4
+1 -1
src/output.ts
··· 45 45 }); 46 46 47 47 const sourceVideo = new CanvasSource(offscreen, { 48 - codec: "vp8", 48 + codec: "vp9", 49 49 bitrate: quality, 50 50 }); 51 51
+16 -3
src/visualizers/index.ts
··· 22 22 params as cdtopParams, 23 23 preview as cdtopPreview, 24 24 } from "./media/cd-top.ts"; 25 + import { 26 + category as cdflipCategory, 27 + draw as cdflip, 28 + params as cdflipParams, 29 + preview as cdflipPreview, 30 + } from "./media/cd-flip.ts"; 25 31 export type VisualizerName = keyof typeof visualizers; 26 32 27 33 // visualizer opts registry, bundles draw & params ··· 60 66 }; 61 67 62 68 export const visualizers: Record<string, VisualizerDef> = { 63 - asciiBars: { 69 + ascii_bars: { 64 70 draw: asciiBars, 65 71 params: asciiBarsParams, 66 72 preview: asciiBarsPreview, ··· 74 80 category: barsCategory, 75 81 audioReactive: true, 76 82 }, 77 - cdbottom: { 83 + cd_bottom: { 78 84 draw: cdbottom, 79 85 params: cdbottomParams, 80 86 preview: cdbottomPreview, 81 87 category: cdbottomCategory, 82 88 audioReactive: false, 83 89 }, 84 - cdtop: { 90 + cd_top: { 85 91 draw: cdtop, 86 92 params: cdtopParams, 87 93 preview: cdtopPreview, 88 94 category: cdtopCategory, 95 + audioReactive: false, 96 + }, 97 + cd_flip: { 98 + draw: cdflip, 99 + params: cdflipParams, 100 + preview: cdflipPreview, 101 + category: cdflipCategory, 89 102 audioReactive: false, 90 103 default: true, 91 104 },
+535
src/visualizers/media/cd-flip.ts
··· 1 + import { createPreviewLoop } from "../utils.ts"; 2 + 3 + // disc dimensions (shared between both faces) 4 + const OUTER_RADIUS_RATIO = 0.42; 5 + const HUB_OUTER_RATIO = 0.28; 6 + const HUB_INNER_RATIO = 0.19; 7 + const HOLE_RADIUS_RATIO = 0.09; 8 + const STREAK_SIZE_RATIO = 1.5; 9 + const OUTER_EDGE_THICKNESS_RATIO = 0.012; 10 + 11 + export const category = "media"; 12 + 13 + export const params = [ 14 + { 15 + key: "labelImg", 16 + type: "img", 17 + default: "", 18 + label: "label img", 19 + }, 20 + { 21 + key: "labelFit", 22 + type: "select", 23 + default: "fill", 24 + values: ["fill", "fit", "stretch"], 25 + label: "fit", 26 + }, 27 + { 28 + key: "bgImg", 29 + type: "img", 30 + default: "", 31 + label: "bg img", 32 + }, 33 + { 34 + key: "spinSpeed", 35 + type: "number", 36 + default: 0.2, 37 + min: 0.1, 38 + max: 52, 39 + step: 0.1, 40 + label: "spin rps", 41 + }, 42 + { 43 + key: "flipSpeed", 44 + type: "number", 45 + default: 0.1, 46 + min: 0.05, 47 + max: 2, 48 + step: 0.05, 49 + label: "flip rps", 50 + }, 51 + ]; 52 + 53 + let spinAngle = 0; 54 + let flipAngle = 0; 55 + 56 + // sprite caches — rebuilt when canvas dimensions change 57 + let cachedW = 0; 58 + let cachedH = 0; 59 + 60 + // bottom face sprites 61 + let bottomHubTextSprite: HTMLCanvasElement | null = null; 62 + 63 + // top face sprites 64 + let topHubLabelSprites: HTMLCanvasElement[] | null = null; 65 + let topOuterTextSprite: HTMLCanvasElement | null = null; 66 + 67 + function filledRing( 68 + ctx: CanvasRenderingContext2D, 69 + outerR: number, 70 + innerR: number, 71 + fillStyle: string, 72 + ) { 73 + ctx.beginPath(); 74 + ctx.arc(0, 0, outerR, 0, Math.PI * 2); 75 + ctx.arc(0, 0, innerR, 0, Math.PI * 2, true); 76 + ctx.fillStyle = fillStyle; 77 + ctx.fill(); 78 + } 79 + 80 + // --- bottom face: arc-text on the hub --- 81 + function buildBottomHubSprite( 82 + hubOuterRadius: number, 83 + hubInnerRadius: number, 84 + ) { 85 + const fontSize = Math.round(hubOuterRadius * 0.13); 86 + const labelRadius = hubInnerRadius + 87 + (hubOuterRadius - hubInnerRadius) * 0.5; 88 + const kerning = 0.09; 89 + const spriteSize = Math.ceil((labelRadius + fontSize) * 2); 90 + 91 + bottomHubTextSprite = document.createElement("canvas"); 92 + bottomHubTextSprite.width = spriteSize; 93 + bottomHubTextSprite.height = spriteSize; 94 + const ctx = bottomHubTextSprite.getContext("2d")!; 95 + ctx.translate(spriteSize / 2, spriteSize / 2); 96 + ctx.fillStyle = "rgba(15,15,15,0.70)"; 97 + ctx.font = `${fontSize}px 'Maple Mono'`; 98 + ctx.textAlign = "center"; 99 + ctx.textBaseline = "middle"; 100 + 101 + function drawArcText(angle: number, label: string) { 102 + ctx.save(); 103 + ctx.rotate(angle); 104 + for (let i = 0; i < label.length; i++) { 105 + const a = -label.length / 2 * kerning + i * kerning; 106 + ctx.save(); 107 + ctx.rotate(a); 108 + ctx.fillText(label[i], 0, -labelRadius); 109 + ctx.restore(); 110 + } 111 + ctx.restore(); 112 + } 113 + 114 + drawArcText(0, "DEFTOKU"); 115 + drawArcText(2.15, "CD-RW"); 116 + drawArcText(4.35, "700MB"); 117 + } 118 + 119 + function buildTopSprites( 120 + outerRadius: number, 121 + hubOuterRadius: number, 122 + ) { 123 + const hubFontSize = Math.round(hubOuterRadius * 0.4); 124 + const hubCharSpacing = 0.23 * hubOuterRadius; 125 + const labels = ["CD-RW", "700MB"]; 126 + topHubLabelSprites = []; 127 + for (const label of labels) { 128 + const w = label.length * hubCharSpacing + hubFontSize; 129 + const h = hubFontSize * 1.2; 130 + const sprite = document.createElement("canvas"); 131 + sprite.width = w; 132 + sprite.height = h; 133 + const sCtx = sprite.getContext("2d")!; 134 + sCtx.fillStyle = "rgba(15,15,15,0.90)"; 135 + sCtx.font = `${hubFontSize}px 'Maple Mono'`; 136 + sCtx.textAlign = "left"; 137 + sCtx.textBaseline = "middle"; 138 + for (let i = 0; i < label.length; i++) { 139 + sCtx.fillText(label[i], i * hubCharSpacing, h / 2); 140 + } 141 + topHubLabelSprites.push(sprite); 142 + } 143 + 144 + // outer ring arc text 145 + const outerLabel = "DEFTOKU | CD-RW | ".repeat(12); 146 + const kerning = 0.0291; 147 + const fontSize = Math.round(hubOuterRadius * 0.13); 148 + const labelRadius = outerRadius * 0.95; 149 + const spriteSize = Math.ceil((labelRadius + fontSize) * 2); 150 + 151 + topOuterTextSprite = document.createElement("canvas"); 152 + topOuterTextSprite.width = spriteSize; 153 + topOuterTextSprite.height = spriteSize; 154 + const otsCtx = topOuterTextSprite.getContext("2d")!; 155 + otsCtx.translate(spriteSize / 2, spriteSize / 2); 156 + otsCtx.fillStyle = "rgba(15,15,15,0.90)"; 157 + otsCtx.font = `${fontSize}px 'Maple Mono'`; 158 + otsCtx.textAlign = "center"; 159 + otsCtx.textBaseline = "middle"; 160 + 161 + for (let i = 0; i < outerLabel.length; i++) { 162 + const a = -outerLabel.length / 2 * kerning + i * kerning; 163 + otsCtx.save(); 164 + otsCtx.rotate(a); 165 + otsCtx.fillText(outerLabel[i], 0, -labelRadius); 166 + otsCtx.restore(); 167 + } 168 + } 169 + 170 + function buildAllSprites( 171 + outerRadius: number, 172 + hubOuterRadius: number, 173 + hubInnerRadius: number, 174 + ) { 175 + buildBottomHubSprite(hubOuterRadius, hubInnerRadius); 176 + buildTopSprites(outerRadius, hubOuterRadius); 177 + } 178 + 179 + // helper: iridescent streak using a rotated linear gradient 180 + function drawStreak( 181 + ctx: CanvasRenderingContext2D, 182 + streakSize: number, 183 + angleDegrees: number, 184 + colorStops: [number, string][], 185 + ) { 186 + ctx.save(); 187 + ctx.rotate(angleDegrees * Math.PI / 180); 188 + const grad = ctx.createLinearGradient( 189 + -streakSize, 190 + -streakSize, 191 + streakSize, 192 + streakSize, 193 + ); 194 + colorStops.forEach((stop) => grad.addColorStop(stop[0], stop[1])); 195 + ctx.fillStyle = grad; 196 + ctx.fillRect(-streakSize, -streakSize, streakSize * 2, streakSize * 2); 197 + ctx.restore(); 198 + } 199 + 200 + export function draw( 201 + canvas: HTMLCanvasElement, 202 + _freq: Uint8Array, 203 + options: Record<string, string | number | HTMLImageElement>, 204 + dt = 1, 205 + ) { 206 + const ctx = canvas.getContext("2d"); 207 + if (!ctx) return; 208 + 209 + // --- background --- 210 + const bgImg = options["bgImg"] as HTMLImageElement | undefined; 211 + if (bgImg && bgImg.complete && bgImg.naturalWidth > 0) { 212 + const scale = Math.max( 213 + canvas.width / bgImg.naturalWidth, 214 + canvas.height / bgImg.naturalHeight, 215 + ); 216 + const dw = bgImg.naturalWidth * scale; 217 + const dh = bgImg.naturalHeight * scale; 218 + const dx = (canvas.width - dw) / 2; 219 + const dy = (canvas.height - dh) / 2; 220 + ctx.drawImage(bgImg, dx, dy, dw, dh); 221 + } else { 222 + ctx.fillStyle = "black"; 223 + ctx.fillRect(0, 0, canvas.width, canvas.height); 224 + } 225 + 226 + const cx = canvas.width / 2; 227 + const cy = canvas.height / 2; 228 + const outerRadius = Math.min(canvas.width, canvas.height) * 229 + OUTER_RADIUS_RATIO; 230 + const hubOuterRadius = outerRadius * HUB_OUTER_RATIO; 231 + const hubInnerRadius = outerRadius * HUB_INNER_RATIO; 232 + const holeRadius = outerRadius * HOLE_RADIUS_RATIO; 233 + const streakSize = outerRadius * STREAK_SIZE_RATIO; 234 + const spinSpeed = Number(options.spinSpeed) * dt; 235 + const flipSpeed = Number(options.flipSpeed) * 2 * Math.PI * dt; 236 + 237 + if (canvas.width !== cachedW || canvas.height !== cachedH) { 238 + cachedW = canvas.width; 239 + cachedH = canvas.height; 240 + buildAllSprites(outerRadius, hubOuterRadius, hubInnerRadius); 241 + } 242 + 243 + const cosFlip = Math.cos(flipAngle); 244 + const showingTop = cosFlip > 0; 245 + 246 + // fake depth 247 + ctx.save(); 248 + ctx.translate(cx, cy); 249 + ctx.scale(cosFlip, 1); 250 + 251 + if (showingTop) { 252 + // all disc content rotates with the spin 253 + ctx.save(); 254 + ctx.rotate(spinAngle); 255 + 256 + // disc surface 257 + ctx.save(); 258 + filledRing(ctx, outerRadius, hubInnerRadius, ""); 259 + ctx.clip(); 260 + 261 + const topGrad = ctx.createRadialGradient(0, 0, 0, 0, 0, outerRadius); 262 + topGrad.addColorStop(0.0, "#f7c8e0"); 263 + topGrad.addColorStop(0.5, "#f196c8"); 264 + topGrad.addColorStop(1.0, "#d870a8"); 265 + ctx.fillStyle = topGrad; 266 + ctx.fillRect(-outerRadius, -outerRadius, outerRadius * 2, outerRadius * 2); 267 + 268 + // subtle iridescent streaks 269 + drawStreak(ctx, streakSize, -30, [ 270 + [0.0, "rgba(255,255,255,0)"], 271 + [0.3, "rgba(255,220,240,0.08)"], 272 + [0.5, "rgba(220,180,255,0.05)"], 273 + [1.0, "rgba(255,255,255,0)"], 274 + ]); 275 + drawStreak(ctx, streakSize, 45, [ 276 + [0.0, "rgba(255,255,255,0)"], 277 + [0.4, "rgba(200,220,255,0.06)"], 278 + [0.6, "rgba(255,200,230,0.04)"], 279 + [1.0, "rgba(255,255,255,0)"], 280 + ]); 281 + 282 + ctx.restore(); // end disc clip 283 + 284 + // hub ring 285 + filledRing(ctx, hubOuterRadius, hubInnerRadius, "rgba(200,200,255,0.8)"); 286 + const edgeHalf = 1.5; 287 + filledRing( 288 + ctx, 289 + hubOuterRadius + edgeHalf, 290 + hubOuterRadius - edgeHalf, 291 + "rgba(80,100,160,0.7)", 292 + ); 293 + 294 + // hub labels 295 + if (topHubLabelSprites) { 296 + const labelPositions = [outerRadius * -0.70, outerRadius * 0.45]; 297 + for (let i = 0; i < topHubLabelSprites.length; i++) { 298 + const s = topHubLabelSprites[i]; 299 + ctx.drawImage(s, labelPositions[i], -s.height / 2); 300 + } 301 + } 302 + 303 + // label image drawn over the disc surface 304 + const labelImg = options["labelImg"] as HTMLImageElement | undefined; 305 + if (labelImg && labelImg.complete && labelImg.naturalWidth > 0) { 306 + ctx.save(); 307 + filledRing(ctx, outerRadius, hubInnerRadius, ""); 308 + ctx.clip(); 309 + const discSize = outerRadius * 2; 310 + const labelFit = (options["labelFit"] as string) || "fill"; 311 + let iw: number, ih: number; 312 + if (labelFit === "fit") { 313 + const s = Math.min( 314 + discSize / labelImg.naturalWidth, 315 + discSize / labelImg.naturalHeight, 316 + ); 317 + iw = labelImg.naturalWidth * s; 318 + ih = labelImg.naturalHeight * s; 319 + } else if (labelFit === "stretch") { 320 + iw = discSize; 321 + ih = discSize; 322 + } else { 323 + // fill (cover) 324 + const s = Math.max( 325 + discSize / labelImg.naturalWidth, 326 + discSize / labelImg.naturalHeight, 327 + ); 328 + iw = labelImg.naturalWidth * s; 329 + ih = labelImg.naturalHeight * s; 330 + } 331 + ctx.drawImage(labelImg, -iw / 2, -ih / 2, iw, ih); 332 + ctx.restore(); 333 + } 334 + 335 + // outer ring text 336 + if (topOuterTextSprite) { 337 + const s = topOuterTextSprite.width; 338 + ctx.drawImage(topOuterTextSprite, -s / 2, -s / 2); 339 + } 340 + 341 + // outer edge rings 342 + filledRing( 343 + ctx, 344 + outerRadius, 345 + outerRadius * (1 - OUTER_EDGE_THICKNESS_RATIO), 346 + "rgba(255,255,255,0.35)", 347 + ); 348 + const rimHalf = 0.5; 349 + filledRing( 350 + ctx, 351 + outerRadius + rimHalf, 352 + outerRadius - rimHalf, 353 + "rgba(200,200,255,0.1)", 354 + ); 355 + 356 + // inner clear ring 357 + filledRing(ctx, hubInnerRadius, holeRadius, "rgba(255,255,255,0.15)"); 358 + 359 + ctx.restore(); // end spin rotation 360 + 361 + // specular highlight (slightly offset from spin) 362 + ctx.save(); 363 + ctx.rotate(Math.PI / 4); 364 + ctx.beginPath(); 365 + ctx.arc(0, 0, outerRadius, 0, Math.PI * 2); 366 + ctx.clip(); 367 + const topSpec = ctx.createLinearGradient( 368 + -outerRadius * 0.8, 369 + 0, 370 + -outerRadius * 0.1, 371 + 0, 372 + ); 373 + topSpec.addColorStop(0.0, "rgba(255,255,255,0)"); 374 + topSpec.addColorStop(0.2, "rgba(255,255,255,0.05)"); 375 + topSpec.addColorStop(0.5, "rgba(255,255,255,0.1)"); 376 + topSpec.addColorStop(0.8, "rgba(200, 230, 255, 0.05)"); 377 + topSpec.addColorStop(1.0, "rgba(255,255,255,0)"); 378 + ctx.fillStyle = topSpec; 379 + ctx.fillRect(-outerRadius, -outerRadius, outerRadius, outerRadius * 2); 380 + ctx.restore(); 381 + } else { // bottom face 382 + ctx.scale(-1, 1); 383 + 384 + // stationary lighting — streaks are a fixed "light reflection" 385 + // that doesn't spin with the disc 386 + ctx.save(); 387 + filledRing(ctx, outerRadius, hubInnerRadius, ""); 388 + ctx.clip(); 389 + 390 + const bottomGrad = ctx.createRadialGradient(0, 0, 0, 0, 0, outerRadius); 391 + bottomGrad.addColorStop(0.0, "#ffffff"); 392 + bottomGrad.addColorStop(0.1, "#8ab4f8"); 393 + bottomGrad.addColorStop(0.4, "#5a8fe0"); 394 + bottomGrad.addColorStop(0.7, "#3a5bb0"); 395 + bottomGrad.addColorStop(1.0, "#1a1a40"); 396 + ctx.fillStyle = bottomGrad; 397 + ctx.fillRect(-outerRadius, -outerRadius, outerRadius * 2, outerRadius * 2); 398 + 399 + drawStreak(ctx, streakSize, -30, [ 400 + [0.0, "rgba(255,255,255,0)"], 401 + [0.2, "rgba(255, 200, 100, 0.6)"], 402 + [0.4, "rgba(255, 100, 150, 0.3)"], 403 + [0.6, "rgba(100, 200, 255, 0.5)"], 404 + [1.0, "rgba(255,255,255,0)"], 405 + ]); 406 + drawStreak(ctx, streakSize, 45, [ 407 + [0.0, "rgba(100, 200, 255, 0.4)"], 408 + [0.5, "rgba(200, 100, 255, 0.6)"], 409 + [0.7, "rgba(255, 150, 200, 0.3)"], 410 + [1.0, "rgba(255,255,255,0)"], 411 + ]); 412 + 413 + ctx.restore(); // end stationary lighting 414 + 415 + // rotating elements 416 + ctx.save(); 417 + ctx.rotate(spinAngle); 418 + 419 + // hub ring 420 + filledRing(ctx, hubOuterRadius, hubInnerRadius, "rgba(200,200,255,0.3)"); 421 + const edgeHalf = 1.5; 422 + filledRing( 423 + ctx, 424 + hubOuterRadius + edgeHalf, 425 + hubOuterRadius - edgeHalf, 426 + "rgba(80,100,160,0.3)", 427 + ); 428 + 429 + // hub arc text 430 + if (bottomHubTextSprite) { 431 + const s = bottomHubTextSprite.width; 432 + ctx.drawImage(bottomHubTextSprite, -s / 2, -s / 2); 433 + } 434 + 435 + // outer edge ring 436 + filledRing( 437 + ctx, 438 + outerRadius, 439 + outerRadius * (1 - OUTER_EDGE_THICKNESS_RATIO), 440 + "rgba(255,255,255,0.35)", 441 + ); 442 + const rimHalf = 0.5; 443 + filledRing( 444 + ctx, 445 + outerRadius + rimHalf, 446 + outerRadius - rimHalf, 447 + "rgba(200,200,255,0.1)", 448 + ); 449 + 450 + // inner clear ring 451 + filledRing(ctx, hubInnerRadius, holeRadius, "rgba(255,255,255,0.15)"); 452 + 453 + ctx.restore(); // end spin rotation 454 + 455 + // specular highlight 456 + ctx.save(); 457 + ctx.rotate(Math.PI / 6); 458 + ctx.beginPath(); 459 + ctx.arc(0, 0, outerRadius, 0, Math.PI * 2); 460 + ctx.clip(); 461 + const bottomSpec = ctx.createLinearGradient( 462 + -outerRadius * 0.8, 463 + 0, 464 + -outerRadius * 0.1, 465 + 0, 466 + ); 467 + bottomSpec.addColorStop(0.0, "rgba(255,255,255,0)"); 468 + bottomSpec.addColorStop(0.3, "rgba(255,255,255,0.2)"); 469 + bottomSpec.addColorStop(0.5, "rgba(255,255,255,0.3)"); 470 + bottomSpec.addColorStop(0.7, "rgba(200, 230, 255, 0.3)"); 471 + bottomSpec.addColorStop(1.0, "rgba(255,255,255,0)"); 472 + ctx.fillStyle = bottomSpec; 473 + ctx.fillRect(-outerRadius, -outerRadius, outerRadius, outerRadius * 2); 474 + ctx.restore(); 475 + } 476 + 477 + ctx.restore(); // end 3D transform 478 + 479 + // fake disc thickness 480 + const absSin = Math.abs(Math.sin(flipAngle)); 481 + const edgeAlpha = Math.pow(absSin, 1500); 482 + if (edgeAlpha > 0.001) { 483 + const discThickness = outerRadius * 0.015; 484 + const edgeWidth = absSin * discThickness; 485 + const halfEdge = edgeWidth / 2; 486 + 487 + ctx.save(); 488 + ctx.globalAlpha = edgeAlpha; 489 + 490 + // disc body — metallic cylindrical gradient 491 + const edgeGrad = ctx.createLinearGradient( 492 + cx - halfEdge, 493 + 0, 494 + cx + halfEdge, 495 + 0, 496 + ); 497 + edgeGrad.addColorStop(0.0, "#5a5a7a"); 498 + edgeGrad.addColorStop(0.25, "#a8a8c4"); 499 + edgeGrad.addColorStop(0.5, "#d0d0f0"); 500 + edgeGrad.addColorStop(0.75, "#a8a8c4"); 501 + edgeGrad.addColorStop(1.0, "#5a5a7a"); 502 + ctx.fillStyle = edgeGrad; 503 + ctx.fillRect( 504 + cx - halfEdge, 505 + cy - outerRadius, 506 + edgeWidth, 507 + outerRadius * 2, 508 + ); 509 + 510 + // outer rim highlights (top and bottom of the edge strip) 511 + ctx.fillStyle = "rgba(255,255,255,0.4)"; 512 + ctx.fillRect(cx - halfEdge, cy - outerRadius, edgeWidth, 1.5); 513 + ctx.fillRect(cx - halfEdge, cy + outerRadius - 1.5, edgeWidth, 1.5); 514 + 515 + ctx.restore(); 516 + } 517 + 518 + // advance angles 519 + spinAngle += spinSpeed; 520 + flipAngle += flipSpeed; 521 + } 522 + 523 + export function preview( 524 + canvas: HTMLCanvasElement, 525 + freq: Uint8Array, 526 + getOptions: () => Record<string, string | number | HTMLImageElement>, 527 + fps: number, 528 + ) { 529 + spinAngle = 0; 530 + flipAngle = 0; 531 + 532 + return createPreviewLoop(canvas, fps, (offscreen, dt) => { 533 + draw(offscreen, freq, getOptions(), dt); 534 + }); 535 + }