PageSourceSearch

https://schmetterlinkwiggle.vercel.app/

html schmetterlinkwiggle.vercel.app collected 2026-10-03 07:40:04 UTC 46,552 bytes, 1,288 lines download raw bytes

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.