···
4
+
<meta charset="UTF-8" />
5
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
10
+
href="/favicon/favicon-96x96.png"
13
+
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
14
+
<link rel="shortcut icon" href="/favicon/favicon.ico" />
16
+
rel="apple-touch-icon"
18
+
href="/favicon/apple-touch-icon.png"
20
+
<meta name="apple-mobile-web-app-title" content="Serif.blue" />
21
+
<link rel="manifest" href="/favicon/site.webmanifest" />
25
+
content="Serif.blue - Fancy projects by Kieran"
27
+
<meta name="color-scheme" content="light" />
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" />
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" />
38
+
<title>Sky Gradient Timeline Builder</title>
41
+
font-family: Arial, sans-serif;
45
+
background: #f5f5f5;
50
+
grid-template-columns: 350px 1fr;
52
+
margin-bottom: 20px;
59
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
60
+
height: fit-content;
64
+
word-wrap: break-word;
65
+
overflow-wrap: break-word;
72
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
76
+
margin-bottom: 20px;
78
+
border: 2px dashed #ddd;
83
+
.timeline-selector {
84
+
margin-bottom: 20px;
91
+
margin-bottom: 10px;
96
+
border: 1px solid #ddd;
100
+
background: #f8f9fa;
101
+
transition: all 0.2s;
104
+
.timeline-tab.active {
105
+
background: #007bff;
107
+
border-color: #007bff;
110
+
.timeline-tab:hover {
111
+
background: #e9ecef;
114
+
.timeline-tab.active:hover {
115
+
background: #0056b3;
121
+
margin-bottom: 15px;
126
+
grid-template-columns: repeat(24, 1fr);
128
+
margin-bottom: 20px;
129
+
border: 1px solid #ddd;
130
+
border-radius: 4px;
132
+
background: #f8f9fa;
137
+
border: 1px solid #ccc;
138
+
border-radius: 3px;
141
+
align-items: center;
142
+
justify-content: center;
145
+
position: relative;
146
+
transition: all 0.2s;
150
+
border-color: #007bff;
151
+
transform: scale(1.1);
155
+
.hour-slot.selected {
156
+
border: 2px solid #007bff;
157
+
box-shadow: 0 0 5px rgba(0, 123, 255, 0.5);
164
+
border-radius: 4px;
172
+
align-items: center;
179
+
border-radius: 3px;
191
+
border: 1px solid #ddd;
192
+
border-radius: 8px;
199
+
border-radius: 4px;
202
+
transition: all 0.2s;
207
+
background: #007bff;
211
+
background: #28a745;
215
+
background: #dc3545;
219
+
background: #6c757d;
223
+
background: #ffc107;
228
+
transform: translateY(-1px);
229
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
235
+
border-radius: 4px;
240
+
background: #ffe6e6;
245
+
background: #e6ffe6;
252
+
grid-template-columns: repeat(24, 1fr);
254
+
margin-bottom: 5px;
259
+
text-align: center;
267
+
border: 1px solid #ddd;
268
+
border-radius: 4px;
269
+
background: #f8f9fa;
275
+
margin-bottom: 15px;
277
+
background: #e3f2fd;
278
+
border-radius: 4px;
283
+
<h1>๐
Sky Gradient Timeline Builder</h1>
285
+
<div class="container">
286
+
<div class="sidebar">
287
+
<div class="upload-section">
288
+
<h3>Upload Images</h3>
290
+
<label>Base Image:</label><br />
291
+
<input type="file" id="baseImage" accept="image/*" />
295
+
<label>Matte:</label><br />
296
+
<input type="file" id="matteImage" accept="image/*" />
298
+
<div class="status" id="uploadStatus"></div>
302
+
<h3>Hour Settings</h3>
308
+
margin-bottom: 10px;
311
+
Click an hour slot to configure
317
+
align-items: center;
322
+
<label>From:</label>
326
+
class="color-input"
333
+
class="color-input"
338
+
<div class="slider-group">
339
+
<label>Background:</label>
348
+
<span class="value-display" id="bgValue">40%</span>
351
+
<div class="slider-group">
352
+
<label>Foreground:</label>
361
+
<span class="value-display" id="fgValue">8%</span>
365
+
onclick="applyToSelectedHour()"
366
+
class="btn btn-success"
367
+
style="width: 100%; margin-top: 10px"
369
+
Apply to Selected Hour
375
+
class="preview-canvas"
382
+
style="display: none"
388
+
<div class="config-export">
389
+
<h4>Export Config</h4>
391
+
onclick="copyAllTimelines()"
392
+
class="btn btn-warning"
393
+
style="width: 100%; margin-bottom: 10px"
395
+
๐ Copy All Timelines
398
+
<label style="font-size: 12px; color: #666"
399
+
>Config Output:</label
407
+
font-family: monospace;
412
+
border: 1px solid #ddd;
413
+
border-radius: 4px;
414
+
word-break: break-all;
415
+
white-space: pre-wrap;
416
+
overflow-wrap: break-word;
420
+
onclick="copyToClipboard()"
421
+
class="btn btn-success"
422
+
style="width: 100%; margin-top: 5px"
424
+
๐ Copy to Clipboard
427
+
<hr style="margin: 15px 0" />
429
+
<h4>Import Config</h4>
430
+
<label style="font-size: 12px; color: #666"
431
+
>Paste Config (auto-imports):</label
434
+
id="bulkConfigInput"
435
+
placeholder="Paste timeline config here..."
439
+
font-family: monospace;
444
+
border: 1px solid #ddd;
445
+
border-radius: 4px;
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.
461
+
<div class="new-timeline">
464
+
id="newTimelineName"
465
+
placeholder="Timeline name (e.g. sunny, rainy, cloudy)"
466
+
style="flex: 1; padding: 8px"
469
+
onclick="createTimeline()"
470
+
class="btn btn-success"
476
+
<div class="timeline-tabs" id="timelineTabs">
478
+
class="timeline-tab active"
479
+
data-timeline="sunny"
485
+
<div style="margin: 10px 0">
487
+
onclick="duplicateTimeline()"
488
+
class="btn btn-secondary"
493
+
onclick="deleteTimeline()"
494
+
class="btn btn-danger"
504
+
<span id="currentTimelineName">Sunny</span>
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>
532
+
<div class="timeline-grid" id="timelineGrid">
533
+
<!-- Hours 0-23 will be generated here -->
536
+
<div style="margin-top: 20px">
537
+
<h4>Bulk Actions</h4>
543
+
margin-bottom: 15px;
547
+
onclick="loadPreset('dawn', [5,6,7])"
548
+
class="btn btn-secondary"
553
+
onclick="loadPreset('morning', [8,9,10,11])"
554
+
class="btn btn-secondary"
559
+
onclick="loadPreset('afternoon', [12,13,14,15,16])"
560
+
class="btn btn-secondary"
565
+
onclick="loadPreset('sunset', [17,18,19])"
566
+
class="btn btn-secondary"
571
+
onclick="loadPreset('night', [20,21,22,23,0,1,2,3,4])"
572
+
class="btn btn-secondary"
580
+
border: 1px solid #ddd;
582
+
border-radius: 4px;
583
+
background: #f8f9fa;
586
+
<h5 style="margin: 0 0 10px 0">Custom Range</h5>
591
+
align-items: center;
592
+
margin-bottom: 10px;
595
+
<label style="font-size: 12px">From:</label>
602
+
style="width: 50px; padding: 4px"
604
+
<label style="font-size: 12px">To:</label>
611
+
style="width: 50px; padding: 4px"
614
+
onclick="applyCurrentToRange()"
615
+
class="btn btn-success"
616
+
style="font-size: 11px"
621
+
<div style="font-size: 11px; color: #666">
622
+
Uses current gradient & intensity settings
623
+
for the specified hour range
630
+
<div id="renderGallery" style="margin-top: 20px">
636
+
border-radius: 8px;
637
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
640
+
<h3>๐ผ๏ธ Render Gallery</h3>
645
+
justify-content: space-between;
646
+
align-items: center;
647
+
margin-bottom: 15px;
654
+
style="font-size: 14px; color: #666"
656
+
Ready to render timelines
658
+
<div style="display: flex; gap: 10px">
660
+
onclick="renderAllTimelines()"
661
+
class="btn btn-success"
666
+
onclick="downloadAllRendered()"
667
+
class="btn btn-primary"
668
+
id="downloadAllBtn"
674
+
onclick="clearGallery()"
675
+
class="btn btn-danger"
677
+
๐๏ธ Clear Gallery
684
+
style="margin-bottom: 15px; display: none"
688
+
background: #e9ecef;
689
+
border-radius: 4px;
696
+
background: #28a745;
699
+
transition: width 0.3s;
707
+
text-align: center;
716
+
id="galleryContent"
719
+
grid-template-columns: repeat(
725
+
border: 2px dashed #ddd;
726
+
border-radius: 8px;
728
+
align-items: center;
729
+
justify-content: center;
733
+
id="galleryPlaceholder"
735
+
grid-column: 1 / -1;
736
+
text-align: center;
738
+
font-style: italic;
741
+
Click "Render All" to generate images and see
751
+
let baseImg = null;
752
+
let matteImg = null;
753
+
let selectedHour = 0;
754
+
let currentTimeline = "sunny";
756
+
// Preset configurations
761
+
backgroundIntensity: 45,
762
+
foregroundIntensity: 8,
767
+
backgroundIntensity: 35,
768
+
foregroundIntensity: 5,
773
+
backgroundIntensity: 30,
774
+
foregroundIntensity: 5,
779
+
backgroundIntensity: 50,
780
+
foregroundIntensity: 10,
785
+
backgroundIntensity: 55,
786
+
foregroundIntensity: 12,
790
+
// Timeline data structure - initialize with proper presets
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 };
806
+
timelines.sunny[hour] = { ...presets.night };
810
+
// Default hour configuration
811
+
const defaultHourConfig = {
814
+
backgroundIntensity: 40,
815
+
foregroundIntensity: 8,
818
+
function initializeTimeline() {
819
+
// Create hour slots
820
+
const grid = document.getElementById("timelineGrid");
821
+
grid.innerHTML = "";
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);
830
+
// Initialize with appropriate preset if not exists
831
+
if (!timelines[currentTimeline][hour]) {
832
+
timelines[currentTimeline][hour] =
833
+
getDefaultConfigForHour(hour);
836
+
grid.appendChild(slot);
839
+
updateTimelineDisplay();
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 };
854
+
return { ...presets.night };
858
+
function selectHour(hour) {
859
+
selectedHour = hour;
861
+
// Update UI selection
862
+
document.querySelectorAll(".hour-slot").forEach((slot) => {
863
+
slot.classList.remove("selected");
866
+
.querySelector(`[data-hour="${hour}"]`)
867
+
.classList.add("selected");
869
+
// Load hour configuration
870
+
const config = timelines[currentTimeline][hour] || {
871
+
...defaultHourConfig,
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;
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"})`;
891
+
function applyToSelectedHour() {
893
+
color1: document.getElementById("color1").value,
894
+
color2: document.getElementById("color2").value,
895
+
backgroundIntensity: parseInt(
896
+
document.getElementById("bgIntensity").value,
898
+
foregroundIntensity: parseInt(
899
+
document.getElementById("fgIntensity").value,
903
+
timelines[currentTimeline][selectedHour] = config;
904
+
updateTimelineDisplay();
905
+
showStatus("Hour " + selectedHour + " updated!", "success");
908
+
function updateTimelineDisplay() {
909
+
document.querySelectorAll(".hour-slot").forEach((slot) => {
910
+
const hour = parseInt(slot.dataset.hour);
911
+
const config = timelines[currentTimeline][hour];
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)";
919
+
slot.style.background = "#f8f9fa";
920
+
slot.style.color = "#666";
921
+
slot.style.textShadow = "none";
926
+
function createTimeline() {
927
+
const name = document
928
+
.getElementById("newTimelineName")
931
+
showStatus("Please enter a timeline name!", "error");
935
+
if (timelines[name]) {
936
+
showStatus("Timeline already exists!", "error");
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);
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);
954
+
// Switch to new timeline
955
+
switchTimeline(name);
956
+
document.getElementById("newTimelineName").value = "";
958
+
`Timeline "${name}" created with default presets!`,
963
+
function switchTimeline(timelineName) {
964
+
currentTimeline = timelineName;
966
+
// Update tab selection
967
+
document.querySelectorAll(".timeline-tab").forEach((tab) => {
968
+
tab.classList.remove("active");
971
+
.querySelector(`[data-timeline="${timelineName}"]`)
972
+
.classList.add("active");
974
+
document.getElementById("currentTimelineName").textContent =
976
+
updateTimelineDisplay();
977
+
selectHour(selectedHour);
980
+
function duplicateTimeline() {
981
+
const newName = prompt(
982
+
`Enter name for copy of "${currentTimeline}":`,
984
+
if (!newName || timelines[newName]) {
986
+
"Invalid name or timeline already exists!",
992
+
// Deep copy current timeline
993
+
timelines[newName] = JSON.parse(
994
+
JSON.stringify(timelines[currentTimeline]),
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);
1005
+
switchTimeline(newName);
1006
+
showStatus(`Timeline "${newName}" created as copy!`, "success");
1009
+
function deleteTimeline() {
1010
+
if (Object.keys(timelines).length <= 1) {
1011
+
showStatus("Cannot delete the last timeline!", "error");
1015
+
if (!confirm(`Delete timeline "${currentTimeline}"?`)) return;
1017
+
// Remove timeline
1018
+
delete timelines[currentTimeline];
1022
+
.querySelector(`[data-timeline="${currentTimeline}"]`)
1025
+
// Switch to first available timeline
1026
+
const firstTimeline = Object.keys(timelines)[0];
1027
+
switchTimeline(firstTimeline);
1029
+
showStatus(`Timeline "${currentTimeline}" deleted!`, "success");
1032
+
function loadPreset(presetName, hours) {
1033
+
// Get current UI settings
1035
+
color1: document.getElementById("color1").value,
1036
+
color2: document.getElementById("color2").value,
1037
+
backgroundIntensity: parseInt(
1038
+
document.getElementById("bgIntensity").value,
1040
+
foregroundIntensity: parseInt(
1041
+
document.getElementById("fgIntensity").value,
1045
+
// Apply current settings to specified hours
1046
+
hours.forEach((hour) => {
1047
+
timelines[currentTimeline][hour] = { ...config };
1050
+
updateTimelineDisplay();
1052
+
`Applied current settings to ${presetName} hours: ${hours.join(", ")}`,
1057
+
function applyCurrentToRange() {
1058
+
const start = parseInt(
1059
+
document.getElementById("rangeStart").value,
1061
+
const end = parseInt(document.getElementById("rangeEnd").value);
1063
+
if (start < 0 || start > 23 || end < 0 || end > 23) {
1064
+
showStatus("Hours must be between 0 and 23!", "error");
1069
+
color1: document.getElementById("color1").value,
1070
+
color2: document.getElementById("color2").value,
1071
+
backgroundIntensity: parseInt(
1072
+
document.getElementById("bgIntensity").value,
1074
+
foregroundIntensity: parseInt(
1075
+
document.getElementById("fgIntensity").value,
1079
+
// Generate hour range (handle wrap-around)
1081
+
if (start <= end) {
1082
+
for (let i = start; i <= end; i++) {
1086
+
// Wrap around (e.g., 22 to 2 = 22,23,0,1,2)
1087
+
for (let i = start; i <= 23; i++) {
1090
+
for (let i = 0; i <= end; i++) {
1095
+
// Apply config to all hours in range
1096
+
hours.forEach((hour) => {
1097
+
timelines[currentTimeline][hour] = { ...config };
1100
+
updateTimelineDisplay();
1102
+
`Applied current settings to hours: ${hours.join(", ")}`,
1107
+
function copyAllTimelines() {
1109
+
timelines: timelines,
1111
+
created: new Date().toISOString(),
1112
+
tool: "Sky Gradient Timeline Builder",
1116
+
const configText = JSON.stringify(config, null, 2);
1117
+
document.getElementById("configOutput").value = configText;
1118
+
showStatus("All timelines ready to copy!", "success");
1121
+
function renderCurrentHour() {
1122
+
if (!baseImg || !matteImg) {
1123
+
showStatus("Please upload both images first!", "error");
1127
+
const color1 = document.getElementById("color1").value;
1128
+
const color2 = document.getElementById("color2").value;
1129
+
const bgIntensity = parseInt(
1130
+
document.getElementById("bgIntensity").value,
1132
+
const fgIntensity = parseInt(
1133
+
document.getElementById("fgIntensity").value,
1136
+
// Use the full-size render canvas
1137
+
const canvas = document.getElementById("renderCanvas");
1138
+
const ctx = canvas.getContext("2d");
1140
+
// Set canvas size to match base image
1141
+
canvas.width = baseImg.width;
1142
+
canvas.height = baseImg.height;
1144
+
// Create gradient
1145
+
const gradient = createGradient(
1153
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
1155
+
// Create background layer
1156
+
const backgroundLayer = blendImages(
1161
+
ctx.drawImage(backgroundLayer, 0, 0);
1163
+
// Create and apply foreground layer
1164
+
let foregroundLayer;
1165
+
if (fgIntensity === 0) {
1166
+
foregroundLayer = baseImg;
1168
+
foregroundLayer = blendImages(
1176
+
const maskedForeground = document.createElement("canvas");
1177
+
maskedForeground.width = canvas.width;
1178
+
maskedForeground.height = canvas.height;
1179
+
const maskCtx = maskedForeground.getContext("2d");
1181
+
maskCtx.drawImage(foregroundLayer, 0, 0);
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);
1190
+
const imageData = matteDataCtx.getImageData(
1196
+
const data = imageData.data;
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;
1207
+
data[i + 1] = 255;
1208
+
data[i + 2] = 255;
1211
+
matteDataCtx.putImageData(imageData, 0, 0);
1213
+
maskCtx.globalCompositeOperation = "destination-in";
1214
+
maskCtx.drawImage(
1222
+
ctx.globalCompositeOperation = "source-over";
1223
+
ctx.drawImage(maskedForeground, 0, 0);
1225
+
// Enable download button
1226
+
document.getElementById("downloadBtn").disabled = false;
1228
+
`Hour ${selectedHour} rendered at full resolution!`,
1233
+
function downloadRendered() {
1234
+
const canvas = document.getElementById("renderCanvas");
1235
+
if (canvas.width === 400 && canvas.height === 400) {
1236
+
showStatus("Please render first!", "error");
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`;
1246
+
// Convert to JPEG for smaller file size
1247
+
link.href = canvas.toDataURL("image/jpeg", 0.95);
1250
+
showStatus(`Downloaded: ${link.download}`, "success");
1253
+
// Auto-import on paste
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();
1267
+
let renderedImages = {};
1269
+
function renderAllTimelines() {
1270
+
if (!baseImg || !matteImg) {
1271
+
showStatus("Please upload both images first!", "error");
1275
+
if (Object.keys(timelines).length === 0) {
1276
+
showStatus("No timelines to render!", "error");
1280
+
// Clear previous renders
1281
+
renderedImages = {};
1283
+
// Show progress bar
1284
+
document.getElementById("bulkProgress").style.display = "block";
1285
+
document.getElementById("downloadAllBtn").disabled = true;
1288
+
document.getElementById("galleryContent").innerHTML = "";
1290
+
// Count total hours to render
1291
+
const timelineNames = Object.keys(timelines);
1292
+
let totalHours = 0;
1293
+
let currentHour = 0;
1295
+
timelineNames.forEach((timelineName) => {
1296
+
const hours = Object.keys(timelines[timelineName]);
1297
+
totalHours += hours.length;
1300
+
updateProgress(0, totalHours);
1301
+
updateGalleryInfo(0, totalHours, timelineNames.length);
1303
+
`Rendering all timelines: ${timelineNames.length} timelines, ${totalHours} hours total`,
1307
+
// Render all timelines sequentially
1308
+
let timelineIndex = 0;
1310
+
function renderNextTimeline() {
1311
+
if (timelineIndex >= timelineNames.length) {
1313
+
document.getElementById("downloadAllBtn").disabled =
1316
+
`Render complete! ${totalHours} images rendered.`,
1322
+
const timelineName = timelineNames[timelineIndex];
1323
+
const timelineConfig = timelines[timelineName];
1324
+
const hours = Object.keys(timelineConfig).sort(
1325
+
(a, b) => parseInt(a) - parseInt(b),
1328
+
renderedImages[timelineName] = {};
1330
+
let hourIndex = 0;
1332
+
function renderNextHour() {
1333
+
if (hourIndex >= hours.length) {
1334
+
// Timeline done, move to next
1336
+
setTimeout(renderNextTimeline, 10);
1340
+
const hour = hours[hourIndex];
1341
+
const hourConfig = timelineConfig[hour];
1343
+
// Render this hour
1344
+
const imageData = renderHourToDataURL(hourConfig);
1346
+
renderedImages[timelineName][hour] = imageData;
1348
+
updateProgress(currentHour, totalHours);
1349
+
updateGalleryInfo(
1352
+
timelineNames.length,
1356
+
addToGallery(timelineName, hour, imageData);
1360
+
// Small delay to keep UI responsive
1361
+
setTimeout(renderNextHour, 100);
1367
+
renderNextTimeline();
1370
+
function addToGallery(timelineName, hour, imageDataURL) {
1371
+
const gallery = document.getElementById("galleryContent");
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";
1385
+
const item = document.createElement("div");
1386
+
item.style.cssText = `
1387
+
border: 1px solid #ddd;
1388
+
border-radius: 8px;
1390
+
background: white;
1391
+
transition: transform 0.2s, box-shadow 0.2s;
1395
+
const hourPadded = hour.toString().padStart(2, "0");
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>
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)";
1411
+
item.addEventListener("mouseleave", () => {
1412
+
item.style.transform = "scale(1)";
1413
+
item.style.boxShadow = "none";
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`;
1423
+
`Downloaded ${timelineName} hour ${hourPadded}`,
1428
+
gallery.appendChild(item);
1431
+
function updateGalleryInfo(current, total, timelineCount) {
1432
+
const info = document.getElementById("galleryInfo");
1433
+
info.textContent = `${current}/${total} images rendered across ${timelineCount} timeline(s)`;
1436
+
function clearGallery() {
1437
+
const gallery = document.getElementById("galleryContent");
1438
+
gallery.innerHTML = "";
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";
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);
1457
+
renderedImages = {};
1458
+
document.getElementById("downloadAllBtn").disabled = true;
1459
+
document.getElementById("galleryInfo").textContent =
1460
+
"Ready to render timelines";
1461
+
showStatus("Gallery cleared", "success");
1464
+
function renderAllFromConfig() {
1465
+
if (!baseImg || !matteImg) {
1466
+
showStatus("Please upload both images first!", "error");
1470
+
const configText = document
1471
+
.getElementById("bulkConfigInput")
1473
+
if (!configText) {
1474
+
showStatus("Please paste a config to render!", "error");
1480
+
config = JSON.parse(configText);
1482
+
showStatus("Invalid JSON config!", "error");
1486
+
// Validate config structure
1487
+
if (!config.timelines) {
1489
+
'Config must have "timelines" property!',
1495
+
// Clear previous renders
1496
+
renderedImages = {};
1498
+
// Show progress bar
1499
+
document.getElementById("bulkProgress").style.display = "block";
1500
+
document.getElementById("downloadAllBtn").disabled = true;
1502
+
// Count total hours to render
1503
+
const timelines = Object.keys(config.timelines);
1504
+
let totalHours = 0;
1505
+
let currentHour = 0;
1507
+
timelines.forEach((timeline) => {
1508
+
const hours = Object.keys(config.timelines[timeline]);
1509
+
totalHours += hours.length;
1512
+
updateProgress(0, totalHours);
1514
+
`Starting bulk render: ${timelines.length} timelines, ${totalHours} hours total`,
1518
+
// Render all timelines sequentially with small delays for UI responsiveness
1519
+
let timelineIndex = 0;
1521
+
function renderNextTimeline() {
1522
+
if (timelineIndex >= timelines.length) {
1524
+
document.getElementById("downloadAllBtn").disabled =
1527
+
`Bulk render complete! ${totalHours} images rendered.`,
1533
+
const timelineName = timelines[timelineIndex];
1534
+
const timelineConfig = config.timelines[timelineName];
1535
+
const hours = Object.keys(timelineConfig);
1537
+
renderedImages[timelineName] = {};
1539
+
let hourIndex = 0;
1541
+
function renderNextHour() {
1542
+
if (hourIndex >= hours.length) {
1543
+
// Timeline done, move to next
1545
+
setTimeout(renderNextTimeline, 10);
1549
+
const hour = hours[hourIndex];
1550
+
const hourConfig = timelineConfig[hour];
1552
+
// Render this hour
1553
+
const imageData = renderHourToDataURL(hourConfig);
1555
+
renderedImages[timelineName][hour] = imageData;
1557
+
updateProgress(currentHour, totalHours);
1561
+
// Small delay to keep UI responsive
1562
+
setTimeout(renderNextHour, 50);
1568
+
renderNextTimeline();
1571
+
function renderHourToDataURL(config) {
1573
+
// Create a temporary canvas for this render
1574
+
const canvas = document.createElement("canvas");
1575
+
const ctx = canvas.getContext("2d");
1577
+
// Set canvas size to match base image
1578
+
canvas.width = baseImg.width;
1579
+
canvas.height = baseImg.height;
1581
+
// Create gradient
1582
+
const gradient = createGradient(
1590
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
1592
+
// Create background layer
1593
+
const backgroundLayer = blendImages(
1596
+
config.backgroundIntensity,
1598
+
ctx.drawImage(backgroundLayer, 0, 0);
1600
+
// Create and apply foreground layer
1601
+
let foregroundLayer;
1602
+
if (config.foregroundIntensity === 0) {
1603
+
foregroundLayer = baseImg;
1605
+
foregroundLayer = blendImages(
1608
+
config.foregroundIntensity,
1613
+
const maskedForeground = document.createElement("canvas");
1614
+
maskedForeground.width = canvas.width;
1615
+
maskedForeground.height = canvas.height;
1616
+
const maskCtx = maskedForeground.getContext("2d");
1618
+
maskCtx.drawImage(foregroundLayer, 0, 0);
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);
1627
+
const imageData = matteDataCtx.getImageData(
1633
+
const data = imageData.data;
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;
1644
+
data[i + 1] = 255;
1645
+
data[i + 2] = 255;
1648
+
matteDataCtx.putImageData(imageData, 0, 0);
1650
+
maskCtx.globalCompositeOperation = "destination-in";
1651
+
maskCtx.drawImage(
1659
+
ctx.globalCompositeOperation = "source-over";
1660
+
ctx.drawImage(maskedForeground, 0, 0);
1662
+
// Return as JPEG data URL
1663
+
return canvas.toDataURL("image/jpeg", 0.95);
1665
+
console.error("Failed to render hour:", e);
1670
+
function updateProgress(current, total) {
1671
+
const percentage = total > 0 ? (current / total) * 100 : 0;
1672
+
document.getElementById("progressBar").style.width =
1674
+
document.getElementById("progressText").textContent =
1675
+
`${current}/${total}`;
1678
+
async function downloadAllRendered() {
1679
+
if (Object.keys(renderedImages).length === 0) {
1680
+
showStatus("No rendered images to download!", "error");
1684
+
showStatus("Preparing download...", "success");
1686
+
// Simple approach: create individual downloads if ZIP fails
1688
+
// Try to load JSZip if not available
1689
+
if (typeof JSZip === "undefined") {
1690
+
showStatus("Loading ZIP library...", "success");
1691
+
await loadJSZip();
1694
+
await createZipDownload();
1696
+
console.error("ZIP download failed:", e);
1698
+
"ZIP failed, downloading individual files...",
1701
+
downloadIndividualFiles();
1705
+
function loadJSZip() {
1706
+
return new Promise((resolve, reject) => {
1707
+
const script = document.createElement("script");
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);
1716
+
async function createZipDownload() {
1717
+
const zip = new JSZip();
1719
+
// First, fetch and add the shell script to the root
1721
+
showStatus("Including shell script...", "success");
1723
+
// Try local copy first, then relative path, then original source as fallback
1724
+
const scriptUrls = ["/pfp-updates/bsky-pfp-updates.sh"];
1726
+
let scriptContent = null;
1727
+
for (const url of scriptUrls) {
1729
+
const scriptResponse = await fetch(url);
1730
+
if (scriptResponse.ok) {
1731
+
scriptContent = await scriptResponse.text();
1736
+
`Failed to fetch from ${url}:`,
1739
+
// Continue to next URL
1743
+
if (scriptContent) {
1744
+
zip.file("bsky-pfp-updates.sh", scriptContent);
1747
+
"Could not load script from any source",
1752
+
"Warning: Could not include shell script, continuing without it...",
1755
+
console.error("Script loading error:", error);
1758
+
console.error("Failed to fetch shell script:", e);
1760
+
"Warning: Could not fetch shell script, continuing without it...",
1765
+
// Add the timeline config to the root
1767
+
timelines: timelines,
1769
+
created: new Date().toISOString(),
1770
+
tool: "Sky Gradient Timeline Builder",
1774
+
const configText = JSON.stringify(config, null, 2);
1775
+
zip.file("timeline_config.json", configText);
1777
+
// Create rendered_timelines folder and add all images
1778
+
const renderedFolder = zip.folder("rendered_timelines");
1780
+
for (const [timelineName, timelineImages] of Object.entries(
1783
+
const timelineFolder = renderedFolder.folder(timelineName);
1785
+
for (const [hour, imageDataURL] of Object.entries(
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);
1796
+
const hourPadded = hour.padStart(2, "0");
1797
+
timelineFolder.file(`hour_${hourPadded}.jpg`, bytes);
1801
+
// Generate and download ZIP
1802
+
showStatus("Creating ZIP file...", "success");
1803
+
const content = await zip.generateAsync({
1805
+
compression: "DEFLATE",
1806
+
compressionOptions: { level: 6 },
1809
+
// Create download link
1810
+
const link = document.createElement("a");
1811
+
const url = URL.createObjectURL(content);
1813
+
link.download = "bluesky-pfp-updates.zip";
1815
+
// Trigger download
1816
+
document.body.appendChild(link);
1818
+
document.body.removeChild(link);
1821
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
1823
+
showStatus("ZIP file downloaded with shell script!", "success");
1826
+
function importConfigToTimelines() {
1827
+
const configText = document
1828
+
.getElementById("bulkConfigInput")
1830
+
if (!configText) {
1831
+
showStatus("Please paste a config to import!", "error");
1837
+
config = JSON.parse(configText);
1839
+
showStatus("Invalid JSON config!", "error");
1843
+
// Validate config structure
1844
+
if (!config.timelines) {
1846
+
'Config must have "timelines" property!',
1852
+
// Clear existing timelines (except keep one if empty)
1853
+
const wasEmpty = Object.keys(timelines).length === 0;
1856
+
// Clear existing tabs
1857
+
document.getElementById("timelineTabs").innerHTML = "";
1859
+
// Import all timelines from config
1860
+
const importedTimelines = Object.keys(config.timelines);
1861
+
let importedCount = 0;
1863
+
for (const [timelineName, timelineConfig] of Object.entries(
1866
+
// Validate timeline structure
1867
+
if (typeof timelineConfig !== "object") {
1869
+
`Invalid timeline structure for "${timelineName}"`,
1875
+
// Convert timeline config to our format
1876
+
timelines[timelineName] = {};
1878
+
for (const [hour, hourConfig] of Object.entries(
1881
+
const hourNum = parseInt(hour);
1885
+
hourConfig.color1 &&
1888
+
timelines[timelineName][hourNum] = {
1889
+
color1: hourConfig.color1,
1890
+
color2: hourConfig.color2,
1891
+
backgroundIntensity:
1892
+
hourConfig.backgroundIntensity || 40,
1893
+
foregroundIntensity:
1894
+
hourConfig.foregroundIntensity || 8,
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);
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);
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";
1925
+
// Switch to first imported timeline
1926
+
const firstTimeline = importedTimelines[0];
1927
+
currentTimeline = firstTimeline;
1929
+
.querySelector(`[data-timeline="${firstTimeline}"]`)
1930
+
.classList.add("active");
1931
+
document.getElementById("currentTimelineName").textContent =
1936
+
initializeTimeline();
1939
+
`Successfully imported ${importedCount} timeline(s): ${importedTimelines.join(", ")}`,
1943
+
// Clear the config input
1944
+
document.getElementById("bulkConfigInput").value = "";
1947
+
function downloadIndividualFiles() {
1948
+
let downloadCount = 0;
1949
+
const totalFiles = Object.values(renderedImages).reduce(
1950
+
(sum, timeline) => sum + Object.keys(timeline).length,
1954
+
for (const [timelineName, timelineImages] of Object.entries(
1957
+
for (const [hour, imageDataURL] of Object.entries(
1960
+
const hourPadded = hour.padStart(2, "0");
1961
+
const filename = `${timelineName}_hour_${hourPadded}.jpg`;
1963
+
// Create download link
1964
+
const link = document.createElement("a");
1965
+
link.href = imageDataURL;
1966
+
link.download = filename;
1968
+
// Trigger download with small delay
1969
+
setTimeout(() => {
1970
+
document.body.appendChild(link);
1972
+
document.body.removeChild(link);
1975
+
if (downloadCount === totalFiles) {
1977
+
`Downloaded ${totalFiles} individual files!`,
1981
+
}, downloadCount * 100); // 100ms delay between downloads
1986
+
`Starting ${totalFiles} individual downloads...`,
1991
+
function copyToClipboard() {
1992
+
const textarea = document.getElementById("configOutput");
1993
+
if (!textarea.value.trim()) {
1995
+
"Nothing to copy! Generate a config first.",
2001
+
textarea.select();
2002
+
document.execCommand("copy");
2004
+
// Try the modern API as fallback
2005
+
if (navigator.clipboard) {
2006
+
navigator.clipboard
2007
+
.writeText(textarea.value)
2010
+
"Config copied to clipboard!",
2016
+
"Please manually copy the text",
2022
+
"Config selected - press Ctrl+C to copy",
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);
2035
+
// Image upload handlers
2037
+
.getElementById("baseImage")
2038
+
.addEventListener("change", function (e) {
2039
+
const file = e.target.files[0];
2041
+
const reader = new FileReader();
2042
+
reader.onload = function (e) {
2043
+
const img = new Image();
2044
+
img.onload = function () {
2046
+
showStatus("Base image loaded!", "success");
2049
+
img.src = e.target.result;
2051
+
reader.readAsDataURL(file);
2056
+
.getElementById("matteImage")
2057
+
.addEventListener("change", function (e) {
2058
+
const file = e.target.files[0];
2060
+
const reader = new FileReader();
2061
+
reader.onload = function (e) {
2062
+
const img = new Image();
2063
+
img.onload = function () {
2065
+
showStatus("Matte loaded!", "success");
2068
+
img.src = e.target.result;
2070
+
reader.readAsDataURL(file);
2076
+
.getElementById("bgIntensity")
2077
+
.addEventListener("input", function () {
2078
+
document.getElementById("bgValue").textContent =
2084
+
.getElementById("fgIntensity")
2085
+
.addEventListener("input", function () {
2086
+
document.getElementById("fgValue").textContent =
2092
+
.getElementById("color1")
2093
+
.addEventListener("change", updatePreview);
2095
+
.getElementById("color2")
2096
+
.addEventListener("change", updatePreview);
2098
+
function updatePreview() {
2099
+
if (!baseImg || !matteImg) return;
2101
+
const canvas = document.getElementById("previewCanvas");
2102
+
const ctx = canvas.getContext("2d");
2104
+
const color1 = document.getElementById("color1").value;
2105
+
const color2 = document.getElementById("color2").value;
2106
+
const bgIntensity = parseInt(
2107
+
document.getElementById("bgIntensity").value,
2109
+
const fgIntensity = parseInt(
2110
+
document.getElementById("fgIntensity").value,
2113
+
// Create gradient
2114
+
const gradient = createGradient(
2122
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
2124
+
// Create background layer
2125
+
const backgroundLayer = blendImages(
2138
+
// Create and apply foreground layer
2139
+
let foregroundLayer;
2140
+
if (fgIntensity === 0) {
2141
+
foregroundLayer = baseImg;
2143
+
foregroundLayer = blendImages(
2151
+
const maskedForeground = document.createElement("canvas");
2152
+
maskedForeground.width = canvas.width;
2153
+
maskedForeground.height = canvas.height;
2154
+
const maskCtx = maskedForeground.getContext("2d");
2156
+
maskCtx.drawImage(
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);
2171
+
const imageData = matteDataCtx.getImageData(
2177
+
const data = imageData.data;
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;
2188
+
data[i + 1] = 255;
2189
+
data[i + 2] = 255;
2192
+
matteDataCtx.putImageData(imageData, 0, 0);
2194
+
maskCtx.globalCompositeOperation = "destination-in";
2195
+
maskCtx.drawImage(
2203
+
ctx.globalCompositeOperation = "source-over";
2204
+
ctx.drawImage(maskedForeground, 0, 0);
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");
2213
+
const gradient = gradCtx.createLinearGradient(0, 0, 0, height);
2214
+
gradient.addColorStop(0, color1);
2215
+
gradient.addColorStop(1, color2);
2217
+
gradCtx.fillStyle = gradient;
2218
+
gradCtx.fillRect(0, 0, width, height);
2220
+
return gradCanvas;
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");
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);
2234
+
return blendCanvas;
2238
+
initializeTimeline();