new repo
This commit is contained in:
		
						commit
						3c2d665bfd
					
				
							
								
								
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -0,0 +1,6 @@
 | 
			
		||||
out/
 | 
			
		||||
*.png
 | 
			
		||||
*.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 
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
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:
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
							
								
								
									
										150
									
								
								app/app.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								app/app.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,150 @@
 | 
			
		||||
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")
 | 
			
		||||
    if not url.endswith("/"):
 | 
			
		||||
        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