Compare commits

..

No commits in common. "main" and "0.2.11" have entirely different histories.
main ... 0.2.11

12 changed files with 412 additions and 440 deletions

View File

@ -1,24 +1,19 @@
[tool.bumpversion]
current_version = "0.2.16"
current_version = "0.2.11"
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
serialize = ["{major}.{minor}.{patch}"]
search = "{current_version}"
replace = "{new_version}"
regex = false
ignore_missing_version = false
ignore_missing_files = false
tag = true
commit = true
message = "Bump version: {current_version} → {new_version}"
sign_tags = false
tag_name = "{new_version}"
tag_message = "Bump version: {current_version} → {new_version}"
[[tool.bumpversion.files]]
filename = ".bumpversion.toml"
search = 'current_version = "{current_version}"'
replace = 'current_version = "{new_version}"'
[[tool.bumpversion.files]]
filename = "Dockerfile"
search = 'ARG VERSION="{current_version}"'
replace = 'ARG VERSION="{new_version}"'
allow_dirty = false
commit = true
message = "Bump version: {current_version} → {new_version}"
moveable_tags = []
commit_args = ""
setup_hooks = []

View File

@ -3,14 +3,10 @@ FROM python:3.11-slim
# Set the working directory in the container
WORKDIR /app
# Set version label
ARG VERSION="0.2.16"
LABEL version=$VERSION
# Copy project files into the container
COPY . /app
# Install dependencies
RUN pip install --no-cache-dir -r requirements.txt

108
README.md
View File

