new repo
This commit is contained in:
commit
0442c5ba0a
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
out/
|
||||
*.gif
|
||||
*.mp4
|
||||
__pycache__/
|
||||
*.db
|
51
Dockerfile
Normal file
51
Dockerfile
Normal file
@ -0,0 +1,51 @@
|
||||
# Use Python 3.10.11 slim image as the base image
|
||||
FROM python:3.10.11-slim
|
||||
|
||||
# Set environment variables to avoid writing .pyc files and buffering stdout and stderr
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
# Create a new user 'user' with UID and GID of 1000
|
||||
RUN groupadd -g 1000 user && \
|
||||
useradd -m -s /bin/bash -u 1000 -g user user
|
||||
|
||||
# Set environment variables for the user install
|
||||
ENV PATH=/home/user/.local/bin:$PATH
|
||||
|
||||
# Install system dependencies as root
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends make ffmpeg dumb-init && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set the home directory
|
||||
WORKDIR /home/user/
|
||||
RUN chown -R user:user /home/user
|
||||
|
||||
# Switch to non-root user before copying files and installing Python packages
|
||||
USER user
|
||||
|
||||
# Copy the requirements file to /tmp and install Python dependencies with user flag
|
||||
COPY --chown=user:user requirements.txt /tmp/requirements.txt
|
||||
RUN python -m pip install --upgrade pip
|
||||
RUN pip install --no-cache-dir --user -r /tmp/requirements.txt
|
||||
|
||||
# APPLICATION SETUP
|
||||
|
||||
# Copy the default profiles file and set the appropriate permissions
|
||||
COPY --chown=user:user profiles.default.toml /home/user/.prefect/profiles.toml
|
||||
|
||||
# Copy the application files
|
||||
COPY --chown=user:user app ./app
|
||||
COPY --chown=user:user noaa_animate.py .
|
||||
COPY --chown=user:user start.sh .
|
||||
COPY --chown=user:user init_db.py .
|
||||
RUN chmod +x start.sh
|
||||
RUN mkdir -p out
|
||||
RUN python init_db.py /home/user/.prefect/prefect.db
|
||||
|
||||
# Set the correct ownership (recursively) for /app
|
||||
# Already owned by user due to --chown in COPY commands
|
||||
|
||||
# Define the entrypoint and the commands to execute
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["./start.sh"]
|
19
README.md
Normal file
19
README.md
Normal file
@ -0,0 +1,19 @@
|
||||
# NOAA Animation Web App
|
||||
![preview](preview.jpg)
|
||||
|
||||
Animate images from a website full of urls by way of a proxy server and some Python functions wrapped with the `prefect` decorator.
|
||||
Uses https://services.swpc.noaa.gov as an example.
|
||||
|
||||
|
||||
## Instructions
|
||||
If you have `docker` and `make` installed, just run:
|
||||
|
||||
```bash
|
||||
make
|
||||
```
|
||||
|
||||
and visit `localhost:4200` to see Prefect, and `localhost:9021/iframe` to view the UI.
|
||||
|
||||
An example Flow of about 100 images:
|
||||
![prefect](prefect.jpg)
|
||||
|
147
app/app.py
Normal file
147
app/app.py
Normal file
@ -0,0 +1,147 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
from flask import Flask, Response, render_template, request, send_from_directory
|
||||
from prefect.deployments import run_deployment
|
||||
|
||||
PORT = 9021
|
||||
app = Flask(__name__)
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
|
||||
def deploy_name():
|
||||
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S") + "Z"
|
||||
|
||||
|
||||
def get_host():
|
||||
host = os.environ.get("LIGHTNING_CLOUDSPACE_HOST")
|
||||
if host is None:
|
||||
default_host = os.environ.get("HOST_NAME", "0.0.0.0")
|
||||
return f"{default_host}:{PORT}"
|
||||
else:
|
||||
return f"{PORT}-{host}"
|
||||
|
||||
|
||||
@app.route("/iframe")
|
||||
@app.route("/iframe/")
|
||||
@app.route("/iframe/<path:subpath>")
|
||||
def home(subpath="images/animations/"):
|
||||
host = get_host()
|
||||
initial_url = f"http://{host}/{subpath}"
|
||||
api_url = f"http://{host}/api"
|
||||
return render_template(
|
||||
"index.html", initial_url=initial_url, host=f"http://{host}", api_url=api_url
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api", methods=["POST"])
|
||||
def handle_api():
|
||||
data = request.json # This assumes you're sending JSON data.
|
||||
url = data.get("url")
|
||||
logging.debug(f"Received URL: {url}")
|
||||
params = {"url": url, "limit": 24 * 60, "ext": None}
|
||||
response = run_deployment(
|
||||
name="create-animations/noaa-animate",
|
||||
parameters=params,
|
||||
flow_run_name=f"{deploy_name()}.webapp.{url}",
|
||||
)
|
||||
# response is a FlowRun - need to get what we want from it.
|
||||
|
||||
# Process the data as needed.
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"{url} processed successfully",
|
||||
# "response": response,
|
||||
}, 200
|
||||
|
||||
|
||||
@app.route("/videos/<path:filename>")
|
||||
def custom_static(filename):
|
||||
return send_from_directory("../out", filename)
|
||||
|
||||
|
||||
@app.route("/", methods=["GET"])
|
||||
@app.route("/<path:url>", methods=["GET"])
|
||||
def proxy(url=""):
|
||||
original_base_url = "https://services.swpc.noaa.gov"
|
||||
host = get_host()
|
||||
proxy_base_url = f"http://{host}/"
|
||||
|
||||
target_url = f"{original_base_url}/{url}"
|
||||
logging.debug(f"Fetching URL: {target_url}")
|
||||
|
||||
try:
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36"
|
||||
}
|
||||
response = requests.get(target_url, headers=headers, stream=True)
|
||||
excluded_headers = [
|
||||
"content-encoding",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
"connection",
|
||||
]
|
||||
headers = [
|
||||
(name, value)
|
||||
for (name, value) in response.raw.headers.items()
|
||||
if name.lower() not in excluded_headers
|
||||
]
|
||||
|
||||
if "text/html" in response.headers.get("Content-Type", ""):
|
||||
content = response.content.decode("utf-8")
|
||||
content = re.sub(r"'http://", "'https://", content)
|
||||
content = re.sub(
|
||||
r"https?://services.swpc.noaa.gov", proxy_base_url, content
|
||||
)
|
||||
|
||||
content = content.replace(
|
||||
"</body>",
|
||||
f"""
|
||||
<script>
|
||||
window.addEventListener('load', function() {{
|
||||
var hasSufficientImages = false;
|
||||
var observer = new MutationObserver(function(mutations) {{
|
||||
mutations.forEach(function(mutation) {{
|
||||
if (mutation.type === 'childList') {{
|
||||
console.log('observer');
|
||||
checkImages();
|
||||
}}
|
||||
}});
|
||||
}});
|
||||
observer.observe(document.body, {{ childList: true, subtree: true }});
|
||||
|
||||
function checkImages() {{
|
||||
var links = document.querySelectorAll('a');
|
||||
var imageLinkRegex = /\.(jpg|jpeg|png)(\\?.*)?(#.*)?$/i;
|
||||
var numImages = Array.from(links).filter(link => imageLinkRegex.test(link.href)).length;
|
||||
console.log('Number of eligible links:', numImages);
|
||||
hasSufficientImages = numImages >= 60;
|
||||
window.parent.postMessage({{ type: 'urlUpdate', url: '{original_base_url}/{url}', eligible: hasSufficientImages }}, '*');
|
||||
}}
|
||||
|
||||
// Run initial check in case content is already loaded
|
||||
console.log('initial');
|
||||
checkImages();
|
||||
}});
|
||||
</script>
|
||||
</body>""",
|
||||
)
|
||||
content = content.encode("utf-8")
|
||||
return Response(content, status=response.status_code, headers=headers)
|
||||
else:
|
||||
return Response(
|
||||
response.content, status=response.status_code, headers=headers
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error fetching URL: {e}")
|
||||
return Response(f"Error fetching URL: {e}", status=500)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=9021, debug=True)
|
5
app/makefile
Normal file
5
app/makefile
Normal file
@ -0,0 +1,5 @@
|
||||
start:
|
||||
gunicorn --worker-class gevent --bind 0.0.0.0:9021 app:app
|
||||
|
||||
dev:
|
||||
python app.py
|
271
app/templates/index.html
Normal file
271
app/templates/index.html
Normal file
@ -0,0 +1,271 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Animator</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
iframe {
|
||||
width: 99%;
|
||||
/* Full width to use available space */
|
||||
height: 500px;
|
||||
border: 1px solid #ccc;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
#current-url {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
width: 100%;
|
||||
/* Makes sure it takes the full container width */
|
||||
word-wrap: break-word;
|
||||
/* Ensures the text wraps in the div */
|
||||
margin-bottom: 10px;
|
||||
/* Space before the navigation buttons */
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
/* Aligns buttons in a row */
|
||||
justify-content: center;
|
||||
/* Centers buttons horizontally */
|
||||
gap: 10px;
|
||||
/* Adds space between buttons */
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 5px 10px;
|
||||
margin: 10px;
|
||||
border-radius: 5px;
|
||||
/* Rounded corners */
|
||||
border: 1px solid #ccc;
|
||||
/* Grey border */
|
||||
display: inline-block;
|
||||
/* Ensures buttons are inline and can control additional layout properties */
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#backButton,
|
||||
#forwardButton {
|
||||
background-color: #f0f0f0;
|
||||
/* Light grey background */
|
||||
color: #333;
|
||||
/* Dark text */
|
||||
padding: 0px 10px;
|
||||
/* Reduced vertical padding for narrow height */
|
||||
cursor: pointer;
|
||||
/* Cursor indicates button */
|
||||
height: 24px;
|
||||
/* Fixed height for a narrower button */
|
||||
line-height: 16px;
|
||||
/* Adjust line height to vertically center the text */
|
||||
margin: 2px;
|
||||
/* Small margin to separate buttons slightly */
|
||||
}
|
||||
|
||||
#backButton:hover,
|
||||
#forwardButton:hover {
|
||||
background-color: #e8e8e8;
|
||||
/* Slightly darker background on hover */
|
||||
}
|
||||
|
||||
video {
|
||||
display: none;
|
||||
/* Initially hide the video player */
|
||||
width: 99%;
|
||||
/* Adjust based on your layout needs, or use max-width for responsiveness */
|
||||
height: auto;
|
||||
/* Maintain aspect ratio */
|
||||
margin-top: 20px;
|
||||
/* Ensure it's centered properly */
|
||||
max-width: 640px;
|
||||
/* Max width of the video */
|
||||
border: 1px solid #ccc;
|
||||
/* Optional, adds a border for better visibility */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Animate a Folder of Images</h1>
|
||||
<p>Navigate to a folder of 60+ images.</p>
|
||||
<iframe id="iframe" src="{{ initial_url }}"></iframe>
|
||||
<div class="button-group"> <!-- Button group for inline display -->
|
||||
<button id="backButton" onclick="goBack()">←</button>
|
||||
<button id="forwardButton" onclick="goForward()">→</button>
|
||||
</div>
|
||||
<button id="submit-button" onclick="submitUrl()" style="display:none;">Create Latest Movie</button>
|
||||
<div id="loading-spinner" style="display: none;">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.spinner {
|
||||
border: 8px solid #f3f3f3;
|
||||
/* Light grey */
|
||||
border-top: 8px solid #3498db;
|
||||
/* Blue */
|
||||
border-radius: 50%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
animation: spin 45s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<video id="video-player" controls loop style="display: none;">
|
||||
<source id="video-source" type="video/mp4">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<script>
|
||||
function goBack() {
|
||||
document.getElementById('iframe').contentWindow.history.back();
|
||||
}
|
||||
|
||||
function goForward() {
|
||||
document.getElementById('iframe').contentWindow.history.forward();
|
||||
}
|
||||
|
||||
// function updateUrl(url) {
|
||||
// document.getElementById('url').textContent = url;
|
||||
// }
|
||||
|
||||
function updateUrl(url) {
|
||||
document.getElementById('url').textContent = url;
|
||||
}
|
||||
|
||||
function handleVideoSuccess() {
|
||||
console.log("Video loaded successfully.");
|
||||
document.getElementById('video-player').style.display = 'block'; // Show the video player only if the video loads successfully
|
||||
}
|
||||
|
||||
function handleVideoError() {
|
||||
console.log("Unable to load video.");
|
||||
document.getElementById('video-player').style.display = 'none'; // Hide the video player
|
||||
document.getElementById('submit-button').textContent = 'Generate Movie';
|
||||
}
|
||||
|
||||
function updateVideo(url) {
|
||||
// Convert the full URL to a format suitable for your video path
|
||||
let formattedPath = url.replace(/https?:\/\//, '') // Remove the protocol part
|
||||
.replace(/\./g, '_') // Replace dots with underscores
|
||||
.replace(/\//g, '-'); // Replace slashes with hyphens
|
||||
// Check if the formattedPath ends with a slash, if not append '-'
|
||||
if (!formattedPath.endsWith('-')) {
|
||||
formattedPath += '-';
|
||||
}
|
||||
|
||||
// Append '.mp4' to the formatted path
|
||||
let videoPath = `${formattedPath}latest.mp4`;
|
||||
let videoPlayer = document.getElementById('video-player');
|
||||
let videoSource = document.getElementById('video-source');
|
||||
|
||||
videoPlayer.muted = true;
|
||||
// Set up event listeners before setting the source
|
||||
videoSource.onerror = handleVideoError;
|
||||
// videoSource.onloadedmetadata = handleVideoSuccess;
|
||||
|
||||
console.log("Fetched latest")
|
||||
videoSource.src = `/videos/${videoPath}`;
|
||||
videoPlayer.load();
|
||||
// videoPlayer.style.display = 'block';
|
||||
videoPlayer.play().then(() => {
|
||||
// The video is playing, show the player
|
||||
console.log("Video loaded and playing.");
|
||||
videoPlayer.style.display = 'block';
|
||||
}).catch(error => {
|
||||
// Error playing the video
|
||||
console.log("Failed to play video: ", error);
|
||||
videoPlayer.style.display = 'none';
|
||||
});
|
||||
document.getElementById('submit-button').textContent = 'Generate Latest Movie';
|
||||
}
|
||||
|
||||
window.addEventListener('message', function (event) {
|
||||
if (event.origin === '{{ host }}') {
|
||||
var data = event.data;
|
||||
if (data && data.type === 'urlUpdate') {
|
||||
const submitButton = document.getElementById('submit-button');
|
||||
const videoPlayer = document.getElementById('video-player');
|
||||
updateUrl(data.url);
|
||||
if (data.eligible) {
|
||||
submitButton.style.display = 'block'; // Show the button
|
||||
updateVideo(data.url);
|
||||
} else {
|
||||
submitButton.style.display = 'none'; // Hide the button
|
||||
videoPlayer.style.display = 'none'; // Hide the video
|
||||
}
|
||||
const newSubpath = new URL(data.url).pathname; // Extract the path from the URL
|
||||
// Update the browser's URL to reflect the iframe's navigation
|
||||
const newPath = `/iframe${newSubpath}`; // Construct the new path
|
||||
document.getElementById('share-button').setAttribute('data-url', window.location.origin + newPath);
|
||||
// history.pushState({ path: newPath }, '', newPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function submitUrl() {
|
||||
const url = document.getElementById('url').textContent;
|
||||
const payload = { url: url };
|
||||
document.getElementById('loading-spinner').style.display = 'block'; // Show the loading spinner
|
||||
document.getElementById('submit-button').style.display = 'none'; // Hide the button
|
||||
console.log("Requesting new video.")
|
||||
fetch('{{ api_url }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
}).then(response => response.json())
|
||||
.then(data => {
|
||||
console.log(data);
|
||||
// Hide the loading spinner
|
||||
document.getElementById('loading-spinner').style.display = 'none';
|
||||
document.getElementById('submit-button').style.display = 'block';
|
||||
updateUrl(url);
|
||||
// Re-attempt to load the video
|
||||
updateVideo(url);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
// Hide the loading spinner
|
||||
document.getElementById('loading-spinner').style.display = 'none';
|
||||
document.getElementById('submit-button').style.display = 'block';
|
||||
});
|
||||
}
|
||||
|
||||
function copyUrlToClipboard() {
|
||||
const url = document.getElementById('share-button').getAttribute('data-url');
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
alert('URL copied to clipboard!');
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
<button id="share-button" onclick="copyUrlToClipboard()">Share Link</button>
|
||||
|
||||
<div align="center" id="current-url">Source: <span id="url">Loading...</span></div>
|
||||
</body>
|
||||
|
||||
</html>
|
97
init_db.py
Normal file
97
init_db.py
Normal file
@ -0,0 +1,97 @@
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
# Setup basic configuration for logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
|
||||
|
||||
def initialize_db(db_path):
|
||||
# Check if the database file already exists
|
||||
db_exists = os.path.exists(db_path)
|
||||
if db_exists:
|
||||
logging.info(f"{db_path} exists") # Log a message if the database exists
|
||||
return
|
||||
try:
|
||||
with sqlite3.connect(
|
||||
db_path
|
||||
) as conn: # Using 'with' to ensure that the connection is closed automatically
|
||||
configure_database(conn)
|
||||
except sqlite3.Error as e:
|
||||
logging.error(f"Database error: {e}") # Log any SQLite errors that occur
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Exception in initialize_db: {e}"
|
||||
) # Log any other exceptions that occur
|
||||
|
||||
|
||||
def configure_database(conn):
|
||||
cursor = conn.cursor()
|
||||
# Setting the journal mode to WAL for better concurrency
|
||||
cursor.execute("PRAGMA journal_mode = WAL;")
|
||||
# Setting synchronous to NORMAL for a balance between speed and reliability
|
||||
cursor.execute("PRAGMA synchronous = NORMAL;")
|
||||
# Setting a busy timeout to prevent immediate failures when the database is locked
|
||||
cursor.execute("PRAGMA busy_timeout = 5000;")
|
||||
# Increasing the cache size to reduce the number of disk I/O operations
|
||||
cursor.execute("PRAGMA cache_size = -32000;")
|
||||
# Enabling memory-mapped I/O for potentially faster file operations
|
||||
cursor.execute("PRAGMA mmap_size = 536870912;")
|
||||
# Setting locking mode to EXCLUSIVE can enhance performance for single-user scenarios
|
||||
cursor.execute("PRAGMA locking_mode = EXCLUSIVE;")
|
||||
# Ensuring foreign key constraints are enforced for data integrity
|
||||
cursor.execute("PRAGMA foreign_keys = ON;")
|
||||
conn.commit() # Commit all PRAGMA configurations
|
||||
|
||||
logging.info("Set up database with multi-user optimizations.")
|
||||
|
||||
|
||||
def batch_transact(db_path, operations):
|
||||
try:
|
||||
with sqlite3.connect(
|
||||
db_path
|
||||
) as conn: # Ensure that the connection is handled properly
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"BEGIN TRANSACTION;"
|
||||
) # Start a transaction for batch operations
|
||||
for operation in operations:
|
||||
cursor.execute(
|
||||
operation
|
||||
) # Execute each SQL operation provided in the operations list
|
||||
cursor.execute("COMMIT;") # Commit all operations at once
|
||||
except sqlite3.Error as e:
|
||||
logging.error(f"Database error during batch transaction: {e}")
|
||||
except Exception as e:
|
||||
logging.error(f"Exception in batch_transact: {e}")
|
||||
|
||||
|
||||
def maintenance(db_path):
|
||||
try:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"PRAGMA optimize;"
|
||||
) # Optimize the database to maintain performance
|
||||
cursor.execute("VACUUM;") # Reclaim space and defragment the database file
|
||||
except sqlite3.Error as e:
|
||||
logging.error(f"Database error during maintenance: {e}")
|
||||
except Exception as e:
|
||||
logging.error(f"Exception in maintenance: {e}")
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Initialize and manage an SQLite database."
|
||||
)
|
||||
parser.add_argument("db_path", type=str, help="Path to the SQLite database file.")
|
||||
args = parser.parse_args()
|
||||
return args
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args() # Parse the command-line arguments for the database path
|
||||
initialize_db(args.db_path) # Use the parsed path to initialize the database
|
10
makefile
Normal file
10
makefile
Normal file
@ -0,0 +1,10 @@
|
||||
run: build
|
||||
docker run --rm -ti --name noaa -e HOST_NAME=localhost -p 9021:9021 -p 4200:4200 noaa
|
||||
|
||||
build:
|
||||
docker build -t noaa .
|
||||
|
||||
lint:
|
||||
isort --profile=black .
|
||||
black .
|
||||
|
266
noaa_animate.py
Normal file
266
noaa_animate.py
Normal file
@ -0,0 +1,266 @@
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from io import BytesIO
|
||||
from typing import Dict, Iterator, List
|
||||
|
||||
import httpx
|
||||
# import imageio
|
||||
import numpy as np
|
||||
from moviepy.editor import ImageSequenceClip
|
||||
from PIL import Image
|
||||
from prefect import flow, task
|
||||
from prefect.task_runners import ConcurrentTaskRunner
|
||||
from prefect.tasks import task_input_hash
|
||||
|
||||
BASE_URL = "https://services.swpc.noaa.gov/images/animations/geospace/"
|
||||
|
||||
|
||||
@task(
|
||||
retries=3,
|
||||
retry_delay_seconds=5,
|
||||
cache_key_fn=task_input_hash,
|
||||
cache_expiration=timedelta(minutes=2),
|
||||
log_prints=True,
|
||||
)
|
||||
def get_file_links(url: str, ext: str | None = None) -> Iterator[str]:
|
||||
response = httpx.get(url)
|
||||
response.raise_for_status()
|
||||
webpage_content = response.text
|
||||
if ext is None:
|
||||
print("Extension not supplied. Inferring (less efficient) png/jpg/jpeg")
|
||||
exts = ["png", "jpg", "jpeg"]
|
||||
else:
|
||||
exts = [ext.lower()]
|
||||
lines = webpage_content.split("\n")
|
||||
for line in lines:
|
||||
for ext in exts:
|
||||
if ext in line: # need to parse the href link
|
||||
start_pos = line.find('href="') + len('href="')
|
||||
end_pos = line.find('"', start_pos)
|
||||
href = line[start_pos:end_pos]
|
||||
if href.endswith(f"latest.{ext}"):
|
||||
print("Skipping latest")
|
||||
continue
|
||||
if href.endswith(ext):
|
||||
if not href.startswith("http"):
|
||||
href = url + href
|
||||
yield href
|
||||
break # Exit the inner loop to avoid duplicate yields for multiple exts
|
||||
|
||||
|
||||
def url_tail_hash(context, parameters):
|
||||
# return a constant
|
||||
return parameters["url"].split("/")[-1]
|
||||
|
||||
|
||||
def out_path_hash(context, parameters):
|
||||
return parameters["output_path"] + f"_L{len(parameters['images'])}"
|
||||
|
||||
|
||||
@task(
|
||||
retries=5,
|
||||
retry_delay_seconds=1,
|
||||
cache_key_fn=task_input_hash,
|
||||
cache_expiration=timedelta(minutes=5),
|
||||
result_storage_key="{parameters[url]}",
|
||||
)
|
||||
def get_content(url: str, params: Dict[str, any] | None = None):
|
||||
response = httpx.get(f"https://{url}", params=params)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
except httpx.HTTPStatusError:
|
||||
return None
|
||||
|
||||
|
||||
def preview_urls(urls):
|
||||
print("URLS (head):")
|
||||
print(urls[:5])
|
||||
print("URLS (tail):")
|
||||
print(urls[-5:])
|
||||
|
||||
|
||||
@task(
|
||||
cache_key_fn=task_input_hash,
|
||||
cache_expiration=timedelta(hours=1),
|
||||
)
|
||||
def get_images(urls: List[str] | List[str], limit: int = 0):
|
||||
if limit > 0:
|
||||
print(f"Limiting to {limit} urls")
|
||||
urls = urls[-limit:]
|
||||
|
||||
urls = [url.replace("https://", "").replace("http://", "") for url in urls]
|
||||
preview_urls(urls)
|
||||
|
||||
futures = get_content.map(urls)
|
||||
images = [
|
||||
(urls[i], f.result()) for i, f in enumerate(futures) if f.result() is not None
|
||||
]
|
||||
return images
|
||||
|
||||
|
||||
def extract_timestamp_from_url(url: str) -> str:
|
||||
# Assuming the timestamp format is in the format shown in the screenshot
|
||||
match = re.search(r"\d{8}_\d{6}", url)
|
||||
return match.group(0) if match else ""
|
||||
|
||||
|
||||
# @task(
|
||||
# cache_key_fn=out_path_hash,
|
||||
# cache_expiration=timedelta(minutes=3),
|
||||
# result_storage_key="{parameters[output_path]}",
|
||||
# )
|
||||
# def create_animation(
|
||||
# images: List[bytes], output_path: str, duration: float = 0.5
|
||||
# ) -> None:
|
||||
# if not images:
|
||||
# raise ValueError("No images!")
|
||||
# pil_images = [Image.open(BytesIO(img_data)).convert("RGB") for img_data in images]
|
||||
# imageio.mimsave(output_path, pil_images, duration=duration)
|
||||
# return output_path
|
||||
|
||||
|
||||
def make_even_dimensions(image):
|
||||
width, height = image.size
|
||||
if width % 2 == 1:
|
||||
width -= 1
|
||||
if height % 2 == 1:
|
||||
height -= 1
|
||||
return image.resize((width, height), Image.ANTIALIAS)
|
||||
|
||||
|
||||
def crop_to_even(image):
|
||||
width, height = image.size
|
||||
# Adjust width and height to be even
|
||||
if width % 2 == 1:
|
||||
width -= 1
|
||||
if height % 2 == 1:
|
||||
height -= 1
|
||||
return image.crop((0, 0, width, height))
|
||||
|
||||
|
||||
@task(
|
||||
cache_key_fn=out_path_hash,
|
||||
cache_expiration=timedelta(hours=4),
|
||||
result_storage_key="{parameters[output_path]}",
|
||||
)
|
||||
def create_mp4_animation(images: List[bytes], output_path: str, fps: int = 24) -> None:
|
||||
# Convert bytes to PIL images and then to numpy arrays
|
||||
frames = [
|
||||
np.array(crop_to_even(Image.open(BytesIO(img_data)).convert("RGB")))
|
||||
for img_data in images
|
||||
]
|
||||
|
||||
# Create a video clip from the image sequence
|
||||
clip = ImageSequenceClip(frames, fps=fps)
|
||||
|
||||
# Write the video clip to a file
|
||||
clip.write_videofile(
|
||||
output_path,
|
||||
codec="libx264",
|
||||
ffmpeg_params=["-pix_fmt", "yuv420p"],
|
||||
preset="medium",
|
||||
bitrate="800k",
|
||||
)
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
def format_output_name(url: str, latest: bool = False):
|
||||
if latest:
|
||||
now = "latest"
|
||||
else:
|
||||
now = datetime.now().strftime("%Y%m%d-%H:%M:%S")
|
||||
return (
|
||||
url.replace("https://", "")
|
||||
.replace("http://", "")
|
||||
.replace("/", "-")
|
||||
.replace(".", "_")
|
||||
+ now
|
||||
)
|
||||
|
||||
|
||||
@task(
|
||||
name="animate",
|
||||
retries=0,
|
||||
retry_delay_seconds=1,
|
||||
log_prints=True,
|
||||
cache_key_fn=task_input_hash,
|
||||
cache_expiration=timedelta(minutes=3),
|
||||
)
|
||||
def animate(
|
||||
url: str = "https://services.swpc.noaa.gov/images/animations/geospace/density/",
|
||||
ext: str = "png",
|
||||
latest: bool = True,
|
||||
limit: int = 0,
|
||||
):
|
||||
urls = get_file_links(url, ext)
|
||||
if len(urls) == 0:
|
||||
raise ValueError("No urls scraped")
|
||||
images = get_images(list(sorted(urls)), limit=limit)
|
||||
if len(images) == 0:
|
||||
raise ValueError("No images retrieved.")
|
||||
print(f"Retrieved {len(images)} images.")
|
||||
sorted_images = sorted(images, key=lambda x: extract_timestamp_from_url(x[0]))
|
||||
print("Head:")
|
||||
print([u for u, i in sorted_images[:5]])
|
||||
frames = [s[1] for s in sorted_images]
|
||||
# create_animation(frames, "out.gif", duration=5)
|
||||
out_name = format_output_name(url, latest=latest)
|
||||
create_mp4_animation(frames, f"out/{out_name}.mp4")
|
||||
|
||||
|
||||
def deploy_name():
|
||||
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S") + "Z"
|
||||
|
||||
|
||||
@flow(
|
||||
name="create-animations",
|
||||
retries=0,
|
||||
retry_delay_seconds=1,
|
||||
log_prints=True,
|
||||
task_runner=ConcurrentTaskRunner(),
|
||||
flow_run_name=None,
|
||||
timeout_seconds=90,
|
||||
)
|
||||
def create_animations(
|
||||
url: str | List[str] = BASE_URL + "velocity/",
|
||||
ext: str | None = None,
|
||||
latest: bool = False,
|
||||
limit: int = 0,
|
||||
):
|
||||
if isinstance(url, str):
|
||||
url = [url]
|
||||
|
||||
futures = animate.map(url, ext, latest, limit)
|
||||
return futures
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# make_animation.from_source(
|
||||
# source=TEST_REPO,
|
||||
# entrypoint="noaa_animate.py:make_animation",
|
||||
# ).deploy(
|
||||
# name="noaa-animate", work_pool_name="process"
|
||||
# )
|
||||
|
||||
from prefect.client.schemas.schedules import CronSchedule
|
||||
|
||||
sched = CronSchedule(cron="*/15 * * * *", timezone="America/Denver")
|
||||
|
||||
links = [
|
||||
BASE_URL + "density/",
|
||||
BASE_URL + "velocity/",
|
||||
BASE_URL + "pressure/",
|
||||
]
|
||||
sched_params = {
|
||||
"latest": True,
|
||||
"url": links,
|
||||
"ext": "png",
|
||||
"limit": 0,
|
||||
}
|
||||
create_animations.serve(
|
||||
"noaa-animate", limit=8, schedule=None, parameters=sched_params
|
||||
)
|
||||
# make_animation(url)
|
BIN
prefect.jpg
Normal file
BIN
prefect.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 448 KiB |
BIN
preview.jpg
Normal file
BIN
preview.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 408 KiB |
14
profiles.default.toml
Normal file
14
profiles.default.toml
Normal file
@ -0,0 +1,14 @@
|
||||
active = "default"
|
||||
PREFECT_API_URL = "http://0.0.0.0:4200/api"
|
||||
|
||||
[profiles.default]
|
||||
PREFECT_TASK_SCHEDULING_MAX_SCHEDULED_QUEUE_SIZE = 4
|
||||
|
||||
PREFECT_API_SERVICES_SCHEDULER_DEPLOYMENT_BATCH_SIZE = 100
|
||||
PREFECT_API_SERVICES_SCHEDULER_ENABLED = true
|
||||
PREFECT_API_SERVICES_SCHEDULER_INSERT_BATCH_SIZE = 500
|
||||
PREFECT_API_SERVICES_SCHEDULER_LOOP_SECONDS = 60
|
||||
PREFECT_API_SERVICES_SCHEDULER_MIN_RUNS = 3
|
||||
PREFECT_API_SERVICES_SCHEDULER_MAX_RUNS = 100
|
||||
PREFECT_API_SERVICES_SCHEDULER_MIN_SCHEDULED_TIME = '0:30:00'
|
||||
PREFECT_API_SERVICES_SCHEDULER_MAX_SCHEDULED_TIME = '0 days, 8:00:00'
|
9
requirements.txt
Normal file
9
requirements.txt
Normal file
@ -0,0 +1,9 @@
|
||||
prefect==2.17.1
|
||||
Flask==3.0.3
|
||||
gunicorn==22.0.0
|
||||
gevent==24.2.1
|
||||
moviepy==1.0.3
|
||||
pillow==10.3.0
|
||||
requests==2.32.3
|
||||
httpx==0.27.0
|
||||
# imageio==2.34.1
|
Loading…
Reference in New Issue
Block a user