the home of serif.blue

feat: add bluesky profile updates

dunkirk.sh 482c5fc2 f57846a8

verified
+2265
bsky-profile-updates.sh site/pfp-updates/bsky-pfp-updates.sh
+38
site/index.html
··· 189 189 </div> 190 190 191 191 <div class="card"> 192 + <h3>Automatic profile updates!</h3> 193 + <p> 194 + I made this inspired by 195 + <a 196 + href="https://bsky.app/profile/did:plc:gq4fo3u6tqzzdkjlwzpb23tj" 197 + >@dame.is</a 198 + >'s (dame.is's sounds hilarious lol) profile picture 199 + which changes with a sky gradient every hour. I wanted 200 + to do something similar but my profile picture has me in 201 + the foreground so I had to do some masking shenanagins 202 + to get it to work. 203 + </p> 204 + <p> 205 + Anyway if you want to set this up for yourself then grab 206 + a background removed version of your profile from 207 + <a href="https://remove.bg">remove.bg</a> (low res 208 + preview version is fine since this will just be a mask) 209 + and then run 210 + <code 211 + >magick pfp-removebg-preview.png -alpha extract 212 + pfp_matte.png</code 213 + >. Now you can head over to the timeline site linked 214 + below and customize your timeline! When you are done 215 + simply download the zip and extract it wherever you want 216 + it to live. Then <code>crontab -e</code> and add your 217 + script (<code 218 + >2 * * * * /home/usrname/pfp/bsky-profile-updates.sh 219 + >/dev/null 2>&1</code 220 + > 221 + ) to run 2 minutes after the hour (or at really whatever 222 + time you want)! 223 + </p> 224 + <a href="/pfp-updates" class="btn" 225 + >Customize your gradients!</a 226 + > 227 + </div> 228 + 229 + <div class="card"> 192 230 <h3>More things soon?</h3> 193 231 <p> 194 232 Yeah probably lol; I just need to find the right next
+2227
site/pfp-updates/index.html
··· 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.0" /> 6 + 7 + <link 8 + rel="icon" 9 + type="image/png" 10 + href="/favicon/favicon-96x96.png" 11 + sizes="96x96" 12 + /> 13 + <link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" /> 14 + <link rel="shortcut icon" href="/favicon/favicon.ico" /> 15 + <link 16 + rel="apple-touch-icon" 17 + sizes="180x180" 18 + href="/favicon/apple-touch-icon.png" 19 + /> 20 + <meta name="apple-mobile-web-app-title" content="Serif.blue" /> 21 + <link rel="manifest" href="/favicon/site.webmanifest" /> 22 + 23 + <meta 24 + name="description" 25 + content="Serif.blue - Fancy projects by Kieran" 26 + /> 27 + <meta name="color-scheme" content="light" /> 28 + 29 + <meta property="og:title" content="Serif.blue - pfp gradient builder" /> 30 + <meta property="og:type" content="website" /> 31 + <meta property="og:url" content="https://serif.blue/pfp-updates" /> 32 + <meta property="og:image" content="/og.png" /> 33 + 34 + <link rel="me" href="https://dunkirk.sh" /> 35 + <link rel="me" href="https://bsky.app/profile/dunkirk.sh" /> 36 + <link rel="me" href="https://github.com/taciturnaxolotl" /> 37 + 38 + <title>Sky Gradient Timeline Builder</title> 39 + <style> 40 + body { 41 + font-family: Arial, sans-serif; 42 + max-width: 1400px; 43 + margin: 0 auto; 44 + padding: 20px; 45 + background: #f5f5f5; 46 + } 47 + 48 + .container { 49 + display: grid; 50 + grid-template-columns: 300px 1fr; 51 + gap: 20px; 52 + margin-bottom: 20px; 53 + } 54 + 55 + .sidebar { 56 + background: white; 57 + padding: 20px; 58 + border-radius: 8px; 59 + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 60 + height: fit-content; 61 + overflow-y: auto; 62 + overflow-x: hidden; 63 + max-height: 90vh; 64 + word-wrap: break-word; 65 + overflow-wrap: break-word; 66 + } 67 + 68 + .timeline-area { 69 + background: white; 70 + padding: 20px; 71 + border-radius: 8px; 72 + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 73 + } 74 + 75 + .upload-section { 76 + margin-bottom: 20px; 77 + padding: 15px; 78 + border: 2px dashed #ddd; 79 + border-radius: 8px; 80 + text-align: center; 81 + } 82 + 83 + .timeline-selector { 84 + margin-bottom: 20px; 85 + } 86 + 87 + .timeline-tabs { 88 + display: flex; 89 + flex-wrap: wrap; 90 + gap: 5px; 91 + margin-bottom: 10px; 92 + } 93 + 94 + .timeline-tab { 95 + padding: 8px 12px; 96 + border: 1px solid #ddd; 97 + border-radius: 4px; 98 + cursor: pointer; 99 + font-size: 12px; 100 + background: #f8f9fa; 101 + transition: all 0.2s; 102 + } 103 + 104 + .timeline-tab.active { 105 + background: #007bff; 106 + color: white; 107 + border-color: #007bff; 108 + } 109 + 110 + .timeline-tab:hover { 111 + background: #e9ecef; 112 + } 113 + 114 + .timeline-tab.active:hover { 115 + background: #0056b3; 116 + } 117 + 118 + .new-timeline { 119 + display: flex; 120 + gap: 5px; 121 + margin-bottom: 15px; 122 + } 123 + 124 + .timeline-grid { 125 + display: grid; 126 + grid-template-columns: repeat(24, 1fr); 127 + gap: 2px; 128 + margin-bottom: 20px; 129 + border: 1px solid #ddd; 130 + border-radius: 4px; 131 + padding: 10px; 132 + background: #f8f9fa; 133 + } 134 + 135 + .hour-slot { 136 + aspect-ratio: 1; 137 + border: 1px solid #ccc; 138 + border-radius: 3px; 139 + cursor: pointer; 140 + display: flex; 141 + align-items: center; 142 + justify-content: center; 143 + font-size: 10px; 144 + font-weight: bold; 145 + position: relative; 146 + transition: all 0.2s; 147 + } 148 + 149 + .hour-slot:hover { 150 + border-color: #007bff; 151 + transform: scale(1.1); 152 + z-index: 10; 153 + } 154 + 155 + .hour-slot.selected { 156 + border: 2px solid #007bff; 157 + box-shadow: 0 0 5px rgba(0, 123, 255, 0.5); 158 + } 159 + 160 + .color-input { 161 + width: 60px; 162 + height: 30px; 163 + border: none; 164 + border-radius: 4px; 165 + cursor: pointer; 166 + margin: 0 5px; 167 + } 168 + 169 + .slider-group { 170 + margin: 10px 0; 171 + display: flex; 172 + align-items: center; 173 + gap: 10px; 174 + } 175 + 176 + .slider { 177 + flex: 1; 178 + height: 6px; 179 + border-radius: 3px; 180 + background: #ddd; 181 + outline: none; 182 + } 183 + 184 + .value-display { 185 + min-width: 40px; 186 + font-weight: bold; 187 + } 188 + 189 + .preview-canvas { 190 + max-width: 200px; 191 + border: 1px solid #ddd; 192 + border-radius: 8px; 193 + margin: 10px 0; 194 + } 195 + 196 + .btn { 197 + padding: 8px 12px; 198 + border: none; 199 + border-radius: 4px; 200 + cursor: pointer; 201 + font-size: 12px; 202 + transition: all 0.2s; 203 + margin: 2px; 204 + } 205 + 206 + .btn-primary { 207 + background: #007bff; 208 + color: white; 209 + } 210 + .btn-success { 211 + background: #28a745; 212 + color: white; 213 + } 214 + .btn-danger { 215 + background: #dc3545; 216 + color: white; 217 + } 218 + .btn-secondary { 219 + background: #6c757d; 220 + color: white; 221 + } 222 + .btn-warning { 223 + background: #ffc107; 224 + color: black; 225 + } 226 + 227 + .btn:hover { 228 + transform: translateY(-1px); 229 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); 230 + } 231 + 232 + .status { 233 + margin: 10px 0; 234 + padding: 10px; 235 + border-radius: 4px; 236 + display: none; 237 + } 238 + 239 + .status.error { 240 + background: #ffe6e6; 241 + color: #d00; 242 + display: block; 243 + } 244 + .status.success { 245 + background: #e6ffe6; 246 + color: #060; 247 + display: block; 248 + } 249 + 250 + .hour-labels { 251 + display: grid; 252 + grid-template-columns: repeat(24, 1fr); 253 + gap: 2px; 254 + margin-bottom: 5px; 255 + padding: 0 10px; 256 + } 257 + 258 + .hour-label { 259 + text-align: center; 260 + font-size: 10px; 261 + color: #666; 262 + } 263 + 264 + .config-export { 265 + margin-top: 20px; 266 + padding: 15px; 267 + border: 1px solid #ddd; 268 + border-radius: 4px; 269 + background: #f8f9fa; 270 + } 271 + 272 + .timeline-info { 273 + font-size: 12px; 274 + color: #666; 275 + margin-bottom: 15px; 276 + padding: 10px; 277 + background: #e3f2fd; 278 + border-radius: 4px; 279 + } 280 + </style> 281 + </head> 282 + <body> 283 + <h1>🌅 Sky Gradient Timeline Builder</h1> 284 + 285 + <div class="container"> 286 + <div class="sidebar"> 287 + <div class="upload-section"> 288 + <h3>Upload Images</h3> 289 + <div> 290 + <label>Base Image:</label><br /> 291 + <input type="file" id="baseImage" accept="image/*" /> 292 + </div> 293 + <br /> 294 + <div> 295 + <label>Matte:</label><br /> 296 + <input type="file" id="matteImage" accept="image/*" /> 297 + </div> 298 + <div class="status" id="uploadStatus"></div> 299 + </div> 300 + 301 + <div> 302 + <h3>Hour Settings</h3> 303 + <div 304 + id="hourInfo" 305 + style=" 306 + font-size: 12px; 307 + color: #666; 308 + margin-bottom: 10px; 309 + " 310 + > 311 + Click an hour slot to configure 312 + </div> 313 + 314 + <div 315 + style=" 316 + display: flex; 317 + align-items: center; 318 + gap: 10px; 319 + margin: 15px 0; 320 + " 321 + > 322 + <label>From:</label> 323 + <input 324 + type="color" 325 + id="color1" 326 + class="color-input" 327 + value="#4682b4" 328 + /> 329 + <label>To:</label> 330 + <input 331 + type="color" 332 + id="color2" 333 + class="color-input" 334 + value="#87ceeb" 335 + /> 336 + </div> 337 + 338 + <div class="slider-group"> 339 + <label>Background:</label> 340 + <input 341 + type="range" 342 + id="bgIntensity" 343 + class="slider" 344 + min="0" 345 + max="100" 346 + value="40" 347 + /> 348 + <span class="value-display" id="bgValue">40%</span> 349 + </div> 350 + 351 + <div class="slider-group"> 352 + <label>Foreground:</label> 353 + <input 354 + type="range" 355 + id="fgIntensity" 356 + class="slider" 357 + min="0" 358 + max="50" 359 + value="8" 360 + /> 361 + <span class="value-display" id="fgValue">8%</span> 362 + </div> 363 + 364 + <button 365 + onclick="applyToSelectedHour()" 366 + class="btn btn-success" 367 + style="width: 100%; margin-top: 10px" 368 + > 369 + Apply to Selected Hour 370 + </button> 371 + 372 + <h4>Preview</h4> 373 + <canvas 374 + id="previewCanvas" 375 + class="preview-canvas" 376 + width="150" 377 + height="150" 378 + ></canvas> 379 + 380 + <canvas 381 + id="renderCanvas" 382 + style="display: none" 383 + width="400" 384 + height="400" 385 + ></canvas> 386 + </div> 387 + 388 + <div class="config-export"> 389 + <h4>Export Config</h4> 390 + <button 391 + onclick="copyAllTimelines()" 392 + class="btn btn-warning" 393 + style="width: 100%; margin-bottom: 10px" 394 + > 395 + 📋 Copy All Timelines 396 + </button> 397 + 398 + <label style="font-size: 12px; color: #666" 399 + >Config Output:</label 400 + > 401 + <textarea 402 + id="configOutput" 403 + readonly 404 + style=" 405 + width: 100%; 406 + height: 120px; 407 + font-family: monospace; 408 + font-size: 10px; 409 + resize: vertical; 410 + margin-top: 5px; 411 + padding: 8px; 412 + border: 1px solid #ddd; 413 + border-radius: 4px; 414 + word-break: break-all; 415 + white-space: pre-wrap; 416 + overflow-wrap: break-word; 417 + " 418 + ></textarea> 419 + <button 420 + onclick="copyToClipboard()" 421 + class="btn btn-success" 422 + style="width: 100%; margin-top: 5px" 423 + > 424 + 📋 Copy to Clipboard 425 + </button> 426 + 427 + <hr style="margin: 15px 0" /> 428 + 429 + <h4>Import Config</h4> 430 + <label style="font-size: 12px; color: #666" 431 + >Paste Config (auto-imports):</label 432 + > 433 + <textarea 434 + id="bulkConfigInput" 435 + placeholder="Paste timeline config here..." 436 + style=" 437 + width: 100%; 438 + height: 80px; 439 + font-family: monospace; 440 + font-size: 10px; 441 + resize: vertical; 442 + margin-top: 5px; 443 + padding: 8px; 444 + border: 1px solid #ddd; 445 + border-radius: 4px; 446 + " 447 + ></textarea> 448 + </div> 449 + </div> 450 + 451 + <div class="timeline-area"> 452 + <div class="timeline-selector"> 453 + <h3>Weather Timelines</h3> 454 + <div class="timeline-info"> 455 + Create different timelines for various weather 456 + conditions. Each timeline defines how your profile 457 + picture should look throughout the day. 458 + </div> 459 + 460 + <div class="new-timeline"> 461 + <input 462 + type="text" 463 + id="newTimelineName" 464 + placeholder="Timeline name (e.g. sunny, rainy, cloudy)" 465 + style="flex: 1; padding: 8px" 466 + /> 467 + <button 468 + onclick="createTimeline()" 469 + class="btn btn-success" 470 + > 471 + Create 472 + </button> 473 + </div> 474 + 475 + <div class="timeline-tabs" id="timelineTabs"> 476 + <div class="timeline-tab active" data-timeline="sunny"> 477 + Sunny 478 + </div> 479 + </div> 480 + 481 + <div style="margin: 10px 0"> 482 + <button 483 + onclick="duplicateTimeline()" 484 + class="btn btn-secondary" 485 + > 486 + Duplicate Current 487 + </button> 488 + <button 489 + onclick="deleteTimeline()" 490 + class="btn btn-danger" 491 + > 492 + Delete Current 493 + </button> 494 + </div> 495 + </div> 496 + 497 + <div> 498 + <h4> 499 + 24-Hour Timeline: 500 + <span id="currentTimelineName">Sunny</span> 501 + </h4> 502 + <div class="hour-labels"> 503 + <div class="hour-label">0</div> 504 + <div class="hour-label">1</div> 505 + <div class="hour-label">2</div> 506 + <div class="hour-label">3</div> 507 + <div class="hour-label">4</div> 508 + <div class="hour-label">5</div> 509 + <div class="hour-label">6</div> 510 + <div class="hour-label">7</div> 511 + <div class="hour-label">8</div> 512 + <div class="hour-label">9</div> 513 + <div class="hour-label">10</div> 514 + <div class="hour-label">11</div> 515 + <div class="hour-label">12</div> 516 + <div class="hour-label">13</div> 517 + <div class="hour-label">14</div> 518 + <div class="hour-label">15</div> 519 + <div class="hour-label">16</div> 520 + <div class="hour-label">17</div> 521 + <div class="hour-label">18</div> 522 + <div class="hour-label">19</div> 523 + <div class="hour-label">20</div> 524 + <div class="hour-label">21</div> 525 + <div class="hour-label">22</div> 526 + <div class="hour-label">23</div> 527 + </div> 528 + <div class="timeline-grid" id="timelineGrid"> 529 + <!-- Hours 0-23 will be generated here --> 530 + </div> 531 + 532 + <div style="margin-top: 20px"> 533 + <h4>Bulk Actions</h4> 534 + <div 535 + style=" 536 + display: flex; 537 + gap: 10px; 538 + flex-wrap: wrap; 539 + margin-bottom: 15px; 540 + " 541 + > 542 + <button 543 + onclick="loadPreset('dawn', [5,6,7])" 544 + class="btn btn-secondary" 545 + > 546 + Dawn (5-7) 547 + </button> 548 + <button 549 + onclick="loadPreset('morning', [8,9,10,11])" 550 + class="btn btn-secondary" 551 + > 552 + Morning (8-11) 553 + </button> 554 + <button 555 + onclick="loadPreset('afternoon', [12,13,14,15,16])" 556 + class="btn btn-secondary" 557 + > 558 + Afternoon (12-16) 559 + </button> 560 + <button 561 + onclick="loadPreset('sunset', [17,18,19])" 562 + class="btn btn-secondary" 563 + > 564 + Sunset (17-19) 565 + </button> 566 + <button 567 + onclick="loadPreset('night', [20,21,22,23,0,1,2,3,4])" 568 + class="btn btn-secondary" 569 + > 570 + Night (20-4) 571 + </button> 572 + </div> 573 + 574 + <div 575 + style=" 576 + border: 1px solid #ddd; 577 + padding: 10px; 578 + border-radius: 4px; 579 + background: #f8f9fa; 580 + " 581 + > 582 + <h5 style="margin: 0 0 10px 0">Custom Range</h5> 583 + <div 584 + style=" 585 + display: flex; 586 + gap: 5px; 587 + align-items: center; 588 + margin-bottom: 10px; 589 + " 590 + > 591 + <label style="font-size: 12px">From:</label> 592 + <input 593 + type="number" 594 + id="rangeStart" 595 + min="0" 596 + max="23" 597 + value="9" 598 + style="width: 50px; padding: 4px" 599 + /> 600 + <label style="font-size: 12px">To:</label> 601 + <input 602 + type="number" 603 + id="rangeEnd" 604 + min="0" 605 + max="23" 606 + value="11" 607 + style="width: 50px; padding: 4px" 608 + /> 609 + <button 610 + onclick="applyCurrentToRange()" 611 + class="btn btn-success" 612 + style="font-size: 11px" 613 + > 614 + Apply Current 615 + </button> 616 + </div> 617 + <div style="font-size: 11px; color: #666"> 618 + Uses current gradient & intensity settings for 619 + the specified hour range 620 + </div> 621 + </div> 622 + </div> 623 + </div> 624 + </div> 625 + </div> 626 + 627 + <div id="renderGallery" style="margin-top: 20px"> 628 + <h3>🖼️ Render Gallery</h3> 629 + <div 630 + style=" 631 + background: white; 632 + padding: 20px; 633 + border-radius: 8px; 634 + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 635 + " 636 + > 637 + <div 638 + style=" 639 + display: flex; 640 + justify-content: space-between; 641 + align-items: center; 642 + margin-bottom: 15px; 643 + flex-wrap: wrap; 644 + gap: 10px; 645 + " 646 + > 647 + <div id="galleryInfo" style="font-size: 14px; color: #666"> 648 + Ready to render timelines 649 + </div> 650 + <div style="display: flex; gap: 10px"> 651 + <button 652 + onclick="renderAllTimelines()" 653 + class="btn btn-success" 654 + > 655 + 🎨 Render All 656 + </button> 657 + <button 658 + onclick="downloadAllRendered()" 659 + class="btn btn-primary" 660 + id="downloadAllBtn" 661 + disabled 662 + > 663 + 💾 Download ZIP 664 + </button> 665 + <button onclick="clearGallery()" class="btn btn-danger"> 666 + 🗑️ Clear Gallery 667 + </button> 668 + </div> 669 + </div> 670 + 671 + <div 672 + id="bulkProgress" 673 + style="margin-bottom: 15px; display: none" 674 + > 675 + <div 676 + style=" 677 + background: #e9ecef; 678 + border-radius: 4px; 679 + overflow: hidden; 680 + " 681 + > 682 + <div 683 + id="progressBar" 684 + style=" 685 + background: #28a745; 686 + height: 20px; 687 + width: 0%; 688 + transition: width 0.3s; 689 + " 690 + ></div> 691 + </div> 692 + <div 693 + id="progressText" 694 + style=" 695 + font-size: 12px; 696 + text-align: center; 697 + margin-top: 5px; 698 + " 699 + > 700 + 0/0 701 + </div> 702 + </div> 703 + 704 + <div 705 + id="galleryContent" 706 + style=" 707 + display: grid; 708 + grid-template-columns: repeat( 709 + auto-fill, 710 + minmax(120px, 1fr) 711 + ); 712 + gap: 15px; 713 + min-height: 100px; 714 + border: 2px dashed #ddd; 715 + border-radius: 8px; 716 + padding: 20px; 717 + align-items: center; 718 + justify-content: center; 719 + " 720 + > 721 + <div 722 + id="galleryPlaceholder" 723 + style=" 724 + grid-column: 1 / -1; 725 + text-align: center; 726 + color: #999; 727 + font-style: italic; 728 + " 729 + > 730 + Click "Render All" to generate images and see them here 731 + </div> 732 + </div> 733 + </div> 734 + </div> 735 + 736 + <script> 737 + let baseImg = null; 738 + let matteImg = null; 739 + let selectedHour = 0; 740 + let currentTimeline = "sunny"; 741 + 742 + // Preset configurations 743 + const presets = { 744 + dawn: { 745 + color1: "#ff6b35", 746 + color2: "#f7931e", 747 + backgroundIntensity: 45, 748 + foregroundIntensity: 8, 749 + }, 750 + morning: { 751 + color1: "#87ceeb", 752 + color2: "#4682b4", 753 + backgroundIntensity: 35, 754 + foregroundIntensity: 5, 755 + }, 756 + afternoon: { 757 + color1: "#4682b4", 758 + color2: "#daa520", 759 + backgroundIntensity: 30, 760 + foregroundIntensity: 5, 761 + }, 762 + sunset: { 763 + color1: "#ff4500", 764 + color2: "#8b0000", 765 + backgroundIntensity: 50, 766 + foregroundIntensity: 10, 767 + }, 768 + night: { 769 + color1: "#191970", 770 + color2: "#000000", 771 + backgroundIntensity: 55, 772 + foregroundIntensity: 12, 773 + }, 774 + }; 775 + 776 + // Timeline data structure - initialize with proper presets 777 + let timelines = { 778 + sunny: {}, 779 + }; 780 + 781 + // Initialize the default sunny timeline with presets 782 + for (let hour = 0; hour < 24; hour++) { 783 + if (hour >= 5 && hour <= 7) { 784 + timelines.sunny[hour] = { ...presets.dawn }; 785 + } else if (hour >= 8 && hour <= 11) { 786 + timelines.sunny[hour] = { ...presets.morning }; 787 + } else if (hour >= 12 && hour <= 16) { 788 + timelines.sunny[hour] = { ...presets.afternoon }; 789 + } else if (hour >= 17 && hour <= 19) { 790 + timelines.sunny[hour] = { ...presets.sunset }; 791 + } else { 792 + timelines.sunny[hour] = { ...presets.night }; 793 + } 794 + } 795 + 796 + // Default hour configuration 797 + const defaultHourConfig = { 798 + color1: "#4682b4", 799 + color2: "#87ceeb", 800 + backgroundIntensity: 40, 801 + foregroundIntensity: 8, 802 + }; 803 + 804 + function initializeTimeline() { 805 + // Create hour slots 806 + const grid = document.getElementById("timelineGrid"); 807 + grid.innerHTML = ""; 808 + 809 + for (let hour = 0; hour < 24; hour++) { 810 + const slot = document.createElement("div"); 811 + slot.className = "hour-slot"; 812 + slot.dataset.hour = hour; 813 + slot.textContent = hour; 814 + slot.onclick = () => selectHour(hour); 815 + 816 + // Initialize with appropriate preset if not exists 817 + if (!timelines[currentTimeline][hour]) { 818 + timelines[currentTimeline][hour] = 819 + getDefaultConfigForHour(hour); 820 + } 821 + 822 + grid.appendChild(slot); 823 + } 824 + 825 + updateTimelineDisplay(); 826 + selectHour(0); 827 + } 828 + 829 + function getDefaultConfigForHour(hour) { 830 + // Apply appropriate preset based on hour 831 + if (hour >= 5 && hour <= 7) { 832 + return { ...presets.dawn }; 833 + } else if (hour >= 8 && hour <= 11) { 834 + return { ...presets.morning }; 835 + } else if (hour >= 12 && hour <= 16) { 836 + return { ...presets.afternoon }; 837 + } else if (hour >= 17 && hour <= 19) { 838 + return { ...presets.sunset }; 839 + } else { 840 + return { ...presets.night }; 841 + } 842 + } 843 + 844 + function selectHour(hour) { 845 + selectedHour = hour; 846 + 847 + // Update UI selection 848 + document.querySelectorAll(".hour-slot").forEach((slot) => { 849 + slot.classList.remove("selected"); 850 + }); 851 + document 852 + .querySelector(`[data-hour="${hour}"]`) 853 + .classList.add("selected"); 854 + 855 + // Load hour configuration 856 + const config = timelines[currentTimeline][hour] || { 857 + ...defaultHourConfig, 858 + }; 859 + document.getElementById("color1").value = config.color1; 860 + document.getElementById("color2").value = config.color2; 861 + document.getElementById("bgIntensity").value = 862 + config.backgroundIntensity; 863 + document.getElementById("fgIntensity").value = 864 + config.foregroundIntensity; 865 + 866 + // Update displays 867 + document.getElementById("bgValue").textContent = 868 + config.backgroundIntensity + "%"; 869 + document.getElementById("fgValue").textContent = 870 + config.foregroundIntensity + "%"; 871 + document.getElementById("hourInfo").textContent = 872 + `Configuring hour ${hour} (${hour === 0 ? "12" : hour > 12 ? hour - 12 : hour}${hour < 12 ? "AM" : "PM"})`; 873 + 874 + updatePreview(); 875 + } 876 + 877 + function applyToSelectedHour() { 878 + const config = { 879 + color1: document.getElementById("color1").value, 880 + color2: document.getElementById("color2").value, 881 + backgroundIntensity: parseInt( 882 + document.getElementById("bgIntensity").value, 883 + ), 884 + foregroundIntensity: parseInt( 885 + document.getElementById("fgIntensity").value, 886 + ), 887 + }; 888 + 889 + timelines[currentTimeline][selectedHour] = config; 890 + updateTimelineDisplay(); 891 + showStatus("Hour " + selectedHour + " updated!", "success"); 892 + } 893 + 894 + function updateTimelineDisplay() { 895 + document.querySelectorAll(".hour-slot").forEach((slot) => { 896 + const hour = parseInt(slot.dataset.hour); 897 + const config = timelines[currentTimeline][hour]; 898 + 899 + if (config) { 900 + const gradient = `linear-gradient(135deg, ${config.color1}, ${config.color2})`; 901 + slot.style.background = gradient; 902 + slot.style.color = "white"; 903 + slot.style.textShadow = "1px 1px 2px rgba(0,0,0,0.8)"; 904 + } else { 905 + slot.style.background = "#f8f9fa"; 906 + slot.style.color = "#666"; 907 + slot.style.textShadow = "none"; 908 + } 909 + }); 910 + } 911 + 912 + function createTimeline() { 913 + const name = document 914 + .getElementById("newTimelineName") 915 + .value.trim(); 916 + if (!name) { 917 + showStatus("Please enter a timeline name!", "error"); 918 + return; 919 + } 920 + 921 + if (timelines[name]) { 922 + showStatus("Timeline already exists!", "error"); 923 + return; 924 + } 925 + 926 + // Create new timeline with proper presets for each hour 927 + timelines[name] = {}; 928 + for (let hour = 0; hour < 24; hour++) { 929 + timelines[name][hour] = getDefaultConfigForHour(hour); 930 + } 931 + 932 + // Add tab 933 + const tab = document.createElement("div"); 934 + tab.className = "timeline-tab"; 935 + tab.dataset.timeline = name; 936 + tab.textContent = name; 937 + tab.onclick = () => switchTimeline(name); 938 + document.getElementById("timelineTabs").appendChild(tab); 939 + 940 + // Switch to new timeline 941 + switchTimeline(name); 942 + document.getElementById("newTimelineName").value = ""; 943 + showStatus( 944 + `Timeline "${name}" created with default presets!`, 945 + "success", 946 + ); 947 + } 948 + 949 + function switchTimeline(timelineName) { 950 + currentTimeline = timelineName; 951 + 952 + // Update tab selection 953 + document.querySelectorAll(".timeline-tab").forEach((tab) => { 954 + tab.classList.remove("active"); 955 + }); 956 + document 957 + .querySelector(`[data-timeline="${timelineName}"]`) 958 + .classList.add("active"); 959 + 960 + document.getElementById("currentTimelineName").textContent = 961 + timelineName; 962 + updateTimelineDisplay(); 963 + selectHour(selectedHour); 964 + } 965 + 966 + function duplicateTimeline() { 967 + const newName = prompt( 968 + `Enter name for copy of "${currentTimeline}":`, 969 + ); 970 + if (!newName || timelines[newName]) { 971 + showStatus( 972 + "Invalid name or timeline already exists!", 973 + "error", 974 + ); 975 + return; 976 + } 977 + 978 + // Deep copy current timeline 979 + timelines[newName] = JSON.parse( 980 + JSON.stringify(timelines[currentTimeline]), 981 + ); 982 + 983 + // Add tab 984 + const tab = document.createElement("div"); 985 + tab.className = "timeline-tab"; 986 + tab.dataset.timeline = newName; 987 + tab.textContent = newName; 988 + tab.onclick = () => switchTimeline(newName); 989 + document.getElementById("timelineTabs").appendChild(tab); 990 + 991 + switchTimeline(newName); 992 + showStatus(`Timeline "${newName}" created as copy!`, "success"); 993 + } 994 + 995 + function deleteTimeline() { 996 + if (Object.keys(timelines).length <= 1) { 997 + showStatus("Cannot delete the last timeline!", "error"); 998 + return; 999 + } 1000 + 1001 + if (!confirm(`Delete timeline "${currentTimeline}"?`)) return; 1002 + 1003 + // Remove timeline 1004 + delete timelines[currentTimeline]; 1005 + 1006 + // Remove tab 1007 + document 1008 + .querySelector(`[data-timeline="${currentTimeline}"]`) 1009 + .remove(); 1010 + 1011 + // Switch to first available timeline 1012 + const firstTimeline = Object.keys(timelines)[0]; 1013 + switchTimeline(firstTimeline); 1014 + 1015 + showStatus(`Timeline "${currentTimeline}" deleted!`, "success"); 1016 + } 1017 + 1018 + function loadPreset(presetName, hours) { 1019 + // Get current UI settings 1020 + const config = { 1021 + color1: document.getElementById("color1").value, 1022 + color2: document.getElementById("color2").value, 1023 + backgroundIntensity: parseInt( 1024 + document.getElementById("bgIntensity").value, 1025 + ), 1026 + foregroundIntensity: parseInt( 1027 + document.getElementById("fgIntensity").value, 1028 + ), 1029 + }; 1030 + 1031 + // Apply current settings to specified hours 1032 + hours.forEach((hour) => { 1033 + timelines[currentTimeline][hour] = { ...config }; 1034 + }); 1035 + 1036 + updateTimelineDisplay(); 1037 + showStatus( 1038 + `Applied current settings to ${presetName} hours: ${hours.join(", ")}`, 1039 + "success", 1040 + ); 1041 + } 1042 + 1043 + function applyCurrentToRange() { 1044 + const start = parseInt( 1045 + document.getElementById("rangeStart").value, 1046 + ); 1047 + const end = parseInt(document.getElementById("rangeEnd").value); 1048 + 1049 + if (start < 0 || start > 23 || end < 0 || end > 23) { 1050 + showStatus("Hours must be between 0 and 23!", "error"); 1051 + return; 1052 + } 1053 + 1054 + const config = { 1055 + color1: document.getElementById("color1").value, 1056 + color2: document.getElementById("color2").value, 1057 + backgroundIntensity: parseInt( 1058 + document.getElementById("bgIntensity").value, 1059 + ), 1060 + foregroundIntensity: parseInt( 1061 + document.getElementById("fgIntensity").value, 1062 + ), 1063 + }; 1064 + 1065 + // Generate hour range (handle wrap-around) 1066 + let hours = []; 1067 + if (start <= end) { 1068 + for (let i = start; i <= end; i++) { 1069 + hours.push(i); 1070 + } 1071 + } else { 1072 + // Wrap around (e.g., 22 to 2 = 22,23,0,1,2) 1073 + for (let i = start; i <= 23; i++) { 1074 + hours.push(i); 1075 + } 1076 + for (let i = 0; i <= end; i++) { 1077 + hours.push(i); 1078 + } 1079 + } 1080 + 1081 + // Apply config to all hours in range 1082 + hours.forEach((hour) => { 1083 + timelines[currentTimeline][hour] = { ...config }; 1084 + }); 1085 + 1086 + updateTimelineDisplay(); 1087 + showStatus( 1088 + `Applied current settings to hours: ${hours.join(", ")}`, 1089 + "success", 1090 + ); 1091 + } 1092 + 1093 + function copyAllTimelines() { 1094 + const config = { 1095 + timelines: timelines, 1096 + metadata: { 1097 + created: new Date().toISOString(), 1098 + tool: "Sky Gradient Timeline Builder", 1099 + }, 1100 + }; 1101 + 1102 + const configText = JSON.stringify(config, null, 2); 1103 + document.getElementById("configOutput").value = configText; 1104 + showStatus("All timelines ready to copy!", "success"); 1105 + } 1106 + 1107 + function renderCurrentHour() { 1108 + if (!baseImg || !matteImg) { 1109 + showStatus("Please upload both images first!", "error"); 1110 + return; 1111 + } 1112 + 1113 + const color1 = document.getElementById("color1").value; 1114 + const color2 = document.getElementById("color2").value; 1115 + const bgIntensity = parseInt( 1116 + document.getElementById("bgIntensity").value, 1117 + ); 1118 + const fgIntensity = parseInt( 1119 + document.getElementById("fgIntensity").value, 1120 + ); 1121 + 1122 + // Use the full-size render canvas 1123 + const canvas = document.getElementById("renderCanvas"); 1124 + const ctx = canvas.getContext("2d"); 1125 + 1126 + // Set canvas size to match base image 1127 + canvas.width = baseImg.width; 1128 + canvas.height = baseImg.height; 1129 + 1130 + // Create gradient 1131 + const gradient = createGradient( 1132 + canvas.width, 1133 + canvas.height, 1134 + color1, 1135 + color2, 1136 + ); 1137 + 1138 + // Clear canvas 1139 + ctx.clearRect(0, 0, canvas.width, canvas.height); 1140 + 1141 + // Create background layer 1142 + const backgroundLayer = blendImages( 1143 + baseImg, 1144 + gradient, 1145 + bgIntensity, 1146 + ); 1147 + ctx.drawImage(backgroundLayer, 0, 0); 1148 + 1149 + // Create and apply foreground layer 1150 + let foregroundLayer; 1151 + if (fgIntensity === 0) { 1152 + foregroundLayer = baseImg; 1153 + } else { 1154 + foregroundLayer = blendImages( 1155 + baseImg, 1156 + gradient, 1157 + fgIntensity, 1158 + ); 1159 + } 1160 + 1161 + // Apply masking 1162 + const maskedForeground = document.createElement("canvas"); 1163 + maskedForeground.width = canvas.width; 1164 + maskedForeground.height = canvas.height; 1165 + const maskCtx = maskedForeground.getContext("2d"); 1166 + 1167 + maskCtx.drawImage(foregroundLayer, 0, 0); 1168 + 1169 + // Create proper alpha mask 1170 + const matteDataCanvas = document.createElement("canvas"); 1171 + matteDataCanvas.width = matteImg.width; 1172 + matteDataCanvas.height = matteImg.height; 1173 + const matteDataCtx = matteDataCanvas.getContext("2d"); 1174 + matteDataCtx.drawImage(matteImg, 0, 0); 1175 + 1176 + const imageData = matteDataCtx.getImageData( 1177 + 0, 1178 + 0, 1179 + matteImg.width, 1180 + matteImg.height, 1181 + ); 1182 + const data = imageData.data; 1183 + 1184 + for (let i = 0; i < data.length; i += 4) { 1185 + const brightness = 1186 + (data[i] + data[i + 1] + data[i + 2]) / 3; 1187 + if (brightness > 128) { 1188 + data[i + 3] = 255; 1189 + } else { 1190 + data[i + 3] = 0; 1191 + } 1192 + data[i] = 255; 1193 + data[i + 1] = 255; 1194 + data[i + 2] = 255; 1195 + } 1196 + 1197 + matteDataCtx.putImageData(imageData, 0, 0); 1198 + 1199 + maskCtx.globalCompositeOperation = "destination-in"; 1200 + maskCtx.drawImage( 1201 + matteDataCanvas, 1202 + 0, 1203 + 0, 1204 + canvas.width, 1205 + canvas.height, 1206 + ); 1207 + 1208 + ctx.globalCompositeOperation = "source-over"; 1209 + ctx.drawImage(maskedForeground, 0, 0); 1210 + 1211 + // Enable download button 1212 + document.getElementById("downloadBtn").disabled = false; 1213 + showStatus( 1214 + `Hour ${selectedHour} rendered at full resolution!`, 1215 + "success", 1216 + ); 1217 + } 1218 + 1219 + function downloadRendered() { 1220 + const canvas = document.getElementById("renderCanvas"); 1221 + if (canvas.width === 400 && canvas.height === 400) { 1222 + showStatus("Please render first!", "error"); 1223 + return; 1224 + } 1225 + 1226 + // Create download 1227 + const link = document.createElement("a"); 1228 + const timelineName = currentTimeline; 1229 + const hour = selectedHour.toString().padStart(2, "0"); 1230 + link.download = `${timelineName}_hour_${hour}.jpg`; 1231 + 1232 + // Convert to JPEG for smaller file size 1233 + link.href = canvas.toDataURL("image/jpeg", 0.95); 1234 + link.click(); 1235 + 1236 + showStatus(`Downloaded: ${link.download}`, "success"); 1237 + } 1238 + 1239 + // Auto-import on paste 1240 + document 1241 + .getElementById("bulkConfigInput") 1242 + .addEventListener("paste", function (e) { 1243 + // Small delay to let the paste complete 1244 + setTimeout(() => { 1245 + const configText = this.value.trim(); 1246 + if (configText && configText.startsWith("{")) { 1247 + importConfigToTimelines(); 1248 + } 1249 + }, 100); 1250 + }); 1251 + 1252 + // Bulk rendering 1253 + let renderedImages = {}; 1254 + 1255 + function renderAllTimelines() { 1256 + if (!baseImg || !matteImg) { 1257 + showStatus("Please upload both images first!", "error"); 1258 + return; 1259 + } 1260 + 1261 + if (Object.keys(timelines).length === 0) { 1262 + showStatus("No timelines to render!", "error"); 1263 + return; 1264 + } 1265 + 1266 + // Clear previous renders 1267 + renderedImages = {}; 1268 + 1269 + // Show progress bar 1270 + document.getElementById("bulkProgress").style.display = "block"; 1271 + document.getElementById("downloadAllBtn").disabled = true; 1272 + 1273 + // Clear gallery 1274 + document.getElementById("galleryContent").innerHTML = ""; 1275 + 1276 + // Count total hours to render 1277 + const timelineNames = Object.keys(timelines); 1278 + let totalHours = 0; 1279 + let currentHour = 0; 1280 + 1281 + timelineNames.forEach((timelineName) => { 1282 + const hours = Object.keys(timelines[timelineName]); 1283 + totalHours += hours.length; 1284 + }); 1285 + 1286 + updateProgress(0, totalHours); 1287 + updateGalleryInfo(0, totalHours, timelineNames.length); 1288 + showStatus( 1289 + `Rendering all timelines: ${timelineNames.length} timelines, ${totalHours} hours total`, 1290 + "success", 1291 + ); 1292 + 1293 + // Render all timelines sequentially 1294 + let timelineIndex = 0; 1295 + 1296 + function renderNextTimeline() { 1297 + if (timelineIndex >= timelineNames.length) { 1298 + // All done! 1299 + document.getElementById("downloadAllBtn").disabled = 1300 + false; 1301 + showStatus( 1302 + `Render complete! ${totalHours} images rendered.`, 1303 + "success", 1304 + ); 1305 + return; 1306 + } 1307 + 1308 + const timelineName = timelineNames[timelineIndex]; 1309 + const timelineConfig = timelines[timelineName]; 1310 + const hours = Object.keys(timelineConfig).sort( 1311 + (a, b) => parseInt(a) - parseInt(b), 1312 + ); 1313 + 1314 + renderedImages[timelineName] = {}; 1315 + 1316 + let hourIndex = 0; 1317 + 1318 + function renderNextHour() { 1319 + if (hourIndex >= hours.length) { 1320 + // Timeline done, move to next 1321 + timelineIndex++; 1322 + setTimeout(renderNextTimeline, 10); 1323 + return; 1324 + } 1325 + 1326 + const hour = hours[hourIndex]; 1327 + const hourConfig = timelineConfig[hour]; 1328 + 1329 + // Render this hour 1330 + const imageData = renderHourToDataURL(hourConfig); 1331 + if (imageData) { 1332 + renderedImages[timelineName][hour] = imageData; 1333 + currentHour++; 1334 + updateProgress(currentHour, totalHours); 1335 + updateGalleryInfo( 1336 + currentHour, 1337 + totalHours, 1338 + timelineNames.length, 1339 + ); 1340 + 1341 + // Add to gallery 1342 + addToGallery(timelineName, hour, imageData); 1343 + } 1344 + 1345 + hourIndex++; 1346 + // Small delay to keep UI responsive 1347 + setTimeout(renderNextHour, 100); 1348 + } 1349 + 1350 + renderNextHour(); 1351 + } 1352 + 1353 + renderNextTimeline(); 1354 + } 1355 + 1356 + function addToGallery(timelineName, hour, imageDataURL) { 1357 + const gallery = document.getElementById("galleryContent"); 1358 + 1359 + // Remove placeholder if it exists 1360 + const placeholder = 1361 + document.getElementById("galleryPlaceholder"); 1362 + if (placeholder) { 1363 + placeholder.remove(); 1364 + // Reset gallery styles 1365 + gallery.style.minHeight = "auto"; 1366 + gallery.style.border = "none"; 1367 + gallery.style.alignItems = "stretch"; 1368 + gallery.style.justifyContent = "stretch"; 1369 + } 1370 + 1371 + const item = document.createElement("div"); 1372 + item.style.cssText = ` 1373 + border: 1px solid #ddd; 1374 + border-radius: 8px; 1375 + overflow: hidden; 1376 + background: white; 1377 + transition: transform 0.2s, box-shadow 0.2s; 1378 + cursor: pointer; 1379 + `; 1380 + 1381 + const hourPadded = hour.toString().padStart(2, "0"); 1382 + 1383 + item.innerHTML = ` 1384 + <img src="${imageDataURL}" style="width: 100%; height: 80px; object-fit: cover;"> 1385 + <div style="padding: 8px; text-align: center;"> 1386 + <div style="font-size: 11px; font-weight: bold; color: #333;">${timelineName}</div> 1387 + <div style="font-size: 10px; color: #666;">Hour ${hourPadded}</div> 1388 + </div> 1389 + `; 1390 + 1391 + // Add hover effect 1392 + item.addEventListener("mouseenter", () => { 1393 + item.style.transform = "scale(1.05)"; 1394 + item.style.boxShadow = "0 4px 15px rgba(0,0,0,0.2)"; 1395 + }); 1396 + 1397 + item.addEventListener("mouseleave", () => { 1398 + item.style.transform = "scale(1)"; 1399 + item.style.boxShadow = "none"; 1400 + }); 1401 + 1402 + // Click to download individual image 1403 + item.addEventListener("click", () => { 1404 + const link = document.createElement("a"); 1405 + link.href = imageDataURL; 1406 + link.download = `${timelineName}_hour_${hourPadded}.jpg`; 1407 + link.click(); 1408 + showStatus( 1409 + `Downloaded ${timelineName} hour ${hourPadded}`, 1410 + "success", 1411 + ); 1412 + }); 1413 + 1414 + gallery.appendChild(item); 1415 + } 1416 + 1417 + function updateGalleryInfo(current, total, timelineCount) { 1418 + const info = document.getElementById("galleryInfo"); 1419 + info.textContent = `${current}/${total} images rendered across ${timelineCount} timeline(s)`; 1420 + } 1421 + 1422 + function clearGallery() { 1423 + const gallery = document.getElementById("galleryContent"); 1424 + gallery.innerHTML = ""; 1425 + 1426 + // Reset gallery to placeholder state 1427 + gallery.style.minHeight = "100px"; 1428 + gallery.style.border = "2px dashed #ddd"; 1429 + gallery.style.alignItems = "center"; 1430 + gallery.style.justifyContent = "center"; 1431 + gallery.style.padding = "20px"; 1432 + 1433 + // Add placeholder back 1434 + const placeholder = document.createElement("div"); 1435 + placeholder.id = "galleryPlaceholder"; 1436 + placeholder.style.cssText = 1437 + "grid-column: 1 / -1; text-align: center; color: #999; font-style: italic;"; 1438 + placeholder.textContent = 1439 + 'Click "Render All" to generate images and see them here'; 1440 + gallery.appendChild(placeholder); 1441 + 1442 + // Reset state 1443 + renderedImages = {}; 1444 + document.getElementById("downloadAllBtn").disabled = true; 1445 + document.getElementById("galleryInfo").textContent = 1446 + "Ready to render timelines"; 1447 + showStatus("Gallery cleared", "success"); 1448 + } 1449 + 1450 + function renderAllFromConfig() { 1451 + if (!baseImg || !matteImg) { 1452 + showStatus("Please upload both images first!", "error"); 1453 + return; 1454 + } 1455 + 1456 + const configText = document 1457 + .getElementById("bulkConfigInput") 1458 + .value.trim(); 1459 + if (!configText) { 1460 + showStatus("Please paste a config to render!", "error"); 1461 + return; 1462 + } 1463 + 1464 + let config; 1465 + try { 1466 + config = JSON.parse(configText); 1467 + } catch (e) { 1468 + showStatus("Invalid JSON config!", "error"); 1469 + return; 1470 + } 1471 + 1472 + // Validate config structure 1473 + if (!config.timelines) { 1474 + showStatus( 1475 + 'Config must have "timelines" property!', 1476 + "error", 1477 + ); 1478 + return; 1479 + } 1480 + 1481 + // Clear previous renders 1482 + renderedImages = {}; 1483 + 1484 + // Show progress bar 1485 + document.getElementById("bulkProgress").style.display = "block"; 1486 + document.getElementById("downloadAllBtn").disabled = true; 1487 + 1488 + // Count total hours to render 1489 + const timelines = Object.keys(config.timelines); 1490 + let totalHours = 0; 1491 + let currentHour = 0; 1492 + 1493 + timelines.forEach((timeline) => { 1494 + const hours = Object.keys(config.timelines[timeline]); 1495 + totalHours += hours.length; 1496 + }); 1497 + 1498 + updateProgress(0, totalHours); 1499 + showStatus( 1500 + `Starting bulk render: ${timelines.length} timelines, ${totalHours} hours total`, 1501 + "success", 1502 + ); 1503 + 1504 + // Render all timelines sequentially with small delays for UI responsiveness 1505 + let timelineIndex = 0; 1506 + 1507 + function renderNextTimeline() { 1508 + if (timelineIndex >= timelines.length) { 1509 + // All done! 1510 + document.getElementById("downloadAllBtn").disabled = 1511 + false; 1512 + showStatus( 1513 + `Bulk render complete! ${totalHours} images rendered.`, 1514 + "success", 1515 + ); 1516 + return; 1517 + } 1518 + 1519 + const timelineName = timelines[timelineIndex]; 1520 + const timelineConfig = config.timelines[timelineName]; 1521 + const hours = Object.keys(timelineConfig); 1522 + 1523 + renderedImages[timelineName] = {}; 1524 + 1525 + let hourIndex = 0; 1526 + 1527 + function renderNextHour() { 1528 + if (hourIndex >= hours.length) { 1529 + // Timeline done, move to next 1530 + timelineIndex++; 1531 + setTimeout(renderNextTimeline, 10); 1532 + return; 1533 + } 1534 + 1535 + const hour = hours[hourIndex]; 1536 + const hourConfig = timelineConfig[hour]; 1537 + 1538 + // Render this hour 1539 + const imageData = renderHourToDataURL(hourConfig); 1540 + if (imageData) { 1541 + renderedImages[timelineName][hour] = imageData; 1542 + currentHour++; 1543 + updateProgress(currentHour, totalHours); 1544 + } 1545 + 1546 + hourIndex++; 1547 + // Small delay to keep UI responsive 1548 + setTimeout(renderNextHour, 50); 1549 + } 1550 + 1551 + renderNextHour(); 1552 + } 1553 + 1554 + renderNextTimeline(); 1555 + } 1556 + 1557 + function renderHourToDataURL(config) { 1558 + try { 1559 + // Create a temporary canvas for this render 1560 + const canvas = document.createElement("canvas"); 1561 + const ctx = canvas.getContext("2d"); 1562 + 1563 + // Set canvas size to match base image 1564 + canvas.width = baseImg.width; 1565 + canvas.height = baseImg.height; 1566 + 1567 + // Create gradient 1568 + const gradient = createGradient( 1569 + canvas.width, 1570 + canvas.height, 1571 + config.color1, 1572 + config.color2, 1573 + ); 1574 + 1575 + // Clear canvas 1576 + ctx.clearRect(0, 0, canvas.width, canvas.height); 1577 + 1578 + // Create background layer 1579 + const backgroundLayer = blendImages( 1580 + baseImg, 1581 + gradient, 1582 + config.backgroundIntensity, 1583 + ); 1584 + ctx.drawImage(backgroundLayer, 0, 0); 1585 + 1586 + // Create and apply foreground layer 1587 + let foregroundLayer; 1588 + if (config.foregroundIntensity === 0) { 1589 + foregroundLayer = baseImg; 1590 + } else { 1591 + foregroundLayer = blendImages( 1592 + baseImg, 1593 + gradient, 1594 + config.foregroundIntensity, 1595 + ); 1596 + } 1597 + 1598 + // Apply masking 1599 + const maskedForeground = document.createElement("canvas"); 1600 + maskedForeground.width = canvas.width; 1601 + maskedForeground.height = canvas.height; 1602 + const maskCtx = maskedForeground.getContext("2d"); 1603 + 1604 + maskCtx.drawImage(foregroundLayer, 0, 0); 1605 + 1606 + // Create proper alpha mask 1607 + const matteDataCanvas = document.createElement("canvas"); 1608 + matteDataCanvas.width = matteImg.width; 1609 + matteDataCanvas.height = matteImg.height; 1610 + const matteDataCtx = matteDataCanvas.getContext("2d"); 1611 + matteDataCtx.drawImage(matteImg, 0, 0); 1612 + 1613 + const imageData = matteDataCtx.getImageData( 1614 + 0, 1615 + 0, 1616 + matteImg.width, 1617 + matteImg.height, 1618 + ); 1619 + const data = imageData.data; 1620 + 1621 + for (let i = 0; i < data.length; i += 4) { 1622 + const brightness = 1623 + (data[i] + data[i + 1] + data[i + 2]) / 3; 1624 + if (brightness > 128) { 1625 + data[i + 3] = 255; 1626 + } else { 1627 + data[i + 3] = 0; 1628 + } 1629 + data[i] = 255; 1630 + data[i + 1] = 255; 1631 + data[i + 2] = 255; 1632 + } 1633 + 1634 + matteDataCtx.putImageData(imageData, 0, 0); 1635 + 1636 + maskCtx.globalCompositeOperation = "destination-in"; 1637 + maskCtx.drawImage( 1638 + matteDataCanvas, 1639 + 0, 1640 + 0, 1641 + canvas.width, 1642 + canvas.height, 1643 + ); 1644 + 1645 + ctx.globalCompositeOperation = "source-over"; 1646 + ctx.drawImage(maskedForeground, 0, 0); 1647 + 1648 + // Return as JPEG data URL 1649 + return canvas.toDataURL("image/jpeg", 0.95); 1650 + } catch (e) { 1651 + console.error("Failed to render hour:", e); 1652 + return null; 1653 + } 1654 + } 1655 + 1656 + function updateProgress(current, total) { 1657 + const percentage = total > 0 ? (current / total) * 100 : 0; 1658 + document.getElementById("progressBar").style.width = 1659 + percentage + "%"; 1660 + document.getElementById("progressText").textContent = 1661 + `${current}/${total}`; 1662 + } 1663 + 1664 + async function downloadAllRendered() { 1665 + if (Object.keys(renderedImages).length === 0) { 1666 + showStatus("No rendered images to download!", "error"); 1667 + return; 1668 + } 1669 + 1670 + showStatus("Preparing download...", "success"); 1671 + 1672 + // Simple approach: create individual downloads if ZIP fails 1673 + try { 1674 + // Try to load JSZip if not available 1675 + if (typeof JSZip === "undefined") { 1676 + showStatus("Loading ZIP library...", "success"); 1677 + await loadJSZip(); 1678 + } 1679 + 1680 + await createZipDownload(); 1681 + } catch (e) { 1682 + console.error("ZIP download failed:", e); 1683 + showStatus( 1684 + "ZIP failed, downloading individual files...", 1685 + "warning", 1686 + ); 1687 + downloadIndividualFiles(); 1688 + } 1689 + } 1690 + 1691 + function loadJSZip() { 1692 + return new Promise((resolve, reject) => { 1693 + const script = document.createElement("script"); 1694 + script.src = 1695 + "https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"; 1696 + script.onload = resolve; 1697 + script.onerror = reject; 1698 + document.head.appendChild(script); 1699 + }); 1700 + } 1701 + 1702 + async function createZipDownload() { 1703 + const zip = new JSZip(); 1704 + 1705 + // First, fetch and add the shell script to the root 1706 + try { 1707 + showStatus("Including shell script...", "success"); 1708 + try { 1709 + // Try local copy first, then relative path, then original source as fallback 1710 + const scriptUrls = ["/pfp-updates/bsky-pfp-updates.sh"]; 1711 + 1712 + let scriptContent = null; 1713 + for (const url of scriptUrls) { 1714 + try { 1715 + const scriptResponse = await fetch(url); 1716 + if (scriptResponse.ok) { 1717 + scriptContent = await scriptResponse.text(); 1718 + break; 1719 + } 1720 + } catch (err) { 1721 + console.log( 1722 + `Failed to fetch from ${url}:`, 1723 + err, 1724 + ); 1725 + // Continue to next URL 1726 + } 1727 + } 1728 + 1729 + if (scriptContent) { 1730 + zip.file("bsky-pfp-updates.sh", scriptContent); 1731 + } else { 1732 + throw new Error( 1733 + "Could not load script from any source", 1734 + ); 1735 + } 1736 + } catch (error) { 1737 + showStatus( 1738 + "Warning: Could not include shell script, continuing without it...", 1739 + "warning", 1740 + ); 1741 + console.error("Script loading error:", error); 1742 + } 1743 + } catch (e) { 1744 + console.error("Failed to fetch shell script:", e); 1745 + showStatus( 1746 + "Warning: Could not fetch shell script, continuing without it...", 1747 + "warning", 1748 + ); 1749 + } 1750 + 1751 + // Add the timeline config to the root 1752 + const config = { 1753 + timelines: timelines, 1754 + metadata: { 1755 + created: new Date().toISOString(), 1756 + tool: "Sky Gradient Timeline Builder", 1757 + version: "1.0", 1758 + }, 1759 + }; 1760 + const configText = JSON.stringify(config, null, 2); 1761 + zip.file("timeline_config.json", configText); 1762 + 1763 + // Create rendered_timelines folder and add all images 1764 + const renderedFolder = zip.folder("rendered_timelines"); 1765 + 1766 + for (const [timelineName, timelineImages] of Object.entries( 1767 + renderedImages, 1768 + )) { 1769 + const timelineFolder = renderedFolder.folder(timelineName); 1770 + 1771 + for (const [hour, imageDataURL] of Object.entries( 1772 + timelineImages, 1773 + )) { 1774 + // Convert data URL to binary data 1775 + const base64Data = imageDataURL.split(",")[1]; 1776 + const binaryData = atob(base64Data); 1777 + const bytes = new Uint8Array(binaryData.length); 1778 + for (let i = 0; i < binaryData.length; i++) { 1779 + bytes[i] = binaryData.charCodeAt(i); 1780 + } 1781 + 1782 + const hourPadded = hour.padStart(2, "0"); 1783 + timelineFolder.file(`hour_${hourPadded}.jpg`, bytes); 1784 + } 1785 + } 1786 + 1787 + // Generate and download ZIP 1788 + showStatus("Creating ZIP file...", "success"); 1789 + const content = await zip.generateAsync({ 1790 + type: "blob", 1791 + compression: "DEFLATE", 1792 + compressionOptions: { level: 6 }, 1793 + }); 1794 + 1795 + // Create download link 1796 + const link = document.createElement("a"); 1797 + const url = URL.createObjectURL(content); 1798 + link.href = url; 1799 + link.download = "bluesky-pfp-updates.zip"; 1800 + 1801 + // Trigger download 1802 + document.body.appendChild(link); 1803 + link.click(); 1804 + document.body.removeChild(link); 1805 + 1806 + // Cleanup 1807 + setTimeout(() => URL.revokeObjectURL(url), 1000); 1808 + 1809 + showStatus("ZIP file downloaded with shell script!", "success"); 1810 + } 1811 + 1812 + function importConfigToTimelines() { 1813 + const configText = document 1814 + .getElementById("bulkConfigInput") 1815 + .value.trim(); 1816 + if (!configText) { 1817 + showStatus("Please paste a config to import!", "error"); 1818 + return; 1819 + } 1820 + 1821 + let config; 1822 + try { 1823 + config = JSON.parse(configText); 1824 + } catch (e) { 1825 + showStatus("Invalid JSON config!", "error"); 1826 + return; 1827 + } 1828 + 1829 + // Validate config structure 1830 + if (!config.timelines) { 1831 + showStatus( 1832 + 'Config must have "timelines" property!', 1833 + "error", 1834 + ); 1835 + return; 1836 + } 1837 + 1838 + // Clear existing timelines (except keep one if empty) 1839 + const wasEmpty = Object.keys(timelines).length === 0; 1840 + timelines = {}; 1841 + 1842 + // Clear existing tabs 1843 + document.getElementById("timelineTabs").innerHTML = ""; 1844 + 1845 + // Import all timelines from config 1846 + const importedTimelines = Object.keys(config.timelines); 1847 + let importedCount = 0; 1848 + 1849 + for (const [timelineName, timelineConfig] of Object.entries( 1850 + config.timelines, 1851 + )) { 1852 + // Validate timeline structure 1853 + if (typeof timelineConfig !== "object") { 1854 + showStatus( 1855 + `Invalid timeline structure for "${timelineName}"`, 1856 + "warning", 1857 + ); 1858 + continue; 1859 + } 1860 + 1861 + // Convert timeline config to our format 1862 + timelines[timelineName] = {}; 1863 + 1864 + for (const [hour, hourConfig] of Object.entries( 1865 + timelineConfig, 1866 + )) { 1867 + const hourNum = parseInt(hour); 1868 + if ( 1869 + hourNum >= 0 && 1870 + hourNum <= 23 && 1871 + hourConfig.color1 && 1872 + hourConfig.color2 1873 + ) { 1874 + timelines[timelineName][hourNum] = { 1875 + color1: hourConfig.color1, 1876 + color2: hourConfig.color2, 1877 + backgroundIntensity: 1878 + hourConfig.backgroundIntensity || 40, 1879 + foregroundIntensity: 1880 + hourConfig.foregroundIntensity || 8, 1881 + }; 1882 + } 1883 + } 1884 + 1885 + // Create tab for this timeline 1886 + const tab = document.createElement("div"); 1887 + tab.className = "timeline-tab"; 1888 + tab.dataset.timeline = timelineName; 1889 + tab.textContent = timelineName; 1890 + tab.onclick = () => switchTimeline(timelineName); 1891 + document.getElementById("timelineTabs").appendChild(tab); 1892 + 1893 + importedCount++; 1894 + } 1895 + 1896 + if (importedCount === 0) { 1897 + showStatus("No valid timelines found in config!", "error"); 1898 + // Restore default if nothing imported 1899 + timelines.sunny = {}; 1900 + for (let hour = 0; hour < 24; hour++) { 1901 + timelines.sunny[hour] = getDefaultConfigForHour(hour); 1902 + } 1903 + const tab = document.createElement("div"); 1904 + tab.className = "timeline-tab active"; 1905 + tab.dataset.timeline = "sunny"; 1906 + tab.textContent = "sunny"; 1907 + tab.onclick = () => switchTimeline("sunny"); 1908 + document.getElementById("timelineTabs").appendChild(tab); 1909 + currentTimeline = "sunny"; 1910 + } else { 1911 + // Switch to first imported timeline 1912 + const firstTimeline = importedTimelines[0]; 1913 + currentTimeline = firstTimeline; 1914 + document 1915 + .querySelector(`[data-timeline="${firstTimeline}"]`) 1916 + .classList.add("active"); 1917 + document.getElementById("currentTimelineName").textContent = 1918 + firstTimeline; 1919 + } 1920 + 1921 + // Update UI 1922 + initializeTimeline(); 1923 + 1924 + showStatus( 1925 + `Successfully imported ${importedCount} timeline(s): ${importedTimelines.join(", ")}`, 1926 + "success", 1927 + ); 1928 + 1929 + // Clear the config input 1930 + document.getElementById("bulkConfigInput").value = ""; 1931 + } 1932 + 1933 + function downloadIndividualFiles() { 1934 + let downloadCount = 0; 1935 + const totalFiles = Object.values(renderedImages).reduce( 1936 + (sum, timeline) => sum + Object.keys(timeline).length, 1937 + 0, 1938 + ); 1939 + 1940 + for (const [timelineName, timelineImages] of Object.entries( 1941 + renderedImages, 1942 + )) { 1943 + for (const [hour, imageDataURL] of Object.entries( 1944 + timelineImages, 1945 + )) { 1946 + const hourPadded = hour.padStart(2, "0"); 1947 + const filename = `${timelineName}_hour_${hourPadded}.jpg`; 1948 + 1949 + // Create download link 1950 + const link = document.createElement("a"); 1951 + link.href = imageDataURL; 1952 + link.download = filename; 1953 + 1954 + // Trigger download with small delay 1955 + setTimeout(() => { 1956 + document.body.appendChild(link); 1957 + link.click(); 1958 + document.body.removeChild(link); 1959 + downloadCount++; 1960 + 1961 + if (downloadCount === totalFiles) { 1962 + showStatus( 1963 + `Downloaded ${totalFiles} individual files!`, 1964 + "success", 1965 + ); 1966 + } 1967 + }, downloadCount * 100); // 100ms delay between downloads 1968 + } 1969 + } 1970 + 1971 + showStatus( 1972 + `Starting ${totalFiles} individual downloads...`, 1973 + "success", 1974 + ); 1975 + } 1976 + 1977 + function copyToClipboard() { 1978 + const textarea = document.getElementById("configOutput"); 1979 + if (!textarea.value.trim()) { 1980 + showStatus( 1981 + "Nothing to copy! Generate a config first.", 1982 + "error", 1983 + ); 1984 + return; 1985 + } 1986 + 1987 + textarea.select(); 1988 + document.execCommand("copy"); 1989 + 1990 + // Try the modern API as fallback 1991 + if (navigator.clipboard) { 1992 + navigator.clipboard 1993 + .writeText(textarea.value) 1994 + .then(() => { 1995 + showStatus( 1996 + "Config copied to clipboard!", 1997 + "success", 1998 + ); 1999 + }) 2000 + .catch(() => { 2001 + showStatus( 2002 + "Please manually copy the text", 2003 + "error", 2004 + ); 2005 + }); 2006 + } else { 2007 + showStatus( 2008 + "Config selected - press Ctrl+C to copy", 2009 + "success", 2010 + ); 2011 + } 2012 + } 2013 + 2014 + function showStatus(message, type) { 2015 + const status = document.getElementById("uploadStatus"); 2016 + status.textContent = message; 2017 + status.className = `status ${type}`; 2018 + setTimeout(() => (status.style.display = "none"), 3000); 2019 + } 2020 + 2021 + // Image upload handlers 2022 + document 2023 + .getElementById("baseImage") 2024 + .addEventListener("change", function (e) { 2025 + const file = e.target.files[0]; 2026 + if (file) { 2027 + const reader = new FileReader(); 2028 + reader.onload = function (e) { 2029 + const img = new Image(); 2030 + img.onload = function () { 2031 + baseImg = img; 2032 + showStatus("Base image loaded!", "success"); 2033 + updatePreview(); 2034 + }; 2035 + img.src = e.target.result; 2036 + }; 2037 + reader.readAsDataURL(file); 2038 + } 2039 + }); 2040 + 2041 + document 2042 + .getElementById("matteImage") 2043 + .addEventListener("change", function (e) { 2044 + const file = e.target.files[0]; 2045 + if (file) { 2046 + const reader = new FileReader(); 2047 + reader.onload = function (e) { 2048 + const img = new Image(); 2049 + img.onload = function () { 2050 + matteImg = img; 2051 + showStatus("Matte loaded!", "success"); 2052 + updatePreview(); 2053 + }; 2054 + img.src = e.target.result; 2055 + }; 2056 + reader.readAsDataURL(file); 2057 + } 2058 + }); 2059 + 2060 + // Slider updates 2061 + document 2062 + .getElementById("bgIntensity") 2063 + .addEventListener("input", function () { 2064 + document.getElementById("bgValue").textContent = 2065 + this.value + "%"; 2066 + updatePreview(); 2067 + }); 2068 + 2069 + document 2070 + .getElementById("fgIntensity") 2071 + .addEventListener("input", function () { 2072 + document.getElementById("fgValue").textContent = 2073 + this.value + "%"; 2074 + updatePreview(); 2075 + }); 2076 + 2077 + document 2078 + .getElementById("color1") 2079 + .addEventListener("change", updatePreview); 2080 + document 2081 + .getElementById("color2") 2082 + .addEventListener("change", updatePreview); 2083 + 2084 + function updatePreview() { 2085 + if (!baseImg || !matteImg) return; 2086 + 2087 + const canvas = document.getElementById("previewCanvas"); 2088 + const ctx = canvas.getContext("2d"); 2089 + 2090 + const color1 = document.getElementById("color1").value; 2091 + const color2 = document.getElementById("color2").value; 2092 + const bgIntensity = parseInt( 2093 + document.getElementById("bgIntensity").value, 2094 + ); 2095 + const fgIntensity = parseInt( 2096 + document.getElementById("fgIntensity").value, 2097 + ); 2098 + 2099 + // Create gradient 2100 + const gradient = createGradient( 2101 + canvas.width, 2102 + canvas.height, 2103 + color1, 2104 + color2, 2105 + ); 2106 + 2107 + // Clear canvas 2108 + ctx.clearRect(0, 0, canvas.width, canvas.height); 2109 + 2110 + // Create background layer 2111 + const backgroundLayer = blendImages( 2112 + baseImg, 2113 + gradient, 2114 + bgIntensity, 2115 + ); 2116 + ctx.drawImage( 2117 + backgroundLayer, 2118 + 0, 2119 + 0, 2120 + canvas.width, 2121 + canvas.height, 2122 + ); 2123 + 2124 + // Create and apply foreground layer 2125 + let foregroundLayer; 2126 + if (fgIntensity === 0) { 2127 + foregroundLayer = baseImg; 2128 + } else { 2129 + foregroundLayer = blendImages( 2130 + baseImg, 2131 + gradient, 2132 + fgIntensity, 2133 + ); 2134 + } 2135 + 2136 + // Apply masking 2137 + const maskedForeground = document.createElement("canvas"); 2138 + maskedForeground.width = canvas.width; 2139 + maskedForeground.height = canvas.height; 2140 + const maskCtx = maskedForeground.getContext("2d"); 2141 + 2142 + maskCtx.drawImage( 2143 + foregroundLayer, 2144 + 0, 2145 + 0, 2146 + canvas.width, 2147 + canvas.height, 2148 + ); 2149 + 2150 + // Create proper alpha mask 2151 + const matteDataCanvas = document.createElement("canvas"); 2152 + matteDataCanvas.width = matteImg.width; 2153 + matteDataCanvas.height = matteImg.height; 2154 + const matteDataCtx = matteDataCanvas.getContext("2d"); 2155 + matteDataCtx.drawImage(matteImg, 0, 0); 2156 + 2157 + const imageData = matteDataCtx.getImageData( 2158 + 0, 2159 + 0, 2160 + matteImg.width, 2161 + matteImg.height, 2162 + ); 2163 + const data = imageData.data; 2164 + 2165 + for (let i = 0; i < data.length; i += 4) { 2166 + const brightness = 2167 + (data[i] + data[i + 1] + data[i + 2]) / 3; 2168 + if (brightness > 128) { 2169 + data[i + 3] = 255; 2170 + } else { 2171 + data[i + 3] = 0; 2172 + } 2173 + data[i] = 255; 2174 + data[i + 1] = 255; 2175 + data[i + 2] = 255; 2176 + } 2177 + 2178 + matteDataCtx.putImageData(imageData, 0, 0); 2179 + 2180 + maskCtx.globalCompositeOperation = "destination-in"; 2181 + maskCtx.drawImage( 2182 + matteDataCanvas, 2183 + 0, 2184 + 0, 2185 + canvas.width, 2186 + canvas.height, 2187 + ); 2188 + 2189 + ctx.globalCompositeOperation = "source-over"; 2190 + ctx.drawImage(maskedForeground, 0, 0); 2191 + } 2192 + 2193 + function createGradient(width, height, color1, color2) { 2194 + const gradCanvas = document.createElement("canvas"); 2195 + gradCanvas.width = width; 2196 + gradCanvas.height = height; 2197 + const gradCtx = gradCanvas.getContext("2d"); 2198 + 2199 + const gradient = gradCtx.createLinearGradient(0, 0, 0, height); 2200 + gradient.addColorStop(0, color1); 2201 + gradient.addColorStop(1, color2); 2202 + 2203 + gradCtx.fillStyle = gradient; 2204 + gradCtx.fillRect(0, 0, width, height); 2205 + 2206 + return gradCanvas; 2207 + } 2208 + 2209 + function blendImages(base, overlay, intensity) { 2210 + const blendCanvas = document.createElement("canvas"); 2211 + blendCanvas.width = base.width; 2212 + blendCanvas.height = base.height; 2213 + const blendCtx = blendCanvas.getContext("2d"); 2214 + 2215 + blendCtx.drawImage(base, 0, 0); 2216 + blendCtx.globalCompositeOperation = "overlay"; 2217 + blendCtx.globalAlpha = intensity / 100; 2218 + blendCtx.drawImage(overlay, 0, 0, base.width, base.height); 2219 + 2220 + return blendCanvas; 2221 + } 2222 + 2223 + // Initialize 2224 + initializeTimeline(); 2225 + </script> 2226 + </body> 2227 + </html>