the home of serif.blue
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>