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