@ -1,108 +0,0 @@
# AI Image Frame Server
This project is a Flask-based web server designed to generate and display images from various AI models, primarily interacting with ComfyUI. It can be configured to automatically generate new images at scheduled times and provides a gallery to view the generated images.
## Features
* **Web Interface:** A simple web interface to view generated images.
* **Image Generation:** Integrates with ComfyUI to generate images based on given prompts and models.
* **Scheduled Generation:** Automatically generates new images at a configurable time.
* **Docker Support:** Comes with a `Dockerfile` and `docker-compose.yml` for easy setup and deployment.
* **Configurable:** Most options can be configured through a `user_config.cfg` file.
* **Authentication:** Optional password protection for image creation.
## Prerequisites
* Python 3.11
* Docker (for containerized deployment)
* An instance of ComfyUI running and accessible from the server.
## Installation and Setup
### Manual Setup
1. **Clone the repository:**
```bash
git clone <repository_url>
cd ai_image_frame_server
```
2. **Install dependencies:**
```bash
pip install -r requirements.txt
```
3. **Configure the application:**
* Copy the `user_config.cfg.sample` to `user_config.cfg`.
* Edit `user_config.cfg` with your settings. See the [Configuration](#configuration) section for more details.
4. **Run the application:**
```bash
export SECRET_KEY='a_very_secret_key'
python ai_frame_image_server.py
```
### Docker Setup
1. **Clone the repository:**
```bash
git clone <repository_url>
cd ai_image_frame_server
```
2. **Configure the application:**
* Copy the `user_config.cfg.sample` to `user_config.cfg`.
* Edit `user_config.cfg` with your settings. The `comfyui_url` should be the address of your ComfyUI instance, accessible from within the Docker network (e.g., `http://host.docker.internal:8188` or your server's IP).
3. **Build and run with Docker Compose:**
```bash
docker-compose up --build
```
The application will be available at `http://localhost:8088`.
## Configuration
The application is configured via the `user_config.cfg` file.
| Section | Key | Description | Default (from sample) |
| :-------- | :------------------- | :-------------------------------------------------------------------------- | :-------------------- |
| `[frame]` | `reload_interval` | How often the gallery page reloads in milliseconds. | `30000` |
| `[frame]` | `auto_regen` | Enable or disable automatic image generation (`True`/`False`). | `True` |
| `[frame]` | `regen_time` | The time to automatically generate a new image (HH:MM). | `03:00` |
| `[frame]` | `port` | The port the Flask application will run on. | `5000` |
| `[frame]` | `create_requires_auth` | Require a password to create images (`True`/`False`). | `False` |
| `[frame]` | `password_for_auth` | The password to use for image creation if authentication is enabled. | `create` |
| `[comfyui]` | `comfyui_url` | The URL of your ComfyUI instance. | `http://comfyui` |
| `[comfyui]` | `models` | A comma-separated list of models to use for generation. | `zavychromaxl_v100.safetensors,ponyDiffusionV6XL_v6StartWithThisOne.safetensors` |
| `[comfyui]` | `output_dir` | The directory to save generated images to. | `./output/` |
| `[comfyui]` | `prompt` | The prompt to use for generating a random prompt for stable diffusion. | `"Generate a random detailed prompt for stable diffusion."` |
| `[comfyui]` | `width` | The width of the generated image. | `1568` |
| `[comfyui]` | `height` | The height of the generated image. | `672` |
| `[comfyui]` | `topics` | A comma-separated list of topics to generate prompts from. | |
| `[comfyui]` | `FLUX` | Enable FLUX models (`True`/`False`). | `False` |
| `[comfyui]` | `ONLY_FLUX` | Only use FLUX models (`True`/`False`). | `False` |
| `[comfyui:flux]` | `models` | A comma-separated list of FLUX models. | `flux1-dev-Q4_0.gguf,flux1-schnell-Q4_0.gguf` |
| `[openwebui]` | `base_url` | The base URL for OpenWebUI. | `https://openwebui` |
| `[openwebui]` | `api_key` | The API key for OpenWebUI. | `sk-` |
| `[openwebui]` | `models` | A comma-separated list of models for OpenWebUI. | `llama3:latest,cogito:14b,gemma3:12b` |
## Usage
* **Gallery:** Open your browser to `http://<server_ip>:<port>` to see the gallery of generated images.
* **Create Image:** Navigate to `/create` to manually trigger image generation.
## Dependencies
* Flask
* comfy_api_simplified
* APScheduler
* Pillow
* And others, see `requirements.txt`.
## Contributing
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
## License
This project is unlicensed.

View File

@ -1,18 +0,0 @@
#!/bin/bash
set -ex
# Check if an argument is provided
if [ -z "$1" ]; then
echo "Usage: $0 <part>"
echo "Example: $0 patch"
exit 1
fi
PART=$1
# Bump the version
bump-my-version bump $PART
# Push the changes
git push
git push origin --tags

View File

@ -1,51 +0,0 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: black;
color: white;
font-family: sans-serif;
padding: 2rem;
}
.button-link {
background: #333;
color: white;
text-decoration: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
display: inline-block;
text-align: center;
border: none;
}
.button-link:hover {
background: #555;
}
.version {
position: fixed;
bottom: 8px;
right: 12px;
color: #666;
font-size: 12px;
font-family: monospace;
user-select: none;
opacity: 0.6;
}
.version a {
color: inherit;
text-decoration: none;
cursor: pointer;
}
.version a:hover {
text-decoration: underline;
}

View File

@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}AI Image Frame{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
{% block head %}{% endblock %}
</head>
<body>
{% block content %}{% endblock %}
<!-- Version number at bottom right -->
<div class="version">
<a href="{{ url_for('settings_route.config_editor') }}">v{{ version }}</a>
</div>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@ -1,15 +1,28 @@
{% extends "base.html" %}
<!DOCTYPE html>
<html lang="en">
{% block title %}Create An Image{% endblock %}
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Create An Image</title>
<style>
/* ---------- reset ---------- */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
{% block head %}
<style>
/* ---------- layout ---------- */
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
background: black;
color: white;
font-family: Arial, sans-serif;
padding: 20px;
}
@ -50,6 +63,7 @@
background: #555;
}
/* ---------- spinner ---------- */
#spinner-overlay {
position: fixed;
inset: 0;
@ -99,15 +113,15 @@
height: 150px;
}
}
</style>
{% endblock %}
</style>
</head>
{% block content %}
<h1 style="margin-bottom: 20px;">Create An Image</h1>
<body>
<h1 style="margin-bottom: 20px;">Create An Image</h1>
<textarea id="prompt-box" placeholder="Enter your custom prompt here..."></textarea>
<textarea id="prompt-box" placeholder="Enter your custom prompt here..."></textarea>
<div class="button-group">
<div class="button-group">
<button onclick="showSpinner(); location.href='/'">Back</button>
<button onclick="sendPrompt()">Send Prompt</button>
@ -116,11 +130,13 @@
<select id="model-select">
<option value="" selected>Random</option>
<!-- Group: FLUX -->
<optgroup label="FLUX">
{% for m in models if 'flux' in m|lower %}
<option value="{{ m }}">{{ m.rsplit('.', 1)[0] }}</option>
{% endfor %}
</optgroup>
<!-- Group: SDXL -->
<optgroup label="SDXL">
{% for m in models if 'flux' not in m|lower %}
<option value="{{ m }}">{{ m.rsplit('.', 1)[0] }}</option>
@ -137,15 +153,16 @@
{% endfor %}
</optgroup>
</select>
</div>
<div id="spinner-overlay">
</div>
<!-- waiting overlay -->
<div id="spinner-overlay">
<div class="spinner"></div>
</div>
{% endblock %}
</div>
{% block scripts %}
<script>
<script>
const overlay = document.getElementById('spinner-overlay');
function showSpinner() { overlay.style.visibility = 'visible'; }
@ -173,14 +190,15 @@
});
}
// wrapper for Random Prompt button so it also sends the model
function randomPrompt() {
showSpinner();
const model = document.getElementById('model-select').value;
const topic = document.getElementById('topic-select').value;
const topic = document.getElementById('topic-select').value; // this line was missing
const formData = new URLSearchParams();
formData.append('model', model);
formData.append('topic', topic);
formData.append('topic', topic); // include topic in request
fetch('/create', {
method: 'POST',
@ -195,5 +213,7 @@
alert("Error requesting random prompt: " + error);
});
}
</script>
{% endblock %}
</script>
</body>
</html>

