1<!DOCTYPE html> 2<html lang="en"> 3<head> 4<meta charset="UTF-8" /> 5<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" /> 6<title>Schmetterlink® â Wiggle Studio</title> 7<meta name="description" content="Draw with wiggly brushes and export your sketch as an animated GIF. Made by Studio Schmetterlink."> 8<meta property="og:title" content="Schmetterlink® â Wiggle Studio"> 9<meta property="og:description" content="Draw with wiggly brushes and export your sketch as an animated GIF."> 10<meta property="og:type" content="website"> 11<meta name="theme-color" content="#c9ffa0"> 12<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%A6%8B%3C/text%3E%3C/svg%3E"> 13<link rel="preconnect" href="https://fonts.googleapis.com"> 14<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 15<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700;900&display=swap" rel="stylesheet">
16<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gif.js"></script>
16 17<style> 18 :root { 19 --lime: #c9ffa0; 20 --ink: #000; 21 --paper: #fff; 22 --blue: #0099ff; 23 --pink: #ff3a7a; 24 --yellow: #ffcc00; 25 --purple: #9933ff; 26 --teal: #00cc88; 27 --orange: #ff7a00; 28 --border: 2px solid var(--ink); 29 --radius: 14px; 30 --shadow: 4px 4px 0 0 var(--ink); 31 } 32 33 * { 34 box-sizing: border-box; 35 -webkit-tap-highlight-color: transparent; 36 touch-action: manipulation; /* kills double-tap-to-zoom while keeping taps/scroll */ 37 } 38 39 html, body { 40 margin: 0; padding: 0; height: 100%; 41 background: var(--lime); 42 color: var(--ink); 43 font-family: 'Inter', system-ui, -apple-system, sans-serif; 44 overscroll-behavior: none; 45 overflow: hidden; /* app is a fixed full screen â no scroll/zoom drift */ 46 } 47 48 body { 49 display: flex; flex-direction: column; 50 height: 100dvh; 51 position: fixed; inset: 0; /* lock to the viewport so swipes don't scroll/zoom */ 52 padding: 16px; 53 gap: 14px; 54 } 55 56 header { 57 display: flex; align-items: center; justify-content: space-between; 58 flex-wrap: wrap; gap: 8px; 59 } 60 61 .brand { 62 font-weight: 900; font-size: clamp(20px, 3.5vw, 32px); 63 letter-spacing: -0.02em; 64 display: flex; align-items: baseline; gap: 6px; 65 } 66 .brand sup { font-size: 0.55em; font-weight: 700; } 67 .brand .sep { opacity: 0.4; padding: 0 4px; } 68 .brand .title { font-weight: 500; } 69 70 .tagline { 71 font-size: 13px; font-weight: 500; 72 background: var(--ink); color: var(--lime); 73 padding: 4px 10px; border-radius: 999px; 74 } 75 76 .stage { 77 flex: 1; min-height: 0; 78 background: var(--paper); 79 border: var(--border); 80 border-radius: var(--radius); 81 box-shadow: var(--shadow); 82 position: relative; 83 overflow: hidden; 84 touch-action: none; 85 } 86 .stage canvas { 87 display: block; width: 100%; height: 100%; 88 cursor: crosshair; 89 touch-action: none; 90 } 91 92 .toolbar { 93 display: grid; 94 grid-template-columns: auto 1fr auto; 95 gap: 12px; 96 align-items: stretch; 97 } 98 @media (max-width: 900px) { 99 .toolbar { 100 grid-template-columns: 1fr; 101 } 102 } 103 104 .panel { 105 background: var(--paper); 106 border: var(--border); 107 border-radius: var(--radius); 108 box-shadow: var(--shadow); 109 padding: 10px; 110 display: flex; align-items: center; gap: 8px; 111 flex-wrap: wrap; 112 } 113 114 .panel .label { 115 font-size: 10px; font-weight: 700; text-transform: uppercase; 116 letter-spacing: 0.08em; opacity: 0.55; 117 padding: 0 6px; 118 } 119 120 .chip { 121 display: inline-flex; align-items: center; justify-content: center; 122 min-width: 44px; height: 44px; padding: 0 10px; 123 border: var(--border); border-radius: 10px; 124 background: var(--paper); color: var(--ink); 125 font-family: inherit; font-size: 13px; font-weight: 700; 126 cursor: pointer; user-select: none; 127 transition: transform 0.06s ease; 128 } 129 .chip:hover { transform: translate(-1px, -1px); } 130 .chip:active { transform: translate(1px, 1px); } 131 .chip.active { background: var(--ink); color: var(--lime); } 132 .chip.icon { gap: 6px; } 133 .chip svg { width: 18px; height: 18px; } 134 .brush-thumb { 135 display: inline-block; 136 width: 40px; height: 40px; 137 background-color: transparent; 138 background-size: contain; 139 background-position: center; 140 background-repeat: no-repeat; 141 flex-shrink: 0; 142 } 143 .chip.active .brush-thumb { filter: invert(1); } 144 145 #swatches { display: flex; gap: 6px; flex-wrap: wrap; } 146 .swatch { 147 width: 32px; height: 32px; 148 border: var(--border); 149 border-radius: 50%; 150 cursor: pointer; 151 transition: transform 0.06s ease; 152 position: relative; 153 } 154 .swatch:hover { transform: translate(-1px, -1px); } 155 .swatch.active::after { 156 content: ''; position: absolute; 157 inset: 3px; border: 2.5px solid #fff; 158 border-radius: 50%; 159 mix-blend-mode: difference; /* visible on any swatch colour; stays inside so it never clips */ 160 pointer-events: none; 161 } 162 163 .size-row { display: flex; align-items: center; gap: 8px; } 164 .size-row input[type=range] { 165 -webkit-appearance: none; appearance: none; 166 width: 110px; height: 6px; background: var(--ink); border-radius: 999px; 167 outline: none; 168 } 169 .size-row input[type=range]::-webkit-slider-thumb { 170 -webkit-appearance: none; width: 18px; height: 18px; 171 background: var(--lime); border: 2px solid var(--ink); 172 border-radius: 50%; cursor: pointer; 173 } 174 .size-row input[type=range]::-moz-range-thumb { 175 width: 18px; height: 18px; 176 background: var(--lime); border: 2px solid var(--ink); 177 border-radius: 50%; cursor: pointer; 178 } 179 .size-value { 180 font-variant-numeric: tabular-nums; font-weight: 700; font-size: 12px; 181 min-width: 28px; text-align: right; 182 } 183 184 .emoji-tray { 185 display: none; gap: 4px; align-items: center; 186 padding-left: 4px; border-left: 2px dashed var(--ink); margin-left: 4px; 187 } 188 .emoji-tray.show { display: flex; } 189 .emoji-tray .chip { padding: 0 8px; font-size: 18px; min-width: 36px; height: 36px; } 190 .emoji-tray input { 191 width: 50px; height: 36px; text-align: center; font-size: 18px; 192 border: var(--border); border-radius: 10px; background: var(--paper); 193 font-family: inherit; 194 } 195 .emoji-tray #textInput { 196 width: 130px; text-align: left; font-size: 14px; font-weight: 600; 197 padding: 0 10px; 198 } 199 200 .actions { display: flex; gap: 8px; flex-wrap: wrap; } 201 .actions .primary { 202 background: var(--ink); color: var(--lime); 203 } 204 .actions .primary:hover { background: #222; } 205 206 /* Export modal */ 207 .modal { 208 position: fixed; inset: 0; 209 background: rgba(0,0,0,0.55); 210 display: none; align-items: center; justify-content: center; 211 padding: 16px; z-index: 100; 212 } 213 .modal.show { display: flex; } 214 .modal-card { 215 background: var(--paper); 216 border: var(--border); border-radius: var(--radius); 217 box-shadow: var(--shadow); 218 padding: 20px; max-width: 420px; width: 100%; 219 display: flex; flex-direction: column; gap: 14px; 220 } 221 .modal-card h2 { margin: 0; font-size: 22px; font-weight: 900; } 222 .modal-card p { margin: 0; font-size: 13px; line-height: 1.5; } 223 .modal-card .row { display: flex; gap: 8px; flex-wrap: wrap; } 224 .modal-card .progress { 225 width: 100%; height: 10px; background: #eee; 226 border: var(--border); border-radius: 999px; overflow: hidden; 227 } 228 .modal-card .progress > span { 229 display: block; height: 100%; width: 0%; 230 background: var(--lime); transition: width 0.15s linear; 231 } 232 .modal-card .preview { 233 border: var(--border); border-radius: 10px; 234 max-width: 100%; max-height: 280px; align-self: center; 235 } 236 .close-x {
237 align-self: flex-end; background: transparent; border: none; 238 font-size: 22px; cursor: pointer; line-height: 1; padding: 0; 239 } 240 241 /* Footer */ 242 footer { 243 font-size: 11px; opacity: 0.6; text-align: center; 244 font-weight: 500; 245 } 246 247 .hint { 248 position: absolute; left: 50%; top: 50%; 249 transform: translate(-50%, -50%); 250 font-size: 14px; font-weight: 500; 251 color: rgba(0,0,0,0.35); 252 pointer-events: none; 253 text-align: center; 254 } 255 .hint.hide { display: none; } 256 257 /* ---------- Mobile / small screens ---------- */ 258 @media (max-width: 600px) { 259 :root { --shadow: 3px 3px 0 0 var(--ink); --radius: 12px; } 260 body { padding: 4px; gap: 5px; } 261 262 header { gap: 4px; } 263 .brand { font-size: 17px; gap: 4px; } 264 .brand .sep { padding: 0 2px; } 265 .tagline { display: none; } 266 267 .stage { min-height: 48vh; } 268 269 .toolbar { gap: 6px; } 270 /* min-width:0 lets the grid items shrink to the viewport so their 271 nowrap/overflow-x children scroll internally instead of overflowing. */ 272 .toolbar > .panel { min-width: 0; } 273 .panel { padding: 6px 5px; gap: 4px; justify-content: center; } 274 .panel .label { display: none; } 275 276 /* Brushes: compact icon-only buttons, all 8 in one row (labels hidden) */ 277 .panel > .chip.icon { 278 min-width: 0; width: 44px; height: 44px; padding: 0; gap: 0; 279 font-size: 0; 280 } 281 .panel > .chip.icon svg { width: 21px; height: 21px; } 282 .panel > .chip.icon .brush-thumb { width: 40px; height: 40px; } 283 284 /* Emoji tray stays usable on its own line */ 285 .emoji-tray { margin-left: 0; padding-left: 0; border-left: none; 286 flex-basis: 100%; justify-content: center; } 287 .emoji-tray.show { margin-top: 2px; } 288 289 /* Colours + size on one row; all 8 swatches sized to fit without scroll */ 290 #colorPanel { flex-wrap: nowrap; gap: 6px; } 291 #swatches { 292 flex: 1 1 auto; min-width: 0; 293 flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; 294 gap: 4px; justify-content: space-between; 295 } 296 #swatches::-webkit-scrollbar { display: none; } 297 .swatch { width: 26px; height: 26px; flex: 0 0 auto; } 298 .size-row { flex: 0 0 auto; gap: 6px; } 299 .size-row input[type=range] { width: 56px; } 300 .size-value { min-width: 18px; } 301 302 /* Actions: Export grows, all comfy tap targets */ 303 .actions { width: 100%; } 304 .actions .chip { height: 44px; } 305 #exportBtn { flex: 1; } 306 307 footer { font-size: 10px; } 308 309 .modal { padding: 12px; } 310 .modal-card { padding: 16px; gap: 10px; } 311 .modal-card h2 { font-size: 18px; } 312 .modal-card .preview { max-height: 200px; } 313 } 314</style> 315</head> 316<body> 317 318<header> 319 <div class="brand"> 320 <span>Schmetterlink<sup>®</sup></span> 321 <span class="sep">/</span> 322 <span class="title">Wiggle Studio</span> 323 </div> 324 <div class="tagline">Anything but boring</div> 325</header> 326 327<div class="stage" id="stage"> 328 <canvas id="canvas"></canvas> 329 <div class="hint" id="hint">Draw something. Every line wiggles.</div> 330</div> 331 332<div class="toolbar"> 333 <!-- Brushes --> 334 <div class="panel" id="brushPanel"> 335 <span class="label">Brush</span> 336 <button class="chip icon active" data-brush="pen" title="Pen"> 337 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M16.5 3.5 L20.5 7.5 L7.5 20.5 L2.5 21.5 L4 16 Z"/><path d="M6 14 L10 18"/></svg> 338 Pen 339 </button> 340 <button class="chip icon" data-brush="marker" title="Marker"> 341 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20 L13 11 L19 17 L10 20 Z M13 11 L17 7 L21 11 L19 17"/></svg> 342 Marker 343 </button> 344 <button class="chip icon" data-brush="spray" title="Spray"> 345 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="8" cy="16" r="0.8"/><circle cx="11" cy="13" r="0.8"/><circle cx="6" cy="13" r="0.8"/><circle cx="9" cy="18" r="0.8"/><circle cx="5" cy="18" r="0.8"/><path d="M14 8 L20 4 M14 12 L20 12 M14 16 L20 20"/></svg> 346 Spray 347 </button> 348 <button class="chip icon" data-brush="neon" title="Neon"> 349 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M4 20 C 8 4, 16 4, 20 20"/></svg> 350 Neon 351 </button> 352 <button class="chip icon" data-brush="emoji" title="Emoji"> 353 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="12" cy="12" r="9"/><circle cx="9" cy="10" r="0.8" fill="currentColor"/><circle cx="15" cy="10" r="0.8" fill="currentColor"/><path d="M8 14 Q12 18 16 14" stroke-linecap="round"/></svg> 354 Emoji 355 </button> 356 <button class="chip icon" data-brush="type" title="Type"> 357 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 6 L5 4.5 L19 4.5 L19 6 M12 4.5 L12 19.5 M9 19.5 L15 19.5"/></svg> 358 Type 359 </button> 360 <button class="chip icon" data-brush="play" title="Play" id="brushPlay" disabled style="opacity:0.5;">
361 <span class="brush-thumb" id="thumbPlay"></span> 362 Play 363 </button> 364 <button class="chip icon" data-brush="work" title="Work" id="brushWork" disabled style="opacity:0.5;"> 365 <span class="brush-thumb" id="thumbWork"></span> 366 Work 367 </button> 368 <button class="chip icon" data-brush="eraser" title="Eraser"> 369 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linejoin="round"><path d="M4 18 L14 18 M3 14 L10 21 L14 21 L21 14 L13 6 Z M9 10 L17 18"/></svg> 370 Eraser 371 </button> 372 <div class="emoji-tray" id="emojiTray"> 373 <button class="chip" data-emoji="ð¦">ð¦</button> 374 <button class="chip active" data-emoji="ð¸">ð¸</button> 375 <button class="chip" data-emoji="â">â</button> 376 <button class="chip" data-emoji="ð¥">ð¥</button> 377 <button class="chip" data-emoji="â¤ï¸">â¤ï¸</button> 378 <input id="emojiInput" type="text" maxlength="2" placeholder="â¨" title="Custom emoji"/> 379 </div> 380 <div class="emoji-tray" id="typeTray"> 381 <input id="textInput" type="text" maxlength="24" value="wiggle" placeholder="napÃÅ¡ slovoâ¦" title="Type a word â every letter wiggles"/> 382 </div> 383 </div> 384 385 <!-- Colors + Size --> 386 <div class="panel" id="colorPanel"> 387 <span class="label">Color</span> 388 <div id="swatches"></div> 389 <span class="label" style="margin-left:8px;">Size</span> 390 <div class="size-row"> 391 <input id="size" type="range" min="2" max="60" value="8"/> 392 <span class="size-value" id="sizeValue">8</span> 393 </div> 394 </div> 395 396 <!-- Actions --> 397 <div class="panel actions"> 398 <button class="chip icon" id="undoBtn" title="Undo"> 399 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14 L4 9 L9 4"/><path d="M4 9 H14.5 A5.5 5.5 0 0 1 14.5 20 H8"/></svg> 400 Undo 401 </button> 402 <button class="chip" id="clearBtn" title="Clear">Clear</button> 403 <button class="chip primary" id="exportBtn" title="Export GIF">Export GIF â</button> 404 </div> 405</div> 406 407<footer>Made with wiggles in the spirit of <a href="https://www.schmetterlink.xyz/" target="_blank" rel="noopener" style="color:inherit">schmetterlink.xyz</a></footer> 408 409<!-- Export modal --> 410<div class="modal" id="modal"> 411 <div class="modal-card"> 412 <button class="close-x" id="closeModal" aria-label="Close">Ã</button> 413 <h2>Export as animated GIF</h2> 414 <p>Choose how your drawing should come to life.</p> 415 <div class="row"> 416 <button class="chip primary" id="exportWiggle">ð Wiggle loop</button> 417 <button class="chip primary" id="exportLapse">â± Timelapse</button> 418 </div> 419 <div class="progress" id="progressWrap" style="display:none;"><span id="progressBar"></span></div> 420 <img class="preview" id="preview" alt="GIF preview" style="display:none;"/> 421 <div class="row" id="downloadRow" style="display:none;"> 422 <a class="chip primary" id="downloadLink" download="schmetterlink-wiggle.gif">⬠Download GIF</a> 423 <button class="chip" id="exportAgain">Make another</button> 424 </div> 425 </div> 426</div> 427
428<script> 429/* =================================================================== 430 * Schmetterlink Wiggle Studio 431 * - vanilla canvas drawing with wiggle animation 432 * - 5 brushes: pen, marker, spray, neon, emoji 433 * - GIF export: wiggle loop + timelapse (via gif.js) 434 * =================================================================== */ 435 436const COLORS = [ 437 '#000000', '#ffffff', '#0099ff', '#ff3a7a', 438 '#ffcc00', '#9933ff', '#00cc88', '#ff7a00', 439]; 440 441const stage = document.getElementById('stage'); 442const canvas = document.getElementById('canvas'); 443const ctx = canvas.getContext('2d'); 444const hint = document.getElementById('hint'); 445 446// --- State ---------------------------------------------------------- 447const state = { 448 brush: 'pen', 449 color: '#000000', 450 size: 8, 451 emoji: 'ð¸', 452 text: 'wiggle', // word drawn by the Type brush 453 strokes: [], // { brush, color, size, emoji, points: [{x,y}], seed } 454 currentStroke: null, 455 wiggleAmp: 2.2, // px 456 wiggleFrames: 6, 457 fpsWiggle: 10, // animation playback FPS 458 dpr: Math.min(window.devicePixelRatio || 1, 2), 459 charImages: {}, // { play: HTMLCanvasElement, work: HTMLCanvasElement } 460}; 461 462// --- Canvas sizing -------------------------------------------------- 463function resizeCanvas() { 464 const rect = stage.getBoundingClientRect(); 465 canvas.width = Math.floor(rect.width * state.dpr); 466 canvas.height = Math.floor(rect.height * state.dpr); 467 canvas.style.width = rect.width + 'px'; 468 canvas.style.height = rect.height + 'px'; 469} 470window.addEventListener('resize', () => { resizeCanvas(); }); 471resizeCanvas(); 472 473// --- Deterministic seeded random ----------------------------------- 474function hash2(a, b) { 475 // tiny integer hash, returns a symmetric [-1, 1) 476 let h = (a * 374761393 + b * 668265263) | 0; 477 h = (h ^ (h >>> 13)) * 1274126177; 478 h = h ^ (h >>> 16); 479 // (h >>> 0) makes it unsigned [0, 2^32); /2^31 â [0, 2); -1 â [-1, 1) 480 return ((h >>> 0) / 0x80000000) - 1; 481} 482 483// --- Wiggle offset for a stroke point at a given frame -------------- 484function wiggleOffset(strokeSeed, pointIdx, frame) { 485 const amp = state.wiggleAmp * state.dpr; 486 const dx = hash2(strokeSeed + pointIdx * 17, frame * 31) * amp; 487 const dy = hash2(strokeSeed + pointIdx * 17 + 7, frame * 31 + 11) * amp; 488 return [dx, dy]; 489} 490 491// --- Drawing primitives -------------------------------------------- 492function drawStroke(stroke, frame, opts = {}) { 493 const { brush, color, size, points, seed } = stroke; 494 const upTo = opts.upTo != null ? opts.upTo : points.length; 495 if (upTo < 1) return; 496 497 const dpr = state.dpr; 498 const lineSize = size * dpr; 499 500 if (brush === 'pen') { 501 ctx.save(); 502 ctx.lineWidth = lineSize; 503 ctx.strokeStyle = color; 504 ctx.lineCap = 'round'; 505 ctx.lineJoin = 'round'; 506 ctx.beginPath(); 507 for (let i = 0; i < upTo; i++) { 508 const [dx, dy] = wiggleOffset(seed, i, frame); 509 const x = points[i].x * dpr + dx; 510 const y = points[i].y * dpr + dy; 511 if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); 512 } 513 ctx.stroke(); 514 ctx.restore(); 515 } 516 else if (brush === 'marker') { 517 ctx.save(); 518 ctx.lineWidth = lineSize * 1.4; 519 ctx.strokeStyle = color; 520 ctx.globalAlpha = 0.55; 521 ctx.globalCompositeOperation = 'multiply'; 522 ctx.lineCap = 'round'; 523 ctx.lineJoin = 'round'; 524 ctx.beginPath(); 525 for (let i = 0; i < upTo; i++) { 526 const [dx, dy] = wiggleOffset(seed, i, frame); 527 const x = points[i].x * dpr + dx; 528 const y = points[i].y * dpr + dy; 529 if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); 530 } 531 ctx.stroke(); 532 ctx.restore(); 533 } 534 else if (brush === 'spray') { 535 ctx.save(); 536 ctx.fillStyle = color; 537 const r = lineSize * 1.2; 538 const density = Math.max(6, Math.floor(lineSize * 0.8)); 539 for (let i = 0; i < upTo; i++) { 540 const [dx, dy] = wiggleOffset(seed, i, frame); 541 const cx = points[i].x * dpr + dx; 542 const cy = points[i].y * dpr + dy; 543 for (let k = 0; k < density; k++) { 544 const a = hash2(seed + i * 13, frame * 17 + k * 3) * Math.PI; 545 const rr = (hash2(seed + i * 7 + k, frame * 5) * 0.5 + 0.5) * r;
546 const px = cx + Math.cos(a * 2) * rr; 547 const py = cy + Math.sin(a * 2) * rr; 548 ctx.globalAlpha = 0.35 + (hash2(seed + k, frame + i) * 0.5 + 0.5) * 0.45; 549 ctx.beginPath(); 550 ctx.arc(px, py, dpr * 0.9, 0, Math.PI * 2); 551 ctx.fill(); 552 } 553 } 554 ctx.restore(); 555 } 556 else if (brush === 'neon') { 557 ctx.save(); 558 ctx.lineCap = 'round'; 559 ctx.lineJoin = 'round'; 560 // Glow pass 561 ctx.shadowColor = color; 562 ctx.shadowBlur = lineSize * 2.5; 563 ctx.strokeStyle = color; 564 ctx.lineWidth = lineSize * 0.85; 565 ctx.globalAlpha = 0.85; 566 ctx.beginPath(); 567 for (let i = 0; i < upTo; i++) { 568 const [dx, dy] = wiggleOffset(seed, i, frame); 569 const x = points[i].x * dpr + dx; 570 const y = points[i].y * dpr + dy; 571 if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); 572 } 573 ctx.stroke(); 574 // White inner core 575 ctx.shadowBlur = 0; 576 ctx.strokeStyle = '#ffffff'; 577 ctx.lineWidth = Math.max(1, lineSize * 0.35); 578 ctx.globalAlpha = 0.9; 579 ctx.beginPath(); 580 for (let i = 0; i < upTo; i++) { 581 const [dx, dy] = wiggleOffset(seed, i, frame); 582 const x = points[i].x * dpr + dx; 583 const y = points[i].y * dpr + dy; 584 if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); 585 } 586 ctx.stroke(); 587 ctx.restore(); 588 } 589 else if (brush === 'play' || brush === 'work') { 590 const img = state.charImages[brush]; 591 if (!img) return; 592 const baseSize = Math.max(112, lineSize * 18); 593 const ar = img.width / img.height; 594 const w = ar >= 1 ? baseSize : baseSize * ar; 595 const h = ar >= 1 ? baseSize / ar : baseSize; 596 const spacing = Math.max(baseSize * 0.85, 4); 597 let acc = 0; 598 for (let i = 0; i < upTo; i++) { 599 const [dx, dy] = wiggleOffset(seed, i, frame); 600 const x = points[i].x * dpr + dx; 601 const y = points[i].y * dpr + dy; 602 const stamp = () => { 603 const rot = hash2(seed + i, frame) * 0.5; 604 ctx.save(); 605 ctx.translate(x, y); 606 ctx.rotate(rot); 607 ctx.drawImage(img, -w / 2, -h / 2, w, h); 608 ctx.restore(); 609 }; 610 if (i === 0) { stamp(); continue; } 611 const prev = points[i - 1]; 612 const segLen = Math.hypot(points[i].x - prev.x, points[i].y - prev.y) * dpr; 613 acc += segLen; 614 if (acc >= spacing) { stamp(); acc = 0; } 615 } 616 } 617 else if (brush === 'eraser') { 618 ctx.save(); 619 ctx.lineWidth = lineSize * 1.6; 620 ctx.lineCap = 'round'; 621 ctx.lineJoin = 'round'; 622 ctx.globalCompositeOperation = 'destination-out'; 623 ctx.strokeStyle = 'rgba(0,0,0,1)'; 624 ctx.beginPath(); 625 for (let i = 0; i < upTo; i++) { 626 const [dx, dy] = wiggleOffset(seed, i, frame); 627 const x = points[i].x * dpr + dx; 628 const y = points[i].y * dpr + dy; 629 if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); 630 } 631 ctx.stroke(); 632 ctx.restore(); 633 } 634 else if (brush === 'emoji' || brush === 'type') { 635 if (stroke.glyphs && stroke.glyphs.length === 0) return; 636 ctx.save(); 637 const glyphs = (stroke.glyphs && stroke.glyphs.length) ? stroke.glyphs : [stroke.emoji || 'â¨']; 638 const isText = stroke.isText; 639 const fs = Math.max(14, lineSize * 2.2); 640 ctx.font = isText 641 ? `700 ${fs}px 'Inter', system-ui, sans-serif` 642 : `${fs}px 'Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji',sans-serif`; 643 ctx.fillStyle = color; // colours the text; emoji glyphs ignore it 644 ctx.textAlign = 'center'; 645 ctx.textBaseline = 'middle'; 646 const spacing = Math.max(fs * (isText ? 0.62 : 0.6), 1); 647 let gi = 0; 648 let acc = 0; // place at first point too 649 for (let i = 0; i < upTo; i++) { 650 const [dx, dy] = wiggleOffset(seed, i, frame); 651 const x = points[i].x * dpr + dx; 652 const y = points[i].y * dpr + dy; 653 const stamp = () => { 654 const rot = hash2(seed + i, frame) * 0.4; 655 ctx.save(); 656 ctx.translate(x, y); 657 ctx.rotate(rot); 658 ctx.fillText(glyphs[gi % glyphs.length], 0, 0); 659 ctx.restore(); 660 gi++; 661 }; 662 if (i === 0) { stamp(); continue; } 663 const prev = points[i-1]; 664 const segLen = Math.hypot(points[i].x - prev.x, points[i].y - prev.y) * dpr; 665 acc += segLen; 666 if (acc >= spacing) { stamp(); acc = 0; } 667 }
668 ctx.restore(); 669 } 670} 671 672// --- Render loop ---------------------------------------------------- 673function clearCanvas() { 674 ctx.save(); 675 ctx.fillStyle = '#ffffff'; 676 ctx.fillRect(0, 0, canvas.width, canvas.height); 677 ctx.restore(); 678} 679 680let animFrame = 0; 681let lastTick = 0; 682function render(now) { 683 // Throttle wiggle frame index to wiggleFPS for stop-motion look 684 if (now - lastTick > 1000 / state.fpsWiggle) { 685 animFrame = (animFrame + 1) % state.wiggleFrames; 686 lastTick = now; 687 } 688 clearCanvas(); 689 for (const s of state.strokes) drawStroke(s, animFrame); 690 if (state.currentStroke) drawStroke(state.currentStroke, animFrame); 691 requestAnimationFrame(render); 692} 693requestAnimationFrame((t) => { lastTick = t; render(t); }); 694 695// --- Input handling ------------------------------------------------- 696function getPoint(e) { 697 const rect = canvas.getBoundingClientRect(); 698 return { x: e.clientX - rect.left, y: e.clientY - rect.top }; 699} 700 701function startStroke(p) { 702 hint.classList.add('hide'); 703 const isType = state.brush === 'type'; 704 state.currentStroke = { 705 brush: state.brush, 706 color: state.color, 707 size: state.size, 708 emoji: state.emoji, 709 // Type brush stamps the word one letter per position; emoji stamps a single glyph 710 glyphs: isType ? [...state.text] : [state.emoji || 'â¨'], 711 isText: isType, 712 points: [p], 713 seed: Math.floor(Math.random() * 1e9), 714 }; 715} 716function extendStroke(p) { 717 if (!state.currentStroke) return; 718 const pts = state.currentStroke.points; 719 const last = pts[pts.length - 1]; 720 // simple thinning to reduce point count 721 if (Math.hypot(p.x - last.x, p.y - last.y) < 1.5) return; 722 pts.push(p); 723} 724function endStroke() { 725 if (!state.currentStroke) return; 726 if (state.currentStroke.points.length > 0) { 727 state.strokes.push(state.currentStroke); 728 } 729 state.currentStroke = null; 730} 731 732canvas.addEventListener('pointerdown', (e) => { 733 e.preventDefault(); 734 canvas.setPointerCapture(e.pointerId); 735 startStroke(getPoint(e)); 736}); 737canvas.addEventListener('pointermove', (e) => { 738 if (!state.currentStroke) return; 739 extendStroke(getPoint(e)); 740}); 741canvas.addEventListener('pointerup', () => endStroke()); 742canvas.addEventListener('pointercancel', () => endStroke()); 743canvas.addEventListener('pointerleave', () => endStroke()); 744 745// --- Disable all zoom on iOS Safari (it ignores viewport user-scalable) --- 746['gesturestart', 'gesturechange', 'gestureend'].forEach((evt) => { 747 document.addEventListener(evt, (e) => e.preventDefault(), { passive: false }); 748}); 749// Belt-and-suspenders: block two-finger pinch on touch surfaces 750document.addEventListener('touchmove', (e) => { 751 if (e.touches && e.touches.length > 1) e.preventDefault(); 752}, { passive: false }); 753 754// --- UI: brushes ---------------------------------------------------- 755const brushButtons = document.querySelectorAll('[data-brush]'); 756const emojiTray = document.getElementById('emojiTray'); 757const typeTray = document.getElementById('typeTray'); 758brushButtons.forEach(btn => { 759 btn.addEventListener('click', () => { 760 state.brush = btn.dataset.brush; 761 brushButtons.forEach(b => b.classList.toggle('active', b === btn)); 762 emojiTray.classList.toggle('show', state.brush === 'emoji'); 763 typeTray.classList.toggle('show', state.brush === 'type'); 764 }); 765}); 766 767// --- UI: emoji ------------------------------------------------------ 768const emojiButtons = emojiTray.querySelectorAll('[data-emoji]'); 769emojiButtons.forEach(btn => { 770 btn.addEventListener('click', () => { 771 state.emoji = btn.dataset.emoji; 772 emojiButtons.forEach(b => b.classList.toggle('active', b === btn)); 773 }); 774}); 775document.getElementById('emojiInput').addEventListener('input', (e) => { 776 const v = [...e.target.value][0]; 777 if (v) { 778 state.emoji = v; 779 emojiButtons.forEach(b => b.classList.remove('active')); 780 } 781}); 782 783// --- UI: type (custom word) ----------------------------------------- 784const textInput = document.getElementById('textInput'); 785textInput.addEventListener('input', (e) => { 786 state.text = e.target.value; 787}); 788 789// --- UI: colors ----------------------------------------------------- 790const swatchesEl = document.getElementById('swatches'); 791COLORS.forEach((c, i) => { 792 const d = document.createElement('button'); 793 d.className = 'swatch' + (i === 0 ? ' active' : ''); 794 d.style.background = c; 795 d.title = c; 796 d.dataset.color = c; 797 d.addEventListener('click', () => { 798 state.color = c; 799 swatchesEl.querySelectorAll('.swatch').forEach(s => s.classList.toggle('active', s === d)); 800 }); 801 swatchesEl.appendChild(d); 802}); 803 804// --- UI: size ------------------------------------------------------- 805const sizeInput = document.getElementById('size'); 806const sizeValue = document.getElementById('sizeValue'); 807sizeInput.addEventListener('input', () => { 808 state.size = parseInt(sizeInput.value, 10); 809 sizeValue.textContent = state.size; 810}); 811 812// --- UI: undo / clear ----------------------------------------------
813document.getElementById('undoBtn').addEventListener('click', () => { 814 state.strokes.pop(); 815 if (state.strokes.length === 0) hint.classList.remove('hide'); 816}); 817document.getElementById('clearBtn').addEventListener('click', () => { 818 if (state.strokes.length === 0) return; 819 if (confirm('Clear the canvas?')) { 820 state.strokes = []; 821 hint.classList.remove('hide'); 822 } 823}); 824 825// --- Export modal --------------------------------------------------- 826const modal = document.getElementById('modal'); 827const closeModal = document.getElementById('closeModal'); 828const exportBtn = document.getElementById('exportBtn'); 829const exportWiggleBtn = document.getElementById('exportWiggle'); 830const exportLapseBtn = document.getElementById('exportLapse'); 831const progressWrap = document.getElementById('progressWrap'); 832const progressBar = document.getElementById('progressBar'); 833const previewImg = document.getElementById('preview'); 834const downloadRow = document.getElementById('downloadRow'); 835const downloadLink = document.getElementById('downloadLink'); 836const exportAgainBtn = document.getElementById('exportAgain'); 837 838function openModal() { 839 modal.classList.add('show'); 840 resetModalUI(); 841} 842function closeModalFn() { modal.classList.remove('show'); } 843function resetModalUI() { 844 progressWrap.style.display = 'none'; 845 progressBar.style.width = '0%'; 846 previewImg.style.display = 'none'; 847 previewImg.src = ''; 848 downloadRow.style.display = 'none'; 849 exportWiggleBtn.disabled = false; 850 exportLapseBtn.disabled = false; 851} 852exportBtn.addEventListener('click', () => { 853 if (state.strokes.length === 0) { 854 hint.textContent = 'Draw something first.'; 855 setTimeout(() => { hint.textContent = 'Draw something. Every line wiggles.'; }, 1500); 856 return; 857 } 858 openModal(); 859}); 860closeModal.addEventListener('click', closeModalFn); 861modal.addEventListener('click', (e) => { if (e.target === modal) closeModalFn(); }); 862exportAgainBtn.addEventListener('click', resetModalUI); 863 864// --- GIF rendering -------------------------------------------------- 865function makeOffscreen(scale = 1) { 866 const rect = stage.getBoundingClientRect(); 867 const w = Math.floor(rect.width * scale); 868 const h = Math.floor(rect.height * scale); 869 const c = document.createElement('canvas'); 870 c.width = w; c.height = h; 871 return { canvas: c, ctx: c.getContext('2d'), w, h, scale }; 872} 873 874// Draw helper that renders into a given context (independent of main ctx) 875function drawSceneTo(targetCtx, targetW, targetH, frame, strokes, upToStrokeIdx, partialPointIdx) { 876 // background 877 targetCtx.save(); 878 targetCtx.fillStyle = '#ffffff'; 879 targetCtx.fillRect(0, 0, targetW, targetH); 880 targetCtx.restore(); 881 882 const oldCtx = ctx; 883 const oldDpr = state.dpr; 884 const stageRect = stage.getBoundingClientRect(); 885 const scale = targetW / stageRect.width; 886 state.dpr = scale; 887 888 // swap global ctx temporarily 889 window.__ctx_swap = ctx; 890 // Reassign via Object.defineProperty workaround: just call draw with target 891 // (drawStroke uses module-level ctx; we shadow it) 892 drawStrokeOn(targetCtx, strokes, upToStrokeIdx, partialPointIdx, frame); 893 894 state.dpr = oldDpr; 895} 896 897// Render strokes onto an arbitrary context. 898function drawStrokeOn(targetCtx, strokes, upToStrokeIdx, partialPointIdx, frame) { 899 // We re-implement the per-brush drawing here so we can target any ctx. 900 const dpr = state.dpr; 901 for (let si = 0; si <= upToStrokeIdx && si < strokes.length; si++) { 902 const stroke = strokes[si]; 903 const upTo = (si === upToStrokeIdx && partialPointIdx != null) 904 ? Math.min(partialPointIdx, stroke.points.length) 905 : stroke.points.length; 906 if (upTo < 1) continue; 907 renderStrokeToCtx(targetCtx, stroke, frame, upTo); 908 } 909} 910
911function renderStrokeToCtx(c, stroke, frame, upTo) { 912 const { brush, color, size, points, seed } = stroke; 913 const dpr = state.dpr; 914 const lineSize = size * dpr; 915 916 if (brush === 'pen') { 917 c.save(); 918 c.lineWidth = lineSize; c.strokeStyle = color; 919 c.lineCap = 'round'; c.lineJoin = 'round'; 920 c.beginPath(); 921 for (let i = 0; i < upTo; i++) { 922 const [dx, dy] = wiggleOffset(seed, i, frame); 923 const x = points[i].x * dpr + dx; 924 const y = points[i].y * dpr + dy; 925 if (i === 0) c.moveTo(x, y); else c.lineTo(x, y); 926 } 927 c.stroke(); c.restore(); 928 } else if (brush === 'marker') { 929 c.save(); 930 c.lineWidth = lineSize * 1.4; c.strokeStyle = color; 931 c.globalAlpha = 0.55; c.globalCompositeOperation = 'multiply'; 932 c.lineCap = 'round'; c.lineJoin = 'round'; 933 c.beginPath(); 934 for (let i = 0; i < upTo; i++) { 935 const [dx, dy] = wiggleOffset(seed, i, frame); 936 const x = points[i].x * dpr + dx; 937 const y = points[i].y * dpr + dy; 938 if (i === 0) c.moveTo(x, y); else c.lineTo(x, y); 939 } 940 c.stroke(); c.restore(); 941 } else if (brush === 'spray') { 942 c.save(); c.fillStyle = color; 943 const r = lineSize * 1.2; 944 const density = Math.max(6, Math.floor(lineSize * 0.8)); 945 for (let i = 0; i < upTo; i++) { 946 const [dx, dy] = wiggleOffset(seed, i, frame); 947 const cx = points[i].x * dpr + dx; 948 const cy = points[i].y * dpr + dy; 949 for (let k = 0; k < density; k++) { 950 const a = hash2(seed + i * 13, frame * 17 + k * 3) * Math.PI; 951 const rr = (hash2(seed + i * 7 + k, frame * 5) * 0.5 + 0.5) * r; 952 const px = cx + Math.cos(a * 2) * rr; 953 const py = cy + Math.sin(a * 2) * rr; 954 c.globalAlpha = 0.35 + (hash2(seed + k, frame + i) * 0.5 + 0.5) * 0.45; 955 c.beginPath(); 956 c.arc(px, py, Math.max(0.6, dpr * 0.9), 0, Math.PI * 2); 957 c.fill(); 958 } 959 } 960 c.restore(); 961 } else if (brush === 'neon') { 962 c.save(); 963 c.lineCap = 'round'; c.lineJoin = 'round'; 964 c.shadowColor = color; c.shadowBlur = lineSize * 2.5; 965 c.strokeStyle = color; c.lineWidth = lineSize * 0.85; 966 c.globalAlpha = 0.85; 967 c.beginPath(); 968 for (let i = 0; i < upTo; i++) { 969 const [dx, dy] = wiggleOffset(seed, i, frame); 970 const x = points[i].x * dpr + dx; 971 const y = points[i].y * dpr + dy; 972 if (i === 0) c.moveTo(x, y); else c.lineTo(x, y); 973 } 974 c.stroke(); 975 c.shadowBlur = 0; c.strokeStyle = '#ffffff'; 976 c.lineWidth = Math.max(1, lineSize * 0.35); c.globalAlpha = 0.9; 977 c.beginPath(); 978 for (let i = 0; i < upTo; i++) { 979 const [dx, dy] = wiggleOffset(seed, i, frame); 980 const x = points[i].x * dpr + dx; 981 const y = points[i].y * dpr + dy; 982 if (i === 0) c.moveTo(x, y); else c.lineTo(x, y); 983 } 984 c.stroke(); c.restore(); 985 } else if (brush === 'play' || brush === 'work') { 986 const img = state.charImages[brush]; 987 if (!img) return; 988 const baseSize = Math.max(112, lineSize * 18); 989 const ar = img.width / img.height; 990 const w = ar >= 1 ? baseSize : baseSize * ar; 991 const h = ar >= 1 ? baseSize / ar : baseSize; 992 const spacing = Math.max(baseSize * 0.85, 4); 993 let acc = 0; 994 for (let i = 0; i < upTo; i++) { 995 const [dx, dy] = wiggleOffset(seed, i, frame); 996 const x = points[i].x * dpr + dx; 997 const y = points[i].y * dpr + dy; 998 const stamp = () => { 999 const rot = hash2(seed + i, frame) * 0.5; 1000 c.save(); 1001 c.translate(x, y); c.rotate(rot); 1002 c.drawImage(img, -w / 2, -h / 2, w, h); 1003 c.restore(); 1004 }; 1005 if (i === 0) { stamp(); continue; } 1006 const prev = points[i - 1]; 1007 const segLen = Math.hypot(points[i].x - prev.x, points[i].y - prev.y) * dpr; 1008 acc += segLen; 1009 if (acc >= spacing) { stamp(); acc = 0; } 1010 } 1011 } else if (brush === 'eraser') { 1012 c.save(); 1013 c.lineWidth = lineSize * 1.6; 1014 c.lineCap = 'round'; c.lineJoin = 'round'; 1015 c.globalCompositeOperation = 'destination-out'; 1016 c.strokeStyle = 'rgba(0,0,0,1)'; 1017 c.beginPath(); 1018 for (let i = 0; i < upTo; i++) { 1019 const [dx, dy] = wiggleOffset(seed, i, frame); 1020 const x = points[i].x * dpr + dx; 1021 const y = points[i].y * dpr + dy; 1022 if (i === 0) c.moveTo(x, y); else c.lineTo(x, y); 1023 } 1024 c.stroke();
1025 c.restore(); 1026 } else if (brush === 'emoji' || brush === 'type') { 1027 if (stroke.glyphs && stroke.glyphs.length === 0) return; 1028 c.save(); 1029 const glyphs = (stroke.glyphs && stroke.glyphs.length) ? stroke.glyphs : [stroke.emoji || 'â¨']; 1030 const isText = stroke.isText; 1031 const fs = Math.max(14, lineSize * 2.2); 1032 c.font = isText 1033 ? `700 ${fs}px 'Inter', system-ui, sans-serif` 1034 : `${fs}px 'Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji',sans-serif`; 1035 c.fillStyle = color; 1036 c.textAlign = 'center'; c.textBaseline = 'middle'; 1037 const spacing = Math.max(fs * (isText ? 0.62 : 0.6), 1); 1038 let gi = 0; 1039 let acc = 0; 1040 for (let i = 0; i < upTo; i++) { 1041 const [dx, dy] = wiggleOffset(seed, i, frame); 1042 const x = points[i].x * dpr + dx; 1043 const y = points[i].y * dpr + dy; 1044 const stamp = () => { 1045 const rot = hash2(seed + i, frame) * 0.4; 1046 c.save(); c.translate(x, y); c.rotate(rot); 1047 c.fillText(glyphs[gi % glyphs.length], 0, 0); 1048 c.restore(); 1049 gi++; 1050 }; 1051 if (i === 0) { stamp(); continue; } 1052 const prev = points[i-1]; 1053 const segLen = Math.hypot(points[i].x - prev.x, points[i].y - prev.y) * dpr; 1054 acc += segLen; 1055 if (acc >= spacing) { stamp(); acc = 0; } 1056 } 1057 c.restore(); 1058 } 1059} 1060 1061// Build a single composed frame 1062function composeFrame(targetCanvas, frame, opts = {}) { 1063 const c = targetCanvas.getContext('2d'); 1064 // Start from transparent so destination-out (eraser) yields transparent pixels. 1065 c.save(); 1066 c.globalCompositeOperation = 'source-over'; 1067 c.clearRect(0, 0, targetCanvas.width, targetCanvas.height); 1068 c.restore(); 1069 1070 const upToStroke = opts.upToStroke != null ? opts.upToStroke : state.strokes.length - 1; 1071 const partial = opts.partialPointIdx; 1072 drawStrokeOn(c, state.strokes, upToStroke, partial, frame); 1073 1074 // Fill the white "paper" behind everything â including holes left by the eraser. 1075 // gif.js.optimized encodes alpha=0 as black, so we must remove transparency before encoding. 1076 c.save(); 1077 c.globalCompositeOperation = 'destination-over'; 1078 c.fillStyle = '#ffffff'; 1079 c.fillRect(0, 0, targetCanvas.width, targetCanvas.height); 1080 c.restore(); 1081} 1082 1083// Pre-load gif.worker.js into a same-origin blob URL so cross-origin 1084// worker loading doesn't get blocked by the browser. 1085let _workerUrlPromise = null; 1086function getWorkerUrl() { 1087 if (!_workerUrlPromise) { 1088 _workerUrlPromise = fetch('https://cdn.jsdelivr.net/npm/[email protected]/dist/gif.worker.js') 1089 .then(r => r.text()) 1090 .then(code => URL.createObjectURL(new Blob([code], { type: 'application/javascript' }))); 1091 } 1092 return _workerUrlPromise; 1093} 1094 1095async function exportGif(mode) { 1096 exportWiggleBtn.disabled = true; 1097 exportLapseBtn.disabled = true; 1098 progressWrap.style.display = 'block'; 1099 progressBar.style.width = '0%'; 1100 1101 const workerUrl = await getWorkerUrl(); 1102 1103 // Use a fixed export size (capped) for predictable file sizes 1104 const stageRect = stage.getBoundingClientRect(); 1105 const maxDim = 720; 1106 const aspect = stageRect.width / stageRect.height; 1107 let W = Math.min(stageRect.width, maxDim); 1108 let H = W / aspect; 1109 if (H > maxDim) { H = maxDim; W = H * aspect; } 1110 W = Math.floor(W); H = Math.floor(H); 1111 1112 const targetCanvas = document.createElement('canvas'); 1113 targetCanvas.width = W; targetCanvas.height = H; 1114 1115 const oldDpr = state.dpr; 1116 state.dpr = W / stageRect.width; 1117 1118 const gif = new GIF({ 1119 workers: 2, 1120 quality: 8, 1121 width: W, 1122 height: H, 1123 workerScript: workerUrl, 1124 background: '#ffffff', 1125 }); 1126 1127 gif.on('progress', (p) => { 1128 progressBar.style.width = (50 + p * 50).toFixed(1) + '%'; 1129 }); 1130 1131 if (mode === 'wiggle') { 1132 const frames = state.wiggleFrames; 1133 const delay = Math.round(1000 / state.fpsWiggle); 1134 for (let f = 0; f < frames; f++) { 1135 composeFrame(targetCanvas, f); 1136 gif.addFrame(targetCanvas, { copy: true, delay }); 1137 progressBar.style.width = ((f + 1) / frames * 50).toFixed(1) + '%'; 1138 await new Promise(r => setTimeout(r, 0)); 1139 } 1140 } else { 1141 // timelapse: progressively reveal strokes 1142 const totalPoints = state.strokes.reduce((sum, s) => sum + s.points.length, 0); 1143 const desiredFrames = Math.min(60, Math.max(20, Math.ceil(totalPoints / 6))); 1144 const step = totalPoints / desiredFrames; 1145 let acc = 0; 1146 let progress = step; 1147 1148 for (let fi = 0; fi < desiredFrames; fi++) { 1149 // find stroke index and partial point at this progress 1150 let remaining = Math.floor(progress); 1151 let si = 0; 1152 let partial = 0; 1153 for (let i = 0; i < state.strokes.length; i++) { 1154 if (remaining <= state.strokes[i].points.length) { 1155 si = i; partial = remaining; break; 1156 } 1157 remaining -= state.strokes[i].points.length; 1158 si = i; partial = state.strokes[i].points.length; 1159 if (i === state.strokes.length - 1) break; 1160 } 1161 composeFrame(targetCanvas, fi % state.wiggleFrames, { 1162 upToStroke: si, 1163 partialPointIdx: partial, 1164 }); 1165 const isLast = fi === desiredFrames - 1; 1166 gif.addFrame(targetCanvas, { copy: true, delay: isLast ? 800 : 60 }); 1167 progressBar.style.width = ((fi + 1) / desiredFrames * 50).toFixed(1) + '%'; 1168 progress += step; 1169 await new Promise(r => setTimeout(r, 0)); 1170 } 1171 // Append wiggle loop at the end (3 frames) 1172 for (let f = 0; f < state.wiggleFrames; f++) { 1173 composeFrame(targetCanvas, f); 1174 gif.addFrame(targetCanvas, { copy: true, delay: Math.round(1000 / state.fpsWiggle) }); 1175 } 1176 } 1177 1178 state.dpr = oldDpr; 1179 1180 gif.on('finished', (blob) => { 1181 const url = URL.createObjectURL(blob); 1182 previewImg.src = url; 1183 previewImg.style.display = 'block'; 1184 downloadLink.href = url; 1185 downloadLink.download = `schmetterlink-${mode}-${Date.now()}.gif`; 1186 downloadRow.style.display = 'flex'; 1187 progressBar.style.width = '100%'; 1188 }); 1189 1190 gif.render(); 1191} 1192 1193exportWiggleBtn.addEventListener('click', () => exportGif('wiggle')); 1194exportLapseBtn.addEventListener('click', () => exportGif('lapse')); 1195 1196// Keyboard shortcuts 1197window.addEventListener('keydown', (e) => { 1198 if (e.metaKey || e.ctrlKey) { 1199 if (e.key === 'z') { e.preventDefault(); state.strokes.pop(); if (!state.strokes.length) hint.classList.remove('hide'); } 1200 } 1201}); 1202 1203// --- Character brushes loader ------------------------------------- 1204// Loads each character PNG (one file per character â already pre-separated), 1205// downscales it, crops to bounding box, removes the white background, and
1206// exposes the result as a brush. 1207async function loadCharacterImage(url, name) { 1208 // Source PNGs are huge (~13k px wide). Use createImageBitmap with 1209 // resizeWidth so the browser can downscale during decode, avoiding the 1210 // full-resolution allocation that crashes Image.decode() on big files. 1211 const MAX = 1500; 1212 const blob = await fetch(url).then(r => { 1213 if (!r.ok) throw new Error(`HTTP ${r.status} for ${url}`); 1214 return r.blob(); 1215 }); 1216 const bmp = await createImageBitmap(blob, { 1217 resizeWidth: MAX, resizeQuality: 'high', 1218 }); 1219 const W = bmp.width, H = bmp.height; 1220 1221 const work = document.createElement('canvas'); 1222 work.width = W; work.height = H; 1223 const wctx = work.getContext('2d'); 1224 wctx.drawImage(bmp, 0, 0); 1225 bmp.close(); 1226 const data = wctx.getImageData(0, 0, W, H); 1227 const px = data.data; 1228 1229 const INK_LUM = 220; 1230 const isInk = (x, y) => { 1231 const i = (y * W + x) * 4; 1232 return (px[i] + px[i + 1] + px[i + 2]) / 3 < INK_LUM; 1233 }; 1234 1235 // Tight bbox of ink content 1236 let x0 = W, y0 = H, x1 = 0, y1 = 0, has = false; 1237 for (let y = 0; y < H; y++) { 1238 for (let x = 0; x < W; x++) { 1239 if (isInk(x, y)) { 1240 if (x < x0) x0 = x; if (y < y0) y0 = y; 1241 if (x > x1) x1 = x; if (y > y1) y1 = y; 1242 has = true; 1243 } 1244 } 1245 } 1246 if (!has) throw new Error(`No ink found in ${url}`); 1247 1248 const pad = Math.round(Math.max(W, H) * 0.01); 1249 const cx0 = Math.max(0, x0 - pad); 1250 const cy0 = Math.max(0, y0 - pad); 1251 const cw = Math.min(W - cx0, x1 - x0 + 1 + 2 * pad); 1252 const ch = Math.min(H - cy0, y1 - y0 + 1 + 2 * pad); 1253 1254 // Crop + whiteâtransparent with alpha feather 1255 const sub = document.createElement('canvas'); 1256 sub.width = cw; sub.height = ch; 1257 const sctx = sub.getContext('2d'); 1258 sctx.drawImage(work, cx0, cy0, cw, ch, 0, 0, cw, ch); 1259 const sd = sctx.getImageData(0, 0, cw, ch); 1260 const sp = sd.data; 1261 for (let i = 0; i < sp.length; i += 4) { 1262 const lum = (sp[i] + sp[i + 1] + sp[i + 2]) / 3; 1263 if (lum > 240) sp[i + 3] = 0; 1264 else if (lum > 200) sp[i + 3] = Math.round(sp[i + 3] * (1 - (lum - 200) / 40)); 1265 } 1266 sctx.putImageData(sd, 0, 0); 1267 state.charImages[name] = sub; 1268 1269 const thumb = document.getElementById(name === 'play' ? 'thumbPlay' : 'thumbWork'); 1270 if (thumb) thumb.style.backgroundImage = `url("${sub.toDataURL('image/png')}")`; 1271 const btn = document.getElementById(name === 'play' ? 'brushPlay' : 'brushWork'); 1272 if (btn) { btn.disabled = false; btn.style.opacity = '1'; } 1273} 1274 1275(async function loadCharacterBrushes() { 1276 try { 1277 await Promise.all([ 1278 loadCharacterImage('play.png', 'play'), 1279 loadCharacterImage('work.png', 'work'), 1280 ]); 1281 } catch (err) { 1282 console.error('Failed to load character brushes:', err); 1283 } 1284})(); 1285</script>
1285 1286 1287</body> 1288</html>
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.