the home of serif.blue

feat: add bluesky profile updates

dunkirk.sh 482c5fc2 f57846a8

verified
Changed files
+2265
site
bsky-profile-updates.sh site/pfp-updates/bsky-pfp-updates.sh
+38
site/index.html
···
</div>
<div class="card">
+
<h3>Automatic profile updates!</h3>
+
<p>
+
I made this inspired by
+
<a
+
href="https://bsky.app/profile/did:plc:gq4fo3u6tqzzdkjlwzpb23tj"
+
>@dame.is</a
+
>'s (dame.is's sounds hilarious lol) profile picture
+
which changes with a sky gradient every hour. I wanted
+
to do something similar but my profile picture has me in
+
the foreground so I had to do some masking shenanagins
+
to get it to work.
+
</p>
+
<p>
+
Anyway if you want to set this up for yourself then grab
+
a background removed version of your profile from
+
<a href="https://remove.bg">remove.bg</a> (low res
+
preview version is fine since this will just be a mask)
+
and then run
+
<code
+
>magick pfp-removebg-preview.png -alpha extract
+
pfp_matte.png</code
+
>. Now you can head over to the timeline site linked
+
below and customize your timeline! When you are done
+
simply download the zip and extract it wherever you want
+
it to live. Then <code>crontab -e</code> and add your
+
script (<code
+
>2 * * * * /home/usrname/pfp/bsky-profile-updates.sh
+
>/dev/null 2>&1</code
+
>
+
) to run 2 minutes after the hour (or at really whatever
+
time you want)!
+
</p>
+
<a href="/pfp-updates" class="btn"
+
>Customize your gradients!</a
+
>
+
</div>
+
+
<div class="card">
<h3>More things soon?</h3>
<p>
Yeah probably lol; I just need to find the right next
+2227
site/pfp-updates/index.html
···
+
<!doctype html>
+
<html lang="en">
+
<head>
+
<meta charset="UTF-8" />
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
+
<link
+
rel="icon"
+
type="image/png"
+
href="/favicon/favicon-96x96.png"
+
sizes="96x96"
+
/>
+
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
+
<link rel="shortcut icon" href="/favicon/favicon.ico" />
+
<link
+
rel="apple-touch-icon"
+
sizes="180x180"
+
href="/favicon/apple-touch-icon.png"
+
/>
+
<meta name="apple-mobile-web-app-title" content="Serif.blue" />
+
<link rel="manifest" href="/favicon/site.webmanifest" />
+
+
<meta
+
name="description"
+
content="Serif.blue - Fancy projects by Kieran"
+
/>
+
<meta name="color-scheme" content="light" />
+
+
<meta property="og:title" content="Serif.blue - pfp gradient builder" />
+
<meta property="og:type" content="website" />
+
<meta property="og:url" content="https://serif.blue/pfp-updates" />
+
<meta property="og:image" content="/og.png" />
+
+
<link rel="me" href="https://dunkirk.sh" />
+
<link rel="me" href="https://bsky.app/profile/dunkirk.sh" />
+
<link rel="me" href="https://github.com/taciturnaxolotl" />
+
+
<title>Sky Gradient Timeline Builder</title>
+
<style>
+
body {
+
font-family: Arial, sans-serif;
+
max-width: 1400px;
+
margin: 0 auto;
+
padding: 20px;
+
background: #f5f5f5;
+
}
+
+
.container {
+
display: grid;
+
grid-template-columns: 300px 1fr;
+
gap: 20px;
+
margin-bottom: 20px;
+
}
+
+
.sidebar {
+
background: white;
+
padding: 20px;
+
border-radius: 8px;
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+
height: fit-content;
+
overflow-y: auto;
+
overflow-x: hidden;
+
max-height: 90vh;
+
word-wrap: break-word;
+
overflow-wrap: break-word;
+
}
+
+
.timeline-area {
+
background: white;
+
padding: 20px;
+
border-radius: 8px;
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+
}
+
+
.upload-section {
+
margin-bottom: 20px;
+
padding: 15px;
+
border: 2px dashed #ddd;
+
border-radius: 8px;
+
text-align: center;
+
}
+
+
.timeline-selector {
+
margin-bottom: 20px;
+
}
+
+
.timeline-tabs {
+
display: flex;
+
flex-wrap: wrap;
+
gap: 5px;
+
margin-bottom: 10px;
+
}
+
+
.timeline-tab {
+
padding: 8px 12px;
+
border: 1px solid #ddd;
+
border-radius: 4px;
+
cursor: pointer;
+
font-size: 12px;
+
background: #f8f9fa;
+
transition: all 0.2s;
+
}
+
+
.timeline-tab.active {
+
background: #007bff;
+
color: white;
+
border-color: #007bff;
+
}
+
+
.timeline-tab:hover {
+
background: #e9ecef;
+
}
+
+
.timeline-tab.active:hover {
+
background: #0056b3;
+
}
+
+
.new-timeline {
+
display: flex;
+
gap: 5px;
+
margin-bottom: 15px;
+
}
+
+
.timeline-grid {
+
display: grid;
+
grid-template-columns: repeat(24, 1fr);
+
gap: 2px;
+
margin-bottom: 20px;
+
border: 1px solid #ddd;
+
border-radius: 4px;
+
padding: 10px;
+
background: #f8f9fa;
+
}
+
+
.hour-slot {
+
aspect-ratio: 1;
+
border: 1px solid #ccc;
+
border-radius: 3px;
+
cursor: pointer;
+
display: flex;
+
align-items: center;
+
justify-content: center;
+
font-size: 10px;
+
font-weight: bold;
+
position: relative;
+
transition: all 0.2s;
+
}
+
+
.hour-slot:hover {
+
border-color: #007bff;
+
transform: scale(1.1);
+
z-index: 10;
+
}
+
+
.hour-slot.selected {
+
border: 2px solid #007bff;
+
box-shadow: 0 0 5px rgba(0, 123, 255, 0.5);
+
}
+
+
.color-input {
+
width: 60px;
+
height: 30px;
+
border: none;
+
border-radius: 4px;
+
cursor: pointer;
+
margin: 0 5px;
+
}
+
+
.slider-group {
+
margin: 10px 0;
+
display: flex;
+
align-items: center;
+
gap: 10px;
+
}
+
+
.slider {
+
flex: 1;
+
height: 6px;
+
border-radius: 3px;
+
background: #ddd;
+
outline: none;
+
}
+
+
.value-display {
+
min-width: 40px;
+
font-weight: bold;
+
}
+
+
.preview-canvas {
+
max-width: 200px;
+
border: 1px solid #ddd;
+
border-radius: 8px;
+
margin: 10px 0;
+
}
+
+
.btn {
+
padding: 8px 12px;
+
border: none;
+
border-radius: 4px;
+
cursor: pointer;
+
font-size: 12px;
+
transition: all 0.2s;
+
margin: 2px;
+
}
+
+
.btn-primary {
+
background: #007bff;
+
color: white;
+
}
+
.btn-success {
+
background: #28a745;
+
color: white;
+
}
+
.btn-danger {
+
background: #dc3545;
+
color: white;
+
}
+
.btn-secondary {
+
background: #6c757d;
+
color: white;
+
}
+
.btn-warning {
+
background: #ffc107;
+
color: black;
+
}
+
+
.btn:hover {
+
transform: translateY(-1px);
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+
}
+
+
.status {
+
margin: 10px 0;
+
padding: 10px;
+
border-radius: 4px;
+
display: none;
+
}
+
+
.status.error {
+
background: #ffe6e6;
+
color: #d00;
+
display: block;
+
}
+
.status.success {
+
background: #e6ffe6;
+
color: #060;
+
display: block;
+
}
+
+
.hour-labels {
+
display: grid;
+
grid-template-columns: repeat(24, 1fr);
+
gap: 2px;
+
margin-bottom: 5px;
+
padding: 0 10px;
+
}
+
+
.hour-label {
+
text-align: center;
+
font-size: 10px;
+
color: #666;
+
}
+
+
.config-export {
+
margin-top: 20px;
+
padding: 15px;
+
border: 1px solid #ddd;
+
border-radius: 4px;
+
background: #f8f9fa;
+
}
+
+
.timeline-info {
+
font-size: 12px;
+
color: #666;
+
margin-bottom: 15px;
+
padding: 10px;
+
background: #e3f2fd;
+
border-radius: 4px;
+
}
+
</style>
+
</head>
+
<body>
+
<h1>🌅 Sky Gradient Timeline Builder</h1>
+
+
<div class="container">
+
<div class="sidebar">
+
<div class="upload-section">
+
<h3>Upload Images</h3>
+
<div>
+
<label>Base Image:</label><br />
+
<input type="file" id="baseImage" accept="image/*" />
+
</div>
+
<br />
+
<div>
+
<label>Matte:</label><br />
+
<input type="file" id="matteImage" accept="image/*" />
+
</div>
+
<div class="status" id="uploadStatus"></div>
+
</div>
+
+
<div>
+
<h3>Hour Settings</h3>
+
<div
+
id="hourInfo"
+
style="
+
font-size: 12px;
+
color: #666;
+
margin-bottom: 10px;
+
"
+
>
+
Click an hour slot to configure
+
</div>
+
+
<div
+
style="
+
display: flex;
+
align-items: center;
+
gap: 10px;
+
margin: 15px 0;
+
"
+
>
+
<label>From:</label>
+
<input
+
type="color"
+
id="color1"
+
class="color-input"
+
value="#4682b4"
+
/>
+
<label>To:</label>
+
<input
+
type="color"
+
id="color2"
+
class="color-input"
+
value="#87ceeb"
+
/>
+
</div>
+
+
<div class="slider-group">
+
<label>Background:</label>
+
<input
+
type="range"
+
id="bgIntensity"
+
class="slider"
+
min="0"
+
max="100"
+
value="40"
+
/>
+
<span class="value-display" id="bgValue">40%</span>
+
</div>
+
+
<div class="slider-group">
+
<label>Foreground:</label>
+
<input
+
type="range"
+
id="fgIntensity"
+
class="slider"
+
min="0"
+
max="50"
+
value="8"
+
/>
+
<span class="value-display" id="fgValue">8%</span>
+
</div>
+
+
<button
+
onclick="applyToSelectedHour()"
+
class="btn btn-success"
+
style="width: 100%; margin-top: 10px"
+
>
+
Apply to Selected Hour
+
</button>
+
+
<h4>Preview</h4>
+
<canvas
+
id="previewCanvas"
+
class="preview-canvas"
+
width="150"
+
height="150"
+
></canvas>
+
+
<canvas
+
id="renderCanvas"
+
style="display: none"
+
width="400"
+
height="400"
+
></canvas>
+
</div>
+
+
<div class="config-export">
+
<h4>Export Config</h4>
+
<button
+
onclick="copyAllTimelines()"
+
class="btn btn-warning"
+
style="width: 100%; margin-bottom: 10px"
+
>
+
📋 Copy All Timelines
+
</button>
+
+
<label style="font-size: 12px; color: #666"
+
>Config Output:</label
+
>
+
<textarea
+
id="configOutput"
+
readonly
+
style="
+
width: 100%;
+
height: 120px;
+
font-family: monospace;
+
font-size: 10px;
+
resize: vertical;
+
margin-top: 5px;
+
padding: 8px;
+
border: 1px solid #ddd;
+
border-radius: 4px;
+
word-break: break-all;
+
white-space: pre-wrap;
+
overflow-wrap: break-word;
+
"
+
></textarea>
+
<button
+
onclick="copyToClipboard()"
+
class="btn btn-success"
+
style="width: 100%; margin-top: 5px"
+
>
+
📋 Copy to Clipboard
+
</button>
+
+
<hr style="margin: 15px 0" />
+
+
<h4>Import Config</h4>
+
<label style="font-size: 12px; color: #666"
+
>Paste Config (auto-imports):</label
+
>
+
<textarea
+
id="bulkConfigInput"
+
placeholder="Paste timeline config here..."
+
style="
+
width: 100%;
+
height: 80px;
+
font-family: monospace;
+
font-size: 10px;
+
resize: vertical;
+
margin-top: 5px;
+
padding: 8px;
+
border: 1px solid #ddd;
+
border-radius: 4px;
+
"
+
></textarea>
+
</div>
+
</div>
+
+
<div class="timeline-area">
+
<div class="timeline-selector">
+
<h3>Weather Timelines</h3>
+
<div class="timeline-info">
+
Create different timelines for various weather
+
conditions. Each timeline defines how your profile
+
picture should look throughout the day.
+
</div>
+
+
<div class="new-timeline">
+
<input
+
type="text"
+
id="newTimelineName"
+
placeholder="Timeline name (e.g. sunny, rainy, cloudy)"
+
style="flex: 1; padding: 8px"
+
/>
+
<button
+
onclick="createTimeline()"
+
class="btn btn-success"
+
>
+
Create
+
</button>
+
</div>
+
+
<div class="timeline-tabs" id="timelineTabs">
+
<div class="timeline-tab active" data-timeline="sunny">
+
Sunny
+
</div>
+
</div>
+
+
<div style="margin: 10px 0">
+
<button
+
onclick="duplicateTimeline()"
+
class="btn btn-secondary"
+
>
+
Duplicate Current
+
</button>
+
<button
+
onclick="deleteTimeline()"
+
class="btn btn-danger"
+
>
+
Delete Current
+
</button>
+
</div>
+
</div>
+
+
<div>
+
<h4>
+
24-Hour Timeline:
+
<span id="currentTimelineName">Sunny</span>
+
</h4>
+
<div class="hour-labels">
+
<div class="hour-label">0</div>
+
<div class="hour-label">1</div>
+
<div class="hour-label">2</div>
+
<div class="hour-label">3</div>
+
<div class="hour-label">4</div>
+
<div class="hour-label">5</div>
+
<div class="hour-label">6</div>
+
<div class="hour-label">7</div>
+
<div class="hour-label">8</div>
+
<div class="hour-label">9</div>
+
<div class="hour-label">10</div>
+
<div class="hour-label">11</div>
+
<div class="hour-label">12</div>
+
<div class="hour-label">13</div>
+
<div class="hour-label">14</div>
+
<div class="hour-label">15</div>
+
<div class="hour-label">16</div>
+
<div class="hour-label">17</div>
+
<div class="hour-label">18</div>
+
<div class="hour-label">19</div>
+
<div class="hour-label">20</div>
+
<div class="hour-label">21</div>
+
<div class="hour-label">22</div>
+
<div class="hour-label">23</div>
+
</div>
+
<div class="timeline-grid" id="timelineGrid">
+
<!-- Hours 0-23 will be generated here -->
+
</div>
+
+
<div style="margin-top: 20px">
+
<h4>Bulk Actions</h4>
+
<div
+
style="
+
display: flex;
+
gap: 10px;
+
flex-wrap: wrap;
+
margin-bottom: 15px;
+
"
+
>
+
<button
+
onclick="loadPreset('dawn', [5,6,7])"
+
class="btn btn-secondary"
+
>
+
Dawn (5-7)
+
</button>
+
<button
+
onclick="loadPreset('morning', [8,9,10,11])"
+
class="btn btn-secondary"
+
>
+
Morning (8-11)
+
</button>
+
<button
+
onclick="loadPreset('afternoon', [12,13,14,15,16])"
+
class="btn btn-secondary"
+
>
+
Afternoon (12-16)
+
</button>
+
<button
+
onclick="loadPreset('sunset', [17,18,19])"
+
class="btn btn-secondary"
+
>
+
Sunset (17-19)
+
</button>
+
<button
+
onclick="loadPreset('night', [20,21,22,23,0,1,2,3,4])"
+
class="btn btn-secondary"
+
>
+
Night (20-4)
+
</button>
+
</div>
+
+
<div
+
style="
+
border: 1px solid #ddd;
+
padding: 10px;
+
border-radius: 4px;
+
background: #f8f9fa;
+
"
+
>
+
<h5 style="margin: 0 0 10px 0">Custom Range</h5>
+
<div
+
style="
+
display: flex;
+
gap: 5px;
+
align-items: center;
+
margin-bottom: 10px;
+
"
+
>
+
<label style="font-size: 12px">From:</label>
+
<input
+
type="number"
+
id="rangeStart"
+
min="0"
+
max="23"
+
value="9"
+
style="width: 50px; padding: 4px"
+
/>
+
<label style="font-size: 12px">To:</label>
+
<input
+
type="number"
+
id="rangeEnd"
+
min="0"
+
max="23"
+
value="11"
+
style="width: 50px; padding: 4px"
+
/>
+
<button
+
onclick="applyCurrentToRange()"
+
class="btn btn-success"
+
style="font-size: 11px"
+
>
+
Apply Current
+
</button>
+
</div>
+
<div style="font-size: 11px; color: #666">
+
Uses current gradient & intensity settings for
+
the specified hour range
+
</div>
+
</div>
+
</div>
+
</div>
+
</div>
+
</div>
+
+
<div id="renderGallery" style="margin-top: 20px">
+
<h3>🖼️ Render Gallery</h3>
+
<div
+
style="
+
background: white;
+
padding: 20px;
+
border-radius: 8px;
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+
"
+
>
+
<div
+
style="
+
display: flex;
+
justify-content: space-between;
+
align-items: center;
+
margin-bottom: 15px;
+
flex-wrap: wrap;
+
gap: 10px;
+
"
+
>
+
<div id="galleryInfo" style="font-size: 14px; color: #666">
+
Ready to render timelines
+
</div>
+
<div style="display: flex; gap: 10px">
+
<button
+
onclick="renderAllTimelines()"
+
class="btn btn-success"
+
>
+
🎨 Render All
+
</button>
+
<button
+
onclick="downloadAllRendered()"
+
class="btn btn-primary"
+
id="downloadAllBtn"
+
disabled
+
>
+
💾 Download ZIP
+
</button>
+
<button onclick="clearGallery()" class="btn btn-danger">
+
🗑️ Clear Gallery
+
</button>
+
</div>
+
</div>
+
+
<div
+
id="bulkProgress"
+
style="margin-bottom: 15px; display: none"
+
>
+
<div
+
style="
+
background: #e9ecef;
+
border-radius: 4px;
+
overflow: hidden;
+
"
+
>
+
<div
+
id="progressBar"
+
style="
+
background: #28a745;
+
height: 20px;
+
width: 0%;
+
transition: width 0.3s;
+
"
+
></div>
+
</div>
+
<div
+
id="progressText"
+
style="
+
font-size: 12px;
+
text-align: center;
+
margin-top: 5px;
+
"
+
>
+
0/0
+
</div>
+
</div>
+
+
<div
+
id="galleryContent"
+
style="
+
display: grid;
+
grid-template-columns: repeat(
+
auto-fill,
+
minmax(120px, 1fr)
+
);
+
gap: 15px;
+
min-height: 100px;
+
border: 2px dashed #ddd;
+
border-radius: 8px;
+
padding: 20px;
+
align-items: center;
+
justify-content: center;
+
"
+
>
+
<div
+
id="galleryPlaceholder"
+
style="
+
grid-column: 1 / -1;
+
text-align: center;
+
color: #999;
+
font-style: italic;
+
"
+
>
+
Click "Render All" to generate images and see them here
+
</div>
+
</div>
+
</div>
+
</div>
+
+
<script>
+
let baseImg = null;
+
let matteImg = null;
+
let selectedHour = 0;
+
let currentTimeline = "sunny";
+
+
// Preset configurations
+
const presets = {
+
dawn: {
+
color1: "#ff6b35",
+
color2: "#f7931e",
+
backgroundIntensity: 45,
+
foregroundIntensity: 8,
+
},
+
morning: {
+
color1: "#87ceeb",
+
color2: "#4682b4",
+
backgroundIntensity: 35,
+
foregroundIntensity: 5,
+
},
+
afternoon: {
+
color1: "#4682b4",
+
color2: "#daa520",
+
backgroundIntensity: 30,
+
foregroundIntensity: 5,
+
},
+
sunset: {
+
color1: "#ff4500",
+
color2: "#8b0000",
+
backgroundIntensity: 50,
+
foregroundIntensity: 10,
+
},
+
night: {
+
color1: "#191970",
+
color2: "#000000",
+
backgroundIntensity: 55,
+
foregroundIntensity: 12,
+
},
+
};
+
+
// Timeline data structure - initialize with proper presets
+
let timelines = {
+
sunny: {},
+
};
+
+
// Initialize the default sunny timeline with presets
+
for (let hour = 0; hour < 24; hour++) {
+
if (hour >= 5 && hour <= 7) {
+
timelines.sunny[hour] = { ...presets.dawn };
+
} else if (hour >= 8 && hour <= 11) {
+
timelines.sunny[hour] = { ...presets.morning };
+
} else if (hour >= 12 && hour <= 16) {
+
timelines.sunny[hour] = { ...presets.afternoon };
+
} else if (hour >= 17 && hour <= 19) {
+
timelines.sunny[hour] = { ...presets.sunset };
+
} else {
+
timelines.sunny[hour] = { ...presets.night };
+
}
+
}
+
+
// Default hour configuration
+
const defaultHourConfig = {
+
color1: "#4682b4",
+
color2: "#87ceeb",
+
backgroundIntensity: 40,
+
foregroundIntensity: 8,
+
};
+
+
function initializeTimeline() {
+
// Create hour slots
+
const grid = document.getElementById("timelineGrid");
+
grid.innerHTML = "";
+
+
for (let hour = 0; hour < 24; hour++) {
+
const slot = document.createElement("div");
+
slot.className = "hour-slot";
+
slot.dataset.hour = hour;
+
slot.textContent = hour;
+
slot.onclick = () => selectHour(hour);
+
+
// Initialize with appropriate preset if not exists
+
if (!timelines[currentTimeline][hour]) {
+
timelines[currentTimeline][hour] =
+
getDefaultConfigForHour(hour);
+
}
+
+
grid.appendChild(slot);
+
}
+
+
updateTimelineDisplay();
+
selectHour(0);
+
}
+
+
function getDefaultConfigForHour(hour) {
+
// Apply appropriate preset based on hour
+
if (hour >= 5 && hour <= 7) {
+
return { ...presets.dawn };
+
} else if (hour >= 8 && hour <= 11) {
+
return { ...presets.morning };
+
} else if (hour >= 12 && hour <= 16) {
+
return { ...presets.afternoon };
+
} else if (hour >= 17 && hour <= 19) {
+
return { ...presets.sunset };
+
} else {
+
return { ...presets.night };
+
}
+
}
+
+
function selectHour(hour) {
+
selectedHour = hour;
+
+
// Update UI selection
+
document.querySelectorAll(".hour-slot").forEach((slot) => {
+
slot.classList.remove("selected");
+
});
+
document
+
.querySelector(`[data-hour="${hour}"]`)
+
.classList.add("selected");
+
+
// Load hour configuration
+
const config = timelines[currentTimeline][hour] || {
+
...defaultHourConfig,
+
};
+
document.getElementById("color1").value = config.color1;
+
document.getElementById("color2").value = config.color2;
+
document.getElementById("bgIntensity").value =
+
config.backgroundIntensity;
+
document.getElementById("fgIntensity").value =
+
config.foregroundIntensity;
+
+
// Update displays
+
document.getElementById("bgValue").textContent =
+
config.backgroundIntensity + "%";
+
document.getElementById("fgValue").textContent =
+
config.foregroundIntensity + "%";
+
document.getElementById("hourInfo").textContent =
+
`Configuring hour ${hour} (${hour === 0 ? "12" : hour > 12 ? hour - 12 : hour}${hour < 12 ? "AM" : "PM"})`;
+
+
updatePreview();
+
}
+
+
function applyToSelectedHour() {
+
const config = {
+
color1: document.getElementById("color1").value,
+
color2: document.getElementById("color2").value,
+
backgroundIntensity: parseInt(
+
document.getElementById("bgIntensity").value,
+
),
+
foregroundIntensity: parseInt(
+
document.getElementById("fgIntensity").value,
+
),
+
};
+
+
timelines[currentTimeline][selectedHour] = config;
+
updateTimelineDisplay();
+
showStatus("Hour " + selectedHour + " updated!", "success");
+
}
+
+
function updateTimelineDisplay() {
+
document.querySelectorAll(".hour-slot").forEach((slot) => {
+
const hour = parseInt(slot.dataset.hour);
+
const config = timelines[currentTimeline][hour];
+
+
if (config) {
+
const gradient = `linear-gradient(135deg, ${config.color1}, ${config.color2})`;
+
slot.style.background = gradient;
+
slot.style.color = "white";
+
slot.style.textShadow = "1px 1px 2px rgba(0,0,0,0.8)";
+
} else {
+
slot.style.background = "#f8f9fa";
+
slot.style.color = "#666";
+
slot.style.textShadow = "none";
+
}
+
});
+
}
+
+
function createTimeline() {
+
const name = document
+
.getElementById("newTimelineName")
+
.value.trim();
+
if (!name) {
+
showStatus("Please enter a timeline name!", "error");
+
return;
+
}
+
+
if (timelines[name]) {
+
showStatus("Timeline already exists!", "error");
+
return;
+
}
+
+
// Create new timeline with proper presets for each hour
+
timelines[name] = {};
+
for (let hour = 0; hour < 24; hour++) {
+
timelines[name][hour] = getDefaultConfigForHour(hour);
+
}
+
+
// Add tab
+
const tab = document.createElement("div");
+
tab.className = "timeline-tab";
+
tab.dataset.timeline = name;
+
tab.textContent = name;
+
tab.onclick = () => switchTimeline(name);
+
document.getElementById("timelineTabs").appendChild(tab);
+
+
// Switch to new timeline
+
switchTimeline(name);
+
document.getElementById("newTimelineName").value = "";
+
showStatus(
+
`Timeline "${name}" created with default presets!`,
+
"success",
+
);
+
}
+
+
function switchTimeline(timelineName) {
+
currentTimeline = timelineName;
+
+
// Update tab selection
+
document.querySelectorAll(".timeline-tab").forEach((tab) => {
+
tab.classList.remove("active");
+
});
+
document
+
.querySelector(`[data-timeline="${timelineName}"]`)
+
.classList.add("active");
+
+
document.getElementById("currentTimelineName").textContent =
+
timelineName;
+
updateTimelineDisplay();
+
selectHour(selectedHour);
+
}
+
+
function duplicateTimeline() {
+
const newName = prompt(
+
`Enter name for copy of "${currentTimeline}":`,
+
);
+
if (!newName || timelines[newName]) {
+
showStatus(
+
"Invalid name or timeline already exists!",
+
"error",
+
);
+
return;
+
}
+
+
// Deep copy current timeline
+
timelines[newName] = JSON.parse(
+
JSON.stringify(timelines[currentTimeline]),
+
);
+
+
// Add tab
+
const tab = document.createElement("div");
+
tab.className = "timeline-tab";
+
tab.dataset.timeline = newName;
+
tab.textContent = newName;
+
tab.onclick = () => switchTimeline(newName);
+
document.getElementById("timelineTabs").appendChild(tab);
+
+
switchTimeline(newName);
+
showStatus(`Timeline "${newName}" created as copy!`, "success");
+
}
+
+
function deleteTimeline() {
+
if (Object.keys(timelines).length <= 1) {
+
showStatus("Cannot delete the last timeline!", "error");
+
return;
+
}
+
+
if (!confirm(`Delete timeline "${currentTimeline}"?`)) return;
+
+
// Remove timeline
+
delete timelines[currentTimeline];
+
+
// Remove tab
+
document
+
.querySelector(`[data-timeline="${currentTimeline}"]`)
+
.remove();
+
+
// Switch to first available timeline
+
const firstTimeline = Object.keys(timelines)[0];
+
switchTimeline(firstTimeline);
+
+
showStatus(`Timeline "${currentTimeline}" deleted!`, "success");
+
}
+
+
function loadPreset(presetName, hours) {
+
// Get current UI settings
+
const config = {
+
color1: document.getElementById("color1").value,
+
color2: document.getElementById("color2").value,
+
backgroundIntensity: parseInt(
+
document.getElementById("bgIntensity").value,
+
),
+
foregroundIntensity: parseInt(
+
document.getElementById("fgIntensity").value,
+
),
+
};
+
+
// Apply current settings to specified hours
+
hours.forEach((hour) => {
+
timelines[currentTimeline][hour] = { ...config };
+
});
+
+
updateTimelineDisplay();
+
showStatus(
+
`Applied current settings to ${presetName} hours: ${hours.join(", ")}`,
+
"success",
+
);
+
}
+
+
function applyCurrentToRange() {
+
const start = parseInt(
+
document.getElementById("rangeStart").value,
+
);
+
const end = parseInt(document.getElementById("rangeEnd").value);
+
+
if (start < 0 || start > 23 || end < 0 || end > 23) {
+
showStatus("Hours must be between 0 and 23!", "error");
+
return;
+
}
+
+
const config = {
+
color1: document.getElementById("color1").value,
+
color2: document.getElementById("color2").value,
+
backgroundIntensity: parseInt(
+
document.getElementById("bgIntensity").value,
+
),
+
foregroundIntensity: parseInt(
+
document.getElementById("fgIntensity").value,
+
),
+
};
+
+
// Generate hour range (handle wrap-around)
+
let hours = [];
+
if (start <= end) {
+
for (let i = start; i <= end; i++) {
+
hours.push(i);
+
}
+
} else {
+
// Wrap around (e.g., 22 to 2 = 22,23,0,1,2)
+
for (let i = start; i <= 23; i++) {
+
hours.push(i);
+
}
+
for (let i = 0; i <= end; i++) {
+
hours.push(i);
+
}
+
}
+
+
// Apply config to all hours in range
+
hours.forEach((hour) => {
+
timelines[currentTimeline][hour] = { ...config };
+
});
+
+
updateTimelineDisplay();
+
showStatus(
+
`Applied current settings to hours: ${hours.join(", ")}`,
+
"success",
+
);
+
}
+
+
function copyAllTimelines() {
+
const config = {
+
timelines: timelines,
+
metadata: {
+
created: new Date().toISOString(),
+
tool: "Sky Gradient Timeline Builder",
+
},
+
};
+
+
const configText = JSON.stringify(config, null, 2);
+
document.getElementById("configOutput").value = configText;
+
showStatus("All timelines ready to copy!", "success");
+
}
+
+
function renderCurrentHour() {
+
if (!baseImg || !matteImg) {
+
showStatus("Please upload both images first!", "error");
+
return;
+
}
+
+
const color1 = document.getElementById("color1").value;
+
const color2 = document.getElementById("color2").value;
+
const bgIntensity = parseInt(
+
document.getElementById("bgIntensity").value,
+
);
+
const fgIntensity = parseInt(
+
document.getElementById("fgIntensity").value,
+
);
+
+
// Use the full-size render canvas
+
const canvas = document.getElementById("renderCanvas");
+
const ctx = canvas.getContext("2d");
+
+
// Set canvas size to match base image
+
canvas.width = baseImg.width;
+
canvas.height = baseImg.height;
+
+
// Create gradient
+
const gradient = createGradient(
+
canvas.width,
+
canvas.height,
+
color1,
+
color2,
+
);
+
+
// Clear canvas
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+
// Create background layer
+
const backgroundLayer = blendImages(
+
baseImg,
+
gradient,
+
bgIntensity,
+
);
+
ctx.drawImage(backgroundLayer, 0, 0);
+
+
// Create and apply foreground layer
+
let foregroundLayer;
+
if (fgIntensity === 0) {
+
foregroundLayer = baseImg;
+
} else {
+
foregroundLayer = blendImages(
+
baseImg,
+
gradient,
+
fgIntensity,
+
);
+
}
+
+
// Apply masking
+
const maskedForeground = document.createElement("canvas");
+
maskedForeground.width = canvas.width;
+
maskedForeground.height = canvas.height;
+
const maskCtx = maskedForeground.getContext("2d");
+
+
maskCtx.drawImage(foregroundLayer, 0, 0);
+
+
// Create proper alpha mask
+
const matteDataCanvas = document.createElement("canvas");
+
matteDataCanvas.width = matteImg.width;
+
matteDataCanvas.height = matteImg.height;
+
const matteDataCtx = matteDataCanvas.getContext("2d");
+
matteDataCtx.drawImage(matteImg, 0, 0);
+
+
const imageData = matteDataCtx.getImageData(
+
0,
+
0,
+
matteImg.width,
+
matteImg.height,
+
);
+
const data = imageData.data;
+
+
for (let i = 0; i < data.length; i += 4) {
+
const brightness =
+
(data[i] + data[i + 1] + data[i + 2]) / 3;
+
if (brightness > 128) {
+
data[i + 3] = 255;
+
} else {
+
data[i + 3] = 0;
+
}
+
data[i] = 255;
+
data[i + 1] = 255;
+
data[i + 2] = 255;
+
}
+
+
matteDataCtx.putImageData(imageData, 0, 0);
+
+
maskCtx.globalCompositeOperation = "destination-in";
+
maskCtx.drawImage(
+
matteDataCanvas,
+
0,
+
0,
+
canvas.width,
+
canvas.height,
+
);
+
+
ctx.globalCompositeOperation = "source-over";
+
ctx.drawImage(maskedForeground, 0, 0);
+
+
// Enable download button
+
document.getElementById("downloadBtn").disabled = false;
+
showStatus(
+
`Hour ${selectedHour} rendered at full resolution!`,
+
"success",
+
);
+
}
+
+
function downloadRendered() {
+
const canvas = document.getElementById("renderCanvas");
+
if (canvas.width === 400 && canvas.height === 400) {
+
showStatus("Please render first!", "error");
+
return;
+
}
+
+
// Create download
+
const link = document.createElement("a");
+
const timelineName = currentTimeline;
+
const hour = selectedHour.toString().padStart(2, "0");
+
link.download = `${timelineName}_hour_${hour}.jpg`;
+
+
// Convert to JPEG for smaller file size
+
link.href = canvas.toDataURL("image/jpeg", 0.95);
+
link.click();
+
+
showStatus(`Downloaded: ${link.download}`, "success");
+
}
+
+
// Auto-import on paste
+
document
+
.getElementById("bulkConfigInput")
+
.addEventListener("paste", function (e) {
+
// Small delay to let the paste complete
+
setTimeout(() => {
+
const configText = this.value.trim();
+
if (configText && configText.startsWith("{")) {
+
importConfigToTimelines();
+
}
+
}, 100);
+
});
+
+
// Bulk rendering
+
let renderedImages = {};
+
+
function renderAllTimelines() {
+
if (!baseImg || !matteImg) {
+
showStatus("Please upload both images first!", "error");
+
return;
+
}
+
+
if (Object.keys(timelines).length === 0) {
+
showStatus("No timelines to render!", "error");
+
return;
+
}
+
+
// Clear previous renders
+
renderedImages = {};
+
+
// Show progress bar
+
document.getElementById("bulkProgress").style.display = "block";
+
document.getElementById("downloadAllBtn").disabled = true;
+
+
// Clear gallery
+
document.getElementById("galleryContent").innerHTML = "";
+
+
// Count total hours to render
+
const timelineNames = Object.keys(timelines);
+
let totalHours = 0;
+
let currentHour = 0;
+
+
timelineNames.forEach((timelineName) => {
+
const hours = Object.keys(timelines[timelineName]);
+
totalHours += hours.length;
+
});
+
+
updateProgress(0, totalHours);
+
updateGalleryInfo(0, totalHours, timelineNames.length);
+
showStatus(
+
`Rendering all timelines: ${timelineNames.length} timelines, ${totalHours} hours total`,
+
"success",
+
);
+
+
// Render all timelines sequentially
+
let timelineIndex = 0;
+
+
function renderNextTimeline() {
+
if (timelineIndex >= timelineNames.length) {
+
// All done!
+
document.getElementById("downloadAllBtn").disabled =
+
false;
+
showStatus(
+
`Render complete! ${totalHours} images rendered.`,
+
"success",
+
);
+
return;
+
}
+
+
const timelineName = timelineNames[timelineIndex];
+
const timelineConfig = timelines[timelineName];
+
const hours = Object.keys(timelineConfig).sort(
+
(a, b) => parseInt(a) - parseInt(b),
+
);
+
+
renderedImages[timelineName] = {};
+
+
let hourIndex = 0;
+
+
function renderNextHour() {
+
if (hourIndex >= hours.length) {
+
// Timeline done, move to next
+
timelineIndex++;
+
setTimeout(renderNextTimeline, 10);
+
return;
+
}
+
+
const hour = hours[hourIndex];
+
const hourConfig = timelineConfig[hour];
+
+
// Render this hour
+
const imageData = renderHourToDataURL(hourConfig);
+
if (imageData) {
+
renderedImages[timelineName][hour] = imageData;
+
currentHour++;
+
updateProgress(currentHour, totalHours);
+
updateGalleryInfo(
+
currentHour,
+
totalHours,
+
timelineNames.length,
+
);
+
+
// Add to gallery
+
addToGallery(timelineName, hour, imageData);
+
}
+
+
hourIndex++;
+
// Small delay to keep UI responsive
+
setTimeout(renderNextHour, 100);
+
}
+
+
renderNextHour();
+
}
+
+
renderNextTimeline();
+
}
+
+
function addToGallery(timelineName, hour, imageDataURL) {
+
const gallery = document.getElementById("galleryContent");
+
+
// Remove placeholder if it exists
+
const placeholder =
+
document.getElementById("galleryPlaceholder");
+
if (placeholder) {
+
placeholder.remove();
+
// Reset gallery styles
+
gallery.style.minHeight = "auto";
+
gallery.style.border = "none";
+
gallery.style.alignItems = "stretch";
+
gallery.style.justifyContent = "stretch";
+
}
+
+
const item = document.createElement("div");
+
item.style.cssText = `
+
border: 1px solid #ddd;
+
border-radius: 8px;
+
overflow: hidden;
+
background: white;
+
transition: transform 0.2s, box-shadow 0.2s;
+
cursor: pointer;
+
`;
+
+
const hourPadded = hour.toString().padStart(2, "0");
+
+
item.innerHTML = `
+
<img src="${imageDataURL}" style="width: 100%; height: 80px; object-fit: cover;">
+
<div style="padding: 8px; text-align: center;">
+
<div style="font-size: 11px; font-weight: bold; color: #333;">${timelineName}</div>
+
<div style="font-size: 10px; color: #666;">Hour ${hourPadded}</div>
+
</div>
+
`;
+
+
// Add hover effect
+
item.addEventListener("mouseenter", () => {
+
item.style.transform = "scale(1.05)";
+
item.style.boxShadow = "0 4px 15px rgba(0,0,0,0.2)";
+
});
+
+
item.addEventListener("mouseleave", () => {
+
item.style.transform = "scale(1)";
+
item.style.boxShadow = "none";
+
});
+
+
// Click to download individual image
+
item.addEventListener("click", () => {
+
const link = document.createElement("a");
+
link.href = imageDataURL;
+
link.download = `${timelineName}_hour_${hourPadded}.jpg`;
+
link.click();
+
showStatus(
+
`Downloaded ${timelineName} hour ${hourPadded}`,
+
"success",
+
);
+
});
+
+
gallery.appendChild(item);
+
}
+
+
function updateGalleryInfo(current, total, timelineCount) {
+
const info = document.getElementById("galleryInfo");
+
info.textContent = `${current}/${total} images rendered across ${timelineCount} timeline(s)`;
+
}
+
+
function clearGallery() {
+
const gallery = document.getElementById("galleryContent");
+
gallery.innerHTML = "";
+
+
// Reset gallery to placeholder state
+
gallery.style.minHeight = "100px";
+
gallery.style.border = "2px dashed #ddd";
+
gallery.style.alignItems = "center";
+
gallery.style.justifyContent = "center";
+
gallery.style.padding = "20px";
+
+
// Add placeholder back
+
const placeholder = document.createElement("div");
+
placeholder.id = "galleryPlaceholder";
+
placeholder.style.cssText =
+
"grid-column: 1 / -1; text-align: center; color: #999; font-style: italic;";
+
placeholder.textContent =
+
'Click "Render All" to generate images and see them here';
+
gallery.appendChild(placeholder);
+
+
// Reset state
+
renderedImages = {};
+
document.getElementById("downloadAllBtn").disabled = true;
+
document.getElementById("galleryInfo").textContent =
+
"Ready to render timelines";
+
showStatus("Gallery cleared", "success");
+
}
+
+
function renderAllFromConfig() {
+
if (!baseImg || !matteImg) {
+
showStatus("Please upload both images first!", "error");
+
return;
+
}
+
+
const configText = document
+
.getElementById("bulkConfigInput")
+
.value.trim();
+
if (!configText) {
+
showStatus("Please paste a config to render!", "error");
+
return;
+
}
+
+
let config;
+
try {
+
config = JSON.parse(configText);
+
} catch (e) {
+
showStatus("Invalid JSON config!", "error");
+
return;
+
}
+
+
// Validate config structure
+
if (!config.timelines) {
+
showStatus(
+
'Config must have "timelines" property!',
+
"error",
+
);
+
return;
+
}
+
+
// Clear previous renders
+
renderedImages = {};
+
+
// Show progress bar
+
document.getElementById("bulkProgress").style.display = "block";
+
document.getElementById("downloadAllBtn").disabled = true;
+
+
// Count total hours to render
+
const timelines = Object.keys(config.timelines);
+
let totalHours = 0;
+
let currentHour = 0;
+
+
timelines.forEach((timeline) => {
+
const hours = Object.keys(config.timelines[timeline]);
+
totalHours += hours.length;
+
});
+
+
updateProgress(0, totalHours);
+
showStatus(
+
`Starting bulk render: ${timelines.length} timelines, ${totalHours} hours total`,
+
"success",
+
);
+
+
// Render all timelines sequentially with small delays for UI responsiveness
+
let timelineIndex = 0;
+
+
function renderNextTimeline() {
+
if (timelineIndex >= timelines.length) {
+
// All done!
+
document.getElementById("downloadAllBtn").disabled =
+
false;
+
showStatus(
+
`Bulk render complete! ${totalHours} images rendered.`,
+
"success",
+
);
+
return;
+
}
+
+
const timelineName = timelines[timelineIndex];
+
const timelineConfig = config.timelines[timelineName];
+
const hours = Object.keys(timelineConfig);
+
+
renderedImages[timelineName] = {};
+
+
let hourIndex = 0;
+
+
function renderNextHour() {
+
if (hourIndex >= hours.length) {
+
// Timeline done, move to next
+
timelineIndex++;
+
setTimeout(renderNextTimeline, 10);
+
return;
+
}
+
+
const hour = hours[hourIndex];
+
const hourConfig = timelineConfig[hour];
+
+
// Render this hour
+
const imageData = renderHourToDataURL(hourConfig);
+
if (imageData) {
+
renderedImages[timelineName][hour] = imageData;
+
currentHour++;
+
updateProgress(currentHour, totalHours);
+
}
+
+
hourIndex++;
+
// Small delay to keep UI responsive
+
setTimeout(renderNextHour, 50);
+
}
+
+
renderNextHour();
+
}
+
+
renderNextTimeline();
+
}
+
+
function renderHourToDataURL(config) {
+
try {
+
// Create a temporary canvas for this render
+
const canvas = document.createElement("canvas");
+
const ctx = canvas.getContext("2d");
+
+
// Set canvas size to match base image
+
canvas.width = baseImg.width;
+
canvas.height = baseImg.height;
+
+
// Create gradient
+
const gradient = createGradient(
+
canvas.width,
+
canvas.height,
+
config.color1,
+
config.color2,
+
);
+
+
// Clear canvas
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+
// Create background layer
+
const backgroundLayer = blendImages(
+
baseImg,
+
gradient,
+
config.backgroundIntensity,
+
);
+
ctx.drawImage(backgroundLayer, 0, 0);
+
+
// Create and apply foreground layer
+
let foregroundLayer;
+
if (config.foregroundIntensity === 0) {
+
foregroundLayer = baseImg;
+
} else {
+
foregroundLayer = blendImages(
+
baseImg,
+
gradient,
+
config.foregroundIntensity,
+
);
+
}
+
+
// Apply masking
+
const maskedForeground = document.createElement("canvas");
+
maskedForeground.width = canvas.width;
+
maskedForeground.height = canvas.height;
+
const maskCtx = maskedForeground.getContext("2d");
+
+
maskCtx.drawImage(foregroundLayer, 0, 0);
+
+
// Create proper alpha mask
+
const matteDataCanvas = document.createElement("canvas");
+
matteDataCanvas.width = matteImg.width;
+
matteDataCanvas.height = matteImg.height;
+
const matteDataCtx = matteDataCanvas.getContext("2d");
+
matteDataCtx.drawImage(matteImg, 0, 0);
+
+
const imageData = matteDataCtx.getImageData(
+
0,
+
0,
+
matteImg.width,
+
matteImg.height,
+
);
+
const data = imageData.data;
+
+
for (let i = 0; i < data.length; i += 4) {
+
const brightness =
+
(data[i] + data[i + 1] + data[i + 2]) / 3;
+
if (brightness > 128) {
+
data[i + 3] = 255;
+
} else {
+
data[i + 3] = 0;
+
}
+
data[i] = 255;
+
data[i + 1] = 255;
+
data[i + 2] = 255;
+
}
+
+
matteDataCtx.putImageData(imageData, 0, 0);
+
+
maskCtx.globalCompositeOperation = "destination-in";
+
maskCtx.drawImage(
+
matteDataCanvas,
+
0,
+
0,
+
canvas.width,
+
canvas.height,
+
);
+
+
ctx.globalCompositeOperation = "source-over";
+
ctx.drawImage(maskedForeground, 0, 0);
+
+
// Return as JPEG data URL
+
return canvas.toDataURL("image/jpeg", 0.95);
+
} catch (e) {
+
console.error("Failed to render hour:", e);
+
return null;
+
}
+
}
+
+
function updateProgress(current, total) {
+
const percentage = total > 0 ? (current / total) * 100 : 0;
+
document.getElementById("progressBar").style.width =
+
percentage + "%";
+
document.getElementById("progressText").textContent =
+
`${current}/${total}`;
+
}
+
+
async function downloadAllRendered() {
+
if (Object.keys(renderedImages).length === 0) {
+
showStatus("No rendered images to download!", "error");
+
return;
+
}
+
+
showStatus("Preparing download...", "success");
+
+
// Simple approach: create individual downloads if ZIP fails
+
try {
+
// Try to load JSZip if not available
+
if (typeof JSZip === "undefined") {
+
showStatus("Loading ZIP library...", "success");
+
await loadJSZip();
+
}
+
+
await createZipDownload();
+
} catch (e) {
+
console.error("ZIP download failed:", e);
+
showStatus(
+
"ZIP failed, downloading individual files...",
+
"warning",
+
);
+
downloadIndividualFiles();
+
}
+
}
+
+
function loadJSZip() {
+
return new Promise((resolve, reject) => {
+
const script = document.createElement("script");
+
script.src =
+
"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js";
+
script.onload = resolve;
+
script.onerror = reject;
+
document.head.appendChild(script);
+
});
+
}
+
+
async function createZipDownload() {
+
const zip = new JSZip();
+
+
// First, fetch and add the shell script to the root
+
try {
+
showStatus("Including shell script...", "success");
+
try {
+
// Try local copy first, then relative path, then original source as fallback
+
const scriptUrls = ["/pfp-updates/bsky-pfp-updates.sh"];
+
+
let scriptContent = null;
+
for (const url of scriptUrls) {
+
try {
+
const scriptResponse = await fetch(url);
+
if (scriptResponse.ok) {
+
scriptContent = await scriptResponse.text();
+
break;
+
}
+
} catch (err) {
+
console.log(
+
`Failed to fetch from ${url}:`,
+
err,
+
);
+
// Continue to next URL
+
}
+
}
+
+
if (scriptContent) {
+
zip.file("bsky-pfp-updates.sh", scriptContent);
+
} else {
+
throw new Error(
+
"Could not load script from any source",
+
);
+
}
+
} catch (error) {
+
showStatus(
+
"Warning: Could not include shell script, continuing without it...",
+
"warning",
+
);
+
console.error("Script loading error:", error);
+
}
+
} catch (e) {
+
console.error("Failed to fetch shell script:", e);
+
showStatus(
+
"Warning: Could not fetch shell script, continuing without it...",
+
"warning",
+
);
+
}
+
+
// Add the timeline config to the root
+
const config = {
+
timelines: timelines,
+
metadata: {
+
created: new Date().toISOString(),
+
tool: "Sky Gradient Timeline Builder",
+
version: "1.0",
+
},
+
};
+
const configText = JSON.stringify(config, null, 2);
+
zip.file("timeline_config.json", configText);
+
+
// Create rendered_timelines folder and add all images
+
const renderedFolder = zip.folder("rendered_timelines");
+
+
for (const [timelineName, timelineImages] of Object.entries(
+
renderedImages,
+
)) {
+
const timelineFolder = renderedFolder.folder(timelineName);
+
+
for (const [hour, imageDataURL] of Object.entries(
+
timelineImages,
+
)) {
+
// Convert data URL to binary data
+
const base64Data = imageDataURL.split(",")[1];
+
const binaryData = atob(base64Data);
+
const bytes = new Uint8Array(binaryData.length);
+
for (let i = 0; i < binaryData.length; i++) {
+
bytes[i] = binaryData.charCodeAt(i);
+
}
+
+
const hourPadded = hour.padStart(2, "0");
+
timelineFolder.file(`hour_${hourPadded}.jpg`, bytes);
+
}
+
}
+
+
// Generate and download ZIP
+
showStatus("Creating ZIP file...", "success");
+
const content = await zip.generateAsync({
+
type: "blob",
+
compression: "DEFLATE",
+
compressionOptions: { level: 6 },
+
});
+
+
// Create download link
+
const link = document.createElement("a");
+
const url = URL.createObjectURL(content);
+
link.href = url;
+
link.download = "bluesky-pfp-updates.zip";
+
+
// Trigger download
+
document.body.appendChild(link);
+
link.click();
+
document.body.removeChild(link);
+
+
// Cleanup
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
+
+
showStatus("ZIP file downloaded with shell script!", "success");
+
}
+
+
function importConfigToTimelines() {
+
const configText = document
+
.getElementById("bulkConfigInput")
+
.value.trim();
+
if (!configText) {
+
showStatus("Please paste a config to import!", "error");
+
return;
+
}
+
+
let config;
+
try {
+
config = JSON.parse(configText);
+
} catch (e) {
+
showStatus("Invalid JSON config!", "error");
+
return;
+
}
+
+
// Validate config structure
+
if (!config.timelines) {
+
showStatus(
+
'Config must have "timelines" property!',
+
"error",
+
);
+
return;
+
}
+
+
// Clear existing timelines (except keep one if empty)
+
const wasEmpty = Object.keys(timelines).length === 0;
+
timelines = {};
+
+
// Clear existing tabs
+
document.getElementById("timelineTabs").innerHTML = "";
+
+
// Import all timelines from config
+
const importedTimelines = Object.keys(config.timelines);
+
let importedCount = 0;
+
+
for (const [timelineName, timelineConfig] of Object.entries(
+
config.timelines,
+
)) {
+
// Validate timeline structure
+
if (typeof timelineConfig !== "object") {
+
showStatus(
+
`Invalid timeline structure for "${timelineName}"`,
+
"warning",
+
);
+
continue;
+
}
+
+
// Convert timeline config to our format
+
timelines[timelineName] = {};
+
+
for (const [hour, hourConfig] of Object.entries(
+
timelineConfig,
+
)) {
+
const hourNum = parseInt(hour);
+
if (
+
hourNum >= 0 &&
+
hourNum <= 23 &&
+
hourConfig.color1 &&
+
hourConfig.color2
+
) {
+
timelines[timelineName][hourNum] = {
+
color1: hourConfig.color1,
+
color2: hourConfig.color2,
+
backgroundIntensity:
+
hourConfig.backgroundIntensity || 40,
+
foregroundIntensity:
+
hourConfig.foregroundIntensity || 8,
+
};
+
}
+
}
+
+
// Create tab for this timeline
+
const tab = document.createElement("div");
+
tab.className = "timeline-tab";
+
tab.dataset.timeline = timelineName;
+
tab.textContent = timelineName;
+
tab.onclick = () => switchTimeline(timelineName);
+
document.getElementById("timelineTabs").appendChild(tab);
+
+
importedCount++;
+
}
+
+
if (importedCount === 0) {
+
showStatus("No valid timelines found in config!", "error");
+
// Restore default if nothing imported
+
timelines.sunny = {};
+
for (let hour = 0; hour < 24; hour++) {
+
timelines.sunny[hour] = getDefaultConfigForHour(hour);
+
}
+
const tab = document.createElement("div");
+
tab.className = "timeline-tab active";
+
tab.dataset.timeline = "sunny";
+
tab.textContent = "sunny";
+
tab.onclick = () => switchTimeline("sunny");
+
document.getElementById("timelineTabs").appendChild(tab);
+
currentTimeline = "sunny";
+
} else {
+
// Switch to first imported timeline
+
const firstTimeline = importedTimelines[0];
+
currentTimeline = firstTimeline;
+
document
+
.querySelector(`[data-timeline="${firstTimeline}"]`)
+
.classList.add("active");
+
document.getElementById("currentTimelineName").textContent =
+
firstTimeline;
+
}
+
+
// Update UI
+
initializeTimeline();
+
+
showStatus(
+
`Successfully imported ${importedCount} timeline(s): ${importedTimelines.join(", ")}`,
+
"success",
+
);
+
+
// Clear the config input
+
document.getElementById("bulkConfigInput").value = "";
+
}
+
+
function downloadIndividualFiles() {
+
let downloadCount = 0;
+
const totalFiles = Object.values(renderedImages).reduce(
+
(sum, timeline) => sum + Object.keys(timeline).length,
+
0,
+
);
+
+
for (const [timelineName, timelineImages] of Object.entries(
+
renderedImages,
+
)) {
+
for (const [hour, imageDataURL] of Object.entries(
+
timelineImages,
+
)) {
+
const hourPadded = hour.padStart(2, "0");
+
const filename = `${timelineName}_hour_${hourPadded}.jpg`;
+
+
// Create download link
+
const link = document.createElement("a");
+
link.href = imageDataURL;
+
link.download = filename;
+
+
// Trigger download with small delay
+
setTimeout(() => {
+
document.body.appendChild(link);
+
link.click();
+
document.body.removeChild(link);
+
downloadCount++;
+
+
if (downloadCount === totalFiles) {
+
showStatus(
+
`Downloaded ${totalFiles} individual files!`,
+
"success",
+
);
+
}
+
}, downloadCount * 100); // 100ms delay between downloads
+
}
+
}
+
+
showStatus(
+
`Starting ${totalFiles} individual downloads...`,
+
"success",
+
);
+
}
+
+
function copyToClipboard() {
+
const textarea = document.getElementById("configOutput");
+
if (!textarea.value.trim()) {
+
showStatus(
+
"Nothing to copy! Generate a config first.",
+
"error",
+
);
+
return;
+
}
+
+
textarea.select();
+
document.execCommand("copy");
+
+
// Try the modern API as fallback
+
if (navigator.clipboard) {
+
navigator.clipboard
+
.writeText(textarea.value)
+
.then(() => {
+
showStatus(
+
"Config copied to clipboard!",
+
"success",
+
);
+
})
+
.catch(() => {
+
showStatus(
+
"Please manually copy the text",
+
"error",
+
);
+
});
+
} else {
+
showStatus(
+
"Config selected - press Ctrl+C to copy",
+
"success",
+
);
+
}
+
}
+
+
function showStatus(message, type) {
+
const status = document.getElementById("uploadStatus");
+
status.textContent = message;
+
status.className = `status ${type}`;
+
setTimeout(() => (status.style.display = "none"), 3000);
+
}
+
+
// Image upload handlers
+
document
+
.getElementById("baseImage")
+
.addEventListener("change", function (e) {
+
const file = e.target.files[0];
+
if (file) {
+
const reader = new FileReader();
+
reader.onload = function (e) {
+
const img = new Image();
+
img.onload = function () {
+
baseImg = img;
+
showStatus("Base image loaded!", "success");
+
updatePreview();
+
};
+
img.src = e.target.result;
+
};
+
reader.readAsDataURL(file);
+
}
+
});
+
+
document
+
.getElementById("matteImage")
+
.addEventListener("change", function (e) {
+
const file = e.target.files[0];
+
if (file) {
+
const reader = new FileReader();
+
reader.onload = function (e) {
+
const img = new Image();
+
img.onload = function () {
+
matteImg = img;
+
showStatus("Matte loaded!", "success");
+
updatePreview();
+
};
+
img.src = e.target.result;
+
};
+
reader.readAsDataURL(file);
+
}
+
});
+
+
// Slider updates
+
document
+
.getElementById("bgIntensity")
+
.addEventListener("input", function () {
+
document.getElementById("bgValue").textContent =
+
this.value + "%";
+
updatePreview();
+
});
+
+
document
+
.getElementById("fgIntensity")
+
.addEventListener("input", function () {
+
document.getElementById("fgValue").textContent =
+
this.value + "%";
+
updatePreview();
+
});
+
+
document
+
.getElementById("color1")
+
.addEventListener("change", updatePreview);
+
document
+
.getElementById("color2")
+
.addEventListener("change", updatePreview);
+
+
function updatePreview() {
+
if (!baseImg || !matteImg) return;
+
+
const canvas = document.getElementById("previewCanvas");
+
const ctx = canvas.getContext("2d");
+
+
const color1 = document.getElementById("color1").value;
+
const color2 = document.getElementById("color2").value;
+
const bgIntensity = parseInt(
+
document.getElementById("bgIntensity").value,
+
);
+
const fgIntensity = parseInt(
+
document.getElementById("fgIntensity").value,
+
);
+
+
// Create gradient
+
const gradient = createGradient(
+
canvas.width,
+
canvas.height,
+
color1,
+
color2,
+
);
+
+
// Clear canvas
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+
// Create background layer
+
const backgroundLayer = blendImages(
+
baseImg,
+
gradient,
+
bgIntensity,
+
);
+
ctx.drawImage(
+
backgroundLayer,
+
0,
+
0,
+
canvas.width,
+
canvas.height,
+
);
+
+
// Create and apply foreground layer
+
let foregroundLayer;
+
if (fgIntensity === 0) {
+
foregroundLayer = baseImg;
+
} else {
+
foregroundLayer = blendImages(
+
baseImg,
+
gradient,
+
fgIntensity,
+
);
+
}
+
+
// Apply masking
+
const maskedForeground = document.createElement("canvas");
+
maskedForeground.width = canvas.width;
+
maskedForeground.height = canvas.height;
+
const maskCtx = maskedForeground.getContext("2d");
+
+
maskCtx.drawImage(
+
foregroundLayer,
+
0,
+
0,
+
canvas.width,
+
canvas.height,
+
);
+
+
// Create proper alpha mask
+
const matteDataCanvas = document.createElement("canvas");
+
matteDataCanvas.width = matteImg.width;
+
matteDataCanvas.height = matteImg.height;
+
const matteDataCtx = matteDataCanvas.getContext("2d");
+
matteDataCtx.drawImage(matteImg, 0, 0);
+
+
const imageData = matteDataCtx.getImageData(
+
0,
+
0,
+
matteImg.width,
+
matteImg.height,
+
);
+
const data = imageData.data;
+
+
for (let i = 0; i < data.length; i += 4) {
+
const brightness =
+
(data[i] + data[i + 1] + data[i + 2]) / 3;
+
if (brightness > 128) {
+
data[i + 3] = 255;
+
} else {
+
data[i + 3] = 0;
+
}
+
data[i] = 255;
+
data[i + 1] = 255;
+
data[i + 2] = 255;
+
}
+
+
matteDataCtx.putImageData(imageData, 0, 0);
+
+
maskCtx.globalCompositeOperation = "destination-in";
+
maskCtx.drawImage(
+
matteDataCanvas,
+
0,
+
0,
+
canvas.width,
+
canvas.height,
+
);
+
+
ctx.globalCompositeOperation = "source-over";
+
ctx.drawImage(maskedForeground, 0, 0);
+
}
+
+
function createGradient(width, height, color1, color2) {
+
const gradCanvas = document.createElement("canvas");
+
gradCanvas.width = width;
+
gradCanvas.height = height;
+
const gradCtx = gradCanvas.getContext("2d");
+
+
const gradient = gradCtx.createLinearGradient(0, 0, 0, height);
+
gradient.addColorStop(0, color1);
+
gradient.addColorStop(1, color2);
+
+
gradCtx.fillStyle = gradient;
+
gradCtx.fillRect(0, 0, width, height);
+
+
return gradCanvas;
+
}
+
+
function blendImages(base, overlay, intensity) {
+
const blendCanvas = document.createElement("canvas");
+
blendCanvas.width = base.width;
+
blendCanvas.height = base.height;
+
const blendCtx = blendCanvas.getContext("2d");
+
+
blendCtx.drawImage(base, 0, 0);
+
blendCtx.globalCompositeOperation = "overlay";
+
blendCtx.globalAlpha = intensity / 100;
+
blendCtx.drawImage(overlay, 0, 0, base.width, base.height);
+
+
return blendCanvas;
+
}
+
+
// Initialize
+
initializeTimeline();
+
</script>
+
</body>
+
</html>