View File

@ -1,9 +1,24 @@
{% extends "base.html" %}
<!DOCTYPE html>
<html lang="en">
{% block title %}Image Archive{% endblock %}
{% block head %}
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Image Archive</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: black;
color: white;
font-family: sans-serif;
padding: 2rem;
}
h1 {
text-align: center;
margin-bottom: 2rem;
@ -87,14 +102,18 @@
text-align: left;
margin-top: 20px;
max-height: 25vh;
/* NEW: restrict height */
overflow-y: auto;
/* NEW: allow vertical scroll */
}
/* Back button fixed top right */
.home-button {
position: fixed;
top: 20px;
right: 20px;
z-index: 500;
/* lower than lightbox (999) */
}
.favourites-button {
@ -113,6 +132,24 @@
cursor: pointer;
}
.button-link {
background: #333;
color: white;
text-decoration: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
display: inline-block;
text-align: center;
border: none;
}
.button-link:hover {
background: #555;
}
@media (max-width: 600px) {
body {
padding: 1rem;
@ -144,7 +181,9 @@
max-width: 90%;
padding: 8px 16px;
max-height: 20vh;
/* smaller height for mobile */
overflow-y: auto;
/* keep scroll on mobile too */
}
.button-link {
@ -153,16 +192,18 @@
}
}
</style>
{% endblock %}
</head>
{% block content %}
<body>
<a href="/" class="button-link home-button">Home</a>
<button class="button-link favourites-button" id="favourites-button" onclick="toggleFavouritesView()">Show Favourites</button>
<h1 id="page-title">Image Archive</h1>
<!-- Empty gallery container; images will be loaded incrementally -->
<div class="gallery" id="gallery"></div>
<!-- Lightbox -->
<div class="lightbox" id="lightbox" tabindex="-1" onkeyup="handleLightboxKeys(event)">
<span class="close" onclick="closeLightbox()">&times;</span>
<span class="favourite-heart" id="favourite-heart" onclick="toggleFavourite()">&#9825;</span>
@ -171,9 +212,8 @@
<p id="lightbox-prompt"></p>
<span class="arrow right" onclick="nextImage()">&#10095;</span>
</div>
{% endblock %}
{% block scripts %}
<!-- Pass image filenames from Flask to JS -->
<script>
let allImages = JSON.parse(`[
{% for image in images %}
@ -184,10 +224,10 @@
<script>
const gallery = document.getElementById('gallery');
const batchSize = 9;
const batchSize = 9; // images to load per batch
let loadedCount = 0;
let currentIndex = 0;
const detailsCache = {};
const detailsCache = {}; // Cache for image details
let showingFavourites = false;
let filteredImages = allImages;
@ -236,16 +276,19 @@
renderGallery();
}
// Load initial batch
renderGallery();
// Load more images when scrolling near bottom
window.addEventListener('scroll', () => {
const imagesToLoad = showingFavourites ? filteredImages : allImages;
if (loadedCount >= imagesToLoad.length) return;
if (loadedCount >= imagesToLoad.length) return; // all loaded
if ((window.innerHeight + window.scrollY) >= (document.body.offsetHeight - 100)) {
loadNextBatch();
}
});
// Get current images in gallery for lightbox navigation
function getGalleryImages() {
return Array.from(gallery.querySelectorAll('img'));
}
@ -261,7 +304,7 @@
function updateFavouriteHeart(isFavourited) {
const heart = document.getElementById('favourite-heart');
heart.innerHTML = isFavourited ? '&#9829;' : '&#9825;';
heart.innerHTML = isFavourited ? '&#9829;' : '&#9825;'; // solid vs outline heart
heart.style.color = isFavourited ? 'red' : 'white';
}
@ -318,7 +361,7 @@
return response.json();
})
.then(data => {
detailsCache[filename] = data;
detailsCache[filename] = data; // Cache the data
document.getElementById("lightbox-prompt").textContent =
`Model:${data.model} - Created:${data.date}\n\n${data.prompt}`;
})
@ -333,6 +376,7 @@
const imagesToLoad = showingFavourites ? filteredImages : allImages;
if (currentIndex + 1 >= images.length && loadedCount < imagesToLoad.length) {
loadNextBatch();
// Wait briefly to ensure DOM updates
setTimeout(() => {
const updatedImages = getGalleryImages();
if (currentIndex + 1 < updatedImages.length) {
@ -352,9 +396,11 @@
showImageAndLoadDetails(currentIndex);
}
function closeLightbox() {
document.getElementById("lightbox").style.display = "none";
if (showingFavourites) {
// Refresh the gallery if a favourite was removed
const currentImage = getGalleryImages()[currentIndex];
const wasFavourited = currentImage.dataset.favourited === 'true';
const originalImage = allImages.find(img => img.filename === currentImage.dataset.filename);
@ -376,4 +422,6 @@
}
}
</script>
{% endblock %}
</body>
</html>

View File

@ -1,15 +1,25 @@
{% extends "base.html" %}
{% block title %}Image Created{% endblock %}
{% block head %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Created</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
background: black;
color: white;
font-family: Arial, sans-serif;
padding: 20px;
text-align: center;
}
.message {
@ -40,13 +50,13 @@
background: #555;
}
</style>
{% endblock %}
{% block content %}
</head>
<body>
<div class="message">Image will be made with <i>{{ model }}</i> using prompt:</div>
<div class="prompt-text">
{{ prompt }}
</div>
<button onclick="location.href='/'">Home</button>
{% endblock %}
</body>
</html>

View File

@ -1,20 +1,32 @@
{% extends "base.html" %}
<!DOCTYPE html>
<html lang="en">
{% block title %}AI Image of the Day{% endblock %}
{% block head %}
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Image of the Day</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
background: black;
color: white;
font-family: Arial, sans-serif;
position: relative;
padding-top: 20px;
padding-bottom: 20px;
}
.image-container {
max-width: 90vw;
max-height: 80vh;
@ -42,7 +54,9 @@
max-width: 80vw;
text-align: left;
max-height: 30vh;
/* NEW: limit height */
overflow-y: auto;
/* NEW: allow scrolling */
}
.button-group {
@ -52,6 +66,46 @@
justify-content: center;
}
.button-link {
background: #333;
color: white;
text-decoration: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
display: inline-block;
text-align: center;
}
.button-link:hover {
background: #555;
}
/* New style for version number */
.version {
position: fixed;
bottom: 8px;
right: 12px;
color: #666;
font-size: 12px;
font-family: monospace;
user-select: none;
opacity: 0.6;
}
.version a {
color: inherit;
text-decoration: none;
cursor: pointer;
}
.version a:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.image-container {
max-width: 100vw;
@ -80,9 +134,14 @@
}
}
</style>
{% endblock %}
<script>
setInterval(() => {
location.reload();
}, {{ reload_interval }}); // Refresh every X ms
</script>
</head>
{% block content %}
<body>
{% if image %}
<div class="image-container">
<img src="{{ url_for('image_routes.serve_image', filename=image) }}" alt="Latest Image" />
@ -97,12 +156,11 @@
{% else %}
<p>No images found</p>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
setInterval(() => {
location.reload();
}, {{ reload_interval }}); // Refresh every X ms
</script>
{% endblock %}
<!-- Version number at bottom right -->
<div class="version">
<a href="{{ url_for('settings_route.config_editor') }}">v{{ version }}</a>
</div>
</body>
</html>

View File

@ -1,15 +1,25 @@
{% extends "base.html" %}
{% block title %}Login{% endblock %}
{% block head %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
background: black;
color: white;
font-family: Arial, sans-serif;
padding: 20px;
text-align: center;
}
.message {
@ -49,9 +59,8 @@
background: #555;
}
</style>
{% endblock %}
{% block content %}
</head>
<body>
<div class="message">Please enter the password to continue:</div>
<form method="post">
<div class="prompt-text">
@ -60,4 +69,5 @@
</div>
<button type="submit">Login</button>
</form>
{% endblock %}
</body>
</html>

View File

@ -1,10 +1,21 @@
{% extends "base.html" %}
<!DOCTYPE html>
<html lang="en">
{% block title %}Config Editor{% endblock %}
{% block head %}
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Config Editor</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: black;
color: white;
font-family: Arial, sans-serif;
min-height: 100vh;
padding: 40px 20px;
display: flex;
@ -63,11 +74,21 @@
margin-top: 20px;
}
.back-button-wrapper {
width: 100%;
display: flex;
justify-content: center;
margin-top: 20px;
.button-link {
background: #333;
height: 40px;
color: white;
text-decoration: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 16px;
transition: background 0.3s;
display: inline-block;
text-align: center;
}
.button-link:hover {
background: #555;
}
@media (max-width: 768px) {
@ -81,10 +102,18 @@
margin-bottom: 30px;
}
}
</style>
{% endblock %}
{% block content %}
.back-button-wrapper {
width: 100%;
display: flex;
justify-content: center;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="box">
<h2>Topics</h2>
<form method="post">
@ -157,4 +186,7 @@
<div class="back-button-wrapper">
<a href="/" class="button-link">Back to Home</a>
</div>
{% endblock %}
</body>
</html>